Files
firefly-iii/app/Console/Commands/Correction/CorrectsAmounts.php
T

487 lines
20 KiB
PHP

<?php
/*
* CorrectAmounts.php
* Copyright (c) 2023 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* 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 FireflyIII\Console\Commands\Correction;
use FireflyIII\Console\Commands\ShowsFriendlyMessages;
use FireflyIII\Enums\TransactionTypeEnum;
use FireflyIII\Models\AutoBudget;
use FireflyIII\Models\AvailableBudget;
use FireflyIII\Models\Bill;
use FireflyIII\Models\BudgetLimit;
use FireflyIII\Models\CurrencyExchangeRate;
use FireflyIII\Models\PiggyBank;
use FireflyIII\Models\RecurrenceTransaction;
use FireflyIII\Models\RuleTrigger;
use FireflyIII\Models\Transaction;
use FireflyIII\Models\TransactionJournal;
use FireflyIII\Models\TransactionType;
use FireflyIII\Repositories\Account\AccountRepositoryInterface;
use FireflyIII\Services\Internal\Destroy\GenericDestroyService;
use FireflyIII\Services\Internal\Destroy\JournalDestroyService;
use FireflyIII\Support\Facades\Amount;
use FireflyIII\Support\Facades\Steam;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use ValueError;
class CorrectsAmounts extends Command
{
use ShowsFriendlyMessages;
protected $description = 'This command makes sure positive and negative amounts are recorded correctly.';
protected $signature = 'correction:amounts';
private JournalDestroyService $service;
private GenericDestroyService $genericService;
public function handle(): int
{
$this->service = new JournalDestroyService();
$this->genericService = new GenericDestroyService();
// transfers must not have foreign currency info if both accounts have the same currency.
$this->correctTransfers();
// deposits between assets and liabilities must not have foreign currency info if both accounts have the same currency.
// $this->correctDeposits();
// auto budgets must be positive
$this->fixAutoBudgets();
// available budgets must be positive
$this->fixAvailableBudgets();
// bills must be positive (both amounts)
$this->fixBills();
// budget limits must be positive
$this->fixBudgetLimits();
// currency_exchange_rates must be positive
$this->fixExchangeRates();
// piggy_banks must be positive
$this->fixPiggyBanks();
// recurrences_transactions amount must be positive
$this->fixRecurrences();
// rule_triggers must be positive or zero (amount_less, amount_more, amount_is)
$this->fixRuleTriggers();
return 0;
}
// private function correctDeposits(): void
// {
// Log::debug('Will now correct deposits.');
//
// /** @var AccountRepositoryInterface $repository */
// $repository = app(AccountRepositoryInterface::class);
// $type = TransactionType::query()->where('type', TransactionTypeEnum::DEPOSIT->value)->first();
// $journals = TransactionJournal::leftJoin('transactions', 'transactions.transaction_journal_id', '=', 'transaction_journals.id')
// ->whereNotNull('transactions.foreign_amount')
// ->where('transaction_journals.transaction_type_id', $type->id)
// ->distinct()
// ->get(['transaction_journals.*'])
// ;
//
// /** @var TransactionJournal $journal */
// foreach ($journals as $journal) {
// $repository->setUser($journal->user);
// $primary = Amount::getPrimaryCurrencyByUserGroup($journal->userGroup);
//
// $valid = $this->validateJournal($journal);
// if (false === $valid) {
// // Log::debug(sprintf('Journal #%d does not need to be fixed or is invalid (see previous messages)', $journal->id));
//
// continue;
// }
// Log::debug(sprintf('Journal #%d is ready to be corrected (if necessary).', $journal->id));
// $source = $journal->transactions()->where('amount', '<', '0')->first();
// $destination = $journal->transactions()->where('amount', '>', '0')->first();
// $sourceAccount = $source->account;
// $destAccount = $destination->account;
// $sourceCurrency = $repository->getAccountCurrency($sourceAccount) ?? $primary;
// $destCurrency = $repository->getAccountCurrency($destAccount) ?? $primary;
// Log::debug(sprintf('Currency of source account #%d "%s" is %s', $sourceAccount->id, $sourceAccount->name, $sourceCurrency->code));
// Log::debug(sprintf('Currency of destination account #%d "%s" is %s', $destAccount->id, $destAccount->name, $destCurrency->code));
// if ($sourceCurrency->id === $destCurrency->id) {
// Log::debug('Both accounts have the same currency. Removing foreign currency info.');
// $source->foreign_currency_id = null;
// $source->foreign_amount = null;
// $source->save();
// $destination->foreign_currency_id = null;
// $destination->foreign_amount = null;
// // also make sure that both transactions use the same amounts and currencies, since the currency is the same anyway.
// $destination->amount = bcmul($source->amount, '-1');
// $destination->save();
//
// continue;
// }
//
// // validate source transaction
// if ($destCurrency->id !== $source->foreign_currency_id) {
// Log::debug(sprintf(
// '[a] Journal #%d: transaction #%d refers to foreign currency "%s" but should refer to "%s".',
// $journal->id,
// $source->id,
// $source->foreignCurrency->code,
// $destCurrency->code
// ));
// $source->foreign_currency_id = $destCurrency->id;
// $source->save();
// }
// if ($sourceCurrency->id !== $source->transaction_currency_id) {
// Log::debug(sprintf(
// '[b] Journal #%d: transaction #%d refers to currency "%s" but should refer to "%s".',
// $journal->id,
// $source->id,
// $source->transactionCurrency->code,
// $sourceCurrency->code
// ));
// $source->transaction_currency_id = $sourceCurrency->id;
// $source->save();
// }
//
// // validate destination:
// if ($sourceCurrency->id !== $destination->foreign_currency_id) {
// Log::debug(sprintf(
// '[c] Journal #%d: transaction #%d refers to foreign currency "%s" but should refer to "%s".',
// $journal->id,
// $destination->id,
// $destination->foreignCurrency->code,
// $sourceCurrency->code
// ));
// $destination->foreign_currency_id = $sourceCurrency->id;
// $destination->save();
// }
//
// if ($destCurrency->id !== $destination->transaction_currency_id) {
// Log::debug(sprintf(
// '[d] Journal #%d: transaction #%d refers to currency "%s" but should refer to "%s".',
// $journal->id,
// $destination->id,
// $destination->transactionCurrency->code,
// $destCurrency->code
// ));
// $destination->transaction_currency_id = $destCurrency->id;
// $destination->save();
// }
// Log::debug(sprintf('Done with journal #%d.', $journal->id));
// }
// }
private function correctTransfers(): void
{
Log::debug('Will now correct transfers.');
/** @var AccountRepositoryInterface $repository */
$repository = app(AccountRepositoryInterface::class);
$type = TransactionType::query()->where('type', TransactionTypeEnum::TRANSFER->value)->first();
$journals = TransactionJournal::leftJoin('transactions', 'transactions.transaction_journal_id', '=', 'transaction_journals.id')
->whereNotNull('transactions.foreign_amount')
->where('transaction_journals.transaction_type_id', $type->id)
->distinct()
->get(['transaction_journals.*'])
;
/** @var TransactionJournal $journal */
foreach ($journals as $journal) {
$repository->setUser($journal->user);
$primary = Amount::getPrimaryCurrencyByUserGroup($journal->userGroup);
$valid = $this->validateJournal($journal);
if (false === $valid) {
// Log::debug(sprintf('Journal #%d does not need to be fixed or is invalid (see previous messages)', $journal->id));
continue;
}
Log::debug(sprintf('Journal #%d is ready to be corrected (if necessary).', $journal->id));
$source = $journal->transactions()->where('amount', '<', '0')->first();
$destination = $journal->transactions()->where('amount', '>', '0')->first();
$sourceAccount = $source->account;
$destAccount = $destination->account;
$sourceCurrency = $repository->getAccountCurrency($sourceAccount) ?? $primary;
$destCurrency = $repository->getAccountCurrency($destAccount) ?? $primary;
Log::debug(sprintf('Currency of source account #%d "%s" is %s', $sourceAccount->id, $sourceAccount->name, $sourceCurrency->code));
Log::debug(sprintf('Currency of destination account #%d "%s" is %s', $destAccount->id, $destAccount->name, $destCurrency->code));
if ($sourceCurrency->id === $destCurrency->id) {
Log::debug('Both accounts have the same currency. Removing foreign currency info.');
$source->foreign_currency_id = null;
$source->foreign_amount = null;
$source->save();
$destination->foreign_currency_id = null;
$destination->foreign_amount = null;
$destination->save();
continue;
}
// validate source transaction
if ($destCurrency->id !== $source->foreign_currency_id) {
Log::debug(sprintf(
'[a] Journal #%d: transaction #%d refers to foreign currency "%s" but should refer to "%s".',
$journal->id,
$source->id,
$source->foreignCurrency->code,
$destCurrency->code
));
$source->foreign_currency_id = $destCurrency->id;
$source->save();
}
if ($sourceCurrency->id !== $source->transaction_currency_id) {
Log::debug(sprintf(
'[b] Journal #%d: transaction #%d refers to currency "%s" but should refer to "%s".',
$journal->id,
$source->id,
$source->transactionCurrency->code,
$sourceCurrency->code
));
$source->transaction_currency_id = $sourceCurrency->id;
$source->save();
}
// validate destination:
if ($sourceCurrency->id !== $destination->foreign_currency_id) {
Log::debug(sprintf(
'[c] Journal #%d: transaction #%d refers to foreign currency "%s" but should refer to "%s".',
$journal->id,
$destination->id,
$destination->foreignCurrency->code,
$sourceCurrency->code
));
$destination->foreign_currency_id = $sourceCurrency->id;
$destination->save();
}
if ($destCurrency->id !== $destination->transaction_currency_id) {
Log::debug(sprintf(
'[d] Journal #%d: transaction #%d refers to currency "%s" but should refer to "%s".',
$journal->id,
$destination->id,
$destination->transactionCurrency->code,
$destCurrency->code
));
$destination->transaction_currency_id = $destCurrency->id;
$destination->save();
}
Log::debug(sprintf('Done with journal #%d.', $journal->id));
}
}
private function deleteJournal(TransactionJournal $journal): void
{
$this->service->destroy($journal);
}
private function fixAutoBudgets(): void
{
$count = AutoBudget::query()
->where('amount', '<', 0)
->update(['amount' => DB::raw('amount * -1')])
;
if (0 === $count) {
return;
}
$this->friendlyInfo(sprintf('Corrected %d auto budget amount(s).', $count));
}
private function fixAvailableBudgets(): void
{
$count = AvailableBudget::query()
->where('amount', '<', 0)
->update(['amount' => DB::raw('amount * -1')])
;
if (0 === $count) {
return;
}
$this->friendlyInfo(sprintf('Corrected %d available budget amount(s).', $count));
}
private function fixBills(): void
{
$count = 0;
$count += Bill::query()
->where('amount_max', '<', 0)
->update(['amount_max' => DB::raw('amount_max * -1')])
;
$count += Bill::query()
->where('amount_min', '<', 0)
->update(['amount_min' => DB::raw('amount_min * -1')])
;
if (0 === $count) {
return;
}
$this->friendlyInfo(sprintf('Corrected %d bill amount(s).', $count));
}
private function fixBudgetLimits(): void
{
$count = BudgetLimit::query()
->where('amount', '<', 0)
->update(['amount' => DB::raw('amount * -1')])
;
if (0 === $count) {
return;
}
$this->friendlyInfo(sprintf('Corrected %d budget limit amount(s).', $count));
}
private function fixExchangeRates(): void
{
$count = CurrencyExchangeRate::query()
->where('rate', '<', 0)
->update(['rate' => DB::raw('rate * -1')])
;
if (0 === $count) {
return;
}
$this->friendlyInfo(sprintf('Corrected %d currency exchange rate(s).', $count));
}
private function fixPiggyBanks(): void
{
$count = PiggyBank::query()
->where('target_amount', '<', 0)
->update(['target_amount' => DB::raw('target_amount * -1')])
;
if (0 === $count) {
return;
}
$this->friendlyInfo(sprintf('Corrected %d piggy bank amount(s).', $count));
}
private function fixRecurrences(): void
{
$count = 0;
$count += RecurrenceTransaction::query()
->where('amount', '<', 0)
->update(['amount' => DB::raw('amount * -1')])
;
$count += RecurrenceTransaction::query()
->where('foreign_amount', '<', 0)
->update(['foreign_amount' => DB::raw('foreign_amount * -1')])
;
if (0 === $count) {
return;
}
$this->friendlyInfo(sprintf('Corrected %d recurring transaction amount(s).', $count));
}
private function fixRuleTrigger(RuleTrigger $item): bool
{
try {
$check = bccomp((string) $item->trigger_value, '0');
} catch (ValueError) {
$this->friendlyError(sprintf(
'Rule #%d contained invalid %s-trigger "%s". The trigger has been removed, and the rule is disabled.',
$item->rule_id,
$item->trigger_type,
$item->trigger_value
));
$item->rule->active = false;
$item->rule->save();
$this->genericService->deleteRuleTrigger($item);
return false;
}
if (-1 === $check) {
$item->trigger_value = Steam::positive($item->trigger_value);
$item->save();
return true;
}
return false;
}
/**
* Foreach loop is unavoidable here.
*/
private function fixRuleTriggers(): void
{
$set = RuleTrigger::query()->whereIn('trigger_type', ['amount_less', 'amount_more', 'amount_is'])->get();
$fixed = 0;
/** @var RuleTrigger $item */
foreach ($set as $item) {
$result = $this->fixRuleTrigger($item);
if ($result) {
++$fixed;
}
}
if (0 === $fixed) {
return;
}
$this->friendlyInfo(sprintf('Corrected %d rule trigger amount(s).', $fixed));
}
private function validateJournal(TransactionJournal $journal): bool
{
$countSource = $journal->transactions()->where('amount', '<', 0)->count();
$countDest = $journal->transactions()->where('amount', '>', 0)->count();
if (1 !== $countSource || 1 !== $countDest) {
$this->friendlyError(sprintf('Transaction journal #%d has bad transaction information. Will delete.', $journal->id));
$this->deleteJournal($journal);
Log::error(sprintf('Transaction journal #%d has bad transaction information. Will delete.', $journal->id));
return false;
}
/** @var null|Transaction $source */
$source = $journal->transactions()->where('amount', '<', 0)->first();
/** @var null|Transaction $destination */
$destination = $journal->transactions()->where('amount', '>', 0)->first();
if (null === $source || null === $destination) {
$this->friendlyError(sprintf('Could not find source OR destination for journal #%d .', $journal->id));
Log::error(sprintf('Could not find source OR destination for journal #%d .', $journal->id));
$this->deleteJournal($journal);
return false;
}
if (null === $source->foreign_currency_id || null === $destination->foreign_currency_id) {
// Log::debug('No foreign currency information is present, can safely continue with other transactions.');
return false;
}
if (null === $source->foreign_amount || null === $destination->foreign_amount) {
$this->friendlyError(sprintf('Transactions of journal #%d have no foreign amount, but have foreign currency info. Will reset this.', $journal->id));
$source->foreign_currency_id = null;
$source->save();
$destination->foreign_currency_id = null;
$source->save();
return false;
}
$sourceAccount = $source->account;
$destAccount = $destination->account;
if (null === $sourceAccount || null === $destAccount) {
$this->friendlyError(sprintf('Could not find accounts for journal #%d,', $journal->id));
$this->deleteJournal($journal);
return false;
}
return true;
}
}