GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.
Passed
Push — master ( 53c71b...87c8b3 )
by James
15:15 queued 05:54
created

app/Console/Commands/UpgradeDatabase.php (1 issue)

1
<?php
2
3
/**
4
 * UpgradeDatabase.php
5
 * Copyright (c) 2018 [email protected]
6
 *
7
 * This file is part of Firefly III.
8
 *
9
 * Firefly III is free software: you can redistribute it and/or modify
10
 * it under the terms of the GNU General Public License as published by
11
 * the Free Software Foundation, either version 3 of the License, or
12
 * (at your option) any later version.
13
 *
14
 * Firefly III 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 General Public License for more details.
18
 *
19
 * You should have received a copy of the GNU General Public License
20
 * along with Firefly III. If not, see <http://www.gnu.org/licenses/>.
21
 */
22
23
/** @noinspection MultipleReturnStatementsInspection */
24
/** @noinspection PhpStaticAsDynamicMethodCallInspection */
25
/** @noinspection PhpDynamicAsStaticMethodCallInspection */
26
27
declare(strict_types=1);
28
29
namespace FireflyIII\Console\Commands;
30
31
use DB;
32
use Exception;
33
use FireflyIII\Models\Account;
34
use FireflyIII\Models\AccountMeta;
35
use FireflyIII\Models\AccountType;
36
use FireflyIII\Models\Attachment;
37
use FireflyIII\Models\Bill;
38
use FireflyIII\Models\BudgetLimit;
39
use FireflyIII\Models\Note;
40
use FireflyIII\Models\Preference;
41
use FireflyIII\Models\Rule;
42
use FireflyIII\Models\RuleAction;
43
use FireflyIII\Models\RuleGroup;
44
use FireflyIII\Models\RuleTrigger;
45
use FireflyIII\Models\Transaction;
46
use FireflyIII\Models\TransactionCurrency;
47
use FireflyIII\Models\TransactionJournal;
48
use FireflyIII\Models\TransactionJournalMeta;
49
use FireflyIII\Models\TransactionType;
50
use FireflyIII\Repositories\Account\AccountRepositoryInterface;
51
use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface;
52
use FireflyIII\Repositories\Journal\JournalRepositoryInterface;
53
use FireflyIII\User;
54
use Illuminate\Console\Command;
55
use Illuminate\Database\QueryException;
56
use Illuminate\Support\Collection;
57
use Log;
58
use Schema;
59
use UnexpectedValueException;
60
61
/**
62
 * Class UpgradeDatabase.
63
 *
64
 * Upgrade user database.
65
 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
66
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
67
 *
68
 * @codeCoverageIgnore
69
 */
