From 33d89d52c2fefddebc0f4c9d2830d2105c033358 Mon Sep 17 00:00:00 2001 From: James Cole Date: Sun, 5 Nov 2017 19:48:43 +0100 Subject: [PATCH] Code that allows transaction reconciliation. #736 --- app/Helpers/Collector/JournalCollector.php | 6 +- .../Json/TransactionController.php | 93 +++++++++++++++++++ .../Controllers/TransactionController.php | 18 +++- app/Models/Transaction.php | 1 + .../Journal/JournalRepository.php | 63 ++++++++++++- .../Journal/JournalRepositoryInterface.php | 22 +++++ app/Repositories/Journal/JournalTasker.php | 10 ++ app/Support/Twig/Extension/Transaction.php | 29 +++++- .../2017_11_04_170844_changes_for_v470a.php | 33 +++++++ public/js/ff/transactions/list.js | 90 +++++++++++++++++- resources/lang/en_US/firefly.php | 3 +- resources/views/list/journals.twig | 15 +-- resources/views/partials/transaction-row.twig | 12 ++- resources/views/search/index.twig | 6 +- resources/views/transactions/show.twig | 17 +--- routes/web.php | 4 + 16 files changed, 387 insertions(+), 35 deletions(-) create mode 100644 app/Http/Controllers/Json/TransactionController.php create mode 100644 database/migrations/2017_11_04_170844_changes_for_v470a.php diff --git a/app/Helpers/Collector/JournalCollector.php b/app/Helpers/Collector/JournalCollector.php index fb71210ad1..218c69a5d5 100644 --- a/app/Helpers/Collector/JournalCollector.php +++ b/app/Helpers/Collector/JournalCollector.php @@ -80,6 +80,7 @@ class JournalCollector implements JournalCollectorInterface 'transactions.id as id', 'transactions.description as transaction_description', 'transactions.account_id', + 'transactions.reconciled', 'transactions.identifier', 'transactions.transaction_journal_id', 'transactions.amount as transaction_amount', @@ -171,7 +172,7 @@ class JournalCollector implements JournalCollectorInterface $q1->where( function (EloquentBuilder $q2) use ($amount) { // amount < 0 and .amount > -$amount - $amount = bcmul($amount,'-1'); + $amount = bcmul($amount, '-1'); $q2->where('transactions.amount', '<', 0)->where('transactions.amount', '>', $amount); } ) @@ -199,7 +200,7 @@ class JournalCollector implements JournalCollectorInterface $q1->where( function (EloquentBuilder $q2) use ($amount) { // amount < 0 and .amount < -$amount - $amount = bcmul($amount,'-1'); + $amount = bcmul($amount, '-1'); $q2->where('transactions.amount', '<', 0)->where('transactions.amount', '<', $amount); } ) @@ -211,6 +212,7 @@ class JournalCollector implements JournalCollectorInterface ); } ); + return $this; } diff --git a/app/Http/Controllers/Json/TransactionController.php b/app/Http/Controllers/Json/TransactionController.php new file mode 100644 index 0000000000..707c2a20ef --- /dev/null +++ b/app/Http/Controllers/Json/TransactionController.php @@ -0,0 +1,93 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Http\Controllers\Json; + + +use FireflyIII\Http\Controllers\Controller; +use FireflyIII\Models\TransactionType; +use FireflyIII\Repositories\Journal\JournalRepositoryInterface; +use FireflyIII\Support\SingleCacheProperties; +use Illuminate\Http\Request; +use Response; + +class TransactionController extends Controller +{ + public function amounts(Request $request, JournalRepositoryInterface $repository) + { + $ids = $request->get('transactions'); + + $cache = new SingleCacheProperties; + $cache->addProperty('json-reconcile-amounts'); + $cache->addProperty($ids); + if ($cache->has()) { + return Response::json($cache->get()); + } + + $totals = []; + // for each transaction, get amount(s) + foreach ($ids as $transactionId) { + $transaction = $repository->findTransaction(intval($transactionId)); + $transactionType = $transaction->transactionJournal->transactionType->type; + + // default amount: + $currencyId = $transaction->transaction_currency_id; + if (!isset($totals[$currencyId])) { + $totals[$currencyId] = [ + 'amount' => '0', + 'currency' => $transaction->transactionCurrency, + 'type' => $transactionType, + ]; + } + // add default amount: + $totals[$currencyId]['amount'] = bcadd($totals[$currencyId]['amount'], app('steam')->positive($transaction->amount)); + + // foreign amount: + if (!is_null($transaction->foreign_amount)) { + $currencyId = $transaction->foreign_currency_id; + if (!isset($totals[$currencyId])) { + $totals[$currencyId] = [ + 'amount' => '0', + 'currency' => $transaction->foreignCurrency, + 'type' => $transactionType, + ]; + } + // add foreign amount: + $totals[$currencyId]['amount'] = bcadd($totals[$currencyId]['amount'], app('steam')->positive($transaction->foreign_amount)); + } + } + $entries = []; + foreach ($totals as $entry) { + $amount = $entry['amount']; + if ($entry['type'] === TransactionType::WITHDRAWAL) { + $amount = bcmul($entry['amount'], '-1'); + } + $entries[] = app('amount')->formatAnything($entry['currency'], $amount, false); + } + $result = ['amounts' => join(' / ', $entries)]; + $cache->store($result); + + return Response::json($result); + } + +} \ No newline at end of file diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 9343a6324e..7b9393909e 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -133,6 +133,22 @@ class TransactionController extends Controller } + /** + * @param Request $request + */ + public function reconcile(Request $request, JournalRepositoryInterface $repository) + { + $transactionIds = $request->get('transactions'); + foreach ($transactionIds as $transactionId) { + $transactionId = intval($transactionId); + $transaction = $repository->findTransaction($transactionId); + Log::debug(sprintf('Transaction ID is %d', $transaction->id)); + + $repository->reconcile($transaction); + } + + } + /** * @param Request $request * @param JournalRepositoryInterface $repository @@ -181,8 +197,6 @@ class TransactionController extends Controller $subTitle = trans('firefly.' . $what) . ' "' . $journal->description . '"'; return view('transactions.show', compact('journal', 'events', 'subTitle', 'what', 'transactions', 'linkTypes', 'links')); - - } /** diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 217f3484fa..504782567b 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -93,6 +93,7 @@ class Transaction extends Model 'identifier' => 'int', 'encrypted' => 'boolean', // model does not have these fields though 'bill_name_encrypted' => 'boolean', + 'reconciled' => 'boolean', ]; protected $fillable = ['account_id', 'transaction_journal_id', 'description', 'amount', 'identifier', 'transaction_currency_id', 'foreign_currency_id', diff --git a/app/Repositories/Journal/JournalRepository.php b/app/Repositories/Journal/JournalRepository.php index f2688a5b68..6738da804e 100644 --- a/app/Repositories/Journal/JournalRepository.php +++ b/app/Repositories/Journal/JournalRepository.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Repositories\Journal; use FireflyIII\Models\Account; +use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionJournal; use FireflyIII\Models\TransactionType; use FireflyIII\User; @@ -123,6 +124,38 @@ class JournalRepository implements JournalRepositoryInterface return $journal; } + /** + * @param Transaction $transaction + * + * @return Transaction|null + */ + public function findOpposingTransaction(Transaction $transaction): ?Transaction + { + $opposing = Transaction::leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id') + ->where('transaction_journals.user_id', $this->user->id) + ->where('transactions.transaction_journal_id', $transaction->transaction_journal_id) + ->where('transactions.identifier', $transaction->identifier) + ->where('amount', bcmul($transaction->amount, '-1')) + ->first(['transactions.*']); + + return $opposing; + } + + /** + * @param int $transactionid + * + * @return Transaction|null + */ + public function findTransaction(int $transactionid): ?Transaction + { + $transaction = Transaction::leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id') + ->where('transaction_journals.user_id', $this->user->id) + ->where('transactions.id', $transactionid) + ->first(['transactions.*']); + + return $transaction; + } + /** * Get users first transaction journal * @@ -158,6 +191,32 @@ class JournalRepository implements JournalRepositoryInterface return $journal->transactionType->type === TransactionType::TRANSFER; } + /** + * @param Transaction $transaction + * + * @return bool + */ + public function reconcile(Transaction $transaction): bool + { + Log::debug(sprintf('Going to reconcile transaction #%d', $transaction->id)); + $opposing = $this->findOpposingTransaction($transaction); + + if (is_null($opposing)) { + Log::debug('Opposing transaction is NULL. Cannot reconcile.'); + + return false; + } + Log::debug(sprintf('Opposing transaction ID is #%d', $opposing->id)); + + $transaction->reconciled = true; + $opposing->reconciled = true; + $transaction->save(); + $opposing->save(); + + return true; + + } + /** * @param TransactionJournal $journal * @param int $order @@ -303,7 +362,7 @@ class JournalRepository implements JournalRepositoryInterface } // update note: - if (isset($data['notes']) && !is_null($data['notes']) ) { + if (isset($data['notes']) && !is_null($data['notes'])) { $this->updateNote($journal, strval($data['notes'])); } @@ -345,7 +404,7 @@ class JournalRepository implements JournalRepositoryInterface $journal->budgets()->detach(); // update note: - if (isset($data['notes']) && !is_null($data['notes']) ) { + if (isset($data['notes']) && !is_null($data['notes'])) { $this->updateNote($journal, strval($data['notes'])); } diff --git a/app/Repositories/Journal/JournalRepositoryInterface.php b/app/Repositories/Journal/JournalRepositoryInterface.php index 41d2b3aed2..1979b0dbce 100644 --- a/app/Repositories/Journal/JournalRepositoryInterface.php +++ b/app/Repositories/Journal/JournalRepositoryInterface.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Repositories\Journal; use FireflyIII\Models\Account; +use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionJournal; use FireflyIII\Models\TransactionType; use FireflyIII\User; @@ -73,6 +74,20 @@ interface JournalRepositoryInterface */ public function find(int $journalId): TransactionJournal; + /** + * @param Transaction $transaction + * + * @return Transaction|null + */ + public function findOpposingTransaction(Transaction $transaction): ?Transaction; + + /** + * @param int $transactionid + * + * @return Transaction|null + */ + public function findTransaction(int $transactionid): ?Transaction; + /** * Get users very first transaction journal * @@ -92,6 +107,13 @@ interface JournalRepositoryInterface */ public function isTransfer(TransactionJournal $journal): bool; + /** + * @param Transaction $transaction + * + * @return bool + */ + public function reconcile(Transaction $transaction): bool; + /** * @param TransactionJournal $journal * @param int $order diff --git a/app/Repositories/Journal/JournalTasker.php b/app/Repositories/Journal/JournalTasker.php index 469deb95b2..8c4f2b2043 100644 --- a/app/Repositories/Journal/JournalTasker.php +++ b/app/Repositories/Journal/JournalTasker.php @@ -28,6 +28,7 @@ use FireflyIII\Models\AccountType; use FireflyIII\Models\PiggyBankEvent; use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionJournal; +use FireflyIII\Support\SingleCacheProperties; use FireflyIII\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\JoinClause; @@ -74,6 +75,13 @@ class JournalTasker implements JournalTaskerInterface */ public function getTransactionsOverview(TransactionJournal $journal): array { + $cache = new SingleCacheProperties; + $cache->addProperty('transaction-overview'); + $cache->addProperty($journal->id); + $cache->addProperty($journal->updated_at); + if ($cache->has()) { + return $cache->get(); + } // get all transaction data + the opposite site in one list. $set = $journal ->transactions()// "source" @@ -136,6 +144,7 @@ class JournalTasker implements JournalTaskerInterface 'journal_type' => $transactionType, 'updated_at' => $journal->updated_at, 'source_id' => $entry->id, + 'source' => $journal->transactions()->find($entry->id), 'source_amount' => $entry->amount, 'foreign_source_amount' => $entry->foreign_amount, 'description' => $entry->description, @@ -174,6 +183,7 @@ class JournalTasker implements JournalTaskerInterface $transactions[] = $transaction; } + $cache->store($transactions); return $transactions; } diff --git a/app/Support/Twig/Extension/Transaction.php b/app/Support/Twig/Extension/Transaction.php index 1dc87d68e0..4efb1fb73b 100644 --- a/app/Support/Twig/Extension/Transaction.php +++ b/app/Support/Twig/Extension/Transaction.php @@ -39,6 +39,7 @@ use Twig_Extension; */ class Transaction extends Twig_Extension { + /** * Can show the amount of a transaction, if that transaction has been collected by the journal collector. * @@ -127,13 +128,13 @@ class Transaction extends Twig_Extension $string = app('amount')->formatAnything($fakeCurrency, $amount, true); // then display (if present) the foreign amount: - if(!is_null($transaction['foreign_source_amount'])) { + if (!is_null($transaction['foreign_source_amount'])) { $amount = $transaction['journal_type'] === TransactionType::WITHDRAWAL ? $transaction['foreign_source_amount'] : $transaction['foreign_destination_amount']; $fakeCurrency = new TransactionCurrency; $fakeCurrency->decimal_places = $transaction['foreign_currency_dp']; $fakeCurrency->symbol = $transaction['foreign_currency_symbol']; - $string .= ' ('.app('amount')->formatAnything($fakeCurrency, $amount, true).')'; + $string .= ' (' . app('amount')->formatAnything($fakeCurrency, $amount, true) . ')'; } $cache->store($string); @@ -401,6 +402,30 @@ class Transaction extends Twig_Extension return $txt; } + /** + * @param TransactionModel $transaction + * + * @return string + */ + public function isReconciled(TransactionModel $transaction): string + { + $cache = new SingleCacheProperties; + $cache->addProperty('transaction-reconciled'); + $cache->addProperty($transaction->id); + $cache->addProperty($transaction->updated_at); + if ($cache->has()) { + return $cache->get(); + } + $icon = ''; + if (intval($transaction->reconciled) === 1) { + $icon = ''; + } + + $cache->store($icon); + + return $icon; + } + /** * @param TransactionModel $transaction * diff --git a/database/migrations/2017_11_04_170844_changes_for_v470a.php b/database/migrations/2017_11_04_170844_changes_for_v470a.php new file mode 100644 index 0000000000..af94df3f41 --- /dev/null +++ b/database/migrations/2017_11_04_170844_changes_for_v470a.php @@ -0,0 +1,33 @@ +boolean('reconciled')->after('deleted_at')->default(0); + } + ); + } +} diff --git a/public/js/ff/transactions/list.js b/public/js/ff/transactions/list.js index 58729480ef..a537436c8e 100644 --- a/public/js/ff/transactions/list.js +++ b/public/js/ff/transactions/list.js @@ -20,6 +20,9 @@ /** global: edit_selected_txt, delete_selected_txt */ +/** + * + */ $(document).ready(function () { "use strict"; $('.mass_edit_all').show(); @@ -44,8 +47,43 @@ $(document).ready(function () { $('.mass_edit').click(goToMassEdit); // click the delete button: $('.mass_delete').click(goToMassDelete); + // click reconcile button + $('.mass_reconcile').click(goToReconcile); }); +/** + * + * @returns {boolean} + */ +function goToReconcile() { + + var checked = $('.select_all_single:checked'); + var ids = []; + $.each(checked, function (i, v) { + ids.push(parseInt($(v).data('transaction'))); + }); + + // go to specially crafted URL: + var bases = document.getElementsByTagName('base'); + var baseHref = null; + + if (bases.length > 0) { + baseHref = bases[0].href; + } + + $.post(baseHref + 'transactions/reconcile', {transactions: ids}).done(function () { + alert('OK then.'); + }).fail(function () { + alert('Could not reconcile transactions: please check the logs and try again later.'); + }); + + return false; +} + +/** + * + * @returns {boolean} + */ function goToMassEdit() { "use strict"; var checkedArray = getCheckboxes(); @@ -62,6 +100,10 @@ function goToMassEdit() { return false; } +/** + * + * @returns {boolean} + */ function goToMassDelete() { "use strict"; var checkedArray = getCheckboxes(); @@ -77,6 +119,10 @@ function goToMassDelete() { return false; } +/** + * + * @returns {Array} + */ function getCheckboxes() { "use strict"; var list = []; @@ -90,13 +136,19 @@ function getCheckboxes() { return list; } - +/** + * + */ function countChecked() { "use strict"; var checked = $('.select_all_single:checked').length; if (checked > 0) { $('.mass_edit span').text(edit_selected_txt + ' (' + checked + ')'); $('.mass_delete span').text(delete_selected_txt + ' (' + checked + ')'); + + // get amount for the transactions: + getAmounts(); + $('.mass_button_options').show(); } else { @@ -104,17 +156,49 @@ function countChecked() { } } +function getAmounts() { + $('.mass_reconcile span').html(reconcile_selected_txt + ' ()'); + var checked = $('.select_all_single:checked'); + var ids = []; + $.each(checked, function (i, v) { + ids.push(parseInt($(v).data('transaction'))); + }); + // go to specially crafted URL: + var bases = document.getElementsByTagName('base'); + var baseHref = null; + + if (bases.length > 0) { + baseHref = bases[0].href; + } + + $.getJSON(baseHref + 'json/transactions/amount', {transactions: ids}).done(function (data) { + $('.mass_reconcile span').text(reconcile_selected_txt + ' (' + data.amounts + ')'); + console.log(data); + }); + return; +} + +/** + * + */ function checkAll() { "use strict"; $('.select_all_single').prop('checked', true); } +/** + * + */ function uncheckAll() { "use strict"; $('.select_all_single').prop('checked', false); } +/** + * + * @returns {boolean} + */ function stopMassSelect() { "use strict"; @@ -145,6 +229,10 @@ function stopMassSelect() { return false; } +/** + * + * @returns {boolean} + */ function startMassSelect() { "use strict"; // show "select all" box in table header. diff --git a/resources/lang/en_US/firefly.php b/resources/lang/en_US/firefly.php index d2b50eeb36..36db837878 100644 --- a/resources/lang/en_US/firefly.php +++ b/resources/lang/en_US/firefly.php @@ -663,8 +663,7 @@ return [ 'stored_journal' => 'Successfully created new transaction ":description"', 'select_transactions' => 'Select transactions', 'stop_selection' => 'Stop selecting transactions', - 'edit_selected' => 'Edit selected', - 'delete_selected' => 'Delete selected', + 'reconcile_selected' => 'Reconcile', 'mass_delete_journals' => 'Delete a number of transactions', 'mass_edit_journals' => 'Edit a number of transactions', 'cannot_edit_other_fields' => 'You cannot mass-edit other fields than the ones here, because there is no room to show them. Please follow the link and edit them by one-by-one, if you need to edit these fields.', diff --git a/resources/views/list/journals.twig b/resources/views/list/journals.twig index 4b202354a2..fdb0685e80 100644 --- a/resources/views/list/journals.twig +++ b/resources/views/list/journals.twig @@ -33,13 +33,15 @@