diff --git a/app/Api/V1/Controllers/Controller.php b/app/Api/V1/Controllers/Controller.php index b1d0952059..18e8462f02 100644 --- a/app/Api/V1/Controllers/Controller.php +++ b/app/Api/V1/Controllers/Controller.php @@ -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); diff --git a/app/Events/Model/BudgetLimit/Created.php b/app/Events/Model/BudgetLimit/Created.php new file mode 100644 index 0000000000..95b84f0523 --- /dev/null +++ b/app/Events/Model/BudgetLimit/Created.php @@ -0,0 +1,46 @@ +. + */ + +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; + } +} diff --git a/app/Events/Model/BudgetLimit/Deleted.php b/app/Events/Model/BudgetLimit/Deleted.php new file mode 100644 index 0000000000..9792d5c4e7 --- /dev/null +++ b/app/Events/Model/BudgetLimit/Deleted.php @@ -0,0 +1,46 @@ +. + */ + +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; + } +} diff --git a/app/Events/Model/BudgetLimit/Updated.php b/app/Events/Model/BudgetLimit/Updated.php new file mode 100644 index 0000000000..bbf36f3aae --- /dev/null +++ b/app/Events/Model/BudgetLimit/Updated.php @@ -0,0 +1,46 @@ +. + */ + +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; + } +} diff --git a/app/Handlers/Events/Model/BudgetLimitHandler.php b/app/Handlers/Events/Model/BudgetLimitHandler.php new file mode 100644 index 0000000000..a19b4731df --- /dev/null +++ b/app/Handlers/Events/Model/BudgetLimitHandler.php @@ -0,0 +1,221 @@ +. + */ + +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); + } + } + +} diff --git a/app/Http/Controllers/Budget/BudgetLimitController.php b/app/Http/Controllers/Budget/BudgetLimitController.php index b69b5e4c1d..b1d00e34e6 100644 --- a/app/Http/Controllers/Budget/BudgetLimitController.php +++ b/app/Http/Controllers/Budget/BudgetLimitController.php @@ -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; } } diff --git a/app/Http/Controllers/Budget/IndexController.php b/app/Http/Controllers/Budget/IndexController.php index aa6b6bd26d..5838305182 100644 --- a/app/Http/Controllers/Budget/IndexController.php +++ b/app/Http/Controllers/Budget/IndexController.php @@ -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'; diff --git a/app/Models/BudgetLimit.php b/app/Models/BudgetLimit.php index b8419b0fce..f1e1c30775 100644 --- a/app/Models/BudgetLimit.php +++ b/app/Models/BudgetLimit.php @@ -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). * diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 5588d32550..608cf20806 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -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); - } } diff --git a/composer.json b/composer.json index b2f151bca4..4013e58c6d 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/composer.lock b/composer.lock index 70e9ebe321..383e647ac7 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/resources/views/budgets/index.twig b/resources/views/budgets/index.twig index b70e8ca528..0ebfacb9a8 100644 --- a/resources/views/budgets/index.twig +++ b/resources/views/budgets/index.twig @@ -261,6 +261,7 @@ {% if not budgetLimit.in_range %} {{ trans('firefly.budget_limit_not_in_range', {start: budgetLimit.start_date, end: budgetLimit.end_date}) }} +
{% endif %}