Better management and calculation of available budgets.

This commit is contained in:
James Cole 2023-04-26 11:31:46 +02:00
parent 1c7988fad2
commit 28efc15820
No known key found for this signature in database
GPG Key ID: B49A324B7EAD6D80
12 changed files with 464 additions and 67 deletions

View File

@ -88,8 +88,8 @@ abstract class Controller extends BaseController
if ($page < 1) {
$page = 1;
}
if ($page > pow(2,16)) {
$page = pow(2,16);
if ($page > pow(2, 16)) {
$page = pow(2, 16);
}
$bag->set('page', $page);

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* Created.php
* Copyright (c) 2023 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace FireflyIII\Events\Model\BudgetLimit;
use FireflyIII\Events\Event;
use FireflyIII\Models\BudgetLimit;
use Illuminate\Queue\SerializesModels;
/**
* Class Created
*/
class Created extends Event
{
use SerializesModels;
public BudgetLimit $budgetLimit;
/**
* @param BudgetLimit $budgetLimit
*/
public function __construct(BudgetLimit $budgetLimit)
{
$this->budgetLimit = $budgetLimit;
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* Created.php
* Copyright (c) 2023 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace FireflyIII\Events\Model\BudgetLimit;
use FireflyIII\Events\Event;
use FireflyIII\Models\BudgetLimit;
use Illuminate\Queue\SerializesModels;
/**
* Class Deleted
*/
class Deleted extends Event
{
use SerializesModels;
public BudgetLimit $budgetLimit;
/**
* @param BudgetLimit $budgetLimit
*/
public function __construct(BudgetLimit $budgetLimit)
{
$this->budgetLimit = $budgetLimit;
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* Created.php
* Copyright (c) 2023 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace FireflyIII\Events\Model\BudgetLimit;
use FireflyIII\Events\Event;
use FireflyIII\Models\BudgetLimit;
use Illuminate\Queue\SerializesModels;
/**
* Class Updated
*/
class Updated extends Event
{
use SerializesModels;
public BudgetLimit $budgetLimit;
/**
* @param BudgetLimit $budgetLimit
*/
public function __construct(BudgetLimit $budgetLimit)
{
$this->budgetLimit = $budgetLimit;
}
}

View File

@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
/*
* BudgetLimitHandler.php
* Copyright (c) 2023 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace FireflyIII\Handlers\Events\Model;
use FireflyIII\Events\Model\BudgetLimit\Created;
use FireflyIII\Events\Model\BudgetLimit\Deleted;
use FireflyIII\Events\Model\BudgetLimit\Updated;
use FireflyIII\Models\AvailableBudget;
use FireflyIII\Models\BudgetLimit;
use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface;
use Illuminate\Support\Facades\Log;
use Spatie\Period\Boundaries;
use Spatie\Period\Period;
use Spatie\Period\Precision;
/**
* Class BudgetLimitHandler
*/
class BudgetLimitHandler
{
/**
* @param Created $event
* @return void
*/
public function created(Created $event): void
{
Log::debug(sprintf('BudgetLimitHandler::created(%s)', $event->budgetLimit->id));
$this->updateAvailableBudget($event->budgetLimit);
}
/**
* @param Updated $event
* @return void
*/
public function updated(Updated $event): void
{
Log::debug(sprintf('BudgetLimitHandler::updated(%s)', $event->budgetLimit->id));
$this->updateAvailableBudget($event->budgetLimit);
}
/**
* @param Deleted $event
* @return void
*/
public function deleted(Deleted $event): void
{
Log::debug(sprintf('BudgetLimitHandler::deleted(%s)', $event->budgetLimit->id));
$this->updateAvailableBudget($event->budgetLimit);
}
/**
* @param AvailableBudget $availableBudget
* @return void
*/
private function calculateAmount(AvailableBudget $availableBudget): void
{
$repository = app(BudgetLimitRepositoryInterface::class);
$repository->setUser($availableBudget->user);
$newAmount = '0';
$abPeriod = Period::make($availableBudget->start_date, $availableBudget->end_date, Precision::DAY());
Log::debug(
sprintf(
'Now at AB #%d, ("%s" to "%s")',
$availableBudget->id,
$availableBudget->start_date->format('Y-m-d'),
$availableBudget->end_date->format('Y-m-d')
)
);
// have to recalc everything just in case.
$set = $repository->getAllBudgetLimitsByCurrency($availableBudget->transactionCurrency, $availableBudget->start_date, $availableBudget->end_date);
/** @var BudgetLimit $budgetLimit */
foreach ($set as $budgetLimit) {
Log::debug(
sprintf(
'Found interesting budget limit #%d ("%s" to "%s")',
$budgetLimit->id,
$budgetLimit->start_date->format('Y-m-d'),
$budgetLimit->end_date->format('Y-m-d')
)
);
// overlap in days:
$limitPeriod = Period::make(
$budgetLimit->start_date,
$budgetLimit->end_date,
precision: Precision::DAY(),
boundaries: Boundaries::EXCLUDE_NONE()
);
// if both equal eachother, amount from this BL must be added to the AB
if ($limitPeriod->equals($abPeriod)) {
Log::debug('Limit period is the same as AB period.');
$newAmount = bcadd($newAmount, $budgetLimit->amount);
}
// if budget limit period inside AB period, can be added in full.
if (!$limitPeriod->equals($abPeriod) && $abPeriod->contains($limitPeriod)) {
Log::debug('Limit period falls inside of AB period.');
$newAmount = bcadd($newAmount, $budgetLimit->amount);
}
if (!$limitPeriod->equals($abPeriod) && $abPeriod->overlapsWith($limitPeriod)) {
Log::debug('Limit period overlaps AB period.');
$overlap = $abPeriod->overlap($limitPeriod);
if (null !== $overlap) {
$length = $overlap->length();
$daily = bcmul($this->getDailyAmount($budgetLimit), (string)$length);
Log::debug(sprintf('Length of overlap is %d days, so daily amount is %s', $length, $daily));
$newAmount = bcadd($newAmount, $daily);
}
}
}
Log::debug(sprintf('Concluded new amount for this AB must be %s', $newAmount));
$availableBudget->amount = $newAmount;
$availableBudget->save();
}
/**
* @param BudgetLimit $budgetLimit
* @return string
*/
private function getDailyAmount(BudgetLimit $budgetLimit): string
{
$limitPeriod = Period::make(
$budgetLimit->start_date,
$budgetLimit->end_date,
precision: Precision::DAY(),
boundaries: Boundaries::EXCLUDE_NONE()
);
$days = $limitPeriod->length();
$amount = bcdiv((string)$budgetLimit->amount, (string)$days, 12);
Log::debug(
sprintf('Total amount for budget limit #%d is %s. Nr. of days is %d. Amount per day is %s', $budgetLimit->id, $budgetLimit->amount, $days, $amount)
);
return $amount;
}
/**
* @param BudgetLimit $budgetLimit
* @return void
* @throws \FireflyIII\Exceptions\FireflyException
* @throws \Psr\Container\ContainerExceptionInterface
* @throws \Psr\Container\NotFoundExceptionInterface
*/
private function updateAvailableBudget(BudgetLimit $budgetLimit): void
{
Log::debug(sprintf('Now in updateAvailableBudget(#%d)', $budgetLimit->id));
// based on the view range of the user (month week quarter etc) the budget limit could
// either overlap multiple available budget periods or be contained in a single one.
// all have to be created or updated.
$viewRange = app('preferences')->get('viewRange', '1M')->data;
$start = app('navigation')->startOfPeriod($budgetLimit->start_date, $viewRange);
$end = app('navigation')->startOfPeriod($budgetLimit->end_date, $viewRange);
$end = app('navigation')->endOfPeriod($end, $viewRange);
$user = $budgetLimit->budget->user;
// limit period in total is:
$limitPeriod = Period::make($start, $end, precision: Precision::DAY(), boundaries: Boundaries::EXCLUDE_NONE());
// from the start until the end of the budget limit, need to loop!
$current = clone $start;
while ($current <= $end) {
$currentEnd = app('navigation')->endOfPeriod($current, $viewRange);
// create or find AB for this particular period, and set the amount accordingly.
/** @var AvailableBudget $availableBudget */
$availableBudget = $user->availableBudgets()->where('start_date', $current->format('Y-m-d'))->where(
'end_date',
$currentEnd->format('Y-m-d')
)->where('transaction_currency_id', $budgetLimit->transaction_currency_id)->first();
if (null !== $availableBudget) {
Log::debug('Found AB, will update.');
$this->calculateAmount($availableBudget);
continue;
}
// if not exists:
$currentPeriod = Period::make($current, $currentEnd, precision: Precision::DAY(), boundaries: Boundaries::EXCLUDE_NONE());
$daily = $this->getDailyAmount($budgetLimit);
$amount = bcmul($daily, (string)$currentPeriod->length(), 12);
// no need to calculate if period is equal.
if ($currentPeriod->equals($limitPeriod)) {
$amount = $budgetLimit->amount;
}
Log::debug(sprintf('Will create AB for period %s to %s', $current->format('Y-m-d'), $currentEnd->format('Y-m-d')));
$availableBudget = new AvailableBudget(
[
'user_id' => $budgetLimit->budget->user->id,
'transaction_currency_id' => $budgetLimit->transaction_currency_id,
'start_date' => $current,
'end_date' => $currentEnd,
'amount' => $amount,
]
);
$availableBudget->save();
// prep for next loop
$current = app('navigation')->addPeriod($current, $viewRange, 0);
}
}
}

View File

@ -89,12 +89,13 @@ class BudgetLimitController extends Controller
$collection = $this->currencyRepos->get();
$budgetLimits = $this->blRepository->getBudgetLimits($budget, $start, $end);
// remove already budgeted currencies:
// remove already budgeted currencies with the same date range
$currencies = $collection->filter(
static function (TransactionCurrency $currency) use ($budgetLimits) {
/** @var AvailableBudget $budget */
foreach ($budgetLimits as $budget) {
if ($budget->transaction_currency_id === $currency->id) {
static function (TransactionCurrency $currency) use ($budgetLimits, $start, $end) {
/** @var BudgetLimit $limit */
foreach ($budgetLimits as $limit) {
if ($limit->transaction_currency_id === $currency->id && $limit->start_date->isSameDay($start) && $limit->end_date->isSameDay($end)
) {
return false;
}
}

View File

@ -40,9 +40,9 @@ use Illuminate\Contracts\View\Factory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use JsonException;
use Illuminate\Support\Facades\Log;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
@ -101,12 +101,23 @@ class IndexController extends Controller
*/
public function index(Request $request, Carbon $start = null, Carbon $end = null)
{
Log::debug('Start of IndexController::index()');
Log::debug(sprintf('Start of IndexController::index("%s", "%s")', $start?->format('Y-m-d'), $end?->format('Y-m-d')));
// collect some basic vars:
$range = app('navigation')->getViewRange(true);
$start = $start ?? session('start', today(config('app.timezone'))->startOfMonth());
$end = $end ?? app('navigation')->endOfPeriod($start, $range);
$range = app('navigation')->getViewRange(true);
$isCustomRange = session('is_custom_range', false);
if (false === $isCustomRange) {
$start = $start ?? session('start', today(config('app.timezone'))->startOfMonth());
$end = $end ?? app('navigation')->endOfPeriod($start, $range);
}
// overrule start and end if necessary:
if (true === $isCustomRange) {
$start = $start ?? session('start', today(config('app.timezone'))->startOfMonth());
$end = $end ?? session('end', today(config('app.timezone'))->endOfMonth());
}
$defaultCurrency = app('amount')->getDefaultCurrency();
$currencies = $this->currencyRepository->get();
$budgeted = '0';

View File

@ -24,6 +24,9 @@ declare(strict_types=1);
namespace FireflyIII\Models;
use Eloquent;
use FireflyIII\Events\Model\BudgetLimit\Created;
use FireflyIII\Events\Model\BudgetLimit\Deleted;
use FireflyIII\Events\Model\BudgetLimit\Updated;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
@ -81,6 +84,12 @@ class BudgetLimit extends Model
/** @var array Fields that can be filled */
protected $fillable = ['budget_id', 'start_date', 'end_date', 'amount', 'transaction_currency_id'];
protected $dispatchesEvents = [
'created' => Created::class,
'updated' => Updated::class,
'deleted' => Deleted::class,
];
/**
* Route binder. Converts the key in the URL to the specified object (or throw 404).
*

View File

@ -29,6 +29,9 @@ use FireflyIII\Events\AdminRequestedTestMessage;
use FireflyIII\Events\ChangedPiggyBankAmount;
use FireflyIII\Events\DestroyedTransactionGroup;
use FireflyIII\Events\DetectedNewIPAddress;
use FireflyIII\Events\Model\BudgetLimit\Created;
use FireflyIII\Events\Model\BudgetLimit\Deleted;
use FireflyIII\Events\Model\BudgetLimit\Updated;
use FireflyIII\Events\NewVersionAvailable;
use FireflyIII\Events\RegisteredUser;
use FireflyIII\Events\RequestedNewPassword;
@ -42,6 +45,7 @@ use FireflyIII\Events\UpdatedAccount;
use FireflyIII\Events\UpdatedTransactionGroup;
use FireflyIII\Events\UserChangedEmail;
use FireflyIII\Events\WarnUserAboutBill;
use FireflyIII\Models\Budget;
use FireflyIII\Models\BudgetLimit;
use FireflyIII\Models\PiggyBank;
use FireflyIII\Models\PiggyBankRepetition;
@ -160,6 +164,17 @@ class EventServiceProvider extends ServiceProvider
ChangedPiggyBankAmount::class => [
'FireflyIII\Handlers\Events\PiggyBankEventHandler@changePiggyAmount',
],
// budget related events: CRUD budget limit
Created::class => [
'FireflyIII\Handlers\Events\Model\BudgetLimitHandler@created',
],
Updated::class => [
'FireflyIII\Handlers\Events\Model\BudgetLimitHandler@updated',
],
Deleted::class => [
'FireflyIII\Handlers\Events\Model\BudgetLimitHandler@deleted',
],
];
/**
@ -169,7 +184,6 @@ class EventServiceProvider extends ServiceProvider
{
parent::boot();
$this->registerCreateEvents();
$this->registerBudgetEvents();
}
/**
@ -188,57 +202,4 @@ class EventServiceProvider extends ServiceProvider
}
);
}
/**
* TODO needs a dedicated method.
*/
protected function registerBudgetEvents(): void
{
$func = static function (BudgetLimit $limit) {
Log::debug('Trigger budget limit event.');
// find available budget with same period and same currency or create it.
// then set it or add money:
$user = $limit->budget->user;
$availableBudget = $user
->availableBudgets()
->where('start_date', $limit->start_date->format('Y-m-d'))
->where('end_date', $limit->end_date->format('Y-m-d'))
->where('transaction_currency_id', $limit->transaction_currency_id)
->first();
// update!
if (null !== $availableBudget) {
$repository = app(BudgetLimitRepositoryInterface::class);
$repository->setUser($user);
$set = $repository->getAllBudgetLimitsByCurrency($limit->transactionCurrency, $limit->start_date, $limit->end_date);
$sum = (string)$set->sum('amount');
Log::debug(
sprintf(
'Because budget limit #%d had its amount changed to %s, available budget limit #%d will be updated.',
$limit->id,
$limit->amount,
$availableBudget->id
)
);
$availableBudget->amount = $sum;
$availableBudget->save();
return;
}
Log::debug('Does not exist, create it.');
// create it.
$data = [
'amount' => $limit->amount,
'start' => $limit->start_date,
'end' => $limit->end_date,
'currency_id' => $limit->transaction_currency_id,
];
$repository = app(AvailableBudgetRepositoryInterface::class);
$repository->setUser($user);
$repository->store($data);
};
BudgetLimit::created($func);
BudgetLimit::updated($func);
}
}

View File

@ -104,6 +104,7 @@
"ramsey/uuid": "^4.7",
"rcrowe/twigbridge": "^0.14",
"spatie/laravel-ignition": "^2",
"spatie/period": "^2.4",
"symfony/http-client": "^6.0",
"symfony/mailgun-mailer": "^6.0",
"therobfonz/laravel-mandrill-driver": "^5.0"

56
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "7578458e9e378acae56c08bbd053f38b",
"content-hash": "3d09d838fdf529c07df3563a3d96de5c",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -5806,6 +5806,60 @@
],
"time": "2023-04-12T09:26:00+00:00"
},
{
"name": "spatie/period",
"version": "2.4.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/period.git",
"reference": "85fbbea7b24fdff0c924aeed5b109be93c025850"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/period/zipball/85fbbea7b24fdff0c924aeed5b109be93c025850",
"reference": "85fbbea7b24fdff0c924aeed5b109be93c025850",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"larapack/dd": "^1.1",
"nesbot/carbon": "^2.63",
"pestphp/pest": "^1.22",
"phpunit/phpunit": "^9.5",
"spatie/ray": "^1.31"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Period\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Brent Roose",
"email": "brent@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Complex period comparisons",
"homepage": "https://github.com/spatie/period",
"keywords": [
"period",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/period/issues",
"source": "https://github.com/spatie/period/tree/2.4.0"
},
"time": "2023-02-20T14:31:09+00:00"
},
{
"name": "symfony/console",
"version": "v6.2.8",

View File

@ -261,6 +261,7 @@
{% if not budgetLimit.in_range %}
<small class="text-muted">
{{ trans('firefly.budget_limit_not_in_range', {start: budgetLimit.start_date, end: budgetLimit.end_date}) }}
</small><br>
{% endif %}
<div class="input-group bl_entry" data-budget-limit-id="{{ budgetLimit.id }}">