You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

685 lines
29 KiB

4 years ago
4 years ago
2 years ago
4 years ago
4 years ago
4 years ago
4 months ago
4 months ago
4 months ago
2 months ago
4 years ago
2 months ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
4 years ago
2 years ago
2 years ago
4 years ago
2 years ago
2 years ago
2 years ago
4 years ago
4 years ago
4 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 months ago
  1. <?php
  2. /*
  3. * AutoImports.php
  4. * Copyright (c) 2021 james@firefly-iii.org
  5. *
  6. * This file is part of the Firefly III Data Importer
  7. * (https://github.com/firefly-iii/data-importer).
  8. *
  9. * This program is free software: you can redistribute it and/or modify
  10. * it under the terms of the GNU Affero General Public License as
  11. * published by the Free Software Foundation, either version 3 of the
  12. * License, or (at your option) any later version.
  13. *
  14. * This program is distributed in the hope that it will be useful,
  15. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. * GNU Affero General Public License for more details.
  18. *
  19. * You should have received a copy of the GNU Affero General Public License
  20. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  21. */
  22. declare(strict_types=1);
  23. namespace App\Console;
  24. use App\Enums\ExitCode;
  25. use App\Events\ImportedTransactions;
  26. use App\Exceptions\ImporterErrorException;
  27. use App\Services\Camt\Conversion\RoutineManager as CamtRoutineManager;
  28. use App\Services\CSV\Conversion\RoutineManager as CSVRoutineManager;
  29. use App\Services\Nordigen\Conversion\RoutineManager as NordigenRoutineManager;
  30. use App\Services\SimpleFIN\Conversion\RoutineManager as SimpleFINRoutineManager;
  31. use App\Services\Nordigen\Model\Account;
  32. use App\Services\Nordigen\Model\Balance;
  33. use App\Services\Shared\Authentication\SecretManager;
  34. use App\Services\Shared\Configuration\Configuration;
  35. use App\Services\Shared\Conversion\ConversionStatus;
  36. use App\Services\Shared\Conversion\RoutineStatusManager;
  37. use App\Services\Shared\File\FileContentSherlock;
  38. use App\Services\Shared\Import\Routine\RoutineManager;
  39. use App\Services\Shared\Import\Status\SubmissionStatus;
  40. use App\Services\Shared\Import\Status\SubmissionStatusManager;
  41. use App\Services\Spectre\Conversion\RoutineManager as SpectreRoutineManager;
  42. use Carbon\Carbon;
  43. use GrumpyDictator\FFIIIApiSupport\Exceptions\ApiHttpException;
  44. use GrumpyDictator\FFIIIApiSupport\Model\Account as LocalAccount;
  45. use GrumpyDictator\FFIIIApiSupport\Request\GetAccountRequest;
  46. use GrumpyDictator\FFIIIApiSupport\Response\GetAccountResponse;
  47. use Illuminate\Contracts\Filesystem\FileNotFoundException;
  48. use Illuminate\Support\Facades\Log;
  49. use Illuminate\Support\Facades\Storage;
  50. use JsonException;
  51. /**
  52. * Trait AutoImports
  53. */
  54. trait AutoImports
  55. {
  56. protected array $conversionErrors = [];
  57. protected array $conversionMessages = [];
  58. protected array $conversionWarnings = [];
  59. protected array $conversionRateLimits = []; // only conversion can have rate limits.
  60. protected string $identifier;
  61. protected array $importErrors = [];
  62. protected array $importMessages = [];
  63. protected array $importWarnings = [];
  64. protected array $importerAccounts = [];
  65. private function getFiles(string $directory): array
  66. {
  67. $ignore = ['.', '..'];
  68. if ('' === $directory) {
  69. $this->error(sprintf('Directory "%s" is empty or invalid.', $directory));
  70. return [];
  71. }
  72. $array = scandir($directory);
  73. if (!is_array($array)) {
  74. $this->error(sprintf('Directory "%s" is empty or invalid.', $directory));
  75. return [];
  76. }
  77. $files = array_diff($array, $ignore);
  78. $importableFiles = [];
  79. $jsonFiles = [];
  80. foreach ($files as $file) {
  81. // import importable file with JSON companion
  82. if (in_array($this->getExtension($file), ['csv', 'xml'], true)) {
  83. $importableFiles[] = $file;
  84. }
  85. // import JSON config files.
  86. if ('json' === $this->getExtension($file)) {
  87. $jsonFiles[] = $file;
  88. }
  89. }
  90. $return = [];
  91. foreach ($importableFiles as $importableFile) {
  92. $jsonFile = $this->getJsonConfiguration($directory, $importableFile);
  93. if (null !== $jsonFile) {
  94. $return[$jsonFile] = sprintf('%s/%s', $directory, $importableFile);
  95. }
  96. }
  97. foreach ($jsonFiles as $jsonFile) {
  98. $fullJson = sprintf('%s/%s', $directory, $jsonFile);
  99. if (!array_key_exists($fullJson, $return)) {
  100. $return[$fullJson] = $fullJson;
  101. }
  102. }
  103. return $return;
  104. }
  105. private function getExtension(string $file): string
  106. {
  107. $parts = explode('.', $file);
  108. if (1 === count($parts)) {
  109. return '';
  110. }
  111. return strtolower($parts[count($parts) - 1]);
  112. }
  113. private function getExtensionLength(string $file): int
  114. {
  115. $parts = explode('.', $file);
  116. if (1 === count($parts)) {
  117. return 0;
  118. }
  119. return strlen($parts[count($parts) - 1]) + 1;
  120. }
  121. private function getJsonConfiguration(string $directory, string $file): ?string
  122. {
  123. $extensionLength = $this->getExtensionLength($file);
  124. $short = substr($file, 0, -$extensionLength);
  125. $jsonFile = sprintf('%s.json', $short);
  126. $fullJson = sprintf('%s/%s', $directory, $jsonFile);
  127. if (file_exists($fullJson)) {
  128. return $fullJson;
  129. }
  130. if (Storage::disk('configurations')->exists($jsonFile)) {
  131. return Storage::disk('configurations')->path($jsonFile);
  132. }
  133. $fallbackConfig = $this->getFallbackConfig($directory);
  134. if (null !== $fallbackConfig) {
  135. $this->line('Found fallback configuration file, which will be used for this file.');
  136. return $fallbackConfig;
  137. }
  138. $this->warn(sprintf('Cannot find JSON file "%s" nor fallback file expected to go with file "%s". This file will be ignored.', $jsonFile, $file));
  139. return null;
  140. }
  141. private function getFallbackConfig(string $directory): ?string
  142. {
  143. if (false === config('importer.fallback_in_dir')) {
  144. return null;
  145. }
  146. $configJsonFile = sprintf('%s/%s', $directory, config('importer.fallback_configuration'));
  147. if (file_exists($configJsonFile) && is_readable($configJsonFile)) {
  148. return $configJsonFile;
  149. }
  150. return null;
  151. }
  152. private function importFiles(string $directory, array $files): array
  153. {
  154. $exitCodes = [];
  155. foreach ($files as $jsonFile => $importableFile) {
  156. try {
  157. $exitCodes[$importableFile] = $this->importFile($jsonFile, $importableFile);
  158. } catch (ImporterErrorException $e) {
  159. Log::error(sprintf('Could not complete import from file "%s".', $importableFile));
  160. Log::error(sprintf('[%s]: %s', config('importer.version'), $e->getMessage()));
  161. $exitCodes[$importableFile] = 1;
  162. }
  163. // report has already been sent. Reset errors and continue.
  164. $this->conversionErrors = [];
  165. $this->conversionMessages = [];
  166. $this->conversionWarnings = [];
  167. $this->conversionRateLimits = [];
  168. $this->importErrors = [];
  169. $this->importMessages = [];
  170. $this->importWarnings = [];
  171. }
  172. Log::debug(sprintf('Collection of exit codes: %s', implode(', ', array_values($exitCodes))));
  173. return $exitCodes;
  174. }
  175. /**
  176. * @throws ImporterErrorException
  177. */
  178. private function importFile(string $jsonFile, string $importableFile): int
  179. {
  180. Log::debug(sprintf('ImportFile: importable "%s"', $importableFile));
  181. Log::debug(sprintf('ImportFile: JSON "%s"', $jsonFile));
  182. // do JSON check
  183. $jsonResult = $this->verifyJSON($jsonFile);
  184. if (false === $jsonResult) {
  185. $message = sprintf('The importer can\'t import %s: could not decode the JSON in config file %s.', $importableFile, $jsonFile);
  186. $this->error($message);
  187. Log::error(sprintf('[%s] Exit code is %s.', config('importer.version'), ExitCode::CANNOT_PARSE_CONFIG->name));
  188. return ExitCode::CANNOT_PARSE_CONFIG->value;
  189. }
  190. $configuration = Configuration::fromArray(json_decode((string) file_get_contents($jsonFile), true));
  191. // sanity check. If the importableFile is a .json file, and it parses as valid json, don't import it:
  192. if ('file' === $configuration->getFlow() && str_ends_with(strtolower($importableFile), '.json') && $this->verifyJSON($importableFile)) {
  193. Log::warning('Almost tried to import a JSON file as a file lol. Skip it.');
  194. // don't report this.
  195. Log::debug(sprintf('[%s] Exit code is %s.', config('importer.version'), ExitCode::SUCCESS->name));
  196. return ExitCode::SUCCESS->value;
  197. }
  198. $configuration->updateDateRange();
  199. $this->line(sprintf('Going to convert from file %s using configuration %s and flow "%s".', $importableFile, $jsonFile, $configuration->getFlow()));
  200. // this is it!
  201. $this->startConversion($configuration, $importableFile);
  202. $this->reportConversion();
  203. // crash here if the conversion failed.
  204. if (0 !== count($this->conversionErrors)) {
  205. $this->error(sprintf('[a] Too many errors in the data conversion (%d), exit.', count($this->conversionErrors)));
  206. Log::debug(sprintf('[%s] Exit code is %s.', config('importer.version'), ExitCode::TOO_MANY_ERRORS_PROCESSING->name));
  207. $exitCode = ExitCode::TOO_MANY_ERRORS_PROCESSING->value;
  208. // could still be that there were simply no transactions (from GoCardless). This can result
  209. // in another exit code.
  210. if ($this->isNothingDownloaded()) {
  211. Log::debug(sprintf('[%s] Exit code changed to %s.', config('importer.version'), ExitCode::NOTHING_WAS_IMPORTED->name));
  212. $exitCode = ExitCode::NOTHING_WAS_IMPORTED->value;
  213. }
  214. // could also be that the end user license agreement is expired.
  215. if ($this->isExpiredAgreement()) {
  216. Log::debug(sprintf('[%s] Exit code changed to %s.', config('importer.version'), ExitCode::AGREEMENT_EXPIRED->name));
  217. $exitCode = ExitCode::AGREEMENT_EXPIRED->value;
  218. }
  219. // report about it anyway:
  220. event(
  221. new ImportedTransactions(
  222. basename($jsonFile),
  223. array_merge($this->conversionMessages, $this->importMessages),
  224. array_merge($this->conversionWarnings, $this->importWarnings),
  225. array_merge($this->conversionErrors, $this->importErrors),
  226. $this->conversionRateLimits
  227. )
  228. );
  229. return $exitCode;
  230. }
  231. $this->line(sprintf('Done converting from file %s using configuration %s.', $importableFile, $jsonFile));
  232. $this->startImport($configuration);
  233. $this->reportImport();
  234. $this->reportBalanceDifferences($configuration);
  235. $this->line('Done!');
  236. // merge things:
  237. $messages = array_merge($this->importMessages, $this->conversionMessages);
  238. $warnings = array_merge($this->importWarnings, $this->conversionWarnings);
  239. $errors = array_merge($this->importErrors, $this->conversionErrors);
  240. event(new ImportedTransactions(basename($jsonFile), $messages, $warnings, $errors, $this->conversionRateLimits));
  241. if (count($this->importErrors) > 0 || count($this->conversionRateLimits) > 0) {
  242. Log::error(sprintf('Exit code is %s.', ExitCode::GENERAL_ERROR->name));
  243. return ExitCode::GENERAL_ERROR->value;
  244. }
  245. if (0 === count($messages) && 0 === count($warnings) && 0 === count($errors)) {
  246. Log::error(sprintf('Exit code is %s.', ExitCode::NOTHING_WAS_IMPORTED->name));
  247. return ExitCode::NOTHING_WAS_IMPORTED->value;
  248. }
  249. Log::error(sprintf('Exit code is %s.', ExitCode::SUCCESS->name));
  250. return ExitCode::SUCCESS->value;
  251. }
  252. /**
  253. * @throws ImporterErrorException
  254. */
  255. private function startConversion(Configuration $configuration, string $importableFile): void
  256. {
  257. $this->conversionMessages = [];
  258. $this->conversionWarnings = [];
  259. $this->conversionErrors = [];
  260. $this->conversionRateLimits = [];
  261. $flow = $configuration->getFlow();
  262. Log::debug(sprintf('[%s] Now in %s', config('importer.version'), __METHOD__));
  263. if ('' === $importableFile && 'file' === $flow) {
  264. $this->warn('Importable file path is empty. That means there is no importable file to import.');
  265. exit(1);
  266. }
  267. $manager = null;
  268. if ('file' === $flow) {
  269. $contentType = $configuration->getContentType();
  270. if ('unknown' === $contentType) {
  271. Log::debug('Content type is "unknown" in startConversion(), detect it.');
  272. $detector = new FileContentSherlock();
  273. $contentType = $detector->detectContentType($importableFile);
  274. }
  275. if ('unknown' === $contentType || 'csv' === $contentType) {
  276. Log::debug(sprintf('Content type is "%s" in startConversion(), use the CSV routine.', $contentType));
  277. $manager = new CSVRoutineManager(null);
  278. $this->identifier = $manager->getIdentifier();
  279. $manager->setContent((string) file_get_contents($importableFile));
  280. }
  281. if ('camt' === $contentType) {
  282. Log::debug('Content type is "camt" in startConversion(), use the CAMT routine.');
  283. $manager = new CamtRoutineManager(null);
  284. $this->identifier = $manager->getIdentifier();
  285. $manager->setContent((string) file_get_contents($importableFile));
  286. }
  287. }
  288. if ('nordigen' === $flow) {
  289. $manager = new NordigenRoutineManager(null);
  290. $this->identifier = $manager->getIdentifier();
  291. }
  292. if ('spectre' === $flow) {
  293. $manager = new SpectreRoutineManager(null);
  294. $this->identifier = $manager->getIdentifier();
  295. }
  296. if ('simplefin' === $flow) {
  297. $manager = new SimpleFINRoutineManager(null);
  298. $this->identifier = $manager->getIdentifier();
  299. }
  300. if (null === $manager) {
  301. $this->error(sprintf('There is no support for flow "%s"', $flow));
  302. exit(1);
  303. }
  304. RoutineStatusManager::startOrFindConversion($this->identifier);
  305. RoutineStatusManager::setConversionStatus(ConversionStatus::CONVERSION_RUNNING, $this->identifier);
  306. // then push stuff into the routine:
  307. $manager->setConfiguration($configuration);
  308. $transactions = [];
  309. try {
  310. $transactions = $manager->start();
  311. } catch (ImporterErrorException $e) {
  312. Log::error(sprintf('[%s]: %s', config('importer.version'), $e->getMessage()));
  313. RoutineStatusManager::setConversionStatus(ConversionStatus::CONVERSION_ERRORED, $this->identifier);
  314. $this->conversionMessages = $manager->getAllMessages();
  315. $this->conversionWarnings = $manager->getAllWarnings();
  316. $this->conversionErrors = $manager->getAllErrors();
  317. $this->conversionRateLimits = $manager->getAllRateLimits();
  318. }
  319. if (0 === count($transactions)) {
  320. Log::error('[a] Zero transactions!');
  321. RoutineStatusManager::setConversionStatus(ConversionStatus::CONVERSION_DONE, $this->identifier);
  322. $this->conversionMessages = $manager->getAllMessages();
  323. $this->conversionWarnings = $manager->getAllWarnings();
  324. $this->conversionErrors = $manager->getAllErrors();
  325. $this->conversionRateLimits = $manager->getAllRateLimits();
  326. }
  327. // save transactions in 'jobs' directory under the same key as the conversion thing.
  328. $disk = \Storage::disk('jobs');
  329. try {
  330. $disk->put(sprintf('%s.json', $this->identifier), json_encode($transactions, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
  331. } catch (JsonException $e) {
  332. Log::error(sprintf('JSON exception: %s', $e->getMessage()));
  333. RoutineStatusManager::setConversionStatus(ConversionStatus::CONVERSION_ERRORED, $this->identifier);
  334. $this->conversionMessages = $manager->getAllMessages();
  335. $this->conversionWarnings = $manager->getAllWarnings();
  336. $this->conversionErrors = $manager->getAllErrors();
  337. $this->conversionRateLimits = $manager->getAllRateLimits();
  338. $transactions = [];
  339. }
  340. if (count($transactions) > 0) {
  341. // set done:
  342. RoutineStatusManager::setConversionStatus(ConversionStatus::CONVERSION_DONE, $this->identifier);
  343. $this->conversionMessages = $manager->getAllMessages();
  344. $this->conversionWarnings = $manager->getAllWarnings();
  345. $this->conversionErrors = $manager->getAllErrors();
  346. $this->conversionRateLimits = $manager->getAllRateLimits();
  347. }
  348. $this->importerAccounts = $manager->getServiceAccounts();
  349. }
  350. private function reportConversion(): void
  351. {
  352. $list = [
  353. [$this->conversionMessages, 'info'],
  354. [$this->conversionWarnings, 'warn'],
  355. [$this->conversionErrors, 'error'],
  356. [$this->conversionRateLimits, 'warn'],
  357. ];
  358. foreach ($list as $set) {
  359. /** @var string $func */
  360. $func = $set[1];
  361. /** @var array $all */
  362. $all = $set[0];
  363. /**
  364. * @var int $index
  365. * @var array $messages
  366. */
  367. foreach ($all as $index => $messages) {
  368. if (count($messages) > 0) {
  369. foreach ($messages as $message) {
  370. $this->{$func}(sprintf('Conversion index (%s) %d: %s', $func, $index, $message)); // @phpstan-ignore-line
  371. }
  372. }
  373. }
  374. }
  375. }
  376. private function startImport(Configuration $configuration): void
  377. {
  378. Log::debug(sprintf('Now at %s', __METHOD__));
  379. $routine = new RoutineManager($this->identifier);
  380. SubmissionStatusManager::startOrFindSubmission($this->identifier);
  381. $disk = \Storage::disk('jobs');
  382. $fileName = sprintf('%s.json', $this->identifier);
  383. // get files from disk:
  384. if (!$disk->has($fileName)) {
  385. SubmissionStatusManager::setSubmissionStatus(SubmissionStatus::SUBMISSION_ERRORED, $this->identifier);
  386. $message = sprintf('[a100]: File "%s" not found, cannot continue.', $fileName);
  387. $this->error($message);
  388. SubmissionStatusManager::addError($this->identifier, 0, $message);
  389. $this->importMessages = $routine->getAllMessages();
  390. $this->importWarnings = $routine->getAllWarnings();
  391. $this->importErrors = $routine->getAllErrors();
  392. return;
  393. }
  394. try {
  395. $json = $disk->get($fileName);
  396. $transactions = json_decode((string) $json, true, 512, JSON_THROW_ON_ERROR);
  397. Log::debug(sprintf('Found %d transactions on the drive.', count($transactions)));
  398. } catch (FileNotFoundException|JsonException $e) {
  399. SubmissionStatusManager::setSubmissionStatus(SubmissionStatus::SUBMISSION_ERRORED, $this->identifier);
  400. $message = sprintf('[a101]: File "%s" could not be decoded, cannot continue..', $fileName);
  401. $this->error($message);
  402. SubmissionStatusManager::addError($this->identifier, 0, $message);
  403. $this->importMessages = $routine->getAllMessages();
  404. $this->importWarnings = $routine->getAllWarnings();
  405. $this->importErrors = $routine->getAllErrors();
  406. return;
  407. }
  408. if (0 === count($transactions)) {
  409. SubmissionStatusManager::setSubmissionStatus(SubmissionStatus::SUBMISSION_DONE, $this->identifier);
  410. Log::error('No transactions in array, there is nothing to import.');
  411. $this->importMessages = $routine->getAllMessages();
  412. $this->importWarnings = $routine->getAllWarnings();
  413. $this->importErrors = $routine->getAllErrors();
  414. return;
  415. }
  416. $routine->setTransactions($transactions);
  417. SubmissionStatusManager::setSubmissionStatus(SubmissionStatus::SUBMISSION_RUNNING, $this->identifier);
  418. // then push stuff into the routine:
  419. $routine->setConfiguration($configuration);
  420. try {
  421. $routine->start();
  422. } catch (ImporterErrorException $e) {
  423. Log::error(sprintf('[%s]: %s', config('importer.version'), $e->getMessage()));
  424. SubmissionStatusManager::setSubmissionStatus(SubmissionStatus::SUBMISSION_ERRORED, $this->identifier);
  425. SubmissionStatusManager::addError($this->identifier, 0, $e->getMessage());
  426. $this->importMessages = $routine->getAllMessages();
  427. $this->importWarnings = $routine->getAllWarnings();
  428. $this->importErrors = $routine->getAllErrors();
  429. return;
  430. }
  431. // set done:
  432. SubmissionStatusManager::setSubmissionStatus(SubmissionStatus::SUBMISSION_DONE, $this->identifier);
  433. $this->importMessages = $routine->getAllMessages();
  434. $this->importWarnings = $routine->getAllWarnings();
  435. $this->importErrors = $routine->getAllErrors();
  436. }
  437. private function reportImport(): void
  438. {
  439. $list = [
  440. 'info' => $this->importMessages,
  441. 'warn' => $this->importWarnings,
  442. 'error' => $this->importErrors,
  443. ];
  444. $this->info(sprintf('There are %d message(s)', count($this->importMessages)));
  445. $this->info(sprintf('There are %d warning(s)', count($this->importWarnings)));
  446. $this->info(sprintf('There are %d error(s)', count($this->importErrors)));
  447. foreach ($list as $func => $set) {
  448. /**
  449. * @var int $index
  450. * @var array $messages
  451. */
  452. foreach ($set as $index => $messages) {
  453. if (count($messages) > 0) {
  454. foreach ($messages as $message) {
  455. $this->{$func}(sprintf('Import index %d: %s', $index, $message)); // @phpstan-ignore-line
  456. }
  457. }
  458. }
  459. }
  460. }
  461. private function reportBalanceDifferences(Configuration $configuration): void
  462. {
  463. if ('nordigen' !== $configuration->getFlow()) {
  464. return;
  465. }
  466. $count = count($this->importerAccounts);
  467. $localAccounts = $configuration->getAccounts();
  468. $url = SecretManager::getBaseUrl();
  469. $token = SecretManager::getAccessToken();
  470. Log::debug(sprintf('The importer has collected %d account(s) to report the balance difference on.', $count));
  471. /** @var Account $account */
  472. foreach ($this->importerAccounts as $account) {
  473. // check if account exists:
  474. if (!array_key_exists($account->getIdentifier(), $localAccounts)) {
  475. Log::debug(sprintf('GoCardless account "%s" (IBAN "%s") is not being imported, so skipped.', $account->getIdentifier(), $account->getIban()));
  476. continue;
  477. }
  478. // local account ID exists, we can check the balance over at Firefly III.
  479. $accountId = $localAccounts[$account->getIdentifier()];
  480. $accountRequest = new GetAccountRequest($url, $token);
  481. $accountRequest->setVerify(config('importer.connection.verify'));
  482. $accountRequest->setTimeOut(config('importer.connection.timeout'));
  483. $accountRequest->setId($accountId);
  484. try {
  485. /** @var GetAccountResponse $result */
  486. $result = $accountRequest->get();
  487. } catch (ApiHttpException $e) {
  488. Log::error('Could not get Firefly III account for balance check. Will ignore this issue.');
  489. Log::debug($e->getMessage());
  490. continue;
  491. }
  492. $localAccount = $result->getAccount();
  493. $this->reportBalanceDifference($account, $localAccount);
  494. }
  495. }
  496. private function reportBalanceDifference(Account $account, LocalAccount $localAccount): void
  497. {
  498. Log::debug(sprintf('Report balance difference between GoCardless account "%s" and Firefly III account #%d.', $account->getIdentifier(), $localAccount->id));
  499. Log::debug(sprintf('GoCardless account has %d balance entry (entries)', count($account->getBalances())));
  500. /** @var Balance $balance */
  501. foreach ($account->getBalances() as $index => $balance) {
  502. Log::debug(sprintf('Now comparing balance entry "%s" (#%d of %d)', $balance->type, $index + 1, count($account->getBalances())));
  503. $this->reportSingleDifference($account, $localAccount, $balance);
  504. }
  505. }
  506. private function reportSingleDifference(Account $account, LocalAccount $localAccount, Balance $balance): void
  507. {
  508. // compare currencies, and warn if necessary.
  509. if ($balance->currency !== $localAccount->currencyCode) {
  510. Log::warning(sprintf('GoCardless account "%s" has currency %s, Firefly III account #%d uses %s.', $account->getIdentifier(), $localAccount->id, $balance->currency, $localAccount->currencyCode));
  511. $this->line(sprintf('Balance comparison (%s): Firefly III account #%d: Currency mismatch', $balance->type, $localAccount->id));
  512. }
  513. // compare dates, warn
  514. $date = Carbon::parse($balance->date);
  515. $localDate = Carbon::parse($localAccount->currentBalanceDate);
  516. if (!$date->isSameDay($localDate)) {
  517. Log::warning(sprintf('GoCardless balance is from day %s, Firefly III account from %s.', $date->format('Y-m-d'), $date->format('Y-m-d')));
  518. $this->line(sprintf('Balance comparison (%s): Firefly III account #%d: Date mismatch', $balance->type, $localAccount->id));
  519. }
  520. // compare balance, warn (also a message)
  521. Log::debug(sprintf('Comparing %s and %s', $balance->amount, $localAccount->currentBalance));
  522. if (0 !== bccomp($balance->amount, (string) $localAccount->currentBalance)) {
  523. Log::warning(sprintf('GoCardless balance is %s, Firefly III balance is %s.', $balance->amount, $localAccount->currentBalance));
  524. $this->line(sprintf('Balance comparison (%s): Firefly III account #%d: GoCardless reports %s %s, Firefly III reports %s %d', $balance->type, $localAccount->id, $balance->currency, $balance->amount, $localAccount->currencyCode, $localAccount->currentBalance));
  525. }
  526. if (0 === bccomp($balance->amount, (string) $localAccount->currentBalance)) {
  527. $this->line(sprintf('Balance comparison (%s): Firefly III account #%d: Balance OK', $balance->type, $localAccount->id));
  528. }
  529. }
  530. /**
  531. * @throws ImporterErrorException
  532. */
  533. private function importUpload(string $jsonFile, string $importableFile): void
  534. {
  535. // do JSON check
  536. $jsonResult = $this->verifyJSON($jsonFile);
  537. if (false === $jsonResult) {
  538. $message = sprintf('The importer can\'t import %s: could not decode the JSON in config file %s.', $importableFile, $jsonFile);
  539. $this->error($message);
  540. return;
  541. }
  542. $configuration = Configuration::fromArray(json_decode((string) file_get_contents($jsonFile), true));
  543. $configuration->updateDateRange();
  544. $this->line(sprintf('Going to convert from file "%s" using configuration "%s" and flow "%s".', $importableFile, $jsonFile, $configuration->getFlow()));
  545. // this is it!
  546. $this->startConversion($configuration, $importableFile);
  547. $this->reportConversion();
  548. // crash here if the conversion failed.
  549. if (0 !== count($this->conversionErrors)) {
  550. $this->error(sprintf('[b] Too many errors in the data conversion (%d), exit.', count($this->conversionErrors)));
  551. throw new ImporterErrorException('Too many errors in the data conversion.');
  552. }
  553. $this->line(sprintf('Done converting from file %s using configuration %s.', $importableFile, $jsonFile));
  554. $this->startImport($configuration);
  555. $this->reportImport();
  556. $this->line('Done!');
  557. event(
  558. new ImportedTransactions(
  559. basename($jsonFile),
  560. array_merge($this->conversionMessages, $this->importMessages),
  561. array_merge($this->conversionWarnings, $this->importWarnings),
  562. array_merge($this->conversionErrors, $this->importErrors),
  563. $this->conversionRateLimits
  564. )
  565. );
  566. }
  567. protected function isNothingDownloaded(): bool
  568. {
  569. foreach ($this->conversionErrors as $errors) {
  570. if (array_any($errors, fn ($error) => str_contains($error, '[a111]'))) {
  571. return true;
  572. }
  573. }
  574. return false;
  575. }
  576. protected function isExpiredAgreement(): bool
  577. {
  578. foreach ($this->conversionErrors as $errors) {
  579. if (array_any($errors, fn ($error) => str_contains($error, 'EUA') && str_contains($error, 'expired'))) {
  580. return true;
  581. }
  582. }
  583. return false;
  584. }
  585. }