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 %}