70
class UpgradeDatabase extends Command
71
{
72
    /**
73
     * The console command description.
74
     *
75
     * @var string
76
     */
77
    protected $description = 'Will run various commands to update database records.';
78
    /**
79
     * The name and signature of the console command.
80
     *
81
     * @var string
82
     */
83
    protected $signature = 'firefly:upgrade-database';
84
85
    /**
86
     * Execute the console command.
87
     */
88
    public function handle(): int
89
    {
90
        $this->setTransactionIdentifier();
91
        $this->updateAccountCurrencies();
92
        $this->createNewTypes();
93
        $this->line('Updating currency information..');
94
        $this->updateTransferCurrencies();
95
        $this->updateOtherCurrencies();
96
        $this->line('Done updating currency information..');
97
        $this->migrateNotes();
98
        $this->migrateAttachmentData();
99
        $this->migrateBillsToRules();
100
        $this->budgetLimitCurrency();
101
        $this->removeCCLiabilities();
102
103
        $this->info('Firefly III database is up to date.');
104
105
        return 0;
106
    }
107
108
    /**
109
     * Since it is one routine these warnings make sense and should be supressed.
110
     *
111
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
112
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
113
     * @SuppressWarnings(PHPMD.NPathComplexity)
114
     */
115
    public function migrateBillsToRules(): void
116
    {
117
        foreach (User::get() as $user) {
118
            /** @var Preference $lang */
119
            $lang               = app('preferences')->getForUser($user, 'language', 'en_US');
120
            $groupName          = (string)trans('firefly.rulegroup_for_bills_title', [], $lang->data);
121
            $ruleGroup          = $user->ruleGroups()->where('title', $groupName)->first();
122
            $currencyPreference = app('preferences')->getForUser($user, 'currencyPreference', config('firefly.default_currency', 'EUR'));
123
124
            if (null === $currencyPreference) {
125
                $this->error('User has no currency preference. Impossible.');
126
127
                return;
128
            }
129
130
            $currency = TransactionCurrency::where('code', $currencyPreference->data)->first();
131
            if (null === $currency) {
132
                $this->line('Fall back to default currency in migrateBillsToRules().');
133
                $currency = app('amount')->getDefaultCurrency();
134
            }
135
136
            if (null === $ruleGroup) {
137
                $array     = RuleGroup::get(['order'])->pluck('order')->toArray();
138
                $order     = \count($array) > 0 ? max($array) + 1 : 1;
139
                $ruleGroup = RuleGroup::create(
140
                    [
141
                        'user_id'     => $user->id,
142
                        'title'       => (string)trans('firefly.rulegroup_for_bills_title', [], $lang->data),
143
                        'description' => (string)trans('firefly.rulegroup_for_bills_description', [], $lang->data),
144
                        'order'       => $order,
145
                        'active'      => 1,
146
                    ]
147
                );
148
            }
149
150
            // loop bills.
151
            $order = 1;
152
            /** @var Collection $collection */
153
            $collection = $user->bills()->get();
154
            /** @var Bill $bill */
155
            foreach ($collection as $bill) {
156
                if ('MIGRATED_TO_RULES' !== $bill->match) {
157
                    $rule = Rule::create(
158
                        [
159
                            'user_id'         => $user->id,
160
                            'rule_group_id'   => $ruleGroup->id,
161
                            'title'           => (string)trans('firefly.rule_for_bill_title', ['name' => $bill->name], $lang->data),
162
                            'description'     => (string)trans('firefly.rule_for_bill_description', ['name' => $bill->name], $lang->data),
163
                            'order'           => $order,
164
                            'active'          => $bill->active,
165
                            'stop_processing' => 1,
166
                        ]
167
                    );
168
                    // add default trigger
169
                    RuleTrigger::create(
170
                        [
171
                            'rule_id'         => $rule->id,
172
                            'trigger_type'    => 'user_action',
173
                            'trigger_value'   => 'store-journal',
174
                            'active'          => 1,
175
                            'stop_processing' => 0,
176
                            'order'           => 1,
177
                        ]
178
                    );
179
                    // add trigger for description
180
                    $match = implode(' ', explode(',', $bill->match));
181
                    RuleTrigger::create(
182
                        [
183
                            'rule_id'         => $rule->id,
184
                            'trigger_type'    => 'description_contains',
185
                            'trigger_value'   => $match,
186
                            'active'          => 1,
187
                            'stop_processing' => 0,
188
                            'order'           => 2,
189
                        ]
190
                    );
191
                    if ($bill->amount_max !== $bill->amount_min) {
192
                        // add triggers for amounts:
193
                        RuleTrigger::create(
194
                            [
195
                                'rule_id'         => $rule->id,
196
                                'trigger_type'    => 'amount_less',
197
                                'trigger_value'   => round($bill->amount_max, $currency->decimal_places),
198
                                'active'          => 1,
199
                                'stop_processing' => 0,
200
                                'order'           => 3,
201
                            ]
202
                        );
203
                        RuleTrigger::create(
204
                            [
205
                                'rule_id'         => $rule->id,
206
                                'trigger_type'    => 'amount_more',
207
                                'trigger_value'   => round((float)$bill->amount_min, $currency->decimal_places),
208
                                'active'          => 1,
209
                                'stop_processing' => 0,
210
                                'order'           => 4,
211
                            ]
212
                        );
213
                    }
214
                    if ($bill->amount_max === $bill->amount_min) {
215
                        RuleTrigger::create(
216
                            [
217
                                'rule_id'         => $rule->id,
218
                                'trigger_type'    => 'amount_exactly',
219
                                'trigger_value'   => round((float)$bill->amount_min, $currency->decimal_places),
220
                                'active'          => 1,
221
                                'stop_processing' => 0,
222
                                'order'           => 3,
223
                            ]
224
                        );
225
                    }
226
227
                    // create action
228
                    RuleAction::create(
229
                        [
230
                            'rule_id'         => $rule->id,
231
                            'action_type'     => 'link_to_bill',
232
                            'action_value'    => $bill->name,
233
                            'order'           => 1,
234
                            'active'          => 1,
235
                            'stop_processing' => 0,
236
                        ]
237
                    );
238
239
                    $order++;
240
                    $bill->match = 'MIGRATED_TO_RULES';
241
                    $bill->save();
242
                    $this->line(sprintf('Updated bill #%d ("%s") so it will use rules.', $bill->id, $bill->name));
243
                }
244
245
                // give bills a currency when they dont have one.
246
                if (null === $bill->transaction_currency_id) {
247
                    $this->line(sprintf('Gave bill #%d ("%s") a currency (%s).', $bill->id, $bill->name, $currency->name));
248
                    $bill->transactionCurrency()->associate($currency);
249
                    $bill->save();
250
                }
251
            }
252
        }
253
    }
254
255
    /**
256
     * This method gives all transactions which are part of a split journal (so more than 2) a sort of "order" so they are easier
257
     * to easier to match to their counterpart. When a journal is split, it has two or three transactions: -3, -4 and -5 for example.
258
     *
259
     * In the database this is reflected as 6 transactions: -3/+3, -4/+4, -5/+5.
260
     *
261
     * When either of these are the same amount, FF3 can't keep them apart: +3/-3, +3/-3, +3/-3. This happens more often than you would
262
     * think. So each set gets a number (1,2,3) to keep them apart.
263
     */
264
    public function setTransactionIdentifier(): void
265
    {
266
        // if table does not exist, return false
267
        if (!Schema::hasTable('transaction_journals')) {
268
            return;
269
        }
270
        $subQuery   = TransactionJournal::leftJoin('transactions', 'transactions.transaction_journal_id', '=', 'transaction_journals.id')
271
                                        ->whereNull('transaction_journals.deleted_at')
272
                                        ->whereNull('transactions.deleted_at')
273
                                        ->groupBy(['transaction_journals.id'])
274
                                        ->select(['transaction_journals.id', DB::raw('COUNT(transactions.id) AS t_count')]);
275
        $result     = DB::table(DB::raw('(' . $subQuery->toSql() . ') AS derived'))
276
                        ->mergeBindings($subQuery->getQuery())
277
                        ->where('t_count', '>', 2)
278
                        ->select(['id', 't_count']);
279
        $journalIds = array_unique($result->pluck('id')->toArray());
280
281
        foreach ($journalIds as $journalId) {
282
            $this->updateJournalidentifiers((int)$journalId);
283
        }
284
285
    }
286
287
    /**
288
     * Each (asset) account must have a reference to a preferred currency. If the account does not have one, it's forced upon the account.
289
     *
290
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
291
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
292
     */
293
    public function updateAccountCurrencies(): void
294
    {
295
        $accounts = Account::leftJoin('account_types', 'account_types.id', '=', 'accounts.account_type_id')
296
                           ->whereIn('account_types.type', [AccountType::DEFAULT, AccountType::ASSET])->get(['accounts.*']);
297
        /** @var AccountRepositoryInterface $repository */
298
        $repository = app(AccountRepositoryInterface::class);
299
        $accounts->each(
300
            function (Account $account) use ($repository) {
301
                $repository->setUser($account->user);
302
                // get users preference, fall back to system pref.
303
                $defaultCurrencyCode = app('preferences')->getForUser($account->user, 'currencyPreference', config('firefly.default_currency', 'EUR'))->data;
304
                $defaultCurrency     = TransactionCurrency::where('code', $defaultCurrencyCode)->first();
305
                $accountCurrency     = (int)$repository->getMetaValue($account, 'currency_id');
306
                $openingBalance      = $account->getOpeningBalance();
307
                $obCurrency          = (int)$openingBalance->transaction_currency_id;
308
309
                if (null === $defaultCurrency) {
310
                    throw new UnexpectedValueException('The default currency is NULL, and this is more or less impossible.');
311
                }
312
313
                // both 0? set to default currency:
314
                if (0 === $accountCurrency && 0 === $obCurrency) {
315
                    AccountMeta::where('account_id', $account->id)->where('name', 'currency_id')->forceDelete();
316
                    AccountMeta::create(['account_id' => $account->id, 'name' => 'currency_id', 'data' => $defaultCurrency->id]);
317
                    $this->line(sprintf('Account #%d ("%s") now has a currency setting (%s).', $account->id, $account->name, $defaultCurrencyCode));
318
319
                    return true;
320
                }
321
322
                // account is set to 0, opening balance is not?
323
                if (0 === $accountCurrency && $obCurrency > 0) {
324
                    AccountMeta::create(['account_id' => $account->id, 'name' => 'currency_id', 'data' => $obCurrency]);
325
                    $this->line(sprintf('Account #%d ("%s") now has a currency setting (%s).', $account->id, $account->name, $defaultCurrencyCode));
326
327
                    return true;
328
                }
329
330
                // do not match and opening balance id is not null.
331
                if ($accountCurrency !== $obCurrency && $openingBalance->id > 0) {
332
                    // update opening balance:
333
                    $openingBalance->transaction_currency_id = $accountCurrency;
334
                    $openingBalance->save();
335
                    $this->line(sprintf('Account #%d ("%s") now has a correct currency for opening balance.', $account->id, $account->name));
336
337
                    return true;
338
                }
339
340
                return true;
341
            }
342
        );
343
344
    }
345
346
    /**
347
     * This routine verifies that withdrawals, deposits and opening balances have the correct currency settings for
348
     * the accounts they are linked to.
349
     *
350
     * Both source and destination must match the respective currency preference of the related asset account.
351
     * So FF3 must verify all transactions.
352
     *
353
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
354
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
355
     */
356
    public function updateOtherCurrencies(): void
357
    {
358
        /** @var CurrencyRepositoryInterface $repository */
359
        $repository = app(CurrencyRepositoryInterface::class);
360
        /** @var AccountRepositoryInterface $accountRepos */
361
        $accountRepos = app(AccountRepositoryInterface::class);
362
        $set          = TransactionJournal
363
            ::leftJoin('transaction_types', 'transaction_types.id', '=', 'transaction_journals.transaction_type_id')
364
            ->whereIn('transaction_types.type', [TransactionType::WITHDRAWAL, TransactionType::DEPOSIT, TransactionType::OPENING_BALANCE])
365
            ->get(['transaction_journals.*']);
366
367
        $set->each(
368
            function (TransactionJournal $journal) use ($repository, $accountRepos) {
369
                // get the transaction with the asset account in it:
370
                /** @var Transaction $transaction */
371
                $transaction = $journal->transactions()
372
                                       ->leftJoin('accounts', 'accounts.id', '=', 'transactions.account_id')
373
                                       ->leftJoin('account_types', 'account_types.id', '=', 'accounts.account_type_id')
374
                                       ->whereIn('account_types.type', [AccountType::DEFAULT, AccountType::ASSET])->first(['transactions.*']);
375
                if (null === $transaction) {
376
                    return;
377
                }
378
                $accountRepos->setUser($journal->user);
379
                /** @var Account $account */
380
                $account  = $transaction->account;
381
                $currency = $repository->findNull((int)$accountRepos->getMetaValue($account, 'currency_id'));
382
                if (null === $currency) {
383
                    return;
384
                }
385
                $transactions = $journal->transactions()->get();
386
                $transactions->each(
387
                    function (Transaction $transaction) use ($currency) {
388
                        if (null === $transaction->transaction_currency_id) {
389
                            $transaction->transaction_currency_id = $currency->id;
390
                            $transaction->save();
391
                        }
392
393
                        // when mismatch in transaction:
394
                        if (!((int)$transaction->transaction_currency_id === (int)$currency->id)) {
395
                            $transaction->foreign_currency_id     = (int)$transaction->transaction_currency_id;
396
                            $transaction->foreign_amount          = $transaction->amount;
397
                            $transaction->transaction_currency_id = $currency->id;
398
                            $transaction->save();
399
                        }
400
                    }
401
                );
402
                // also update the journal, of course:
403
                $journal->transaction_currency_id = $currency->id;
404
                $journal->save();
405
            }
406
        );
407
408
    }
409
410
    /**
411
     * This routine verifies that transfers have the correct currency settings for the accounts they are linked to.
412
     * For transfers, this is can be a destructive routine since we FORCE them into a currency setting whether they
413
     * like it or not. Previous routines MUST have set the currency setting for both accounts for this to work.
414
     *
415
     * A transfer always has the
416
     *
417
     * Both source and destination must match the respective currency preference. So FF3 must verify ALL
418
     * transactions.
419
     */
420
    public function updateTransferCurrencies(): void
421
    {
422
        $set = TransactionJournal
423
            ::leftJoin('transaction_types', 'transaction_types.id', '=', 'transaction_journals.transaction_type_id')
424
            ->where('transaction_types.type', TransactionType::TRANSFER)
425
            ->get(['transaction_journals.*']);
426
427
        $set->each(
428
            function (TransactionJournal $transfer) {
429
                // select all "source" transactions:
430
                /** @var Collection $transactions */
431
                $transactions = $transfer->transactions()->where('amount', '<', 0)->get();
432
                $transactions->each(
433
                    function (Transaction $transaction) {
434
                        $this->updateTransactionCurrency($transaction);
435
                        $this->updateJournalCurrency($transaction);
436
                    }
437
                );
438
            }
439
        );
440
    }
441
442
    /**
443
     *
444
     */
445
    private function budgetLimitCurrency(): void
446
    {
447
        $budgetLimits = BudgetLimit::get();
448
        /** @var BudgetLimit $budgetLimit */
449
        foreach ($budgetLimits as $budgetLimit) {
450
            if (null === $budgetLimit->transaction_currency_id) {
451
                $budget = $budgetLimit->budget;
452
                if (null !== $budget) {
453
                    $user = $budget->user;
454
                    if (null !== $user) {
455
                        $currency                             = \Amount::getDefaultCurrencyByUser($user);
0 ignored issues
show
Bug Best Practice introduced by
The method FireflyIII\Support\Facad...DefaultCurrencyByUser() is not static, but was called statically. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

455
                        /** @scrutinizer ignore-call */ 
456
                        $currency                             = \Amount::getDefaultCurrencyByUser($user);
Loading history...
456
                        $budgetLimit->transaction_currency_id = $currency->id;
457
                        $budgetLimit->save();
458
                        $this->line(
459
                            sprintf('Budget limit #%d (part of budget "%s") now has a currency setting (%s).', $budgetLimit->id, $budget->name, $currency->name)
460
                        );
461
                    }
462
                }
463
            }
464
        }
465
    }
466
467
    private function createNewTypes(): void
468
    {
469
        // create transaction type "Reconciliation".
470
        $type = TransactionType::where('type', TransactionType::RECONCILIATION)->first();
471
        if (null === $type) {
472
            TransactionType::create(['type' => TransactionType::RECONCILIATION]);
473
        }
474
        $account = AccountType::where('type', AccountType::RECONCILIATION)->first();
475
        if (null === $account) {
476
            AccountType::create(['type' => AccountType::RECONCILIATION]);
477
        }
478
    }
479
480
    /**
481
     * Move the description of each attachment (when not NULL) to the notes or to a new note object
482
     * for all attachments.
483
     */
484
    private function migrateAttachmentData(): void
485
    {
486
        $attachments = Attachment::get();
487
488
        /** @var Attachment $att */
489
        foreach ($attachments as $att) {
490
491
            // move description:
492
            $description = (string)$att->description;
493
            if (\strlen($description) > 0) {
494
                // find or create note:
495
                $note = $att->notes()->first();
496
                if (null === $note) {
497
                    $note = new Note;
498
                    $note->noteable()->associate($att);
499
                }
500
                $note->text = $description;
501
                $note->save();
502
503
                // clear description:
504
                $att->description = '';
505
                $att->save();
506
507
                Log::debug(sprintf('Migrated attachment #%s description to note #%d', $att->id, $note->id));
508
            }
509
        }
510
    }
511
512
    /**
513
     * Move all the journal_meta notes to their note object counter parts.
514
     */
515
    private function migrateNotes(): void
516
    {
517
        /** @noinspection PhpUndefinedMethodInspection */
518
        $set = TransactionJournalMeta::whereName('notes')->get();
519
        /** @var TransactionJournalMeta $meta */
520
        foreach ($set as $meta) {
521
            $journal = $meta->transactionJournal;
522
            $note    = $journal->notes()->first();
523
            if (null === $note) {
524
                $note = new Note();
525
                $note->noteable()->associate($journal);
526
            }
527
528
            $note->text = $meta->data;
529
            $note->save();
530
            Log::debug(sprintf('Migrated meta note #%d to Note #%d', $meta->id, $note->id));
531
            try {
532
                $meta->delete();
533
            } catch (Exception $e) {
534
                Log::error(sprintf('Could not delete old meta entry #%d: %s', $meta->id, $e->getMessage()));
535
            }
536
        }
537
    }
538
539
    /**
540
     *
541
     */
542
    private function removeCCLiabilities(): void
543
    {
544
        $ccType = AccountType::where('type', AccountType::CREDITCARD)->first();
545
        $debtType =AccountType::where('type', AccountType::DEBT)->first();
546
        if(null === $ccType || null === $debtType) {
547
            return;
548
        }
549
        /** @var Collection $accounts */
550
        $accounts = Account::where('account_type_id', $ccType->id)->get();
551
        foreach($accounts as $account) {
552
            $account->account_type_id = $debtType->id;
553
            $account->save();
554
            $this->line(sprintf('Converted credit card liability account "%s" (#%d) to generic debt liability.', $account->name, $account->id));
555
        }
556
        if($accounts->count() > 0) {
557
            $this->info('Credit card liability types are no longer supported and have been converted to generic debts. See: http://bit.ly/FF3-credit-cards');
558
        }
559
    }
560
561
    /**
562
     * This method makes sure that the transaction journal uses the currency given in the transaction.
563
     *
564
     * @param Transaction $transaction
565
     */
566
    private function updateJournalCurrency(Transaction $transaction): void
567
    {
568
        /** @var CurrencyRepositoryInterface $repository */
569
        $repository = app(CurrencyRepositoryInterface::class);
570
        /** @var AccountRepositoryInterface $accountRepos */
571
        $accountRepos = app(AccountRepositoryInterface::class);
572
        $accountRepos->setUser($transaction->account->user);
573
        $currency = $repository->findNull((int)$accountRepos->getMetaValue($transaction->account, 'currency_id'));
574
        $journal  = $transaction->transactionJournal;
575
576
        if (null === $currency) {
577
            return;
578
        }
579
580
        if (!((int)$currency->id === (int)$journal->transaction_currency_id)) {
581
            $this->line(
582
                sprintf(
583
                    'Transfer #%d ("%s") has been updated to use %s instead of %s.',
584
                    $journal->id,
585
                    $journal->description,
586
                    $currency->code,
587
                    $journal->transactionCurrency->code
588
                )
589
            );
590
            $journal->transaction_currency_id = $currency->id;
591
            $journal->save();
592
        }
593
594
    }
595
596
    /**
597
     * grab all positive transactiosn from this journal that are not deleted. for each one, grab the negative opposing one
598
     * which has 0 as an identifier and give it the same identifier.
599
     *
600
     * @param int $journalId
601
     */
602
    private function updateJournalidentifiers(int $journalId): void
603
    {
604
        $identifier   = 0;
605
        $processed    = [];
606
        $transactions = Transaction::where('transaction_journal_id', $journalId)->where('amount', '>', 0)->get();
607
        /** @var Transaction $transaction */
608
        foreach ($transactions as $transaction) {
609
            // find opposing:
610
            $amount = bcmul((string)$transaction->amount, '-1');
611
612
            try {
613
                /** @var Transaction $opposing */
614
                $opposing = Transaction::where('transaction_journal_id', $journalId)
615
                                       ->where('amount', $amount)->where('identifier', '=', 0)
616
                                       ->whereNotIn('id', $processed)
617
                                       ->first();
618
            } catch (QueryException $e) {
619
                Log::error($e->getMessage());
620
                $this->error('Firefly III could not find the "identifier" field in the "transactions" table.');
621
                $this->error(sprintf('This field is required for Firefly III version %s to run.', config('firefly.version')));
622
                $this->error('Please run "php artisan migrate" to add this field to the table.');
623
                $this->info('Then, run "php artisan firefly:upgrade-database" to try again.');
624
625
                return;
626
            }
627
            if (null !== $opposing) {
628
                // give both a new identifier:
629
                $transaction->identifier = $identifier;
630
                $opposing->identifier    = $identifier;
631
                $transaction->save();
632
                $opposing->save();
633
                $processed[] = $transaction->id;
634
                $processed[] = $opposing->id;
635
            }
636
            ++$identifier;
637
        }
638
639
    }
640
641
    /**
642
     * This method makes sure that the tranaction uses the same currency as the source account does.
643
     * If not, the currency is updated to include a reference to its original currency as the "foreign" currency.
644
     *
645
     * The transaction that is sent to this function MUST be the source transaction (amount negative).
646
     *
647
     * Method is long and complex but I'll allow it. https://imgur.com/gallery/dVDJiez
648
     *
649
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
650
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
651
     * @SuppressWarnings(PHPMD.NPathComplexity)
652
     *
653
     * @param Transaction $transaction
654
     */
655
    private function updateTransactionCurrency(Transaction $transaction): void
656
    {
657
        /** @var CurrencyRepositoryInterface $repository */
658
        $repository = app(CurrencyRepositoryInterface::class);
659
        /** @var AccountRepositoryInterface $accountRepos */
660
        $accountRepos = app(AccountRepositoryInterface::class);
661
        /** @var JournalRepositoryInterface $journalRepos */
662
        $journalRepos = app(JournalRepositoryInterface::class);
663
664
        $accountRepos->setUser($transaction->account->user);
665
        $journalRepos->setUser($transaction->account->user);
666
        $currency = $repository->findNull((int)$accountRepos->getMetaValue($transaction->account, 'currency_id'));
667
668
        if (null === $currency) {
669
            Log::error(sprintf('Account #%d ("%s") must have currency preference but has none.', $transaction->account->id, $transaction->account->name));
670
671
            return;
672
        }
673
674
        // has no currency ID? Must have, so fill in using account preference:
675
        if (null === $transaction->transaction_currency_id) {
676
            $transaction->transaction_currency_id = (int)$currency->id;
677
            Log::debug(sprintf('Transaction #%d has no currency setting, now set to %s', $transaction->id, $currency->code));
678
            $transaction->save();
679
        }
680
681
        // does not match the source account (see above)? Can be fixed
682
        // when mismatch in transaction and NO foreign amount is set:
683
        if (!((int)$transaction->transaction_currency_id === (int)$currency->id) && null === $transaction->foreign_amount) {
684
            Log::debug(
685
                sprintf(
686
                    'Transaction #%d has a currency setting #%d that should be #%d. Amount remains %s, currency is changed.',
687
                    $transaction->id,
688
                    $transaction->transaction_currency_id,
689
                    $currency->id,
690
                    $transaction->amount
691
                )
692
            );
693
            $transaction->transaction_currency_id = (int)$currency->id;
694
            $transaction->save();
695
        }
696
697
        // grab opposing transaction:
698
        /** @var TransactionJournal $journal */
699
        $journal = $transaction->transactionJournal;
700
        /** @var Transaction $opposing */
701
        $opposing         = $journal->transactions()->where('amount', '>', 0)->where('identifier', $transaction->identifier)->first();
702
        $opposingCurrency = $repository->findNull((int)$accountRepos->getMetaValue($opposing->account, 'currency_id'));
703
704
        if (null === $opposingCurrency) {
705
            Log::error(sprintf('Account #%d ("%s") must have currency preference but has none.', $opposing->account->id, $opposing->account->name));
706
707
            return;
708
        }
709
710
        // if the destination account currency is the same, both foreign_amount and foreign_currency_id must be NULL for both transactions:
711
        if ((int)$opposingCurrency->id === (int)$currency->id) {
712
            // update both transactions to match:
713
            $transaction->foreign_amount       = null;
714
            $transaction->foreign_currency_id  = null;
715
            $opposing->foreign_amount          = null;
716
            $opposing->foreign_currency_id     = null;
717
            $opposing->transaction_currency_id = $currency->id;
718
            $transaction->save();
719
            $opposing->save();
720
            Log::debug(
721
                sprintf(
722
                    'Currency for account "%s" is %s, and currency for account "%s" is also
723
             %s, so %s #%d (#%d and #%d) has been verified to be to %s exclusively.',
724
                    $opposing->account->name, $opposingCurrency->code,
725
                    $transaction->account->name, $transaction->transactionCurrency->code,
726
                    $journal->transactionType->type, $journal->id,
727
                    $transaction->id, $opposing->id, $currency->code
728
                )
729
            );
730
731
            return;
732
        }
733
        // if destination account currency is different, both transactions must have this currency as foreign currency id.
734
        if (!((int)$opposingCurrency->id === (int)$currency->id)) {
735
            $transaction->foreign_currency_id = $opposingCurrency->id;
736
            $opposing->foreign_currency_id    = $opposingCurrency->id;
737
            $transaction->save();
738
            $opposing->save();
739
            Log::debug(sprintf('Verified foreign currency ID of transaction #%d and #%d', $transaction->id, $opposing->id));
740
        }
741
742
        // if foreign amount of one is null and the other is not, use this to restore:
743
        if (null === $transaction->foreign_amount && null !== $opposing->foreign_amount) {
744
            $transaction->foreign_amount = bcmul((string)$opposing->foreign_amount, '-1');
745
            $transaction->save();
746
            Log::debug(sprintf('Restored foreign amount of transaction (1) #%d to %s', $transaction->id, $transaction->foreign_amount));
747
        }
748
749
        // if foreign amount of one is null and the other is not, use this to restore (other way around)
750
        if (null === $opposing->foreign_amount && null !== $transaction->foreign_amount) {
751
            $opposing->foreign_amount = bcmul((string)$transaction->foreign_amount, '-1');
752
            $opposing->save();
753
            Log::debug(sprintf('Restored foreign amount of transaction (2) #%d to %s', $opposing->id, $opposing->foreign_amount));
754
        }
755
756
        // when both are zero, try to grab it from journal:
757
        if (null === $opposing->foreign_amount && null === $transaction->foreign_amount) {
758
            $foreignAmount = $journalRepos->getMetaField($journal, 'foreign_amount');
759
            if (null === $foreignAmount) {
760
                Log::debug(sprintf('Journal #%d has missing foreign currency data, forced to do 1:1 conversion :(.', $transaction->transaction_journal_id));
761
                $transaction->foreign_amount = bcmul((string)$transaction->amount, '-1');
762
                $opposing->foreign_amount    = bcmul((string)$opposing->amount, '-1');
763
                $transaction->save();
764
                $opposing->save();
765
766
                return;
767
            }
768
            $foreignPositive = app('steam')->positive((string)$foreignAmount);
769
            Log::debug(
770
                sprintf(
771
                    'Journal #%d has missing foreign currency info, try to restore from meta-data ("%s").',
772
                    $transaction->transaction_journal_id,
773
                    $foreignAmount
774
                )
775
            );
776
            $transaction->foreign_amount = bcmul($foreignPositive, '-1');
777
            $opposing->foreign_amount    = $foreignPositive;
778
            $transaction->save();
779
            $opposing->save();
780
        }
781
782
    }
783
784
}
785