Allow user to set multi-currency available budget. WIP

This commit is contained in:
James Cole 2019-09-01 10:48:18 +02:00
parent 509276e20b
commit 4cd52963a6
11 changed files with 236 additions and 56 deletions

View File

@ -24,15 +24,23 @@ declare(strict_types=1);
namespace FireflyIII\Http\Controllers\Budget;
use Carbon\Carbon;
use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Http\Controllers\Controller;
use FireflyIII\Models\AvailableBudget;
use FireflyIII\Models\Budget;
use FireflyIII\Models\BudgetLimit;
use FireflyIII\Models\TransactionCurrency;
use FireflyIII\Repositories\Budget\AvailableBudgetRepositoryInterface;
use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface;
use FireflyIII\Repositories\Budget\BudgetRepositoryInterface;
use FireflyIII\Repositories\Budget\OperationsRepositoryInterface;
use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface;
use FireflyIII\Support\Http\Controllers\DateCalculation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Log;
/**
*
@ -40,6 +48,7 @@ use Illuminate\Http\Request;
*/
class BudgetLimitController extends Controller
{
use DateCalculation;
/** @var AvailableBudgetRepositoryInterface */
private $abRepository;
@ -73,6 +82,35 @@ class BudgetLimitController extends Controller
);
}
/**
* @param Budget $budget
* @param Carbon $start
* @param Carbon $end
*
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function create(Budget $budget, Carbon $start, Carbon $end)
{
$collection = $this->currencyRepos->getEnabled();
$budgetLimits = $this->blRepository->getBudgetLimits($budget, $start, $end);
// remove already budgeted currencies:
$currencies = $collection->filter(
static function (TransactionCurrency $currency) use ($budgetLimits) {
/** @var AvailableBudget $budget */
foreach ($budgetLimits as $budget) {
if ($budget->transaction_currency_id === $currency->id) {
return false;
}
}
return true;
}
);
return view('budgets.budget-limits.create', compact('start', 'end', 'currencies', 'budget'));
}
/**
* @param Request $request
* @param BudgetLimit $budgetLimit
@ -90,21 +128,62 @@ class BudgetLimitController extends Controller
/**
* @param Request $request
*
* @return JsonResponse
* @return JsonResponse|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws FireflyException
*/
public function store(Request $request): JsonResponse
public function store(Request $request)
{
$limit = $this->blRepository->store(
[
'budget_id' => $request->get('budget_id'),
'transaction_currency_id' => $request->get('transaction_currency_id'),
'start_date' => $request->get('start'),
'end_date' => $request->get('end'),
'amount' => $request->get('amount'),
]
);
// first search for existing one and update it if necessary.
$currency = $this->currencyRepos->find((int)$request->get('transaction_currency_id'));
$budget = $this->repository->findNull((int)$request->get('budget_id'));
if (null === $currency || null === $budget) {
throw new FireflyException('No valid currency or budget.');
}
$start = Carbon::createFromFormat('Y-m-d', $request->get('start'));
$end = Carbon::createFromFormat('Y-m-d', $request->get('end'));
$start->startOfDay();
$end->endOfDay();
return response()->json($limit->toArray());
Log::debug(sprintf('Start: %s, end: %s', $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')));
$limit = $this->blRepository->find($budget, $currency, $start, $end);
if (null !== $limit) {
$limit->amount = $request->get('amount');
$limit->save();
}
if (null === $limit) {
$limit = $this->blRepository->store(
[
'budget_id' => $request->get('budget_id'),
'transaction_currency_id' => $request->get('transaction_currency_id'),
'start_date' => $request->get('start'),
'end_date' => $request->get('end'),
'amount' => $request->get('amount'),
]
);
}
if ($request->expectsJson()) {
$array = $limit->toArray();
// add some extra meta data:
$spentArr = $this->opsRepository->sumExpenses($limit->start_date, $limit->end_date, null, new Collection([$budget]), $currency);
$array['spent'] = $spentArr[$currency->id]['sum'] ?? '0';
$array['left_formatted'] = app('amount')->formatAnything($limit->transactionCurrency, bcadd($array['spent'], $array['amount']));
$array['amount_formatted'] = app('amount')->formatAnything($limit->transactionCurrency, $limit['amount']);
$array['days_left'] = (string)$this->activeDaysLeft($start, $end);
// left per day:
$array['left_per_day'] = bcdiv(bcadd($array['spent'], $array['amount']), $array['days_left']);
// left per day formatted.
$array['left_per_day_formatted'] = app('amount')->formatAnything($limit->transactionCurrency, $array['left_per_day']);
return response()->json($array);
}
return redirect(route('budgets.index'));
}
/**

View File

@ -149,6 +149,7 @@ class IndexController extends Controller
// number of days for consistent budgeting.
$activeDaysPassed = $this->activeDaysPassed($start, $end); // see method description.
$activeDaysLeft = $this->activeDaysLeft($start, $end); // see method description.
Log::debug(sprintf('Start: %s, end: %s', $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')));
// get all budgets, and paginate them into $budgets.
$collection = $this->repository->getActiveBudgets();
@ -221,18 +222,14 @@ class IndexController extends Controller
public function reorder(Request $request, BudgetRepositoryInterface $repository): JsonResponse
{
$budgetIds = $request->get('budgetIds');
$page = (int)$request->get('page');
$pageSize = (int)app('preferences')->get('listPageSize', 50)->data;
$currentOrder = (($page - 1) * $pageSize) + 1;
foreach ($budgetIds as $budgetId) {
foreach ($budgetIds as $index => $budgetId) {
$budgetId = (int)$budgetId;
$budget = $repository->findNull($budgetId);
if (null !== $budget) {
Log::debug(sprintf('Set budget #%d ("%s") to position %d', $budget->id, $budget->name, $currentOrder));
$repository->setBudgetOrder($budget, $currentOrder);
Log::debug(sprintf('Set budget #%d ("%s") to position %d', $budget->id, $budget->name, $index + 1));
$repository->setBudgetOrder($budget, $index + 1);
}
$currentOrder++;
}
return response()->json(['OK']);

View File

@ -99,6 +99,22 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface
}
}
/**
* @param Budget $budget
* @param TransactionCurrency $currency
* @param Carbon $start
* @param Carbon $end
*
* @return BudgetLimit|null
*/
public function find(Budget $budget, TransactionCurrency $currency, Carbon $start, Carbon $end): ?BudgetLimit
{
return $budget->budgetlimits()
->where('transaction_currency_id', $currency->id)
->where('start_date', $start->format('Y-m-d'))
->where('end_date', $end->format('Y-m-d'))->first();
}
/**
* @param Carbon $start
* @param Carbon $end

View File

@ -55,6 +55,16 @@ interface BudgetLimitRepositoryInterface
*/
public function destroyBudgetLimit(BudgetLimit $budgetLimit): void;
/**
* @param Budget $budget
* @param TransactionCurrency $currency
* @param Carbon $start
* @param Carbon $end
*
* @return BudgetLimit|null
*/
public function find(Budget $budget, TransactionCurrency $currency, Carbon $start, Carbon $end): ?BudgetLimit;
/**
* TODO this method is not multi-currency aware.
*

View File

@ -28,7 +28,6 @@ use FireflyIII\Models\Budget;
use FireflyIII\Models\BudgetLimit;
use FireflyIII\Models\RuleAction;
use FireflyIII\Models\RuleTrigger;
use FireflyIII\Models\TransactionCurrency;
use FireflyIII\Services\Internal\Destroy\BudgetDestroyService;
use FireflyIII\User;
use Illuminate\Support\Collection;
@ -55,7 +54,6 @@ class BudgetRepository implements BudgetRepositoryInterface
/**
* @return bool
* // it's 5.
*/
public function cleanupBudgets(): bool
{
@ -65,23 +63,14 @@ class BudgetRepository implements BudgetRepositoryInterface
} catch (Exception $e) {
Log::debug(sprintf('Could not delete budget limit: %s', $e->getMessage()));
}
Budget::where('order', 0)->update(['order' => 100]);
// do the clean up by hand because Sqlite can be tricky with this.
$budgetLimits = BudgetLimit::orderBy('created_at', 'DESC')->get(['id', 'budget_id', 'start_date', 'end_date']);
$count = [];
/** @var BudgetLimit $budgetLimit */
foreach ($budgetLimits as $budgetLimit) {
$key = $budgetLimit->budget_id . '-' . $budgetLimit->start_date->format('Y-m-d') . $budgetLimit->end_date->format('Y-m-d');
if (isset($count[$key])) {
// delete it!
try {
BudgetLimit::find($budgetLimit->id)->delete();
} catch (Exception $e) {
Log::debug(sprintf('Could not delete budget limit: %s', $e->getMessage()));
}
}
$count[$key] = true;
$budgets = $this->getActiveBudgets();
/**
* @var int $index
* @var Budget $budget
*/
foreach ($budgets as $index => $budget) {
$budget->order = $index + 1;
$budget->save();
}
return true;

View File

@ -39,6 +39,8 @@ $(function () {
$('.create_ab_alt').on('click', createAltAvailableBudget);
$('.budget_amount').on('change', updateBudgetedAmount);
$('.create_bl').on('click', createBudgetLimit);
/*
When the input changes, update the percentages for the budgeted bar:
@ -49,8 +51,7 @@ $(function () {
var selected = $(e.currentTarget);
if (selected.find(":selected").val() !== "x") {
var newUri = budgetIndexUri.replace("START", selected.find(":selected").data('start')).replace('END', selected.find(":selected").data('end'));
console.log(newUri);
window.location.assign(newUri + "?page=" + page);
window.location.assign(newUri);
}
});
@ -88,9 +89,9 @@ function updateBudgetedAmount(e) {
var budgetId = parseInt(input.data('id'));
var budgetLimitId = parseInt(input.data('limit'));
var currencyId = parseInt(input.data('currency'));
console.log(budgetLimitId);
input.prop('disabled', true);
if (0 === budgetLimitId) {
$.post(createBudgetLimitUri, {
$.post(storeBudgetLimitUri, {
_token: token,
budget_id: budgetId,
transaction_currency_id: currencyId,
@ -99,12 +100,21 @@ function updateBudgetedAmount(e) {
end: periodEnd
}).done(function (data) {
alert('done!');
input.prop('disabled', false);
// update amount left.
$('.left_span[data-limit="0"][data-id="' + budgetId + '"]').html(data.left_formatted);
if (data.left_per_day > 0) {
$('.left_span[data-limit="0"][data-id="' + budgetId + '"]').html(data.left_formatted + '(' + data.left_per_day_formatted + ')');
}
console.log(data);
//$('.left_span[data-limit="0"][data-id="' + budgetId + '"]').text('XXXXX');
}).fail(function () {
alert('I failed :(');
});
} else {
$.post(updateBudgetLimitUri.replace('REPLACEME', budgetLimitId), {
$.post(updateBudgetLimitUri.replace('REPLACEME', budgetLimitId.toString()), {
_token: token,
amount: input.val(),
}).done(function (data) {
@ -142,14 +152,21 @@ function sortStop(event, ui) {
});
var arr = {
budgetIds: submit,
page: page,
_token: token
};
$.post('budgets/reorder', arr);
}
function createAltAvailableBudget(e) {
function createBudgetLimit(e) {
var button = $(e.currentTarget);
var budgetId = button.data('id');
$('#defaultModal').empty().load(createBudgetLimitUri.replace('REPLACEME', budgetId.toString()), function () {
$('#defaultModal').modal('show');
});
return false;
}
function createAltAvailableBudget(e) {
$('#defaultModal').empty().load(createAltAvailableBudgetUri, function () {
$('#defaultModal').modal('show');
});
@ -180,18 +197,15 @@ function drawBudgetedBars() {
var bar = $(v);
var budgeted = parseFloat(bar.data('budgeted'));
var available = parseFloat(bar.data('available'));
console.log('Budgeted bar for bar ' + bar.data('id'));
var budgetedTooMuch = budgeted > available;
var pct;
if (budgetedTooMuch) {
console.log('over budget');
// budgeted too much.
pct = (available / budgeted) * 100;
bar.find('.progress-bar-warning').css('width', pct + '%');
bar.find('.progress-bar-danger').css('width', (100 - pct) + '%');
bar.find('.progress-bar-info').css('width', 0);
} else {
console.log('under budget');
pct = (budgeted / available) * 100;
bar.find('.progress-bar-warning').css('width', 0);
bar.find('.progress-bar-danger').css('width', 0);

View File

@ -700,6 +700,8 @@ return [
'update_amount' => 'Update amount',
'update_budget' => 'Update budget',
'update_budget_amount_range' => 'Update (expected) available amount between :start and :end',
'set_budget_limit_title' => 'Set budgeted amount for budget :budget between :start and :end',
'set_budget_limit' => 'Set budgeted amount',
'budget_period_navigator' => 'Period navigator',
'info_on_available_amount' => 'What do I have available?',
'available_amount_indication' => 'Use these amounts to get an indication of what your total budget could be.',

View File

@ -15,7 +15,6 @@
<input type="hidden" name="start" value="{{ start.format('Y-m-d') }}"/>
<input type="hidden" name="end" value="{{ end.format('Y-m-d') }}"/>
<input type="hidden" name="page" value="{{ page }}"/>
<input type="hidden" name="currency_id" value="{{ currency.id }}"/>
<div class="form-group">
<select class="form-control" name="currency_id">

View File

@ -0,0 +1,36 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span><span class="sr-only">{{ 'close'|_ }}</span>
</button>
<h4 class="modal-title">
{{ trans('firefly.set_budget_limit_title',
{start: start.formatLocalized(monthAndDayFormat), end: end.formatLocalized(monthAndDayFormat), budget: budget.name}) }}
</h4>
</div>
<form style="display: inline;" id="income" action="{{ route('budget-limits.store') }}" method="POST">
<div class="modal-body">
<input type="hidden" name="_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="start" value="{{ start.format('Y-m-d') }}"/>
<input type="hidden" name="end" value="{{ end.format('Y-m-d') }}"/>
<input type="hidden" name="budget_id" value="{{ budget.id }}"/>
<div class="form-group">
<select class="form-control" name="transaction_currency_id">
{% for currency in currencies %}
<option label="{{ currency.name }}" value="{{ currency.id }}">{{ currency.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<input step="any" class="form-control" id="amount" value="" autocomplete="off" name="amount" type="number"/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'close'|_ }}</button>
<button type="submit" class="btn btn-primary">{{ 'set_budget_limit'|_ }}</button>
</div>
</form>
</div>
</div>

View File

@ -270,7 +270,7 @@
{% endfor %}
{% endif %}
{% if budget.budgeted|length < currencies.count %}
<a href="#" class="btn btn-success btn-xs create_ab_alt">
<a href="#" class="btn btn-success btn-xs create_bl" data-id="{{ budget.id }}">
<i class="fa fa-plus-circle"></i>
{{ 'bl_create_btn'|_ }}</a>
{% endif %}
@ -279,26 +279,60 @@
{% for spentInfo in budget.spent %}
{{ formatAmountBySymbol(spentInfo.spent, spentInfo.currency_symbol, spentInfo.currency_decimal_places) }}
({{ formatAmountBySymbol(spentInfo.spent / activeDaysPassed, spentInfo.currency_symbol, spentInfo.currency_decimal_places) }})
<br/>
{% endfor %}
</td>
<td class="left" data-id="{{ budget.id }}">
{% for spentInfo in budget.spent %}
{% set countLimit = 0 %}
{% for budgetLimit in budget.budgeted %}
{% if spentInfo.currency_id == budgetLimit.currency_id %}
{% set countLimit = countLimit + 1 %}
<span class="left_span" data-currency="{{ spentInfo.currency_id }}" data-limit="{{ budgetLimit.id }}"
data-value="{{ spentInfo.spent + budgetLimit.amount }}" class="amount_left">
{{ formatAmountBySymbol(spentInfo.spent + budgetLimit.amount, spentInfo.currency_symbol, spentInfo.currency_decimal_places) }}
{% if spentInfo.spent + budgetLimit.amount > 0 %}
({{ formatAmountBySymbol((spentInfo.spent + budgetLimit.amount) / activeDaysLeft, spentInfo.currency_symbol, spentInfo.currency_decimal_places) }})
{% else %}
({{ formatAmountBySymbol(0, spentInfo.currency_symbol, spentInfo.currency_decimal_places) }})
{% endif %}
</span>
<br/>
{% endif %}
{% endfor %}
{% if countLimit == 0 %}
<span class="left_span" data-id="{{ budget.id }}" data-currency="{{ spentInfo.currency_id }}" data-limit="0"
class="amount_left" data-value="{{ spentInfo.spent }}">
{{ formatAmountBySymbol(spentInfo.spent, spentInfo.currency_symbol, spentInfo.currency_decimal_places) }}
</span>
<br/>
{% endif %}
{% endfor %}
<!-- only makes sense to list what's left for budgeted amounts.-->
<!--
{% if budget.budgeted|length > 0 %}
{% for budgetLimit in budget.budgeted %}
{% for spentInfo in budget.spent %}
{% if spentInfo.currency_id == budgetLimit.currency_id %}
<span data-currency="{{ spentInfo.currency_id }}" data-limit="{{ budgetLimit.id }}" class="amount_left">
{{ formatAmountBySymbol(spentInfo.spent + budgetLimit.amount, spentInfo.currency_symbol, spentInfo.currency_decimal_places) }}
{% if spentInfo.spent + budgetLimit.amount > 0 %}
({{ formatAmountBySymbol((spentInfo.spent + budgetLimit.amount) / activeDaysLeft, spentInfo.currency_symbol, spentInfo.currency_decimal_places) }})
{% if spentInfo.spent + budgetLimit.amount > 0 %}
({{ formatAmountBySymbol((spentInfo.spent + budgetLimit.amount) / activeDaysLeft, spentInfo.currency_symbol, spentInfo.currency_decimal_places) }})
{% else %}
({{ formatAmountBySymbol(0, spentInfo.currency_symbol, spentInfo.currency_decimal_places) }})
{% endif %}
{% endif %}
</span>
<br/>
{% endif %}
{% endfor %}
{% endfor %}
{% endif %}
-->
{#{{ "-1"|formatAmount }}#}
{#{{ (repAmount + budgetInformation[budget.id]['spent'])|formatAmount }}#}
@ -381,7 +415,8 @@
var createAltAvailableBudgetUri = "{{ route('available-budgets.create-alternative', [start.format('Y-m-d'), end.format('Y-m-d')]) }}";
var editAvailableBudgetUri = "{{ route('available-budgets.edit', ['REPLACEME']) }}";
var createBudgetLimitUri = "{{ route('budget-limits.store') }}";
var createBudgetLimitUri = "{{ route('budget-limits.create', ['REPLACEME',start.format('Y-m-d'), end.format('Y-m-d')]) }}";
var storeBudgetLimitUri = "{{ route('budget-limits.store') }}";
var updateBudgetLimitUri = "{{ route('budget-limits.update', ['REPLACEME']) }}";
{#var budgetAmountUri = "{{ route('budgets.amount','REPLACE') }}";#}

View File

@ -258,8 +258,11 @@ Route::group(
['middleware' => 'user-full-auth', 'namespace' => 'FireflyIII\Http\Controllers', 'prefix' => 'budget-limits', 'as' => 'budget-limits.'],
static function () {
Route::get('delete/{budgetLimit}', ['uses' => 'Budget\BudgetLimitController@delete', 'as' => 'delete']);
Route::get('create/{budget}/{start_date}/{end_date}', ['uses' => 'Budget\BudgetLimitController@create', 'as' => 'create']);
Route::post('store', ['uses' => 'Budget\BudgetLimitController@store', 'as' => 'store']);
Route::get('delete/{budgetLimit}', ['uses' => 'Budget\BudgetLimitController@delete', 'as' => 'delete']);
Route::post('update/{budgetLimit}', ['uses' => 'Budget\BudgetLimitController@update', 'as' => 'update']);
}
);