setTransactionIdentifier(); $this->migrateRepetitions(); $this->repairPiggyBanks(); $this->updateAccountCurrencies(); $this->updateJournalCurrencies(); $this->currencyInfoToTransactions(); $this->verifyCurrencyInfo(); $this->info('Firefly III database is up to date.'); } /** * Moves the currency id info to the transaction instead of the journal. */ private function currencyInfoToTransactions() { $count = 0; $set = TransactionJournal::with('transactions')->get(); /** @var TransactionJournal $journal */ foreach ($set as $journal) { /** @var Transaction $transaction */ foreach ($journal->transactions as $transaction) { if (is_null($transaction->transaction_currency_id)) { $transaction->transaction_currency_id = $journal->transaction_currency_id; $transaction->save(); $count++; } } // read and use the foreign amounts when present. if ($journal->hasMeta('foreign_amount')) { $amount = Steam::positive($journal->getMeta('foreign_amount')); // update both transactions: foreach ($journal->transactions as $transaction) { $transaction->foreign_amount = $amount; if (bccomp($transaction->amount, '0') === -1) { // update with negative amount: $transaction->foreign_amount = bcmul($amount, '-1'); } // set foreign currency id: $transaction->foreign_currency_id = intval($journal->getMeta('foreign_currency_id')); $transaction->save(); } $journal->deleteMeta('foreign_amount'); $journal->deleteMeta('foreign_currency_id'); } } $this->line(sprintf('Updated currency information for %d transactions', $count)); } /** * Migrate budget repetitions to new format. */ private function migrateRepetitions() { if (!Schema::hasTable('budget_limits')) { return; } // get all budget limits with end_date NULL $set = BudgetLimit::whereNull('end_date')->get(); if ($set->count() > 0) { $this->line(sprintf('Found %d budget limit(s) to update', $set->count())); } /** @var BudgetLimit $budgetLimit */ foreach ($set as $budgetLimit) { // get limit repetition (should be just one): /** @var LimitRepetition $repetition */ $repetition = $budgetLimit->limitrepetitions()->first(); if (!is_null($repetition)) { $budgetLimit->end_date = $repetition->enddate; $budgetLimit->save(); $this->line(sprintf('Updated budget limit #%d', $budgetLimit->id)); $repetition->delete(); } } } /** * Make sure there are only transfers linked to piggy bank events. */ private function repairPiggyBanks() { // if table does not exist, return false if (!Schema::hasTable('piggy_bank_events')) { return; } $set = PiggyBankEvent::with(['PiggyBank', 'TransactionJournal', 'TransactionJournal.TransactionType'])->get(); /** @var PiggyBankEvent $event */ foreach ($set as $event) { if (is_null($event->transaction_journal_id)) { continue; } /** @var TransactionJournal $journal */ $journal = $event->transactionJournal()->first(); if (is_null($journal)) { continue; } $type = $journal->transactionType->type; if ($type !== TransactionType::TRANSFER) { $event->transaction_journal_id = null; $event->save(); $this->line(sprintf('Piggy bank #%d was referenced by an invalid event. This has been fixed.', $event->piggy_bank_id)); } } } /** * This is strangely complex, because the HAVING modifier is a no-no. And subqueries in Laravel are weird. */ private function setTransactionIdentifier() { // if table does not exist, return false if (!Schema::hasTable('transaction_journals')) { return; } $subQuery = TransactionJournal::leftJoin('transactions', 'transactions.transaction_journal_id', '=', 'transaction_journals.id') ->whereNull('transaction_journals.deleted_at') ->whereNull('transactions.deleted_at') ->groupBy(['transaction_journals.id']) ->select(['transaction_journals.id', DB::raw('COUNT(transactions.id) AS t_count')]); $result = DB::table(DB::raw('(' . $subQuery->toSql() . ') AS derived')) ->mergeBindings($subQuery->getQuery()) ->where('t_count', '>', 2) ->select(['id', 't_count']); $journalIds = array_unique($result->pluck('id')->toArray()); foreach ($journalIds as $journalId) { $this->updateJournal(intval($journalId)); } } /** * */ private function updateAccountCurrencies() { $accounts = Account::leftJoin('account_types', 'account_types.id', '=', 'accounts.account_type_id') ->whereIn('account_types.type', [AccountType::DEFAULT, AccountType::ASSET])->get(['accounts.*']); /** @var Account $account */ foreach ($accounts as $account) { // get users preference, fall back to system pref. $defaultCurrencyCode = Preferences::getForUser($account->user, 'currencyPreference', config('firefly.default_currency', 'EUR'))->data; $defaultCurrency = TransactionCurrency::where('code', $defaultCurrencyCode)->first(); $accountCurrency = intval($account->getMeta('currency_id')); $openingBalance = $account->getOpeningBalance(); $openingBalanceCurrency = intval($openingBalance->transaction_currency_id); // both 0? set to default currency: if ($accountCurrency === 0 && $openingBalanceCurrency === 0) { AccountMeta::create(['account_id' => $account->id, 'name' => 'currency_id', 'data' => $defaultCurrency->id]); $this->line(sprintf('Account #%d ("%s") now has a currency setting (%s).', $account->id, $account->name, $defaultCurrencyCode)); continue; } // opening balance 0, account not zero? just continue: if ($accountCurrency > 0 && $openingBalanceCurrency === 0) { continue; } // account is set to 0, opening balance is not? if ($accountCurrency === 0 && $openingBalanceCurrency > 0) { AccountMeta::create(['account_id' => $account->id, 'name' => 'currency_id', 'data' => $openingBalanceCurrency]); $this->line(sprintf('Account #%d ("%s") now has a currency setting (%s).', $account->id, $account->name, $defaultCurrencyCode)); continue; } // both are equal, just continue: if ($accountCurrency === $openingBalanceCurrency) { continue; } // do not match: if ($accountCurrency !== $openingBalanceCurrency) { // update opening balance: $openingBalance->transaction_currency_id = $accountCurrency; $openingBalance->save(); $this->line(sprintf('Account #%d ("%s") now has a correct currency for opening balance.', $account->id, $account->name)); continue; } } } /** * grab all positive transactiosn from this journal that are not deleted. for each one, grab the negative opposing one * which has 0 as an identifier and give it the same identifier. * * @param int $journalId */ private function updateJournal(int $journalId) { $identifier = 0; $processed = []; $transactions = Transaction::where('transaction_journal_id', $journalId)->where('amount', '>', 0)->get(); /** @var Transaction $transaction */ foreach ($transactions as $transaction) { // find opposing: $amount = bcmul(strval($transaction->amount), '-1'); try { /** @var Transaction $opposing */ $opposing = Transaction::where('transaction_journal_id', $journalId) ->where('amount', $amount)->where('identifier', '=', 0) ->whereNotIn('id', $processed) ->first(); } catch (QueryException $e) { Log::error($e->getMessage()); $this->error('Firefly III could not find the "identifier" field in the "transactions" table.'); $this->error(sprintf('This field is required for Firefly III version %s to run.', config('firefly.version'))); $this->error('Please run "php artisan migrate" to add this field to the table.'); $this->info('Then, run "php artisan firefly:upgrade-database" to try again.'); return; } if (!is_null($opposing)) { // give both a new identifier: $transaction->identifier = $identifier; $transaction->save(); $opposing->identifier = $identifier; $opposing->save(); $processed[] = $transaction->id; $processed[] = $opposing->id; } $identifier++; } } /** * Makes sure that withdrawals, deposits and transfers have * a currency setting matching their respective accounts */ private function updateJournalCurrencies() { $types = [ TransactionType::WITHDRAWAL => '<', TransactionType::DEPOSIT => '>', ]; $repository = app(CurrencyRepositoryInterface::class); $notification = '%s #%d uses %s but should use %s. It has been updated. Please verify this in Firefly III.'; $transfer = 'Transfer #%d has been updated to use the correct currencies. Please verify this in Firefly III.'; $driver = DB::connection()->getDriverName(); $pgsql = ['pgsql', 'postgresql']; foreach ($types as $type => $operator) { $query = TransactionJournal ::leftJoin('transaction_types', 'transaction_types.id', '=', 'transaction_journals.transaction_type_id')->leftJoin( 'transactions', function (JoinClause $join) use ($operator) { $join->on('transaction_journals.id', '=', 'transactions.transaction_journal_id')->where('transactions.amount', $operator, '0'); } ) ->leftJoin('accounts', 'accounts.id', '=', 'transactions.account_id') ->leftJoin('account_meta', 'account_meta.account_id', '=', 'accounts.id') ->where('transaction_types.type', $type) ->where('account_meta.name', 'currency_id'); if (in_array($driver, $pgsql)) { $query->where('transaction_journals.transaction_currency_id', '!=', DB::raw('cast(account_meta.data as int)')); } if (!in_array($driver, $pgsql)) { $query->where('transaction_journals.transaction_currency_id', '!=', DB::raw('account_meta.data')); } $set = $query->get(['transaction_journals.*', 'account_meta.data as expected_currency_id', 'transactions.amount as transaction_amount']); /** @var TransactionJournal $journal */ foreach ($set as $journal) { $expectedCurrency = $repository->find(intval($journal->expected_currency_id)); $line = sprintf($notification, $type, $journal->id, $journal->transactionCurrency->code, $expectedCurrency->code); $journal->setMeta('foreign_amount', $journal->transaction_amount); $journal->setMeta('foreign_currency_id', $journal->transaction_currency_id); $journal->transaction_currency_id = $expectedCurrency->id; $journal->save(); $this->line($line); } } /* * For transfers it's slightly different. Both source and destination * must match the respective currency preference. So we must verify ALL * transactions. */ $set = TransactionJournal ::leftJoin('transaction_types', 'transaction_types.id', '=', 'transaction_journals.transaction_type_id') ->where('transaction_types.type', TransactionType::TRANSFER) ->get(['transaction_journals.*']); /** @var TransactionJournal $journal */ foreach ($set as $journal) { $updated = false; /** @var Transaction $sourceTransaction */ $sourceTransaction = $journal->transactions()->where('amount', '<', 0)->first(); $sourceCurrency = $repository->find(intval($sourceTransaction->account->getMeta('currency_id'))); if ($sourceCurrency->id !== $journal->transaction_currency_id) { $updated = true; $journal->transaction_currency_id = $sourceCurrency->id; $journal->save(); } // destination $destinationTransaction = $journal->transactions()->where('amount', '>', 0)->first(); $destinationCurrency = $repository->find(intval($destinationTransaction->account->getMeta('currency_id'))); if ($destinationCurrency->id !== $journal->transaction_currency_id) { $updated = true; $journal->deleteMeta('foreign_amount'); $journal->deleteMeta('foreign_currency_id'); $journal->setMeta('foreign_amount', $destinationTransaction->amount); $journal->setMeta('foreign_currency_id', $destinationCurrency->id); } if ($updated) { $line = sprintf($transfer, $journal->id); $this->line($line); } } } /** * */ private function verifyCurrencyInfo() { $count = 0; $transactions = Transaction::get(); /** @var Transaction $transaction */ foreach ($transactions as $transaction) { $currencyId = intval($transaction->transaction_currency_id); $foreignId = intval($transaction->foreign_currency_id); if ($currencyId === $foreignId) { $transaction->foreign_currency_id = null; $transaction->foreign_amount = null; $transaction->save(); $count++; } } $this->line(sprintf('Updated currency information for %d transactions', $count)); } }