Include budget events.

This commit is contained in:
James Cole
2025-08-18 20:01:22 +02:00
parent 84ee6f16c9
commit b743bf3d9e
7 changed files with 353 additions and 225 deletions

View File

@@ -96,7 +96,6 @@ class ShowController extends Controller
$paginator = new LengthAwarePaginator($budgetLimits, $count, $pageSize, $this->parameters->get('page')); $paginator = new LengthAwarePaginator($budgetLimits, $count, $pageSize, $this->parameters->get('page'));
$paginator->setPath(route('api.v1.budgets.limits.index', [$budget->id]).$this->buildParams()); $paginator->setPath(route('api.v1.budgets.limits.index', [$budget->id]).$this->buildParams());
// enrich // enrich
$enrichment = new BudgetLimitEnrichment(); $enrichment = new BudgetLimitEnrichment();
$enrichment->setUser($admin); $enrichment->setUser($admin);

View File

@@ -27,13 +27,19 @@ namespace FireflyIII\Generator\Webhook;
use FireflyIII\Enums\WebhookResponse; use FireflyIII\Enums\WebhookResponse;
use FireflyIII\Enums\WebhookTrigger; use FireflyIII\Enums\WebhookTrigger;
use FireflyIII\Exceptions\FireflyException; use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Models\Budget;
use FireflyIII\Models\BudgetLimit;
use FireflyIII\Models\Transaction; use FireflyIII\Models\Transaction;
use FireflyIII\Models\TransactionGroup; use FireflyIII\Models\TransactionGroup;
use FireflyIII\Models\TransactionJournal; use FireflyIII\Models\TransactionJournal;
use FireflyIII\Models\Webhook; use FireflyIII\Models\Webhook;
use FireflyIII\Models\WebhookMessage; use FireflyIII\Models\WebhookMessage;
use FireflyIII\Support\JsonApi\Enrichments\AccountEnrichment; use FireflyIII\Support\JsonApi\Enrichments\AccountEnrichment;
use FireflyIII\Support\JsonApi\Enrichments\BudgetEnrichment;
use FireflyIII\Support\JsonApi\Enrichments\BudgetLimitEnrichment;
use FireflyIII\Transformers\AccountTransformer; use FireflyIII\Transformers\AccountTransformer;
use FireflyIII\Transformers\BudgetLimitTransformer;
use FireflyIII\Transformers\BudgetTransformer;
use FireflyIII\Transformers\TransactionGroupTransformer; use FireflyIII\Transformers\TransactionGroupTransformer;
use FireflyIII\User; use FireflyIII\User;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -109,18 +115,16 @@ class StandardMessageGenerator implements MessageGeneratorInterface
*/ */
private function generateMessage(Webhook $webhook, Model $model): void private function generateMessage(Webhook $webhook, Model $model): void
{ {
$class = $model::class; $class = $model::class;
// Line is ignored because all of Firefly III's Models have an id property. // Line is ignored because all of Firefly III's Models have an id property.
Log::debug(sprintf('Now in generateMessage(#%d, %s#%d)', $webhook->id, $class, $model->id)); Log::debug(sprintf('Now in generateMessage(#%d, %s#%d)', $webhook->id, $class, $model->id));
Log::debug($webhook->response);
Log::debug(WebhookResponse::from($webhook->response)->name);
$uuid = Uuid::uuid4(); $uuid = Uuid::uuid4();
$basicMessage = [ $basicMessage = [
'uuid' => $uuid->toString(), 'uuid' => $uuid->toString(),
'user_id' => 0, 'user_id' => 0,
'user_group_id' => 0, 'user_group_id' => 0,
'trigger' => WebhookTrigger::from((int) $webhook->trigger)->name, 'trigger' => WebhookTrigger::from((int)$webhook->trigger)->name,
'response' => WebhookResponse::from((int) $webhook->response)->name, 'response' => WebhookResponse::from((int)$webhook->response)->name,
'url' => $webhook->url, 'url' => $webhook->url,
'version' => sprintf('v%d', $this->getVersion()), 'version' => sprintf('v%d', $this->getVersion()),
'content' => [], 'content' => [],
@@ -133,7 +137,15 @@ class StandardMessageGenerator implements MessageGeneratorInterface
Log::error(sprintf('Webhook #%d was given %s#%d to deal with but can\'t extract user ID from it.', $webhook->id, $class, $model->id)); Log::error(sprintf('Webhook #%d was given %s#%d to deal with but can\'t extract user ID from it.', $webhook->id, $class, $model->id));
return; return;
case Budget::class:
/** @var Budget $model */
$basicMessage['user_id'] = $model->user_id;
$basicMessage['user_group_id'] = $model->user_group_id;
break;
case BudgetLimit::class:
$basicMessage['user_id'] = $model->budget->user_id;
$basicMessage['user_group_id'] = $model->budget->user_group_id;
break;
case TransactionGroup::class: case TransactionGroup::class:
/** @var TransactionGroup $model */ /** @var TransactionGroup $model */
$basicMessage['user_id'] = $model->user_id; $basicMessage['user_id'] = $model->user_id;
@@ -148,6 +160,36 @@ class StandardMessageGenerator implements MessageGeneratorInterface
Log::error(sprintf('The response code for webhook #%d is "%d" and the message generator cant handle it. Soft fail.', $webhook->id, $webhook->response)); Log::error(sprintf('The response code for webhook #%d is "%d" and the message generator cant handle it. Soft fail.', $webhook->id, $webhook->response));
return; return;
case WebhookResponse::BUDGET->value;
$basicMessage['content'] = [];
if($model instanceof Budget) {
$enrichment = new BudgetEnrichment();
$enrichment->setUser($model->user);
$model = $enrichment->enrichSingle($model);
$transformer = new BudgetTransformer();
$basicMessage['content'] = $transformer->transform($model);
}
if($model instanceof BudgetLimit) {
$user = $model->budget->user;
$enrichment = new BudgetEnrichment();
$enrichment->setUser($user);
$enrichment->setStart($model->start_date);
$enrichment->setEnd($model->end_date);
$budget = $enrichment->enrichSingle($model->budget);
$enrichment = new BudgetLimitEnrichment();
$enrichment->setUser($user);
$parameters = new ParameterBag();
$parameters->set('start', $model->start_date);
$parameters->set('end', $model->end_date);
$model = $enrichment->enrichSingle($model);
$transformer = new BudgetLimitTransformer();
$transformer->setParameters($parameters);
$basicMessage['content'] = $transformer->transform($model);
}
break;
case WebhookResponse::NONE->value: case WebhookResponse::NONE->value:
$basicMessage['content'] = []; $basicMessage['content'] = [];
@@ -156,7 +198,7 @@ class StandardMessageGenerator implements MessageGeneratorInterface
case WebhookResponse::TRANSACTIONS->value: case WebhookResponse::TRANSACTIONS->value:
/** @var TransactionGroup $model */ /** @var TransactionGroup $model */
$transformer = new TransactionGroupTransformer(); $transformer = new TransactionGroupTransformer();
try { try {
$basicMessage['content'] = $transformer->transformObject($model); $basicMessage['content'] = $transformer->transformObject($model);
@@ -173,13 +215,13 @@ class StandardMessageGenerator implements MessageGeneratorInterface
case WebhookResponse::ACCOUNTS->value: case WebhookResponse::ACCOUNTS->value:
/** @var TransactionGroup $model */ /** @var TransactionGroup $model */
$accounts = $this->collectAccounts($model); $accounts = $this->collectAccounts($model);
$enrichment = new AccountEnrichment(); $enrichment = new AccountEnrichment();
$enrichment->setDate(null); $enrichment->setDate(null);
$enrichment->setUser($model->user); $enrichment->setUser($model->user);
$accounts = $enrichment->enrich($accounts); $accounts = $enrichment->enrich($accounts);
foreach ($accounts as $account) { foreach ($accounts as $account) {
$transformer = new AccountTransformer(); $transformer = new AccountTransformer();
$transformer->setParameters(new ParameterBag()); $transformer->setParameters(new ParameterBag());
$basicMessage['content'][] = $transformer->transform($account); $basicMessage['content'][] = $transformer->transform($account);
} }
@@ -209,7 +251,7 @@ class StandardMessageGenerator implements MessageGeneratorInterface
private function storeMessage(Webhook $webhook, array $message): void private function storeMessage(Webhook $webhook, array $message): void
{ {
$webhookMessage = new WebhookMessage(); $webhookMessage = new WebhookMessage();
$webhookMessage->webhook()->associate($webhook); $webhookMessage->webhook()->associate($webhook);
$webhookMessage->sent = false; $webhookMessage->sent = false;
$webhookMessage->errored = false; $webhookMessage->errored = false;

View File

@@ -31,6 +31,7 @@ use FireflyIII\Models\AvailableBudget;
use FireflyIII\Models\Budget; use FireflyIII\Models\Budget;
use FireflyIII\Models\BudgetLimit; use FireflyIII\Models\BudgetLimit;
use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface; use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface;
use FireflyIII\Support\Observers\RecalculatesAvailableBudgetsTrait;
use FireflyIII\User; use FireflyIII\User;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerExceptionInterface;
@@ -44,202 +45,10 @@ use Spatie\Period\Precision;
*/ */
class BudgetLimitHandler class BudgetLimitHandler
{ {
public function created(Created $event): void public function created(Created $event): void
{ {
Log::debug(sprintf('BudgetLimitHandler::created(#%s)', $event->budgetLimit->id)); Log::debug(sprintf('BudgetLimitHandler::created(#%s)', $event->budgetLimit->id));
self::updateAvailableBudget($event->budgetLimit);
}
public static function updateAvailableBudget(BudgetLimit $budgetLimit): void
{
Log::debug(sprintf('Now in updateAvailableBudget(limit #%d)', $budgetLimit->id));
/** @var null|Budget $budget */
$budget = Budget::find($budgetLimit->budget_id);
if (null === $budget) {
Log::warning('Budget is null, probably deleted, find deleted version.');
/** @var null|Budget $budget */
$budget = Budget::withTrashed()->find($budgetLimit->budget_id);
}
if (null === $budget) {
Log::warning('Budget is still null, cannot continue, will delete budget limit.');
$budgetLimit->forceDelete();
return;
}
/** @var null|User $user */
$user = $budget->user;
// sanity check. It happens when the budget has been deleted so the original user is unknown.
if (null === $user) {
Log::warning('User is null, cannot continue.');
$budgetLimit->forceDelete();
return;
}
// 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.
try {
$viewRange = app('preferences')->getForUser($user, 'viewRange', '1M')->data;
} catch (ContainerExceptionInterface|NotFoundExceptionInterface $e) {
Log::error($e->getMessage());
$viewRange = '1M';
}
// safety catch
if (null === $viewRange || is_array($viewRange)) {
$viewRange = '1M';
}
$viewRange = (string) $viewRange;
$start = app('navigation')->startOfPeriod($budgetLimit->start_date, $viewRange);
$end = app('navigation')->startOfPeriod($budgetLimit->end_date, $viewRange);
$end = app('navigation')->endOfPeriod($end, $viewRange);
// limit period in total is:
$limitPeriod = Period::make($start, $end, precision: Precision::DAY(), boundaries: Boundaries::EXCLUDE_NONE());
Log::debug(sprintf('Limit period is from %s to %s', $start->format('Y-m-d'), $end->format('Y-m-d')));
// 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 null|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 1 AB, will update.');
self::calculateAmount($availableBudget);
}
if (null === $availableBudget) {
Log::debug('No AB found, will create.');
// if not exists:
$currentPeriod = Period::make($current, $currentEnd, precision: Precision::DAY(), boundaries: Boundaries::EXCLUDE_NONE());
$daily = self::getDailyAmount($budgetLimit);
$amount = bcmul($daily, (string) $currentPeriod->length(), 12);
// no need to calculate if period is equal.
if ($currentPeriod->equals($limitPeriod)) {
$amount = 0 === $budgetLimit->id ? '0' : $budgetLimit->amount;
}
if (0 === bccomp($amount, '0')) {
Log::debug('Amount is zero, will not create AB.');
}
if (0 !== bccomp($amount, '0')) {
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' => $user->id,
'user_group_id' => $user->user_group_id,
'transaction_currency_id' => $budgetLimit->transaction_currency_id,
'start_date' => $current,
'start_date_tz' => $current->format('e'),
'end_date' => $currentEnd,
'end_date_tz' => $currentEnd->format('e'),
'amount' => $amount,
]
);
$availableBudget->save();
Log::debug(sprintf('ID of new AB is #%d', $availableBudget->id));
self::calculateAmount($availableBudget);
}
}
// prep for next loop
$current = app('navigation')->addPeriod($current, $viewRange, 0);
}
}
private static 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 recalculate everything just in case.
$set = $repository->getAllBudgetLimitsByCurrency($availableBudget->transactionCurrency, $availableBudget->start_date, $availableBudget->end_date);
Log::debug(sprintf('Found %d interesting budget limit(s).', $set->count()));
/** @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 each other, amount from this BL must be added to the AB
if ($limitPeriod->equals($abPeriod)) {
Log::debug('This budget limit is equal to the available budget period.');
$newAmount = bcadd($newAmount, (string) $budgetLimit->amount);
}
// if budget limit period is inside AB period, it can be added in full.
if (!$limitPeriod->equals($abPeriod) && $abPeriod->contains($limitPeriod)) {
Log::debug('This budget limit is smaller than the available budget period.');
$newAmount = bcadd($newAmount, (string) $budgetLimit->amount);
}
if (!$limitPeriod->equals($abPeriod) && !$abPeriod->contains($limitPeriod) && $abPeriod->overlapsWith($limitPeriod)) {
Log::debug('This budget limit is something else entirely!');
$overlap = $abPeriod->overlap($limitPeriod);
if ($overlap instanceof Period) {
$length = $overlap->length();
$daily = bcmul(self::getDailyAmount($budgetLimit), (string) $length);
$newAmount = bcadd($newAmount, $daily);
}
}
}
if (0 === bccomp('0', $newAmount)) {
Log::debug('New amount is zero, deleting AB.');
$availableBudget->delete();
return;
}
Log::debug(sprintf('Concluded new amount for this AB must be %s', $newAmount));
$availableBudget->amount = app('steam')->bcround($newAmount, $availableBudget->transactionCurrency->decimal_places);
$availableBudget->save();
}
private static function getDailyAmount(BudgetLimit $budgetLimit): string
{
if (0 === $budgetLimit->id) {
return '0';
}
$limitPeriod = Period::make(
$budgetLimit->start_date,
$budgetLimit->end_date,
precision : Precision::DAY(),
boundaries: Boundaries::EXCLUDE_NONE()
);
$days = $limitPeriod->length();
$amount = bcdiv($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;
} }
public function deleted(Deleted $event): void public function deleted(Deleted $event): void
@@ -249,7 +58,6 @@ class BudgetLimitHandler
public function updated(Updated $event): void public function updated(Updated $event): void
{ {
Log::debug(sprintf('BudgetLimitHandler::updated(#%s)', $event->budgetLimit->id));
self::updateAvailableBudget($event->budgetLimit);
} }
} }

View File

@@ -24,17 +24,35 @@ declare(strict_types=1);
namespace FireflyIII\Handlers\Observer; namespace FireflyIII\Handlers\Observer;
use FireflyIII\Enums\WebhookTrigger;
use FireflyIII\Events\RequestedSendWebhookMessages;
use FireflyIII\Generator\Webhook\MessageGeneratorInterface;
use FireflyIII\Models\BudgetLimit; use FireflyIII\Models\BudgetLimit;
use FireflyIII\Support\Facades\Amount; use FireflyIII\Support\Facades\Amount;
use FireflyIII\Support\Http\Api\ExchangeRateConverter; use FireflyIII\Support\Http\Api\ExchangeRateConverter;
use FireflyIII\Support\Observers\RecalculatesAvailableBudgetsTrait;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class BudgetLimitObserver class BudgetLimitObserver
{ {
use RecalculatesAvailableBudgetsTrait;
public function created(BudgetLimit $budgetLimit): void public function created(BudgetLimit $budgetLimit): void
{ {
Log::debug('Observe "created" of a budget limit.'); Log::debug('Observe "created" of a budget limit.');
$this->updatePrimaryCurrencyAmount($budgetLimit); $this->updatePrimaryCurrencyAmount($budgetLimit);
$this->updateAvailableBudget($budgetLimit);
$user = $budgetLimit->budget->user;
/** @var MessageGeneratorInterface $engine */
$engine = app(MessageGeneratorInterface::class);
$engine->setUser($user);
$engine->setObjects(new Collection()->push($budgetLimit));
$engine->setTrigger(WebhookTrigger::STORE_UPDATE_BUDGET_LIMIT);
$engine->generateMessages();
event(new RequestedSendWebhookMessages());
} }
private function updatePrimaryCurrencyAmount(BudgetLimit $budgetLimit): void private function updatePrimaryCurrencyAmount(BudgetLimit $budgetLimit): void
@@ -60,5 +78,17 @@ class BudgetLimitObserver
{ {
Log::debug('Observe "updated" of a budget limit.'); Log::debug('Observe "updated" of a budget limit.');
$this->updatePrimaryCurrencyAmount($budgetLimit); $this->updatePrimaryCurrencyAmount($budgetLimit);
$this->updateAvailableBudget($budgetLimit);
$user = $budgetLimit->budget->user;
/** @var MessageGeneratorInterface $engine */
$engine = app(MessageGeneratorInterface::class);
$engine->setUser($user);
$engine->setObjects(new Collection()->push($budgetLimit));
$engine->setTrigger(WebhookTrigger::STORE_UPDATE_BUDGET_LIMIT);
$engine->generateMessages();
event(new RequestedSendWebhookMessages());
} }
} }

View File

@@ -23,21 +23,71 @@ declare(strict_types=1);
namespace FireflyIII\Handlers\Observer; namespace FireflyIII\Handlers\Observer;
use FireflyIII\Enums\WebhookTrigger;
use FireflyIII\Events\RequestedSendWebhookMessages;
use FireflyIII\Generator\Webhook\MessageGeneratorInterface;
use FireflyIII\Models\Attachment; use FireflyIII\Models\Attachment;
use FireflyIII\Models\Budget; use FireflyIII\Models\Budget;
use FireflyIII\Models\BudgetLimit; use FireflyIII\Models\BudgetLimit;
use FireflyIII\Repositories\Attachment\AttachmentRepositoryInterface; use FireflyIII\Repositories\Attachment\AttachmentRepositoryInterface;
use FireflyIII\Support\Observers\RecalculatesAvailableBudgetsTrait;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/** /**
* Class BudgetObserver * Class BudgetObserver
*/ */
class BudgetObserver class BudgetObserver
{ {
use RecalculatesAvailableBudgetsTrait;
public function created(Budget $budget): void
{
Log::debug(sprintf('Observe "created" of budget #%d ("%s").', $budget->id, $budget->name));
// fire event.
$user = $budget->user;
/** @var MessageGeneratorInterface $engine */
$engine = app(MessageGeneratorInterface::class);
$engine->setUser($user);
$engine->setObjects(new Collection()->push($budget));
$engine->setTrigger(WebhookTrigger::STORE_BUDGET);
$engine->generateMessages();
event(new RequestedSendWebhookMessages());
}
public function updated(Budget $budget): void
{
Log::debug(sprintf('Observe "updated" of budget #%d ("%s").', $budget->id, $budget->name));
$user = $budget->user;
/** @var MessageGeneratorInterface $engine */
$engine = app(MessageGeneratorInterface::class);
$engine->setUser($user);
$engine->setObjects(new Collection()->push($budget));
$engine->setTrigger(WebhookTrigger::UPDATE_BUDGET);
$engine->generateMessages();
event(new RequestedSendWebhookMessages());
}
public function deleting(Budget $budget): void public function deleting(Budget $budget): void
{ {
app('log')->debug('Observe "deleting" of a budget.'); Log::debug('Observe "deleting" of a budget.');
$repository = app(AttachmentRepositoryInterface::class); $user = $budget->user;
/** @var MessageGeneratorInterface $engine */
$engine = app(MessageGeneratorInterface::class);
$engine->setUser($user);
$engine->setObjects(new Collection()->push($budget));
$engine->setTrigger(WebhookTrigger::DESTROY_BUDGET);
$engine->generateMessages();
event(new RequestedSendWebhookMessages());
$repository = app(AttachmentRepositoryInterface::class);
$repository->setUser($budget->user); $repository->setUser($budget->user);
/** @var Attachment $attachment */ /** @var Attachment $attachment */
@@ -49,7 +99,10 @@ class BudgetObserver
/** @var BudgetLimit $budgetLimit */ /** @var BudgetLimit $budgetLimit */
foreach ($budgetLimits as $budgetLimit) { foreach ($budgetLimits as $budgetLimit) {
// this loop exists so several events are fired. // this loop exists so several events are fired.
$budgetLimit->delete(); $copy = clone $budgetLimit;
$copy->id = 0;
$this->updateAvailableBudget($copy);
$budgetLimit->deleteQuietly(); // delete is quietly when in a loop.
} }
$budget->notes()->delete(); $budget->notes()->delete();

View File

@@ -29,9 +29,6 @@ use FireflyIII\Events\DestroyedTransactionGroup;
use FireflyIII\Events\DetectedNewIPAddress; use FireflyIII\Events\DetectedNewIPAddress;
use FireflyIII\Events\Model\Bill\WarnUserAboutBill; use FireflyIII\Events\Model\Bill\WarnUserAboutBill;
use FireflyIII\Events\Model\Bill\WarnUserAboutOverdueSubscriptions; use FireflyIII\Events\Model\Bill\WarnUserAboutOverdueSubscriptions;
use FireflyIII\Events\Model\BudgetLimit\Created;
use FireflyIII\Events\Model\BudgetLimit\Deleted;
use FireflyIII\Events\Model\BudgetLimit\Updated;
use FireflyIII\Events\Model\PiggyBank\ChangedAmount; use FireflyIII\Events\Model\PiggyBank\ChangedAmount;
use FireflyIII\Events\Model\PiggyBank\ChangedName; use FireflyIII\Events\Model\PiggyBank\ChangedName;
use FireflyIII\Events\Model\Rule\RuleActionFailedOnArray; use FireflyIII\Events\Model\Rule\RuleActionFailedOnArray;
@@ -219,17 +216,6 @@ class EventServiceProvider extends ServiceProvider
'FireflyIII\Handlers\Events\Model\PiggyBankEventHandler@changedPiggyBankName', 'FireflyIII\Handlers\Events\Model\PiggyBankEventHandler@changedPiggyBankName',
], ],
// 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',
],
// rule actions // rule actions
RuleActionFailedOnArray::class => [ RuleActionFailedOnArray::class => [
'FireflyIII\Handlers\Events\Model\RuleHandler@ruleActionFailedOnArray', 'FireflyIII\Handlers\Events\Model\RuleHandler@ruleActionFailedOnArray',

View File

@@ -0,0 +1,210 @@
<?php
namespace FireflyIII\Support\Observers;
use FireflyIII\Models\AvailableBudget;
use FireflyIII\Models\Budget;
use FireflyIII\Models\BudgetLimit;
use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface;
use FireflyIII\User;
use Illuminate\Support\Facades\Log;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Spatie\Period\Boundaries;
use Spatie\Period\Period;
use Spatie\Period\Precision;
trait RecalculatesAvailableBudgetsTrait
{
private function updateAvailableBudget(BudgetLimit $budgetLimit): void
{
Log::debug(sprintf('Now in updateAvailableBudget(limit #%d)', $budgetLimit->id));
/** @var null|Budget $budget */
$budget = Budget::find($budgetLimit->budget_id);
if (null === $budget) {
Log::warning('Budget is null, probably deleted, find deleted version.');
/** @var null|Budget $budget */
$budget = Budget::withTrashed()->find($budgetLimit->budget_id);
}
if (null === $budget) {
Log::warning('Budget is still null, cannot continue, will delete budget limit.');
$budgetLimit->forceDelete();
return;
}
/** @var null|User $user */
$user = $budget->user;
// sanity check. It happens when the budget has been deleted so the original user is unknown.
if (null === $user) {
Log::warning('User is null, cannot continue.');
$budgetLimit->forceDelete();
return;
}
// 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.
try {
$viewRange = app('preferences')->getForUser($user, 'viewRange', '1M')->data;
} catch (ContainerExceptionInterface|NotFoundExceptionInterface $e) {
Log::error($e->getMessage());
$viewRange = '1M';
}
// safety catch
if (null === $viewRange || is_array($viewRange)) {
$viewRange = '1M';
}
$viewRange = (string) $viewRange;
$start = app('navigation')->startOfPeriod($budgetLimit->start_date, $viewRange);
$end = app('navigation')->startOfPeriod($budgetLimit->end_date, $viewRange);
$end = app('navigation')->endOfPeriod($end, $viewRange);
// limit period in total is:
$limitPeriod = Period::make($start, $end, precision: Precision::DAY(), boundaries: Boundaries::EXCLUDE_NONE());
Log::debug(sprintf('Limit period is from %s to %s', $start->format('Y-m-d'), $end->format('Y-m-d')));
// 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 null|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 1 AB, will update.');
$this->calculateAmount($availableBudget);
}
if (null === $availableBudget) {
Log::debug('No AB found, will create.');
// 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 = 0 === $budgetLimit->id ? '0' : $budgetLimit->amount;
}
if (0 === bccomp($amount, '0')) {
Log::debug('Amount is zero, will not create AB.');
}
if (0 !== bccomp($amount, '0')) {
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' => $user->id,
'user_group_id' => $user->user_group_id,
'transaction_currency_id' => $budgetLimit->transaction_currency_id,
'start_date' => $current,
'start_date_tz' => $current->format('e'),
'end_date' => $currentEnd,
'end_date_tz' => $currentEnd->format('e'),
'amount' => $amount,
]
);
$availableBudget->save();
Log::debug(sprintf('ID of new AB is #%d', $availableBudget->id));
$this->calculateAmount($availableBudget);
}
}
// prep for next loop
$current = app('navigation')->addPeriod($current, $viewRange, 0);
}
}
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 recalculate everything just in case.
$set = $repository->getAllBudgetLimitsByCurrency($availableBudget->transactionCurrency, $availableBudget->start_date, $availableBudget->end_date);
Log::debug(sprintf('Found %d interesting budget limit(s).', $set->count()));
/** @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 each other, amount from this BL must be added to the AB
if ($limitPeriod->equals($abPeriod)) {
Log::debug('This budget limit is equal to the available budget period.');
$newAmount = bcadd($newAmount, (string) $budgetLimit->amount);
}
// if budget limit period is inside AB period, it can be added in full.
if (!$limitPeriod->equals($abPeriod) && $abPeriod->contains($limitPeriod)) {
Log::debug('This budget limit is smaller than the available budget period.');
$newAmount = bcadd($newAmount, (string) $budgetLimit->amount);
}
if (!$limitPeriod->equals($abPeriod) && !$abPeriod->contains($limitPeriod) && $abPeriod->overlapsWith($limitPeriod)) {
Log::debug('This budget limit is something else entirely!');
$overlap = $abPeriod->overlap($limitPeriod);
if ($overlap instanceof Period) {
$length = $overlap->length();
$daily = bcmul($this->getDailyAmount($budgetLimit), (string) $length);
$newAmount = bcadd($newAmount, $daily);
}
}
}
if (0 === bccomp('0', $newAmount)) {
Log::debug('New amount is zero, deleting AB.');
$availableBudget->delete();
return;
}
Log::debug(sprintf('Concluded new amount for this AB must be %s', $newAmount));
$availableBudget->amount = app('steam')->bcround($newAmount, $availableBudget->transactionCurrency->decimal_places);
$availableBudget->save();
}
private function getDailyAmount(BudgetLimit $budgetLimit): string
{
if (0 === $budgetLimit->id) {
return '0';
}
$limitPeriod = Period::make(
$budgetLimit->start_date,
$budgetLimit->end_date,
precision : Precision::DAY(),
boundaries: Boundaries::EXCLUDE_NONE()
);
$days = $limitPeriod->length();
$amount = bcdiv($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;
}
}