Settlement.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. <?php
  2. /**
  3. * Copyright © Magento, Inc. All rights reserved.
  4. * See COPYING.txt for license details.
  5. */
  6. namespace Magento\Paypal\Model\Report;
  7. use DateTime;
  8. use Magento\Framework\Filesystem;
  9. use Magento\Framework\Filesystem\DirectoryList;
  10. /**
  11. * Paypal Settlement Report model
  12. *
  13. * Perform fetching reports from remote servers with following saving them to database
  14. * Prepare report rows for \Magento\Paypal\Model\Report\Settlement\Row model
  15. *
  16. * @method string getReportDate()
  17. * @method \Magento\Paypal\Model\Report\Settlement setReportDate(string $value)
  18. * @method string getAccountId()
  19. * @method \Magento\Paypal\Model\Report\Settlement setAccountId(string $value)
  20. * @method string getFilename()
  21. * @method \Magento\Paypal\Model\Report\Settlement setFilename(string $value)
  22. * @method string getLastModified()
  23. * @method \Magento\Paypal\Model\Report\Settlement setLastModified(string $value)
  24. * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
  25. */
  26. class Settlement extends \Magento\Framework\Model\AbstractModel
  27. {
  28. /**
  29. * Default PayPal SFTP host
  30. */
  31. const REPORTS_HOSTNAME = "reports.paypal.com";
  32. /**
  33. * Default PayPal SFTP host for sandbox mode
  34. */
  35. const SANDBOX_REPORTS_HOSTNAME = "reports.sandbox.paypal.com";
  36. /**
  37. * PayPal SFTP path
  38. */
  39. const REPORTS_PATH = "/ppreports/outgoing";
  40. /**
  41. * Original charset of old report files
  42. */
  43. const FILES_IN_CHARSET = "UTF-16";
  44. /**
  45. * Target charset of report files to be parsed
  46. */
  47. const FILES_OUT_CHARSET = "UTF-8";
  48. /**
  49. * Reports rows storage
  50. *
  51. * @var array
  52. */
  53. protected $_rows = [];
  54. /**
  55. * @var array
  56. */
  57. protected $_csvColumns = [
  58. 'old' => [
  59. 'section_columns' => [
  60. '' => 0,
  61. 'TransactionID' => 1,
  62. 'InvoiceID' => 2,
  63. 'PayPalReferenceID' => 3,
  64. 'PayPalReferenceIDType' => 4,
  65. 'TransactionEventCode' => 5,
  66. 'TransactionInitiationDate' => 6,
  67. 'TransactionCompletionDate' => 7,
  68. 'TransactionDebitOrCredit' => 8,
  69. 'GrossTransactionAmount' => 9,
  70. 'GrossTransactionCurrency' => 10,
  71. 'FeeDebitOrCredit' => 11,
  72. 'FeeAmount' => 12,
  73. 'FeeCurrency' => 13,
  74. 'CustomField' => 14,
  75. 'ConsumerID' => 15,
  76. ],
  77. 'rowmap' => [
  78. 'TransactionID' => 'transaction_id',
  79. 'InvoiceID' => 'invoice_id',
  80. 'PayPalReferenceID' => 'paypal_reference_id',
  81. 'PayPalReferenceIDType' => 'paypal_reference_id_type',
  82. 'TransactionEventCode' => 'transaction_event_code',
  83. 'TransactionInitiationDate' => 'transaction_initiation_date',
  84. 'TransactionCompletionDate' => 'transaction_completion_date',
  85. 'TransactionDebitOrCredit' => 'transaction_debit_or_credit',
  86. 'GrossTransactionAmount' => 'gross_transaction_amount',
  87. 'GrossTransactionCurrency' => 'gross_transaction_currency',
  88. 'FeeDebitOrCredit' => 'fee_debit_or_credit',
  89. 'FeeAmount' => 'fee_amount',
  90. 'FeeCurrency' => 'fee_currency',
  91. 'CustomField' => 'custom_field',
  92. 'ConsumerID' => 'consumer_id',
  93. ],
  94. ],
  95. 'new' => [
  96. 'section_columns' => [
  97. '' => 0,
  98. 'Transaction ID' => 1,
  99. 'Invoice ID' => 2,
  100. 'PayPal Reference ID' => 3,
  101. 'PayPal Reference ID Type' => 4,
  102. 'Transaction Event Code' => 5,
  103. 'Transaction Initiation Date' => 6,
  104. 'Transaction Completion Date' => 7,
  105. 'Transaction Debit or Credit' => 8,
  106. 'Gross Transaction Amount' => 9,
  107. 'Gross Transaction Currency' => 10,
  108. 'Fee Debit or Credit' => 11,
  109. 'Fee Amount' => 12,
  110. 'Fee Currency' => 13,
  111. 'Custom Field' => 14,
  112. 'Consumer ID' => 15,
  113. 'Payment Tracking ID' => 16,
  114. 'Store ID' => 17,
  115. ],
  116. 'rowmap' => [
  117. 'Transaction ID' => 'transaction_id',
  118. 'Invoice ID' => 'invoice_id',
  119. 'PayPal Reference ID' => 'paypal_reference_id',
  120. 'PayPal Reference ID Type' => 'paypal_reference_id_type',
  121. 'Transaction Event Code' => 'transaction_event_code',
  122. 'Transaction Initiation Date' => 'transaction_initiation_date',
  123. 'Transaction Completion Date' => 'transaction_completion_date',
  124. 'Transaction Debit or Credit' => 'transaction_debit_or_credit',
  125. 'Gross Transaction Amount' => 'gross_transaction_amount',
  126. 'Gross Transaction Currency' => 'gross_transaction_currency',
  127. 'Fee Debit or Credit' => 'fee_debit_or_credit',
  128. 'Fee Amount' => 'fee_amount',
  129. 'Fee Currency' => 'fee_currency',
  130. 'Custom Field' => 'custom_field',
  131. 'Consumer ID' => 'consumer_id',
  132. 'Payment Tracking ID' => 'payment_tracking_id',
  133. 'Store ID' => 'store_id',
  134. ],
  135. ],
  136. ];
  137. /**
  138. * @var \Magento\Framework\Filesystem\Directory\WriteInterface
  139. */
  140. protected $_tmpDirectory;
  141. /**
  142. * @var \Magento\Store\Model\StoreManagerInterface
  143. */
  144. protected $_storeManager;
  145. /**
  146. * @var \Magento\Framework\App\Config\ScopeConfigInterface
  147. */
  148. protected $_scopeConfig;
  149. /**
  150. * Columns with DateTime data type
  151. *
  152. * @var array
  153. */
  154. private $dateTimeColumns = ['transaction_initiation_date', 'transaction_completion_date'];
  155. /**
  156. * Columns with amount type
  157. *
  158. * @var array
  159. */
  160. private $amountColumns = ['gross_transaction_amount', 'fee_amount'];
  161. /**
  162. * @var \Magento\Framework\Serialize\Serializer\Json
  163. */
  164. private $serializer;
  165. /**
  166. * @param \Magento\Framework\Model\Context $context
  167. * @param \Magento\Framework\Registry $registry
  168. * @param \Magento\Framework\Filesystem $filesystem
  169. * @param \Magento\Store\Model\StoreManagerInterface $storeManager
  170. * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig
  171. * @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource
  172. * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection
  173. * @param array $data
  174. * @param \Magento\Framework\Serialize\Serializer\Json|null $serializer
  175. */
  176. public function __construct(
  177. \Magento\Framework\Model\Context $context,
  178. \Magento\Framework\Registry $registry,
  179. \Magento\Framework\Filesystem $filesystem,
  180. \Magento\Store\Model\StoreManagerInterface $storeManager,
  181. \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
  182. \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null,
  183. \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null,
  184. array $data = [],
  185. \Magento\Framework\Serialize\Serializer\Json $serializer = null
  186. ) {
  187. $this->_tmpDirectory = $filesystem->getDirectoryWrite(DirectoryList::SYS_TMP);
  188. $this->_storeManager = $storeManager;
  189. $this->_scopeConfig = $scopeConfig;
  190. parent::__construct($context, $registry, $resource, $resourceCollection, $data);
  191. $this->serializer = $serializer ?: \Magento\Framework\App\ObjectManager::getInstance()
  192. ->get(\Magento\Framework\Serialize\Serializer\Json::class);
  193. }
  194. /**
  195. * Initialize resource model
  196. *
  197. * @return void
  198. */
  199. protected function _construct()
  200. {
  201. $this->_init(\Magento\Paypal\Model\ResourceModel\Report\Settlement::class);
  202. }
  203. /**
  204. * Stop saving process if file with same report date, account ID and last modified date was already ferched
  205. *
  206. * @return \Magento\Framework\Model\AbstractModel
  207. */
  208. public function beforeSave()
  209. {
  210. $this->_dataSaveAllowed = true;
  211. if ($this->getId()) {
  212. if ($this->getLastModified() == $this->getReportLastModified()) {
  213. $this->_dataSaveAllowed = false;
  214. }
  215. }
  216. $this->setLastModified($this->getReportLastModified());
  217. return parent::beforeSave();
  218. }
  219. /**
  220. * Goes to specified host/path and fetches reports from there.
  221. *
  222. * Save reports to database.
  223. *
  224. * @param \Magento\Framework\Filesystem\Io\Sftp $connection
  225. * @return int Number of report rows that were fetched and saved successfully
  226. * @throws \Magento\Framework\Exception\LocalizedException
  227. */
  228. public function fetchAndSave(\Magento\Framework\Filesystem\Io\Sftp $connection)
  229. {
  230. $fetched = 0;
  231. $listing = $this->_filterReportsList($connection->rawls());
  232. foreach ($listing as $filename => $attributes) {
  233. $localCsv = 'PayPal_STL_' . uniqid(\Magento\Framework\Math\Random::getRandomNumber()) . time() . '.csv';
  234. if ($connection->read($filename, $this->_tmpDirectory->getAbsolutePath($localCsv))) {
  235. if (!$this->_tmpDirectory->isWritable($localCsv)) {
  236. throw new \Magento\Framework\Exception\LocalizedException(
  237. __('We cannot create a target file for reading reports.')
  238. );
  239. }
  240. $encoded = $this->_tmpDirectory->readFile($localCsv);
  241. $csvFormat = 'new';
  242. $fileEncoding = mb_detect_encoding($encoded);
  243. if (self::FILES_OUT_CHARSET != $fileEncoding) {
  244. $decoded = @iconv($fileEncoding, self::FILES_OUT_CHARSET . '//IGNORE', $encoded);
  245. $this->_tmpDirectory->writeFile($localCsv, $decoded);
  246. $csvFormat = 'old';
  247. }
  248. // Set last modified date, this value will be overwritten during parsing
  249. if (isset($attributes['mtime'])) {
  250. $date = new \DateTime();
  251. $lastModified = $date->setTimestamp($attributes['mtime']);
  252. $this->setReportLastModified(
  253. $lastModified->format('Y-m-d H:i:s')
  254. );
  255. }
  256. $this->setReportDate(
  257. $this->_fileNameToDate($filename)
  258. )->setFilename(
  259. $filename
  260. )->parseCsv(
  261. $localCsv,
  262. $csvFormat
  263. );
  264. if ($this->getAccountId()) {
  265. $this->save();
  266. }
  267. if ($this->_dataSaveAllowed) {
  268. $fetched += count($this->_rows);
  269. }
  270. // clean object and remove parsed file
  271. $this->unsetData();
  272. $this->_tmpDirectory->delete($localCsv);
  273. }
  274. }
  275. return $fetched;
  276. }
  277. /**
  278. * Connect to an SFTP server using specified configuration
  279. *
  280. * @param array $config
  281. * @return \Magento\Framework\Filesystem\Io\Sftp
  282. * @throws \InvalidArgumentException
  283. */
  284. public static function createConnection(array $config)
  285. {
  286. if (!isset($config['hostname'])
  287. || !isset($config['username'])
  288. || !isset($config['password'])
  289. || !isset($config['path'])
  290. ) {
  291. throw new \InvalidArgumentException('Required config elements: hostname, username, password, path');
  292. }
  293. $connection = new \Magento\Framework\Filesystem\Io\Sftp();
  294. $connection->open(
  295. ['host' => $config['hostname'], 'username' => $config['username'], 'password' => $config['password']]
  296. );
  297. $connection->cd($config['path']);
  298. return $connection;
  299. }
  300. /**
  301. * Parse CSV file and collect report rows
  302. *
  303. * @param string $localCsv Path to CSV file
  304. * @param string $format CSV format(column names)
  305. * @return $this
  306. * @SuppressWarnings(PHPMD.CyclomaticComplexity)
  307. */
  308. public function parseCsv($localCsv, $format = 'new')
  309. {
  310. $this->_rows = [];
  311. $sectionColumns = $this->_csvColumns[$format]['section_columns'];
  312. $rowMap = $this->_csvColumns[$format]['rowmap'];
  313. $flippedSectionColumns = array_flip($sectionColumns);
  314. $stream = $this->_tmpDirectory->openFile($localCsv, 'r');
  315. while ($line = $stream->readCsv()) {
  316. if (empty($line)) {
  317. // The line was empty, so skip it.
  318. continue;
  319. }
  320. $lineType = $line[0];
  321. switch ($lineType) {
  322. case 'RH':
  323. // Report header.
  324. $lastModified = new \DateTime($line[1]);
  325. $this->setReportLastModified(
  326. $lastModified->format('Y-m-d H:i:s')
  327. );
  328. //$this->setAccountId($columns[2]); -- probably we'll just take that from the section header...
  329. break;
  330. case 'FH':
  331. // File header.
  332. // Nothing interesting here, move along
  333. break;
  334. case 'SH':
  335. // Section header.
  336. $this->setAccountId($line[3]);
  337. $this->loadByAccountAndDate();
  338. break;
  339. case 'CH':
  340. // Section columns.
  341. // In case ever the column order is changed, we will have the items recorded properly
  342. // anyway. We have named, not numbered columns.
  343. $count = count($line);
  344. for ($i = 1; $i < $count; $i++) {
  345. $sectionColumns[$line[$i]] = $i;
  346. }
  347. $flippedSectionColumns = array_flip($sectionColumns);
  348. break;
  349. case 'SB':
  350. // Section body.
  351. $this->_rows[] = $this->getBodyItems($line, $flippedSectionColumns, $rowMap);
  352. break;
  353. case 'SC':
  354. // Section records count.
  355. case 'RC':
  356. // Report records count.
  357. case 'SF':
  358. // Section footer.
  359. case 'FF':
  360. // File footer.
  361. case 'RF':
  362. // Report footer.
  363. // Nothing to see here, move along
  364. break;
  365. default:
  366. break;
  367. }
  368. }
  369. return $this;
  370. }
  371. /**
  372. * Parse columns from line of csv file
  373. *
  374. * @param array $line
  375. * @param array $sectionColumns
  376. * @param array $rowMap
  377. * @return array
  378. */
  379. private function getBodyItems(array $line, array $sectionColumns, array $rowMap)
  380. {
  381. $bodyItem = [];
  382. for ($i = 1, $count = count($line); $i < $count; $i++) {
  383. if (isset($rowMap[$sectionColumns[$i]])) {
  384. if (in_array($rowMap[$sectionColumns[$i]], $this->dateTimeColumns)) {
  385. $line[$i] = $this->formatDateTimeColumns($line[$i]);
  386. }
  387. if (in_array($rowMap[$sectionColumns[$i]], $this->amountColumns)) {
  388. $line[$i] = $this->formatAmountColumn($line[$i]);
  389. }
  390. $bodyItem[$rowMap[$sectionColumns[$i]]] = $line[$i];
  391. }
  392. }
  393. return $bodyItem;
  394. }
  395. /**
  396. * Format date columns in UTC
  397. *
  398. * @param string $lineItem
  399. * @return string
  400. */
  401. private function formatDateTimeColumns($lineItem)
  402. {
  403. /** @var DateTime $date */
  404. $date = new DateTime($lineItem, new \DateTimeZone('UTC'));
  405. return $date->format(\Magento\Framework\Stdlib\DateTime::DATETIME_PHP_FORMAT);
  406. }
  407. /**
  408. * Format amount columns
  409. *
  410. * PayPal api returns amounts in cents, hence the values need to be divided by 100
  411. *
  412. * @param string $lineItem
  413. * @return float
  414. */
  415. private function formatAmountColumn($lineItem)
  416. {
  417. return (int)$lineItem / 100;
  418. }
  419. /**
  420. * Load report by unique key (account + report date)
  421. *
  422. * @return $this
  423. */
  424. public function loadByAccountAndDate()
  425. {
  426. $this->getResource()->loadByAccountAndDate($this, $this->getAccountId(), $this->getReportDate());
  427. return $this;
  428. }
  429. /**
  430. * Return collected rows for further processing.
  431. *
  432. * @return array
  433. */
  434. public function getRows()
  435. {
  436. return $this->_rows;
  437. }
  438. /**
  439. * Return name for row column
  440. *
  441. * @param string $field Field name in row model
  442. * @return \Magento\Framework\Phrase|string
  443. * @SuppressWarnings(PHPMD.CyclomaticComplexity)
  444. */
  445. public function getFieldLabel($field)
  446. {
  447. switch ($field) {
  448. case 'report_date':
  449. return __('Report Date');
  450. case 'account_id':
  451. return __('Merchant Account');
  452. case 'transaction_id':
  453. return __('Transaction ID');
  454. case 'invoice_id':
  455. return __('Invoice ID');
  456. case 'paypal_reference_id':
  457. return __('PayPal Reference ID');
  458. case 'paypal_reference_id_type':
  459. return __('PayPal Reference ID Type');
  460. case 'transaction_event_code':
  461. return __('Event Code');
  462. case 'transaction_event':
  463. return __('Event');
  464. case 'transaction_initiation_date':
  465. return __('Start Date');
  466. case 'transaction_completion_date':
  467. return __('Finish Date');
  468. case 'transaction_debit_or_credit':
  469. return __('Debit or Credit');
  470. case 'gross_transaction_amount':
  471. return __('Gross Amount');
  472. case 'fee_debit_or_credit':
  473. return __('Fee Debit or Credit');
  474. case 'fee_amount':
  475. return __('Fee Amount');
  476. case 'custom_field':
  477. return __('Custom');
  478. default:
  479. return $field;
  480. }
  481. }
  482. /**
  483. * Iterate through website configurations and collect all SFTP configurations
  484. *
  485. * Filter config values if necessary
  486. *
  487. * @param bool $automaticMode Whether to skip settings with disabled Automatic Fetching or not
  488. * @return array
  489. * @SuppressWarnings(PHPMD.CyclomaticComplexity)
  490. * @SuppressWarnings(PHPMD.NPathComplexity)
  491. */
  492. public function getSftpCredentials($automaticMode = false)
  493. {
  494. $configs = [];
  495. $uniques = [];
  496. foreach ($this->_storeManager->getStores() as $store) {
  497. /*@var $store \Magento\Store\Model\Store */
  498. $active = $this->_scopeConfig->isSetFlag(
  499. 'paypal/fetch_reports/active',
  500. \Magento\Store\Model\ScopeInterface::SCOPE_STORE,
  501. $store
  502. );
  503. if (!$active && $automaticMode) {
  504. continue;
  505. }
  506. $cfg = [
  507. 'hostname' => $this->_scopeConfig->getValue(
  508. 'paypal/fetch_reports/ftp_ip',
  509. \Magento\Store\Model\ScopeInterface::SCOPE_STORE,
  510. $store
  511. ),
  512. 'path' => $this->_scopeConfig->getValue(
  513. 'paypal/fetch_reports/ftp_path',
  514. \Magento\Store\Model\ScopeInterface::SCOPE_STORE,
  515. $store
  516. ),
  517. 'username' => $this->_scopeConfig->getValue(
  518. 'paypal/fetch_reports/ftp_login',
  519. \Magento\Store\Model\ScopeInterface::SCOPE_STORE,
  520. $store
  521. ),
  522. 'password' => $this->_scopeConfig->getValue(
  523. 'paypal/fetch_reports/ftp_password',
  524. \Magento\Store\Model\ScopeInterface::SCOPE_STORE,
  525. $store
  526. ),
  527. 'sandbox' => $this->_scopeConfig->getValue(
  528. 'paypal/fetch_reports/ftp_sandbox',
  529. \Magento\Store\Model\ScopeInterface::SCOPE_STORE,
  530. $store
  531. ),
  532. ];
  533. if (empty($cfg['username']) || empty($cfg['password'])) {
  534. continue;
  535. }
  536. if (empty($cfg['hostname']) || $cfg['sandbox']) {
  537. $cfg['hostname'] = $cfg['sandbox'] ? self::SANDBOX_REPORTS_HOSTNAME : self::REPORTS_HOSTNAME;
  538. }
  539. if (empty($cfg['path']) || $cfg['sandbox']) {
  540. $cfg['path'] = self::REPORTS_PATH;
  541. }
  542. // avoid duplicates
  543. if (in_array($this->serializer->serialize($cfg), $uniques)) {
  544. continue;
  545. }
  546. $uniques[] = $this->serializer->serialize($cfg);
  547. $configs[] = $cfg;
  548. }
  549. return $configs;
  550. }
  551. /**
  552. * Converts a filename to date of report.
  553. *
  554. * @param string $filename
  555. * @return string
  556. */
  557. protected function _fileNameToDate($filename)
  558. {
  559. // Currently filenames look like STL-YYYYMMDD, so that is what we care about.
  560. $dateSnippet = substr(basename($filename), 4, 8);
  561. $result = substr($dateSnippet, 0, 4) . '-' . substr($dateSnippet, 4, 2) . '-' . substr($dateSnippet, 6, 2);
  562. return $result;
  563. }
  564. /**
  565. * Filter SFTP file list by filename format
  566. *
  567. * @param array $list List of files as per $connection->rawls()
  568. * @return array Trimmed down list of files
  569. */
  570. protected function _filterReportsList($list)
  571. {
  572. $result = [];
  573. $pattern = '/^STL-(\d{8,8})\.(\d{2,2})\.(.{3,3})\.CSV$/';
  574. foreach ($list as $filename => $data) {
  575. if (preg_match($pattern, $filename)) {
  576. $result[$filename] = $data;
  577. }
  578. }
  579. return $result;
  580. }
  581. }