Code that allows transaction reconciliation. #736

This commit is contained in:
James Cole 2017-11-05 19:48:43 +01:00
parent bb46d034cd
commit 33d89d52c2
No known key found for this signature in database
GPG Key ID: C16961E655E74B5E
16 changed files with 387 additions and 35 deletions

View File

@ -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;
}

View File

@ -0,0 +1,93 @@
<?php
/**
* TransactionController.php
* Copyright (c) 2017 thegrumpydictator@gmail.com
*
* This file is part of Firefly III.
*
* Firefly III is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Firefly III is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Firefly III. If not, see <http://www.gnu.org/licenses/>.
*/
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);
}
}

View File

@ -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'));
}
/**

View File

@ -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',

View File

@ -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']));
}

View File

@ -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

View File

@ -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;
}

View File

@ -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 = '<i class="fa fa-check"></i>';
}
$cache->store($icon);
return $icon;
}
/**
* @param TransactionModel $transaction
*

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class ChangesForV470a extends Migration
{
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
//
}
/**
* Run the migrations.
*
* @SuppressWarnings(PHPMD.ShortMethodName)
* @return void
*/
public function up()
{
Schema::table(
'transactions', function (Blueprint $table) {
$table->boolean('reconciled')->after('deleted_at')->default(0);
}
);
}
}

View File

@ -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 + ' (<i class="fa fa-spinner fa-spin "></i>)');
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.

View File

@ -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.',

View File

@ -33,13 +33,15 @@
</tbody>
</table>
<div class="row mass_edit_all hidden-xs" style="display: none;">
<div class="col-lg-6 col-md-12 col-sm-12 col-xs-12">
<div class="col-lg-8 col-md-12 col-sm-12 col-xs-12">
<div class="mass_button_options btn-group btn-group" style="display:none;">
<a href="#" class="btn btn-default mass_edit"><i class="fa fa-fw fa-pencil"></i> <span>{{ 'edit_selected'|_ }}</span></a>
<a href="#" class="btn btn-danger mass_delete"><i class="fa fa-fw fa-trash"></i> <span>{{ 'delete_selected'|_ }}</span></a>
<a href="#" class="btn btn-default mass_edit"><i class="fa fa-fw fa-pencil"></i> <span>{{ 'edit'|_ }}</span></a>
<a href="#" class="btn btn-default mass_reconcile"><i class="fa fa-fw fa-check"></i> <span>{{ 'reconcile_selected'|_ }} (<i class="fa fa-spinner fa-spin"></i>)</span></a>
<a href="#" class="btn btn-danger mass_delete"><i class="fa fa-fw fa-trash"></i> <span>{{ 'delete'|_ }}</span></a>
</div>
</div>
<div class="col-lg-6 col-md-12 col-sm-12 col-xs-12 hidden-xs">
<div class="col-lg-4 col-md-12 col-sm-12 col-xs-12 hidden-xs">
<div class="mass_buttons btn-group btn-group pull-right">
<a href="#" class="btn btn-default mass_select"><i class="fa fa-fw fa-check-square-o"></i> {{ 'select_transactions'|_ }}</a>
@ -54,6 +56,7 @@
</div>
</div>
<script type="text/javascript">
var edit_selected_txt = "{{ 'edit_selected'|_ }}";
var delete_selected_txt = "{{ 'delete_selected'|_ }}";
var edit_selected_txt = "{{ 'edit'|_ }}";
var delete_selected_txt = "{{ 'delete'|_ }}";
var reconcile_selected_txt = "{{ 'reconcile_selected'|_ }}";
</script>

View File

