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 ( 37b02e...ebbbe1 )
by James
08:59
created

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

Labels
Severity
1
<?php
2
declare(strict_types=1);
3
/**
4
 * UpgradeDatabase.php
5
 * Copyright (c) 2017 [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
namespace FireflyIII\Console\Commands;
24
25
use DB;
26
use Exception;
27
use FireflyIII\Models\Account;
28
use FireflyIII\Models\AccountMeta;
29
use FireflyIII\Models\AccountType;
30
use FireflyIII\Models\Attachment;
31
use FireflyIII\Models\Note;
32
use FireflyIII\Models\Transaction;
33
use FireflyIII\Models\TransactionCurrency;
34
use FireflyIII\Models\TransactionJournal;
35
use FireflyIII\Models\TransactionJournalMeta;
36
use FireflyIII\Models\TransactionType;
37
use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface;
38
use Illuminate\Console\Command;
39
use Illuminate\Database\QueryException;
40
use Illuminate\Support\Collection;
41
use Log;
42
use Preferences;
43
use Schema;
44
45
/**
46
 * Class UpgradeDatabase.
47
 *
48
 * Upgrade user database.
49
 *
50
 *
51
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects) // it just touches a lot of things.
52
 */
53
class UpgradeDatabase extends Command
54
{
55
    /**
56
     * The console command description.
57
     *
58
     * @var string
59
     */
60
    protected $description = 'Will run various commands to update database records.';
61
    /**
62
     * The name and signature of the console command.
63
     *
64
     * @var string
65
     */
66
    protected $signature = 'firefly:upgrade-database';
67
68
    /**
69
     * Execute the console command.
70
     */
71
    public function handle()
72
    {
73
        $this->setTransactionIdentifier();
74
        $this->updateAccountCurrencies();
75
        $this->createNewTypes();
76
        $this->line('Updating currency information..');
77
        $this->updateTransferCurrencies();
78
        $this->updateOtherCurrencies();
79
        $this->line('Done updating currency information..');
80
        $this->migrateNotes();
81
        $this->migrateAttachmentData();
82
        $this->info('Firefly III database is up to date.');
83
84
        return;
85
    }
86
87
    /**
88
     * This method gives all transactions which are part of a split journal (so more than 2) a sort of "order" so they are easier
89
     * to easier to match to their counterpart. When a journal is split, it has two or three transactions: -3, -4 and -5 for example.
90
     *
91
     * In the database this is reflected as 6 transactions: -3/+3, -4/+4, -5/+5.
92
     *
93
     * 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
94
     * think. So each set gets a number (1,2,3) to keep them apart.
95
     */
96
    public function setTransactionIdentifier(): void
97
    {
98
        // if table does not exist, return false
99
        if (!Schema::hasTable('transaction_journals')) {
100
            return;
101
        }
102
        $subQuery   = TransactionJournal::leftJoin('transactions', 'transactions.transaction_journal_id', '=', 'transaction_journals.id')
103
                                        ->whereNull('transaction_journals.deleted_at')
104
                                        ->whereNull('transactions.deleted_at')
105
                                        ->groupBy(['transaction_journals.id'])
106
                                        ->select(['transaction_journals.id', DB::raw('COUNT(transactions.id) AS t_count')]);
107
        $result     = DB::table(DB::raw('(' . $subQuery->toSql() . ') AS derived'))
108
                        ->mergeBindings($subQuery->getQuery())
109
                        ->where('t_count', '>', 2)
110
                        ->select(['id', 't_count']);
111
        $journalIds = array_unique($result->pluck('id')->toArray());
112
113
        foreach ($journalIds as $journalId) {
114
            $this->updateJournalidentifiers((int)$journalId);
115
        }
116
117
        return;
118
    }
119
120
    /**
121
     * Each (asset) account must have a reference to a preferred currency. If the account does not have one, it's forced upon the account.
122
     *
123
     * @SuppressWarnings(PHPMD.CyclomaticComplexity) // it's seven but it can't really be helped.
124
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
125
     */
126
    public function updateAccountCurrencies(): void
127
    {
128
        $accounts = Account::leftJoin('account_types', 'account_types.id', '=', 'accounts.account_type_id')
129
                           ->whereIn('account_types.type', [AccountType::DEFAULT, AccountType::ASSET])->get(['accounts.*']);
130
131
        $accounts->each(
132
            function (Account $account) {
133
                // get users preference, fall back to system pref.
134
                $defaultCurrencyCode = Preferences::getForUser($account->user, 'currencyPreference', config('firefly.default_currency', 'EUR'))->data;
135
                $defaultCurrency     = TransactionCurrency::where('code', $defaultCurrencyCode)->first();
136
                $accountCurrency     = (int)$account->getMeta('currency_id');
137
                $openingBalance      = $account->getOpeningBalance();
138
                $obCurrency          = (int)$openingBalance->transaction_currency_id;
139
140
                // both 0? set to default currency:
141
                if (0 === $accountCurrency && 0 === $obCurrency) {
142
                    AccountMeta::where('account_id', $account->id)->where('name', 'currency_id')->forceDelete();
143
                    AccountMeta::create(['account_id' => $account->id, 'name' => 'currency_id', 'data' => $defaultCurrency->id]);
144
                    $this->line(sprintf('Account #%d ("%s") now has a currency setting (%s).', $account->id, $account->name, $defaultCurrencyCode));
145
146
                    return true;
147
                }
148
149
                // account is set to 0, opening balance is not?
150
                if (0 === $accountCurrency && $obCurrency > 0) {
151
                    AccountMeta::create(['account_id' => $account->id, 'name' => 'currency_id', 'data' => $obCurrency]);
152
                    $this->line(sprintf('Account #%d ("%s") now has a currency setting (%s).', $account->id, $account->name, $defaultCurrencyCode));
153
154
                    return true;
155
                }
156
157
                // do not match and opening balance id is not null.
158
                if ($accountCurrency !== $obCurrency && $openingBalance->id > 0) {
159
                    // update opening balance:
160
                    $openingBalance->transaction_currency_id = $accountCurrency;
161
                    $openingBalance->save();
162
                    $this->line(sprintf('Account #%d ("%s") now has a correct currency for opening balance.', $account->id, $account->name));
163
164
                    return true;
165
                }
166
167
                return true;
168
            }
169
        );
170
171
        return;
172
    }
173
174
    /**
175
     * This routine verifies that withdrawals, deposits and opening balances have the correct currency settings for
176
     * the accounts they are linked to.
177
     *
178
     * Both source and destination must match the respective currency preference of the related asset account.
179
     * So FF3 must verify all transactions.
180
     *
181
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
182
     */
183
    public function updateOtherCurrencies(): void
184
    {
185
        /** @var CurrencyRepositoryInterface $repository */
186
        $repository = app(CurrencyRepositoryInterface::class);
187
        $set        = TransactionJournal
188
            ::leftJoin('transaction_types', 'transaction_types.id', '=', 'transaction_journals.transaction_type_id')
189
            ->whereIn('transaction_types.type', [TransactionType::WITHDRAWAL, TransactionType::DEPOSIT, TransactionType::OPENING_BALANCE])
190
            ->get(['transaction_journals.*']);
191
192
        $set->each(
193
            function (TransactionJournal $journal) use ($repository) {
194
                // get the transaction with the asset account in it:
195
                /** @var Transaction $transaction */
196
                $transaction = $journal->transactions()
197
                                       ->leftJoin('accounts', 'accounts.id', '=', 'transactions.account_id')
198
                                       ->leftJoin('account_types', 'account_types.id', '=', 'accounts.account_type_id')
199
                                       ->whereIn('account_types.type', [AccountType::DEFAULT, AccountType::ASSET])->first(['transactions.*']);
200
                if (null === $transaction) {
201
                    return;
202
                }
203
                /** @var Account $account */
204
                $account      = $transaction->account;
205
                $currency     = $repository->find((int)$account->getMeta('currency_id'));
206
                $transactions = $journal->transactions()->get();
207
                $transactions->each(
208
                    function (Transaction $transaction) use ($currency) {
209
                        if (null === $transaction->transaction_currency_id) {
210
                            $transaction->transaction_currency_id = $currency->id;
211
                            $transaction->save();
212
                        }
213
214
                        // when mismatch in transaction:
215
                        if (!((int)$transaction->transaction_currency_id === (int)$currency->id)) {
216
                            $transaction->foreign_currency_id     = (int)$transaction->transaction_currency_id;
217
                            $transaction->foreign_amount          = $transaction->amount;
218
                            $transaction->transaction_currency_id = $currency->id;
219
                            $transaction->save();
220
                        }
221
                    }
222
                );
223
                // also update the journal, of course:
224
                $journal->transaction_currency_id = $currency->id;
225
                $journal->save();
226
            }
227
        );
228
229
        return;
230
    }
231
232
    /**
233
     * This routine verifies that transfers have the correct currency settings for the accounts they are linked to.
234
     * For transfers, this is can be a destructive routine since we FORCE them into a currency setting whether they
235
     * like it or not. Previous routines MUST have set the currency setting for both accounts for this to work.
236
     *
237
     * A transfer always has the
238
     *
239
     * Both source and destination must match the respective currency preference. So FF3 must verify ALL
240
     * transactions.
241
     */
242
    public function updateTransferCurrencies()
243
    {
244
        $set = TransactionJournal
245
            ::leftJoin('transaction_types', 'transaction_types.id', '=', 'transaction_journals.transaction_type_id')
246
            ->where('transaction_types.type', TransactionType::TRANSFER)
247
            ->get(['transaction_journals.*']);
248
249
        $set->each(
250
            function (TransactionJournal $transfer) {
251
                // select all "source" transactions:
252
                /** @var Collection $transactions */
253
                $transactions = $transfer->transactions()->where('amount', '<', 0)->get();
254
                $transactions->each(
255
                    function (Transaction $transaction) {
256
                        $this->updateTransactionCurrency($transaction);
257
                        $this->updateJournalCurrency($transaction);
258
                    }
259
                );
260
            }
261
        );
262
    }
263
264
    private function createNewTypes(): void
265
    {
266
        // create transaction type "Reconciliation".
267
        $type = TransactionType::where('type', TransactionType::RECONCILIATION)->first();
268
        if (null === $type) {
269
            TransactionType::create(['type' => TransactionType::RECONCILIATION]);
270
        }
271
        $account = AccountType::where('type', AccountType::RECONCILIATION)->first();
272
        if (null === $account) {
273
            AccountType::create(['type' => AccountType::RECONCILIATION]);
274
        }
275
    }
276
277
    /**
278
     * Move the description of each attachment (when not NULL) to the notes or to a new note object
279
     * for all attachments.
280
     */
281
    private function migrateAttachmentData(): void
282
    {
283
        $attachments = Attachment::get();
284
285
        /** @var Attachment $att */
286
        foreach ($attachments as $att) {
287
288
            // move description:
289
            $description = (string)$att->description;
290
            if (strlen($description) > 0) {
291
                // find or create note:
292
                $note = $att->notes()->first();
293
                if (null === $note) {
294
                    $note = new Note;
295
                    $note->noteable()->associate($att);
296
                }
297
                $note->text = $description;
298
                $note->save();
299
300
                // clear description:
301
                $att->description = '';
302
                $att->save();
303
304
                Log::debug(sprintf('Migrated attachment #%s description to note #%d', $att->id, $note->id));
305
            }
306
        }
307
    }
308
309
    /**
310
     * Move all the journal_meta notes to their note object counter parts.
311
     */
312
    private function migrateNotes(): void
313
    {
314
        $set = TransactionJournalMeta::whereName('notes')->get();
315
        /** @var TransactionJournalMeta $meta */
316
        foreach ($set as $meta) {
317
            $journal = $meta->transactionJournal;
318
            $note    = $journal->notes()->first();
319
            if (null === $note) {
320
                $note = new Note();
321
                $note->noteable()->associate($journal);
322
            }
323
324
            $note->text = $meta->data;
325
            $note->save();
326
            Log::debug(sprintf('Migrated meta note #%d to Note #%d', $meta->id, $note->id));
327
            try {
328
                $meta->delete();
329
            } catch (Exception $e) {
330
                Log::error(sprintf('Could not delete old meta entry #%d: %s', $meta->id, $e->getMessage()));
331
            }
332
        }
333
    }
334
335
    /**
336
     * This method makes sure that the transaction journal uses the currency given in the transaction.
337
     *
338
     * @param Transaction $transaction
339
     */
340
    private function updateJournalCurrency(Transaction $transaction): void
341
    {
342
        /** @var CurrencyRepositoryInterface $repository */
343
        $repository = app(CurrencyRepositoryInterface::class);
344
        $currency   = $repository->find((int)$transaction->account->getMeta('currency_id'));
345
        $journal    = $transaction->transactionJournal;
346
347
        if (!((int)$currency->id === (int)$journal->transaction_currency_id)) {
0 ignored issues
show
The property transaction_currency_id does not exist on FireflyIII\Models\TransactionJournal. Did you mean transactionCurrency?
Loading history...
348
            $this->line(
349
                sprintf(
350
                    'Transfer #%d ("%s") has been updated to use %s instead of %s.',
351
                    $journal->id,
352
                    $journal->description,
353
                    $currency->code,
354
                    $journal->transactionCurrency->code
355
                )
356
            );
357
            $journal->transaction_currency_id = $currency->id;
358
            $journal->save();
359
        }
360
361
        return;
362
    }
363
364
    /**
365
     * grab all positive transactiosn from this journal that are not deleted. for each one, grab the negative opposing one
366
     * which has 0 as an identifier and give it the same identifier.
367
     *
368
     * @param int $journalId
369
     */
370
    private function updateJournalidentifiers(int $journalId): void
371
    {
372
        $identifier   = 0;
373
        $processed    = [];
374
        $transactions = Transaction::where('transaction_journal_id', $journalId)->where('amount', '>', 0)->get();
375
        /** @var Transaction $transaction */
376
        foreach ($transactions as $transaction) {
377
            // find opposing:
378
            $amount = bcmul((string)$transaction->amount, '-1');
379
380
            try {
381
                /** @var Transaction $opposing */
382
                $opposing = Transaction::where('transaction_journal_id', $journalId)
383
                                       ->where('amount', $amount)->where('identifier', '=', 0)
384
                                       ->whereNotIn('id', $processed)
385
                                       ->first();
386
            } catch (QueryException $e) {
387
                Log::error($e->getMessage());
388
                $this->error('Firefly III could not find the "identifier" field in the "transactions" table.');
389
                $this->error(sprintf('This field is required for Firefly III version %s to run.', config('firefly.version')));
390
                $this->error('Please run "php artisan migrate" to add this field to the table.');
391
                $this->info('Then, run "php artisan firefly:upgrade-database" to try again.');
392
393
                return;
394
            }
395
            if (null !== $opposing) {
396
                // give both a new identifier:
397
                $transaction->identifier = $identifier;
398
                $opposing->identifier    = $identifier;
399
                $transaction->save();
400
                $opposing->save();
401
                $processed[] = $transaction->id;
402
                $processed[] = $opposing->id;
403
            }
404
            ++$identifier;
405
        }
406
407
        return;
408
    }
409
410
    /**
411
     * This method makes sure that the tranaction uses the same currency as the source account does.
412
     * If not, the currency is updated to include a reference to its original currency as the "foreign" currency.
413
     *
414
     * The transaction that is sent to this function MUST be the source transaction (amount negative).
415
     *
416
     * Method is long and complex bit I'm taking it for granted.
417
     *
418
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
419
     * @SuppressWarnings(PHPMD.NPathComplexity)
420
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
421
     *
422
     * @param Transaction $transaction
423
     */
424
    private function updateTransactionCurrency(Transaction $transaction): void
425
    {
426
        /** @var CurrencyRepositoryInterface $repository */
427
        $repository = app(CurrencyRepositoryInterface::class);
428
        $currency   = $repository->find((int)$transaction->account->getMeta('currency_id'));
429
430
        // has no currency ID? Must have, so fill in using account preference:
431
        if (null === $transaction->transaction_currency_id) {
432
            $transaction->transaction_currency_id = (int)$currency->id;
433
            Log::debug(sprintf('Transaction #%d has no currency setting, now set to %s', $transaction->id, $currency->code));
434
            $transaction->save();
435
        }
436
437
        // does not match the source account (see above)? Can be fixed
438
        // when mismatch in transaction and NO foreign amount is set:
439
        if (!((int)$transaction->transaction_currency_id === (int)$currency->id) && null === $transaction->foreign_amount) {
440
            Log::debug(
441
                sprintf(
442
                    'Transaction #%d has a currency setting #%d that should be #%d. Amount remains %s, currency is changed.',
443
                    $transaction->id,
444
                    $transaction->transaction_currency_id,
445
                    $currency->id,
446
                    $transaction->amount
447
                )
448
            );
449
            $transaction->transaction_currency_id = (int)$currency->id;
450
            $transaction->save();
451
        }
452
453
        // grab opposing transaction:
454
        /** @var TransactionJournal $journal */
455
        $journal = $transaction->transactionJournal;
456
        /** @var Transaction $opposing */
457
        $opposing         = $journal->transactions()->where('amount', '>', 0)->where('identifier', $transaction->identifier)->first();
458
        $opposingCurrency = $repository->find((int)$opposing->account->getMeta('currency_id'));
459
460
        if (null === $opposingCurrency->id) {
461
            Log::error(sprintf('Account #%d ("%s") must have currency preference but has none.', $opposing->account->id, $opposing->account->name));
462
463
            return;
464
        }
465
466
        // if the destination account currency is the same, both foreign_amount and foreign_currency_id must be NULL for both transactions:
467
        if ((int)$opposingCurrency->id === (int)$currency->id) {
468
            // update both transactions to match:
469
            $transaction->foreign_amount       = null;
470
            $transaction->foreign_currency_id  = null;
471
            $opposing->foreign_amount          = null;
472
            $opposing->foreign_currency_id     = null;
473
            $opposing->transaction_currency_id = $currency->id;
474
            $transaction->save();
475
            $opposing->save();
476
            Log::debug(sprintf('Cleaned up transaction #%d and #%d', $transaction->id, $opposing->id));
477
478
            return;
479
        }
480
        // if destination account currency is different, both transactions must have this currency as foreign currency id.
481
        if (!((int)$opposingCurrency->id === (int)$currency->id)) {
482
            $transaction->foreign_currency_id = $opposingCurrency->id;
483
            $opposing->foreign_currency_id    = $opposingCurrency->id;
484
            $transaction->save();
485
            $opposing->save();
486
            Log::debug(sprintf('Verified foreign currency ID of transaction #%d and #%d', $transaction->id, $opposing->id));
487
        }
488
489
        // if foreign amount of one is null and the other is not, use this to restore:
490
        if (null === $transaction->foreign_amount && null !== $opposing->foreign_amount) {
491
            $transaction->foreign_amount = bcmul((string)$opposing->foreign_amount, '-1');
492
            $transaction->save();
493
            Log::debug(sprintf('Restored foreign amount of transaction (1) #%d to %s', $transaction->id, $transaction->foreign_amount));
494
        }
495
496
        // if foreign amount of one is null and the other is not, use this to restore (other way around)
497
        if (null === $opposing->foreign_amount && null !== $transaction->foreign_amount) {
498
            $opposing->foreign_amount = bcmul((string)$transaction->foreign_amount, '-1');
499
            $opposing->save();
500
            Log::debug(sprintf('Restored foreign amount of transaction (2) #%d to %s', $opposing->id, $opposing->foreign_amount));
501
        }
502
503
        // when both are zero, try to grab it from journal:
504
        if (null === $opposing->foreign_amount && null === $transaction->foreign_amount) {
505
            $foreignAmount = $journal->getMeta('foreign_amount');
506
            if (null === $foreignAmount) {
507
                Log::debug(sprintf('Journal #%d has missing foreign currency data, forced to do 1:1 conversion :(.', $transaction->transaction_journal_id));
508
                $transaction->foreign_amount = bcmul((string)$transaction->amount, '-1');
509
                $opposing->foreign_amount    = bcmul((string)$opposing->amount, '-1');
510
                $transaction->save();
511
                $opposing->save();
512
513
                return;
514
            }
515
            $foreignPositive = app('steam')->positive((string)$foreignAmount);
516
            Log::debug(
517
                sprintf(
518
                    'Journal #%d has missing foreign currency info, try to restore from meta-data ("%s").',
519
                    $transaction->transaction_journal_id,
520
                    $foreignAmount
521
                )
522
            );
523
            $transaction->foreign_amount = bcmul($foreignPositive, '-1');
524
            $opposing->foreign_amount    = $foreignPositive;
525
            $transaction->save();
526
            $opposing->save();
527
        }
528
529
        return;
530
    }
531
532
}
533