Passed
Push — master ( d550a6...996863 )
by James
24:09 queued 11:56
created

MigrateToGroups::getTransactionCategory()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 25
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 11
c 1
b 0
f 0
dl 0
loc 25
rs 9.9
cc 3
nc 3
nop 2
1
<?php
2
declare(strict_types=1);
3
/**
4
 * MigrateToGroups.php
5
 * Copyright (c) 2019 [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\Upgrade;
24
25
use DB;
26
use Exception;
27
use FireflyIII\Factory\TransactionGroupFactory;
28
use FireflyIII\Models\Budget;
29
use FireflyIII\Models\Category;
30
use FireflyIII\Models\Transaction;
31
use FireflyIII\Models\TransactionJournal;
32
use FireflyIII\Repositories\Journal\JournalCLIRepositoryInterface;
33
use FireflyIII\Repositories\Journal\JournalRepositoryInterface;
34
use FireflyIII\Services\Internal\Destroy\JournalDestroyService;
35
use Illuminate\Console\Command;
36
use Illuminate\Support\Collection;
37
use Log;
38
39
/**
40
 * This command will take split transactions and migrate them to "transaction groups".
41
 *
42
 * It will only run once, but can be forced to run again.
43
 *
44
 * Class MigrateToGroups
45
 */
46
class MigrateToGroups extends Command
47
{
48
    public const CONFIG_NAME = '480_migrated_to_groups';
49
    /**
50
     * The console command description.
51
     *
52
     * @var string
53
     */
54
    protected $description = 'Migrates a pre-4.7.8 transaction structure to the 4.7.8+ transaction structure.';
55
    /**
56
     * The name and signature of the console command.
57
     *
58
     * @var string
59
     */
60
    protected $signature = 'firefly-iii:migrate-to-groups {--F|force : Force the migration, even if it fired before.}';
61
    /** @var TransactionGroupFactory */
62
    private $groupFactory;
63
    /** @var JournalRepositoryInterface */
64
    private $journalRepository;
65
    /** @var JournalCLIRepositoryInterface */
66
    private $cliRepository;
67
    /** @var JournalDestroyService */
68
    private $service;
69
    private $count;
70
71
    /**
72
     * Execute the console command.
73
     *
74
     * @return int
75
     * @throws Exception
76
     */
77
    public function handle(): int
78
    {
79
        $this->stupidLaravel();
80
        $start = microtime(true);
81
        // @codeCoverageIgnoreStart
82
        if ($this->isMigrated() && true !== $this->option('force')) {
83
            $this->info('Database already seems to be migrated.');
84
85
            return 0;
86
        }
87
88
        if (true === $this->option('force')) {
0 ignored issues
show
introduced by James Cole
The condition true === $this->option('force') is always false.
Loading history...
89
            $this->warn('Forcing the migration.');
90
        }
91
        // @codeCoverageIgnoreEnd
92
93
        Log::debug('---- start group migration ----');
94
        $this->makeGroupsFromSplitJournals();
95
        $end = round(microtime(true) - $start, 2);
96
        $this->info(sprintf('Migrate split journals to groups in %s seconds.', $end));
97
98
        $start = microtime(true);
99
        $this->makeGroupsFromAll();
100
        Log::debug('---- end group migration ----');
101
        $end = round(microtime(true) - $start, 2);
102
        $this->info(sprintf('Migrate all journals to groups in %s seconds.', $end));
103
104
        if (0 !== $this->count) {
105
            $this->line(sprintf('Migrated %d transaction journal(s).', $this->count));
106
        }
107
        if (0 === $this->count) {
108
            $this->line('No journals to migrate to groups.');
109
        }
110
111
112
        $this->markAsMigrated();
113
114
115
        return 0;
116
    }
117
118
    /**
119
     * Laravel will execute ALL __construct() methods for ALL commands whenever a SINGLE command is
120
     * executed. This leads to noticeable slow-downs and class calls. To prevent this, this method should
121
     * be called from the handle method instead of using the constructor to initialize the command.
122
     *
123
     * @codeCoverageIgnore
124
     */
125
    private function stupidLaravel(): void
126
    {
127
        $this->count             = 0;
128
        $this->journalRepository = app(JournalRepositoryInterface::class);
129
        $this->service           = app(JournalDestroyService::class);
130
        $this->groupFactory      = app(TransactionGroupFactory::class);
131
        $this->cliRepository     = app(JournalCLIRepositoryInterface::class);
132
    }
133
134
    /**
135
     * @param TransactionJournal $journal
136
     * @param Transaction $transaction
137
     *
138
     * @return Transaction|null
139
     */
140
    private function findOpposingTransaction(TransactionJournal $journal, Transaction $transaction): ?Transaction
141
    {
142
        $set = $journal->transactions->filter(
143
            static function (Transaction $subject) use ($transaction) {
144
                $amount     = (float)$transaction->amount * -1 === (float)$subject->amount;
145
                $identifier = $transaction->identifier === $subject->identifier;
146
                Log::debug(sprintf('Amount the same? %s', var_export($amount, true)));
147
                Log::debug(sprintf('ID the same?     %s', var_export($identifier, true)));
148
149
                return $amount && $identifier;
150
            }
151
        );
152
153
        return $set->first();
154
    }
155
156
    /**
157
     * @param TransactionJournal $journal
158
     *
159
     * @return Collection
160
     */
161
    private function getDestinationTransactions(TransactionJournal $journal): Collection
162
    {
163
        return $journal->transactions->filter(
164
            static function (Transaction $transaction) {
165
                return $transaction->amount > 0;
166
            }
167
        );
168
    }
169
170
    /**
171
     * @param array $array
172
     */
173
    private function giveGroup(array $array): void
174
    {
175
        $groupId = DB::table('transaction_groups')->insertGetId(
176
            [
177
                'created_at' => date('Y-m-d H:i:s'),
178
                'updated_at' => date('Y-m-d H:i:s'),
179
                'title'      => null,
180
                'user_id'    => $array['user_id'],
181
            ]
182
        );
183
        DB::table('transaction_journals')->where('id', $array['id'])->update(['transaction_group_id' => $groupId]);
184
        $this->count++;
185
    }
186
187
    /**
188
     * @return bool
189
     */
190
    private function isMigrated(): bool
191
    {
192
        $configVar = app('fireflyconfig')->get(self::CONFIG_NAME, false);
193
        if (null !== $configVar) {
194
            return (bool)$configVar->data;
195
        }
196
197
        return false; // @codeCoverageIgnore
198
    }
199
200
    /**
201
     * Gives all journals without a group a group.
202
     */
203
    private function makeGroupsFromAll(): void
204
    {
205
        $orphanedJournals = $this->cliRepository->getJournalsWithoutGroup();
206
        $count            = count($orphanedJournals);
207
        if ($count > 0) {
208
            Log::debug(sprintf('Going to convert %d transaction journals. Please hold..', $count));
209
            $this->line(sprintf('Going to convert %d transaction journals. Please hold..', $count));
210
            /** @var array $journal */
211
            foreach ($orphanedJournals as $array) {
212
                $this->giveGroup($array);
213
            }
214
        }
215
        if (0 === $count) {
216
            $this->info('No need to convert transaction journals.');
217
        }
218
    }
219
220
    /**
221
     * @throws Exception
222
     */
223
    private function makeGroupsFromSplitJournals(): void
224
    {
225
        $splitJournals = $this->cliRepository->getSplitJournals();
226
        if ($splitJournals->count() > 0) {
227
            $this->info(sprintf('Going to convert %d split transaction(s). Please hold..', $splitJournals->count()));
228
            /** @var TransactionJournal $journal */
229
            foreach ($splitJournals as $journal) {
230
                $this->makeMultiGroup($journal);
231
            }
232
        }
233
        if (0 === $splitJournals->count()) {
234
            $this->info('Found no split transaction journals. Nothing to do.');
235
        }
236
    }
237
238
    /**
239
     * @param TransactionJournal $journal
240
     *
241
     * @throws Exception
242
     *
243
     */
244
    private function makeMultiGroup(TransactionJournal $journal): void
245
    {
246
        // double check transaction count.
247
        if ($journal->transactions->count() <= 2) {
248
            // @codeCoverageIgnoreStart
249
            Log::debug(sprintf('Will not try to convert journal #%d because it has 2 or less transactions.', $journal->id));
250
251
            return;
252
            // @codeCoverageIgnoreEnd
253
        }
254
        Log::debug(sprintf('Will now try to convert journal #%d', $journal->id));
255
256
        $this->journalRepository->setUser($journal->user);
257
        $this->groupFactory->setUser($journal->user);
258
        $this->cliRepository->setUser($journal->user);
259
260
        $data             = [
261
            // mandatory fields.
262
            'group_title'  => $journal->description,
263
            'transactions' => [],
264
        ];
265
        $destTransactions = $this->getDestinationTransactions($journal);
266
        $budgetId         = $this->cliRepository->getJournalBudgetId($journal);
267
        $categoryId       = $this->cliRepository->getJournalCategoryId($journal);
268
        $notes            = $this->cliRepository->getNoteText($journal);
269
        $tags             = $this->cliRepository->getTags($journal);
270
        $internalRef      = $this->cliRepository->getMetaField($journal, 'internal-reference');
271
        $sepaCC           = $this->cliRepository->getMetaField($journal, 'sepa_cc');
272
        $sepaCtOp         = $this->cliRepository->getMetaField($journal, 'sepa_ct_op');
273
        $sepaCtId         = $this->cliRepository->getMetaField($journal, 'sepa_ct_id');
274
        $sepaDb           = $this->cliRepository->getMetaField($journal, 'sepa_db');
275
        $sepaCountry      = $this->cliRepository->getMetaField($journal, 'sepa_country');
276
        $sepaEp           = $this->cliRepository->getMetaField($journal, 'sepa_ep');
277
        $sepaCi           = $this->cliRepository->getMetaField($journal, 'sepa_ci');
278
        $sepaBatchId      = $this->cliRepository->getMetaField($journal, 'sepa_batch_id');
279
        $externalId       = $this->cliRepository->getMetaField($journal, 'external-id');
280
        $originalSource   = $this->cliRepository->getMetaField($journal, 'original-source');
281
        $recurrenceId     = $this->cliRepository->getMetaField($journal, 'recurrence_id');
282
        $bunq             = $this->cliRepository->getMetaField($journal, 'bunq_payment_id');
283
        $hash             = $this->cliRepository->getMetaField($journal, 'import_hash');
284
        $hashTwo          = $this->cliRepository->getMetaField($journal, 'import_hash_v2');
285
        $interestDate     = $this->cliRepository->getMetaDate($journal, 'interest_date');
286
        $bookDate         = $this->cliRepository->getMetaDate($journal, 'book_date');
287
        $processDate      = $this->cliRepository->getMetaDate($journal, 'process_date');
288
        $dueDate          = $this->cliRepository->getMetaDate($journal, 'due_date');
289
        $paymentDate      = $this->cliRepository->getMetaDate($journal, 'payment_date');
290
        $invoiceDate      = $this->cliRepository->getMetaDate($journal, 'invoice_date');
291
292
        Log::debug(sprintf('Will use %d positive transactions to create a new group.', $destTransactions->count()));
293
294
        /** @var Transaction $transaction */
295
        foreach ($destTransactions as $transaction) {
296
            Log::debug(sprintf('Now going to add transaction #%d to the array.', $transaction->id));
297
            $opposingTr = $this->findOpposingTransaction($journal, $transaction);
298
299
            if (null === $opposingTr) {
300
                // @codeCoverageIgnoreStart
301
                $this->error(
302
                    sprintf(
303
                        'Journal #%d has no opposing transaction for transaction #%d. Cannot upgrade this entry.',
304
                        $journal->id, $transaction->id
305
                    )
306
                );
307
                continue;
308
                // @codeCoverageIgnoreEnd
309
            }
310
311
            // overrule journal category with transaction category.
312
            $budgetId   = $this->getTransactionBudget($transaction, $opposingTr) ?? $budgetId;
313
            $categoryId = $this->getTransactionCategory($transaction, $opposingTr) ?? $categoryId;
314
315
            $tArray = [
316
                'type'                => strtolower($journal->transactionType->type),
317
                'date'                => $journal->date,
318
                'user'                => $journal->user_id,
319
                'currency_id'         => $transaction->transaction_currency_id,
320
                'foreign_currency_id' => $transaction->foreign_currency_id,
321
                'amount'              => $transaction->amount,
322
                'foreign_amount'      => $transaction->foreign_amount,
323
                'description'         => $transaction->description ?? $journal->description,
324
                'source_id'           => $opposingTr->account_id,
325
                'destination_id'      => $transaction->account_id,
326
                'budget_id'           => $budgetId,
327
                'category_id'         => $categoryId,
328
                'bill_id'             => $journal->bill_id,
329
                'notes'               => $notes,
330
                'tags'                => $tags,
331
                'internal_reference'  => $internalRef,
332
                'sepa_cc'             => $sepaCC,
333
                'sepa_ct_op'          => $sepaCtOp,
334
                'sepa_ct_id'          => $sepaCtId,
335
                'sepa_db'             => $sepaDb,
336
                'sepa_country'        => $sepaCountry,
337
                'sepa_ep'             => $sepaEp,
338
                'sepa_ci'             => $sepaCi,
339
                'sepa_batch_id'       => $sepaBatchId,
340
                'external_id'         => $externalId,
341
                'original-source'     => $originalSource,
342
                'recurrence_id'       => $recurrenceId,
343
                'bunq_payment_id'     => $bunq,
344
                'import_hash'         => $hash,
345
                'import_hash_v2'      => $hashTwo,
346
                'interest_date'       => $interestDate,
347
                'book_date'           => $bookDate,
348
                'process_date'        => $processDate,
349
                'due_date'            => $dueDate,
350
                'payment_date'        => $paymentDate,
351
                'invoice_date'        => $invoiceDate,
352
            ];
353
354
            $data['transactions'][] = $tArray;
355
        }
356
        Log::debug(sprintf('Now calling transaction journal factory (%d transactions in array)', count($data['transactions'])));
357
        $group = $this->groupFactory->create($data);
358
        Log::debug('Done calling transaction journal factory');
359
360
        // delete the old transaction journal.
361
        $this->service->destroy($journal);
362
363
        $this->count++;
364
365
        // report on result:
366
        Log::debug(
367
            sprintf('Migrated journal #%d into group #%d with these journals: #%s',
368
                    $journal->id, $group->id, implode(', #', $group->transactionJournals->pluck('id')->toArray()))
369
        );
370
        $this->line(
371
            sprintf('Migrated journal #%d into group #%d with these journals: #%s',
372
                    $journal->id, $group->id, implode(', #', $group->transactionJournals->pluck('id')->toArray()))
373
        );
374
    }
375
376
    /**
377
     * @param Transaction $left
378
     * @param Transaction $right
379
     *
380
     * @return int|null
381
     */
382
    private function getTransactionBudget(Transaction $left, Transaction $right): ?int
383
    {
384
        Log::debug('Now in getTransactionBudget()');
385
386
        // try to get a budget ID from the left transaction:
387
        /** @var Budget $budget */
388
        $budget = $left->budgets()->first();
389
        if (null !== $budget) {
390
            Log::debug(sprintf('Return budget #%d, from transaction #%d', $budget->id, $left->id));
391
392
            return (int)$budget->id;
393
        }
394
395
        // try to get a budget ID from the right transaction:
396
        /** @var Budget $budget */
397
        $budget = $right->budgets()->first();
398
        if (null !== $budget) {
399
            Log::debug(sprintf('Return budget #%d, from transaction #%d', $budget->id, $right->id));
400
401
            return (int)$budget->id;
402
        }
403
        Log::debug('Neither left or right have a budget, return NULL');
404
405
        // if all fails, return NULL.
406
        return null;
407
    }
408
409
    /**
410
     * @param Transaction $left
411
     * @param Transaction $right
412
     *
413
     * @return int|null
414
     */
415
    private function getTransactionCategory(Transaction $left, Transaction $right): ?int
416
    {
417
        Log::debug('Now in getTransactionCategory()');
418
419
        // try to get a category ID from the left transaction:
420
        /** @var Category $category */
421
        $category = $left->categories()->first();
422
        if (null !== $category) {
423
            Log::debug(sprintf('Return category #%d, from transaction #%d', $category->id, $left->id));
424
425
            return (int)$category->id;
426
        }
427
428
        // try to get a category ID from the left transaction:
429
        /** @var Category $category */
430
        $category = $right->categories()->first();
431
        if (null !== $category) {
432
            Log::debug(sprintf('Return category #%d, from transaction #%d', $category->id, $category->id));
433
434
            return (int)$category->id;
435
        }
436
        Log::debug('Neither left or right have a category, return NULL');
437
438
        // if all fails, return NULL.
439
        return null;
440
    }
441
442
    /**
443
     *
444
     */
445
    private function markAsMigrated(): void
446
    {
447
        app('fireflyconfig')->set(self::CONFIG_NAME, true);
448
    }
449
450
}
451