@ -1,9 +1,11 @@
<tr class="drag" data-date="{{ transaction.date.format('Y-m-d') }}" data-id="{{ transaction.journal_id }}">
<tr class="drag" data-date="{{ transaction.date.format('Y-m-d') }}" data-id="{{ transaction.journal_id }}"
data-transaction-id="{{ transaction.id }}"
>
{# input buttons #}
<td class="hidden-xs">
<div class="select_single" style="display:none;">
<input name="select_all_single[]" class="select_all_single" value="{{ transaction.journal_id }}" type="checkbox"/>
<input name="select_all_single[]" class="select_all_single" data-transaction="{{ transaction.id }}" value="{{ transaction.journal_id }}" type="checkbox"/>
</div>
<div class="btn-group btn-group-xs edit_buttons edit_tr_buttons">{% if sorting %}<a href="#" class="handle btn btn-default btn-xs"><i
class="fa fa-fw fa-arrows-v"></i></a>{% endif %}<a href="{{ route('transactions.edit',transaction.journal_id) }}"
@ -19,13 +21,19 @@
{# description #}
<td>
{# count attachments #}
{{ transaction|transactionReconciled }}
<a href="{{ route('transactions.show',transaction.journal_id) }}">
{{ transaction|transactionDescription }}
</a>
{# is a split journal #}
{{ transaction|transactionIsSplit }}
{# count attachments #}
{{ transaction|transactionHasAtt }}
</td>
<td style="text-align: right;"><span style="margin-right:5px;">{{ transaction|transactionAmount }}</span></td>
<td class="hidden-sm hidden-xs">

View File

@ -107,8 +107,10 @@
{% endblock %}
{% block scripts %}
<script type="text/javascript">
var edit_selected_txt = "{{ 'edit_selected'|_ }}";
var delete_selected_txt = "{{ 'delete_selected'|_ }}";
var edit_selected_txt = "{{ 'edit'|_ }}";
var delete_selected_txt = "{{ 'delete'|_ }}";
var reconcile_selected_txt = "{{ 'reconcile_selected'|_ }}";
var searchQuery = "{{ fullQuery|escape('js') }}";
var searchUri = "{{ route('search.search') }}";
</script>

View File

@ -413,24 +413,13 @@
{{ formatDestinationBefore(transaction) }} &rarr; {{ formatDestinationAfter(transaction) }}
</td>
<td>
{% if journal.transactionType.type == "Withdrawal" %}
{{ formatAmountBySymbol(transaction.source_amount, transaction.transaction_currency_symbol, transaction.transaction_currency_dp, true) }}
{% if(transaction.foreign_source_amount) %}
({{ formatAmountBySymbol(transaction.foreign_source_amount, transaction.foreign_currency_symbol, transaction.foreign_currency_dp, true) }})
{% endif %}
{% else %}
{{ formatAmountBySymbol(transaction.destination_amount, transaction.transaction_currency_symbol,2) }}
{% if(transaction.foreign_source_amount) %}
({{ formatAmountBySymbol(transaction.foreign_destination_amount, transaction.foreign_currency_symbol, transaction.foreign_currency_dp, true) }})
{% endif %}
{% endif %}
{{ transaction|transactionArrayAmount }}
</td>
<td class="hidden-md hidden-xs">
{{ transactionIdBudgets(transaction.source_id) }}
{{ transaction.source|transactionBudgets }}
</td>
<td class="hidden-md hidden-xs">
{{ transactionIdCategories(transaction.source_id) }}
{{ transaction.source|transactionCategories }}
</td>
</tr>
{% endfor %}

View File

@ -462,6 +462,9 @@ Route::group(
// frontpage
Route::get('frontpage/piggy-banks', ['uses' => 'Json\FrontpageController@piggyBanks', 'as' => 'fp.piggy-banks']);
// amount reconciliation
Route::get('transactions/amount', ['uses' => 'Json\TransactionController@amounts', 'as' => 'transactions.amounts']);
// currency conversion:
Route::get('rate/{fromCurrencyCode}/{toCurrencyCode}/{date}', ['uses' => 'Json\ExchangeController@getRate', 'as' => 'rate']);
@ -700,6 +703,7 @@ Route::group(
Route::get('{what}/{moment?}', ['uses' => 'TransactionController@index', 'as' => 'index'])->where(['what' => 'withdrawal|deposit|transfers|transfer']);
Route::get('show/{tj}', ['uses' => 'TransactionController@show', 'as' => 'show']);
Route::post('reorder', ['uses' => 'TransactionController@reorder', 'as' => 'reorder']);
Route::post('reconcile', ['uses' => 'TransactionController@reconcile', 'as' => 'reconcile']);
}
);