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.
 
 
 
 
 

688 lines
30 KiB

<?php
/*
* Accounts.php
* Copyright (c) 2021 james@firefly-iii.org
*
* This file is part of the Firefly III Data Importer
* (https://github.com/firefly-iii/data-importer).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\CSV\Conversion\Task;
use App\Exceptions\ImporterErrorException;
use App\Services\CSV\Conversion\Support\DeterminesTransactionType;
use App\Services\Shared\Authentication\SecretManager;
use GrumpyDictator\FFIIIApiSupport\Exceptions\ApiException;
use GrumpyDictator\FFIIIApiSupport\Exceptions\ApiHttpException as GrumpyApiHttpException;
use GrumpyDictator\FFIIIApiSupport\Model\Account;
use GrumpyDictator\FFIIIApiSupport\Model\AccountType;
use GrumpyDictator\FFIIIApiSupport\Request\GetSearchAccountRequest;
use GrumpyDictator\FFIIIApiSupport\Response\GetAccountsResponse;
use Illuminate\Support\Facades\Log;
/**
* Class Accounts
*/
class Accounts extends AbstractTask
{
use DeterminesTransactionType;
/**
* @throws ImporterErrorException
*/
public function process(array $group): array
{
Log::debug(sprintf('[%s] Now in %s', config('importer.version'), __METHOD__));
$total = count($group['transactions']);
foreach ($group['transactions'] as $index => $transaction) {
Log::debug(sprintf('Now processing transaction %d of %d', $index + 1, $total));
$group['transactions'][$index] = $this->processTransaction($transaction);
}
return $group;
}
/**
* @throws ImporterErrorException
*/
private function processTransaction(array $transaction): array
{
Log::debug(sprintf('[%s] Now in %s', config('importer.version'), __METHOD__));
/*
* Try to find the source and destination accounts in the transaction.
*
* The source account will default back to the user's submitted default account.
* So when everything fails, the transaction will be an expense for amount X.
*/
$sourceArray = $this->getSourceArray($transaction);
$destArray = $this->getDestinationArray($transaction);
$source = $this->findAccount($sourceArray, $this->account);
$destination = $this->findAccount($destArray, null);
// First, set source and destination in the transaction array:
$transaction = $this->setSource($transaction, $source);
$transaction = $this->setDestination($transaction, $destination);
$transaction['type'] = $this->determineType($source['type'], $destination['type']);
Log::debug(sprintf('Transaction type is set to "%s"', $transaction['type']));
Log::debug('Source is now:', $source);
Log::debug('Destination is now:', $destination);
$amount = (string) $transaction['amount'];
$amount = '' === $amount ? '0' : $amount;
if ('0' === $amount) {
Log::error('Amount is ZERO. This will give trouble further down the line.');
}
/*
* If the amount is positive, the transaction is a deposit. We switch Source
* and Destination and see if we can still handle the transaction, but only if the transaction
* isn't already a deposit (it has to be a withdrawal).
*
*/
if ('withdrawal' === $transaction['type'] && 1 === bccomp($amount, '0')) {
// amount is positive
Log::debug(sprintf('%s is positive and type is "%s", switch source/destination', $amount, $transaction['type']));
$transaction = $this->setSource($transaction, $destination);
$transaction = $this->setDestination($transaction, $source);
$transaction['type'] = $this->determineType($destination['type'], $source['type']);
Log::debug('Source is now:', $destination); // yes this is correct.
Log::debug('Destination is now:', $source); // yes this is correct.
// switch variables because processing further ahead will otherwise be messed up:
[$source, $destination] = [$destination, $source];
}
// If the amount is positive and the type is a transfer, switch accounts around.
if ('transfer' === $transaction['type'] && 1 === bccomp($amount, '0')) {
Log::debug('Transaction is a transfer, and amount is positive, will switch accounts.');
$transaction = $this->setSource($transaction, $destination);
$transaction = $this->setDestination($transaction, $source);
Log::debug('Source is now:', $destination); // yes this is correct!
Log::debug('Destination is now:', $source); // yes this is correct!
// also switch amount and foreign currency amount, if both are present.
// if this data is missing, Firefly III will break later either way.
if ($this->hasAllAmountInformation($transaction)) {
Log::debug('This transfer has all necessary (foreign) currency + amount information, so swap these too.');
$transaction = $this->swapCurrencyInformation($transaction);
}
}
// If deposit and amount is positive, do nothing.
if ('deposit' === $transaction['type'] && 1 === bccomp($amount, '0')) {
Log::debug('Transaction is a deposit, and amount is positive. Will not change account types.');
}
/*
* If deposit and amount is positive, but the source is not a revenue, fall back to
* some "original-field-name" values (if they exist) and hope for the best.
*/
if ('deposit' === $transaction['type'] && 1 === bccomp($amount, '0') && 'revenue' !== $source['type'] && '' !== (string) $source['type']) {
Log::warning(sprintf('Transaction is a deposit, and amount is positive, but source is not a revenue ("%s"). Will fall back to original field names.', $source['type']));
$newSource = [
'id' => null,
'name' => $transaction['original-opposing-name'] ?? '(no name)',
'iban' => $transaction['original-opposing-iban'] ?? null,
'number' => $transaction['original-opposing-number'] ?? null,
'bic' => null,
];
$transaction = $this->setSource($transaction, $newSource);
}
// If amount is negative and type is transfer, make sure accounts are "original".
if ('transfer' === $transaction['type'] && -1 === bccomp($amount, '0')) {
Log::debug('Transaction is a transfer, and amount is negative, must not change accounts.');
$transaction = $this->setSource($transaction, $source);
$transaction = $this->setDestination($transaction, $destination);
Log::debug('Source is now:', $source);
Log::debug('Destination is now:', $destination);
}
/*
* Final check. If the type is "withdrawal" but the destination account found is "revenue"
* we found the wrong one. Just submit the name and hope for the best.
*/
if ('revenue' === $destination['type'] && 'withdrawal' === $transaction['type']) {
Log::warning('The found destination account is of type revenue but this is a withdrawal. Out of cheese error.');
Log::debug(
sprintf('Data importer will submit name "%s" and IBAN "%s" and let Firefly III sort it out.', $destination['name'], $destination['iban'])
);
$transaction['destination_id'] = null;
$transaction['destination_name'] = $destination['name'];
$transaction['destination_iban'] = $destination['iban'];
}
/*
* Same but for the other way around.
* If type is "deposit" but the source account is an expense account.
* Submit just the name.
*/
if ('expense' === $source['type'] && 'deposit' === $transaction['type']) {
Log::warning('The found source account is of type expense but this is a deposit. Out of cheese error.');
Log::debug(sprintf('Data importer will submit name "%s" and IBAN "%s" and let Firefly III sort it out.', $source['name'], $source['iban']));
$transaction['source_id'] = null;
$transaction['source_name'] = $source['name'];
$transaction['source_iban'] = $source['iban'];
}
// if new source or destination ID is filled in, drop the other fields:
if (0 !== $transaction['source_id'] && null !== $transaction['source_id']) {
$transaction['source_name'] = null;
$transaction['source_iban'] = null;
$transaction['source_number'] = null;
}
if (0 !== $transaction['destination_id'] && null !== $transaction['destination_id']) {
$transaction['destination_name'] = null;
$transaction['destination_iban'] = null;
$transaction['destination_number'] = null;
}
if ($this->hasAllCurrencies($transaction)) {
Log::debug('Final validation of foreign amount and or normal transaction amount');
// withdrawal
if ('withdrawal' === $transaction['type']) {
// currency info must match $source
// so if we can switch them around we will.
if ($transaction['currency_code'] !== $source['currency_code']
&& $transaction['foreign_currency_code'] === $source['currency_code']) {
Log::debug('Source account accepts %s, so foreign / native numbers are switched now.');
$amount = $transaction['amount'] ?? '0';
$currency = $transaction['currency_code'] ?? '';
$transaction['amount'] = $transaction['foreign_amount'] ?? '0';
$transaction['currency_code'] = $transaction['foreign_currency_code'] ?? '';
$transaction['foreign_amount'] = $amount;
$transaction['foreign_currency_code'] = $currency;
}
}
// deposit
if ('deposit' === $transaction['type']) {
// currency info must match $destination,
// so if we can switch them around we will.
if ($transaction['currency_code'] !== $destination['currency_code']
&& $transaction['foreign_currency_code'] === $destination['currency_code']) {
Log::debug('Destination account accepts %s, so foreign / native numbers are switched now.');
$amount = $transaction['amount'] ?? '0';
$currency = $transaction['currency_code'] ?? '';
$transaction['amount'] = $transaction['foreign_amount'] ?? '0';
$transaction['currency_code'] = $transaction['foreign_currency_code'] ?? '';
$transaction['foreign_amount'] = $amount;
$transaction['foreign_currency_code'] = $currency;
}
}
Log::debug('Final validation of foreign amount and or normal transaction amount finished.');
}
return $transaction;
}
private function getSourceArray(array $transaction): array
{
return [
'transaction_type' => $transaction['type'],
'id' => $transaction['source_id'],
'name' => $transaction['source_name'],
'iban' => $transaction['source_iban'] ?? null,
'number' => $transaction['source_number'] ?? null,
'bic' => $transaction['source_bic'] ?? null,
'direction' => 'source',
];
}
private function getDestinationArray(array $transaction): array
{
return [
'transaction_type' => $transaction['type'],
'id' => $transaction['destination_id'],
'name' => $transaction['destination_name'],
'iban' => $transaction['destination_iban'] ?? null,
'number' => $transaction['destination_number'] ?? null,
'bic' => $transaction['destination_bic'] ?? null,
'direction' => 'destination',
];
}
/**
* @throws ImporterErrorException
*/
private function findAccount(array $array, ?Account $defaultAccount): array
{
Log::debug('Now in findAccount', $array);
if (!$defaultAccount instanceof Account) {
Log::debug('findAccount() default account is NULL.');
}
if ($defaultAccount instanceof Account) {
Log::debug(sprintf('Default account is #%d ("%s")', $defaultAccount->id, $defaultAccount->name));
}
$result = null;
if (array_key_exists('id', $array) && null === $array['id']) {
Log::debug('ID field is NULL, will not search for it.');
}
// if the ID is set, at least search for the ID.
if (array_key_exists('id', $array) && is_int($array['id']) && $array['id'] > 0) {
Log::debug('Will search by ID field.');
$result = $this->findById((string) $array['id']);
}
if ($result instanceof Account) {
$return = $result->toArray();
Log::debug('Result of findById is not null, returning:', $return);
return $return;
}
// if the IBAN is set, search for the IBAN.
if (array_key_exists('iban', $array) && '' !== (string) $array['iban']) {
Log::debug('Will search by IBAN.');
$transactionType = (string) ($array['transaction_type'] ?? null);
$result = $this->findByIban((string) $array['iban'], $transactionType);
}
if ($result instanceof Account) {
$return = $result->toArray();
Log::debug('Result of findByIBAN is not null, returning:', $return);
return $return;
}
if (array_key_exists('iban', $array) && null === $array['iban']) {
Log::debug('IBAN field is NULL, will not search for it.');
}
// If the IBAN search result is NULL, but the IBAN itself is not null,
// data importer will return an array with the IBAN (and optionally the name).
// if the account number is set, search for the account number.
if (array_key_exists('number', $array) && '' !== (string) $array['number']) {
Log::debug('Search by account number.');
$transactionType = (string) ($array['transaction_type'] ?? null);
$result = $this->findByNumber((string) $array['number'], $transactionType);
}
if ($result instanceof Account) {
$return = $result->toArray();
Log::debug('Result of findByNumber is not null, returning:', $return);
return $return;
}
if (array_key_exists('number', $array) && null === $array['number']) {
Log::debug('Number field is NULL, will not search for it.');
}
// find by name, return only if it's an asset or liability account.
if (array_key_exists('name', $array) && '' !== (string) $array['name']) {
Log::debug('Search by name.');
$result = $this->findByName((string) $array['name']);
}
if ($result instanceof Account) {
$return = $result->toArray();
Log::debug('Result of findByName is not null, returning:', $return);
return $return;
}
if (array_key_exists('name', $array) && null === $array['name']) {
Log::debug('Name field is NULL, will not search for it.');
}
Log::debug('Found no account or haven\'t searched for one because of missing data.');
// append an empty type to the array for consistency's sake.
$array['type'] ??= null;
$array['bic'] ??= null;
// Return ID or name if not null
if (null !== $array['id'] || '' !== (string) $array['name']) {
Log::debug('At least the array with account-info has some name info, return that.', $array);
return $array;
}
// Return ID or IBAN if not null
if ('' !== (string) $array['iban']) {
Log::debug('At least the with account-info has some IBAN info, return that.', $array);
return $array;
}
// Return ID or number if not null
if ('' !== (string) $array['number']) {
Log::debug('At least the array with account-info has some account number info, return that.', $array);
return $array;
}
// if the default account is not NULL, return that one instead:
if ($defaultAccount instanceof Account) {
$default = $defaultAccount->toArray();
Log::debug('At least the default account is not null, so will return that:', $default);
return $default;
}
Log::debug('The default account is NULL, so will return what we started with: ', $array);
return $array;
}
/**
* @throws ImporterErrorException
*/
private function findById(string $value): ?Account
{
Log::debug(sprintf('Going to search account with ID "%s"', $value));
$url = SecretManager::getBaseUrl();
$token = SecretManager::getAccessToken();
$request = new GetSearchAccountRequest($url, $token);
$request->setVerify(config('importer.connection.verify'));
$request->setTimeOut(config('importer.connection.timeout'));
$request->setField('id');
$request->setQuery($value);
try {
/** @var GetAccountsResponse $response */
$response = $request->get();
} catch (GrumpyApiHttpException $e) {
throw new ImporterErrorException($e->getMessage());
}
if (1 === count($response)) {
try {
/** @var Account $account */
$account = $response->current();
} catch (ApiException $e) {
throw new ImporterErrorException($e->getMessage());
}
Log::debug(sprintf('[a] Found %s account #%d based on ID "%s"', $account->type, $account->id, $value));
return $account;
}
Log::debug('Found NOTHING in findById.');
return null;
}
/**
* @throws ImporterErrorException
*/
private function findByIban(string $iban, string $transactionType): ?Account
{
Log::debug(sprintf('Going to search account with IBAN "%s"', $iban));
$url = SecretManager::getBaseUrl();
$token = SecretManager::getAccessToken();
$request = new GetSearchAccountRequest($url, $token);
$request->setVerify(config('importer.connection.verify'));
$request->setTimeOut(config('importer.connection.timeout'));
$request->setField('iban');
$request->setQuery($iban);
try {
/** @var GetAccountsResponse $response */
$response = $request->get();
} catch (GrumpyApiHttpException $e) {
throw new ImporterErrorException($e->getMessage());
}
if (0 === count($response)) {
Log::debug('Found NOTHING in findbyiban.');
return null;
}
if (1 === count($response)) {
try {
/** @var Account $account */
$account = $response->current();
} catch (ApiException $e) {
throw new ImporterErrorException($e->getMessage());
}
// catch impossible combination "expense" with "deposit"
if ('expense' === $account->type && 'deposit' === $transactionType) {
Log::debug(
sprintf(
'Out of cheese error (IBAN). Found Found %s account #%d based on IBAN "%s". But not going to use expense/deposit combi.',
$account->type,
$account->id,
$iban
)
);
Log::debug('Firefly III will have to make the correct decision.');
return null;
}
Log::debug(sprintf('[a] Found %s account #%d based on IBAN "%s"', $account->type, $account->id, $iban));
// to fix issue #4293, Firefly III will ignore this account if it's an expense or a revenue account.
if (in_array($account->type, ['expense', 'revenue'], true)) {
Log::debug('[a] Data importer will pretend not to have found anything. Firefly III must handle the IBAN.');
return null;
}
return $account;
}
if (2 === count($response)) {
Log::debug('Found 2 results, Firefly III will have to make the correct decision.');
return null;
}
Log::debug(sprintf('Found %d result(s), Firefly III will have to make the correct decision.', count($response)));
return null;
}
/**
* @throws ImporterErrorException
*/
private function findByNumber(string $accountNumber, string $transactionType): ?Account
{
Log::debug(sprintf('Going to search account with account number "%s"', $accountNumber));
$url = SecretManager::getBaseUrl();
$token = SecretManager::getAccessToken();
$request = new GetSearchAccountRequest($url, $token);
$request->setVerify(config('importer.connection.verify'));
$request->setTimeOut(config('importer.connection.timeout'));
$request->setField('number');
$request->setQuery($accountNumber);
try {
/** @var GetAccountsResponse $response */
$response = $request->get();
} catch (GrumpyApiHttpException $e) {
throw new ImporterErrorException($e->getMessage());
}
if (0 === count($response)) {
Log::debug('Found NOTHING in findbynumber.');
return null;
}
if (1 === count($response)) {
try {
/** @var Account $account */
$account = $response->current();
} catch (ApiException $e) {
throw new ImporterErrorException($e->getMessage());
}
// catch impossible combination "expense" with "deposit"
if ('expense' === $account->type && 'deposit' === $transactionType) {
Log::debug(
sprintf(
'Out of cheese error (account number). Found Found %s account #%d based on account number "%s". But not going to use expense/deposit combi.',
$account->type,
$account->id,
$accountNumber
)
);
Log::debug('Firefly III will have to make the correct decision.');
return null;
}
Log::debug(sprintf('[a] Found %s account #%d based on account number "%s"', $account->type, $account->id, $accountNumber));
// to fix issue #4293, Firefly III will ignore this account if it's an expense or a revenue account.
if (in_array($account->type, ['expense', 'revenue'], true)) {
Log::debug('[a] Data importer will pretend not to have found anything. Firefly III must handle the account number.');
return null;
}
return $account;
}
if (2 === count($response)) {
Log::debug('Found 2 results, Firefly III will have to make the correct decision.');
return null;
}
Log::debug(sprintf('Found %d result(s), Firefly III will have to make the correct decision.', count($response)));
return null;
}
/**
* @throws ImporterErrorException
*/
private function findByName(string $name): ?Account
{
Log::debug(sprintf('Going to search account with name "%s"', $name));
$url = SecretManager::getBaseUrl();
$token = SecretManager::getAccessToken();
$request = new GetSearchAccountRequest($url, $token);
$request->setVerify(config('importer.connection.verify'));
$request->setTimeOut(config('importer.connection.timeout'));
$request->setField('name');
$request->setQuery($name);
try {
/** @var GetAccountsResponse $response */
$response = $request->get();
} catch (GrumpyApiHttpException $e) {
throw new ImporterErrorException($e->getMessage());
}
if (0 === count($response)) {
Log::debug('Found NOTHING in findbyname.');
return null;
}
/** @var Account $account */
foreach ($response as $account) {
if (in_array($account->type, [AccountType::ASSET, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE], true)
&& strtolower((string) $account->name) === strtolower($name)) {
Log::debug(sprintf('[b] Found "%s" account #%d based on name "%s"', $account->type, $account->id, $name));
return $account;
}
}
Log::debug(
sprintf('Found %d account(s) searching for "%s" but not going to use them. Firefly III must handle the values.', count($response), $name)
);
return null;
}
private function setSource(array $transaction, array $source): array
{
return $this->setTransactionAccount('source', $transaction, $source);
}
private function setTransactionAccount(string $direction, array $transaction, array $account): array
{
$transaction[sprintf('%s_id', $direction)] = $account['id'];
$transaction[sprintf('%s_name', $direction)] = $account['name'];
$transaction[sprintf('%s_iban', $direction)] = $account['iban'];
$transaction[sprintf('%s_number', $direction)] = $account['number'];
$transaction[sprintf('%s_bic', $direction)] = $account['bic'];
return $transaction;
}
private function setDestination(array $transaction, array $source): array
{
return $this->setTransactionAccount('destination', $transaction, $source);
}
/**
* Basic check for currency info.
*/
private function hasAllAmountInformation(array $transaction): bool
{
return
array_key_exists('amount', $transaction)
&& array_key_exists('foreign_amount', $transaction)
&& (array_key_exists('foreign_currency_code', $transaction) || array_key_exists('foreign_currency_id', $transaction))
&& (array_key_exists('currency_code', $transaction) || array_key_exists('currency_id', $transaction));
}
private function swapCurrencyInformation(array $transaction): array
{
// swap amount and foreign amount:
$amount = $transaction['amount'];
$transaction['amount'] = $transaction['foreign_amount'];
$transaction['foreign_amount'] = $amount;
Log::debug(sprintf('Amount is now %s', $transaction['amount']));
Log::debug(sprintf('Foreign is now %s', $transaction['foreign_amount']));
// swap currency ID and foreign currency ID, if both exist:
if (array_key_exists('currency_id', $transaction) && array_key_exists('foreign_currency_id', $transaction)) {
$currencyId = $transaction['currency_id'];
$transaction['currency_id'] = $transaction['foreign_currency_id'];
$transaction['foreign_currency_id'] = $currencyId;
Log::debug(sprintf('Currency ID is now %d', $transaction['currency_id']));
Log::debug(sprintf('Foreign currency ID is now %d', $transaction['foreign_currency_id']));
}
// swap currency code and foreign currency code, if both exist:
if (array_key_exists('currency_code', $transaction) && array_key_exists('foreign_currency_code', $transaction)) {
$currencyCode = $transaction['currency_code'];
$transaction['currency_code'] = $transaction['foreign_currency_code'];
$transaction['foreign_currency_code'] = $currencyCode;
Log::debug(sprintf('Currency code is now %s', $transaction['currency_code']));
Log::debug(sprintf('Foreign currency code is now %s', $transaction['foreign_currency_code']));
}
return $transaction;
}
private function hasAllCurrencies(array $transaction): bool
{
$transaction['foreign_currency_code'] ??= '';
$transaction['currency_code'] ??= '';
$transaction['amount'] ??= '';
$transaction['foreign_amount'] ??= '';
return '' !== (string) $transaction['currency_code'] && '' !== (string) $transaction['foreign_currency_code']
&& '' !== (string) $transaction['amount']
&& '' !== (string) $transaction['foreign_amount'];
}
/**
* Returns true if the task requires the default account.
*/
public function requiresDefaultAccount(): bool
{
return true;
}
/**
* Returns true if the task requires the primary currency of the user.
*/
public function requiresTransactionCurrency(): bool
{
return false;
}
}