Merge pull request #12214 from firefly-iii/release-1777794370

🤖 Automatically merge the PR into the develop branch.
This commit is contained in:
github-actions[bot]
2026-05-03 09:46:17 +02:00
committed by GitHub
12 changed files with 137 additions and 89 deletions
@@ -158,7 +158,10 @@ final class TagController extends Controller
'currency_id' => (string) $foreignCurrencyId,
'currency_code' => $journal['foreign_currency_code'],
];
$response[$foreignKey]['difference'] = bcadd((string) $response[$foreignKey]['difference'], Steam::positive($journal['foreign_amount']));
$response[$foreignKey]['difference'] = bcadd(
(string) $response[$foreignKey]['difference'],
Steam::positive($journal['foreign_amount'])
);
$response[$foreignKey]['difference_float'] = (float) $response[$foreignKey]['difference'];
}
}
@@ -155,7 +155,10 @@ final class TagController extends Controller
'currency_id' => (string) $foreignCurrencyId,
'currency_code' => $journal['foreign_currency_code'],
];
$response[$foreignKey]['difference'] = bcadd((string) $response[$foreignKey]['difference'], Steam::positive($journal['foreign_amount']));
$response[$foreignKey]['difference'] = bcadd(
(string) $response[$foreignKey]['difference'],
Steam::positive($journal['foreign_amount'])
);
$response[$foreignKey]['difference_float'] = (float) $response[$foreignKey]['difference']; // intentional float
}
}
@@ -255,7 +255,10 @@ final class IndexController extends Controller
if (count($bill['paid_dates']) < count($bill['pay_dates'])) {
$count = count($bill['pay_dates']) - count($bill['paid_dates']);
if ($count > 0) {
$avg = bcdiv(bcadd((string) $bill['amount_min'], (string) $bill['amount_max']), '2');
$avg = bcdiv(
bcadd((string) $bill['amount_min'], (string) $bill['amount_max']),
'2'
);
$avg = bcmul($avg, (string) $count);
$sums[$groupOrder][$currencyId]['total_left_to_pay'] = bcadd($sums[$groupOrder][$currencyId]['total_left_to_pay'], $avg);
Log::debug(
@@ -198,7 +198,13 @@ final class BudgetLimitController extends Controller
if ($request->expectsJson()) {
$array = $limit->toArray();
// add some extra metadata:
$spentArr = $this->opsRepository->sumExpenses($limit->start_date, $limit->end_date, null, new Collection()->push($budget), $currency);
$spentArr = $this->opsRepository->sumExpenses(
$limit->start_date,
$limit->end_date,
null,
new Collection()->push($budget),
$currency
);
$array['spent'] = $spentArr[$currency->id]['sum'] ?? '0';
$array['left_formatted'] = Amount::formatAnything($limit->transactionCurrency, bcadd($array['spent'], (string) $array['amount']));
$array['amount_formatted'] = Amount::formatAnything($limit->transactionCurrency, $limit['amount']);
@@ -245,13 +245,7 @@ final class IndexController extends Controller
$inPast = $limitPeriod->startsBefore(now()) && $limitPeriod->endsBefore(now());
$currency = $limit->transactionCurrency ?? $primaryCurrency;
$amount = Steam::bcround($limit->amount, $currency->decimal_places);
$spent = $this->opsRepository->sumExpenses(
$limit->start_date,
$limit->end_date,
null,
new Collection()->push($budget),
$currency
);
$spent = $this->opsRepository->sumExpenses($limit->start_date, $limit->end_date, null, new Collection()->push($budget), $currency);
$spentAmount = $spent[$currency->id]['sum'] ?? '0';
$array['budgeted'][] = [
@@ -290,7 +284,10 @@ final class IndexController extends Controller
if (array_key_exists($currency->id, $spentArr) && array_key_exists('sum', $spentArr[$currency->id])) {
$array['spent'][$currency->id]['spent'] = $spentArr[$currency->id]['sum'];
$array['spent'][$currency->id]['spent_outside'] = Steam::negative(bcsub($spentInLimits[$currency->id], $spentArr[$currency->id]['sum']));
$array['spent'][$currency->id]['spent_outside'] = Steam::negative(bcsub(
$spentInLimits[$currency->id],
$spentArr[$currency->id]['sum']
));
$array['spent'][$currency->id]['currency_id'] = $currency->id;
$array['spent'][$currency->id]['currency_symbol'] = $currency->symbol;
$array['spent'][$currency->id]['currency_decimal_places'] = $currency->decimal_places;
@@ -539,7 +539,13 @@ final class BudgetController extends Controller
}
// get spent amount in this period for this currency.
$sum = $this->opsRepository->sumExpenses($currentStart, $currentEnd, $accounts, new Collection()->push($budget), $currency);
$sum = $this->opsRepository->sumExpenses(
$currentStart,
$currentEnd,
$accounts,
new Collection()->push($budget),
$currency
);
$amount = Steam::positive($sum[$currency->id]['sum'] ?? '0');
$chartData[0]['entries'][$title] = Steam::bcround($amount, $currency->decimal_places);
+14 -2
View File
@@ -122,7 +122,13 @@ class CreateAutoBudgetLimits implements ShouldQueue
// if has one, calculate expenses and use that as a base.
$repository = app(OperationsRepositoryInterface::class);
$repository->setUser($autoBudget->budget->user);
$spent = $repository->sumExpenses($previousStart, $previousEnd, null, new Collection()->push($autoBudget->budget), $autoBudget->transactionCurrency);
$spent = $repository->sumExpenses(
$previousStart,
$previousEnd,
null,
new Collection()->push($autoBudget->budget),
$autoBudget->transactionCurrency
);
$currencyId = $autoBudget->transaction_currency_id;
$spentAmount = $spent[$currencyId]['sum'] ?? '0';
Log::debug(sprintf('Spent in previous budget period (%s-%s) is %s', $previousStart->format('Y-m-d'), $previousEnd->format('Y-m-d'), $spentAmount));
@@ -212,7 +218,13 @@ class CreateAutoBudgetLimits implements ShouldQueue
// if has one, calculate expenses and use that as a base.
$repository = app(OperationsRepositoryInterface::class);
$repository->setUser($autoBudget->budget->user);
$spent = $repository->sumExpenses($previousStart, $previousEnd, null, new Collection()->push($autoBudget->budget), $autoBudget->transactionCurrency);
$spent = $repository->sumExpenses(
$previousStart,
$previousEnd,
null,
new Collection()->push($autoBudget->budget),
$autoBudget->transactionCurrency
);
$currencyId = $autoBudget->transaction_currency_id;
$spentAmount = $spent[$currencyId]['sum'] ?? '0';
Log::debug(sprintf('Spent in previous budget period (%s-%s) is %s', $previousStart->format('Y-m-d'), $previousEnd->format('Y-m-d'), $spentAmount));
@@ -60,9 +60,9 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
*/
public function budgeted(Carbon $start, Carbon $end, TransactionCurrency $currency, ?Collection $budgets = null): string
{
$query = BudgetLimit::leftJoin('budgets', 'budgets.id', '=', 'budget_limits.budget_id')
$query = BudgetLimit::leftJoin('budgets', 'budgets.id', '=', 'budget_limits.budget_id')
// same complex where query as below.
->where(static function (Builder $q5) use ($start, $end): void {
->where(static function (Builder $q5) use ($start, $end): void {
$q5->where(static function (Builder $q1) use ($start, $end): void {
$q1->where(static function (Builder $q2) use ($start, $end): void {
$q2->where('budget_limits.end_date', '>=', $start->format('Y-m-d'));
@@ -77,10 +77,11 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
$q4->where('budget_limits.end_date', '>=', $end->format('Y-m-d'));
});
})
->where('budget_limits.transaction_currency_id', $currency->id)
->whereNull('budgets.deleted_at')
->where('budgets.active', true)
->where('budgets.user_id', $this->user->id);
->where('budget_limits.transaction_currency_id', $currency->id)
->whereNull('budgets.deleted_at')
->where('budgets.active', true)
->where('budgets.user_id', $this->user->id)
;
if ($budgets instanceof Collection && $budgets->count() > 0) {
$query->whereIn('budget_limits.budget_id', $budgets->pluck('id')->toArray());
}
@@ -91,13 +92,13 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
/** @var BudgetLimit $budgetLimit */
foreach ($set as $budgetLimit) {
if ($budgetLimit->start_date->isSameDay($start) && $budgetLimit->end_date->isSameDay($end)) {
$result = bcadd((string)$budgetLimit->amount, $result);
$result = bcadd((string) $budgetLimit->amount, $result);
continue;
}
$period = Period::make($start, $end, precision: Precision::DAY(), boundaries: Boundaries::EXCLUDE_NONE());
$amountPerDay = $this->getDailyAmount($budgetLimit);
$result = bcadd($result, bcmul((string)$period->length(), $amountPerDay));
$result = bcadd($result, bcmul((string) $period->length(), $amountPerDay));
}
return $result;
@@ -139,7 +140,8 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
->where('transaction_currency_id', $currency->id)
->where('start_date', $start->format('Y-m-d'))
->where('end_date', $end->format('Y-m-d'))
->first();
->first()
;
}
public function getAllBudgetLimits(?Carbon $start = null, ?Carbon $end = null): Collection
@@ -147,17 +149,19 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
// both are NULL:
if (!$start instanceof Carbon && !$end instanceof Carbon) {
return BudgetLimit::leftJoin('budgets', 'budgets.id', '=', 'budget_limits.budget_id')
->with(['budget'])
->where('budgets.user_id', $this->user->id)
->whereNull('budgets.deleted_at')
->get(['budget_limits.*']);
->with(['budget'])
->where('budgets.user_id', $this->user->id)
->whereNull('budgets.deleted_at')
->get(['budget_limits.*'])
;
}
// one of the two is NULL.
if (!$start instanceof Carbon xor !$end instanceof Carbon) {
$query = BudgetLimit::leftJoin('budgets', 'budgets.id', '=', 'budget_limits.budget_id')
->with(['budget'])
->whereNull('budgets.deleted_at')
->where('budgets.user_id', $this->user->id);
->with(['budget'])
->whereNull('budgets.deleted_at')
->where('budgets.user_id', $this->user->id)
;
if ($end instanceof Carbon) {
// end date must be before $end.
$query->where('end_date', '<=', $end->format('Y-m-d 00:00:00'));
@@ -172,31 +176,32 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
// neither are NULL:
return BudgetLimit::leftJoin('budgets', 'budgets.id', '=', 'budget_limits.budget_id')
->with(['budget'])
->where('budgets.user_id', $this->user->id)
->whereNull('budgets.deleted_at')
->where(static function (Builder $q5) use ($start, $end): void {
$q5->where(static function (Builder $q1) use ($start, $end): void {
$q1->where(static function (Builder $q2) use ($start, $end): void {
$q2->where('budget_limits.end_date', '>=', $start->format('Y-m-d'));
$q2->where('budget_limits.end_date', '<=', $end->format('Y-m-d'));
})->orWhere(static function (Builder $q3) use ($start, $end): void {
$q3->where('budget_limits.start_date', '>=', $start->format('Y-m-d'));
$q3->where('budget_limits.start_date', '<=', $end->format('Y-m-d'));
});
})->orWhere(static function (Builder $q4) use ($start, $end): void {
// or start is before start AND end is after end.
$q4->where('budget_limits.start_date', '<=', $start->format('Y-m-d'));
$q4->where('budget_limits.end_date', '>=', $end->format('Y-m-d'));
});
})
->get(['budget_limits.*']);
->with(['budget'])
->where('budgets.user_id', $this->user->id)
->whereNull('budgets.deleted_at')
->where(static function (Builder $q5) use ($start, $end): void {
$q5->where(static function (Builder $q1) use ($start, $end): void {
$q1->where(static function (Builder $q2) use ($start, $end): void {
$q2->where('budget_limits.end_date', '>=', $start->format('Y-m-d'));
$q2->where('budget_limits.end_date', '<=', $end->format('Y-m-d'));
})->orWhere(static function (Builder $q3) use ($start, $end): void {
$q3->where('budget_limits.start_date', '>=', $start->format('Y-m-d'));
$q3->where('budget_limits.start_date', '<=', $end->format('Y-m-d'));
});
})->orWhere(static function (Builder $q4) use ($start, $end): void {
// or start is before start AND end is after end.
$q4->where('budget_limits.start_date', '<=', $start->format('Y-m-d'));
$q4->where('budget_limits.end_date', '>=', $end->format('Y-m-d'));
});
})
->get(['budget_limits.*'])
;
}
public function getAllBudgetLimitsByCurrency(TransactionCurrency $currency, ?Carbon $start = null, ?Carbon $end = null): Collection
{
return $this->getAllBudgetLimits($start, $end)->filter(
static fn(BudgetLimit $budgetLimit): bool => $budgetLimit->transaction_currency_id === $currency->id
static fn (BudgetLimit $budgetLimit): bool => $budgetLimit->transaction_currency_id === $currency->id
);
}
@@ -235,7 +240,8 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
->orWhere(static function (Builder $q3) use ($start, $end): void {
$q3->where('budget_limits.start_date', '>=', $start->format('Y-m-d 00:00:00'));
$q3->where('budget_limits.start_date', '<=', $end->format('Y-m-d 23:59:59'));
});
})
;
})->orWhere(static function (Builder $q4) use ($start, $end): void {
// or start is before start AND end is after end.
$q4->where('budget_limits.start_date', '<=', $start->format('Y-m-d 23:59:59'));
@@ -243,21 +249,22 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
});
})
->orderBy('budget_limits.start_date', 'DESC')
->get(['budget_limits.*']);
->get(['budget_limits.*'])
;
}
public 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($budgetLimit->amount, (string)$days, 12);
$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
));
'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;
}
@@ -265,7 +272,7 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
#[Override]
public function getNoteText(BudgetLimit $budgetLimit): string
{
return (string)$budgetLimit->notes()->first()?->text;
return (string) $budgetLimit->notes()->first()?->text;
}
#[Override]
@@ -292,39 +299,40 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
{
// if no currency has been provided, use the user's default currency:
/** @var TransactionCurrencyFactory $factory */
$factory = app(TransactionCurrencyFactory::class);
$currency = $factory->find($data['currency_id'] ?? null, $data['currency_code'] ?? null);
$factory = app(TransactionCurrencyFactory::class);
$currency = $factory->find($data['currency_id'] ?? null, $data['currency_code'] ?? null);
if (null === $currency) {
$currency = Amount::getPrimaryCurrencyByUserGroup($this->user->userGroup);
}
$currency->enabled = true;
$currency->enabled = true;
$currency->save();
// find the budget:
/** @var null|Budget $budget */
$budget = $this->user->budgets()->find((int)$data['budget_id']);
$budget = $this->user->budgets()->find((int) $data['budget_id']);
if (null === $budget) {
throw new FireflyException('200004: Budget does not exist.');
}
// find limit with same date range and currency.
$limit = $budget
$limit = $budget
->budgetlimits()
->where('budget_limits.start_date', $data['start_date']->format('Y-m-d'))
->where('budget_limits.end_date', $data['end_date']->format('Y-m-d'))
->where('budget_limits.transaction_currency_id', $currency->id)
->first(['budget_limits.*']);
->first(['budget_limits.*'])
;
if (null !== $limit) {
throw new FireflyException('200027: Budget limit already exists.');
}
Log::debug('No existing budget limit, create a new one');
// this is a lame trick to communicate with the observer.
$singleton = PreferencesSingleton::getInstance();
$singleton = PreferencesSingleton::getInstance();
$singleton->setPreference('fire_webhooks_bl_store', $data['fire_webhooks'] ?? true);
// or create one and return it.
$limit = new BudgetLimit();
$limit = new BudgetLimit();
$limit->budget()->associate($budget);
$limit->start_date = $data['start_date']->format('Y-m-d');
$limit->start_date_tz = $data['start_date']->format('e');
@@ -336,13 +344,13 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
$limit->transaction_currency_id = $currency->id;
$limit->save();
$noteText = (string)($data['notes'] ?? '');
$noteText = (string) ($data['notes'] ?? '');
if ('' !== $noteText) {
$this->setNoteText($limit, $noteText);
}
Log::debug(sprintf('Created new budget limit with ID #%d and amount %s', $limit->id, $data['amount']));
$createWebhookMessages = $data['fire_webhooks'] ?? true;
$createWebhookMessages = $data['fire_webhooks'] ?? true;
event(new CreatedBudgetLimit($limit, $createWebhookMessages));
event(new WebhookMessagesRequestSending());
@@ -354,8 +362,8 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
*/
public function update(BudgetLimit $budgetLimit, array $data): BudgetLimit
{
$budgetLimit->amount = array_key_exists('amount', $data) ? $data['amount'] : $budgetLimit->amount;
$budgetLimit->budget_id = array_key_exists('budget_id', $data) ? $data['budget_id'] : $budgetLimit->budget_id;
$budgetLimit->amount = array_key_exists('amount', $data) ? $data['amount'] : $budgetLimit->amount;
$budgetLimit->budget_id = array_key_exists('budget_id', $data) ? $data['budget_id'] : $budgetLimit->budget_id;
if (array_key_exists('start', $data)) {
$budgetLimit->start_date = $data['start']->startOfDay();
@@ -367,7 +375,7 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
}
// if no currency has been provided, use the user's default currency:
$currency = null;
$currency = null;
// update if relevant:
if (array_key_exists('currency_id', $data) || array_key_exists('currency_code', $data)) {
@@ -379,12 +387,12 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
if (null === $currency) {
$currency = $budgetLimit->transactionCurrency ?? Amount::getPrimaryCurrencyByUserGroup($this->user->userGroup);
}
$currency->enabled = true;
$currency->enabled = true;
$currency->save();
// this is a lame trick to communicate with the observer.
// FIXME so don't do that lol.
$singleton = PreferencesSingleton::getInstance();
$singleton = PreferencesSingleton::getInstance();
$singleton->setPreference('fire_webhooks_bl_update', $data['fire_webhooks'] ?? true);
$budgetLimit->transaction_currency_id = $currency->id;
@@ -392,10 +400,10 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface, UserGroup
// update notes if they exist.
if (array_key_exists('notes', $data)) {
$this->setNoteText($budgetLimit, (string)$data['notes']);
$this->setNoteText($budgetLimit, (string) $data['notes']);
}
Log::debug(sprintf('Updated budget limit with ID #%d', $budgetLimit->id));
$generateMessages = $data['fire_webhooks'] ?? true;
$generateMessages = $data['fire_webhooks'] ?? true;
event(new UpdatedBudgetLimit($budgetLimit, $generateMessages));
event(new WebhookMessagesRequestSending());
@@ -222,7 +222,14 @@ trait AugumentData
$currentEnd->addMonth();
}
// primary currency amount.
$expenses = $opsRepository->sumExpenses($currentStart, $currentEnd, null, $budgetCollection, $entry->transactionCurrency, $this->convertToPrimary);
$expenses = $opsRepository->sumExpenses(
$currentStart,
$currentEnd,
null,
$budgetCollection,
$entry->transactionCurrency,
$this->convertToPrimary
);
$spent = $expenses[$currency->id]['sum'] ?? '0';
$entry->pc_spent = $spent;
@@ -354,7 +354,10 @@ class RecurringEnrichment implements EnrichmentInterface
/** @var RecurrenceRepetition $repetition */
foreach ($set as $repetition) {
$recurrence = $this->collection->filter(static fn (Recurrence $item): bool => (int) $item->id === (int) $repetition->recurrence_id)->first();
$recurrence = $this->collection
->filter(static fn (Recurrence $item): bool => (int) $item->id === (int) $repetition->recurrence_id)
->first()
;
$fromDate = clone ($recurrence->latest_date ?? $recurrence->first_date);
$recurrenceId = (int) $repetition->recurrence_id;
$repId = (int) $repetition->id;
+1 -1
View File
@@ -79,7 +79,7 @@ return [
// see cer.php for exchange rates feature flag.
],
'version' => 'develop/2026-05-03',
'build_time' => 1777786936,
'build_time' => 1777794370,
'api_version' => '2.1.0', // field is no longer used.
'db_version' => 28, // field is no longer used.
+8 -8
View File
@@ -1398,7 +1398,7 @@ return [
'pref_locale' => 'Locale settings',
'pref_languages_help' => 'Firefly III supports several languages. Which one do you prefer?',
'pref_locale_help' => 'Firefly III allows you to set other local settings, like how currencies, numbers and dates are formatted. Entries in this list may not be supported by your system. Firefly III doesn\'t have the correct date settings for every locale; contact me for improvements.',
'pref_locale_exception' => 'Date input fields will always follow your browser settings, not Firefly III settings. If your browser is set to "English (US)" the date will always be formatted mm/dd/yyyy. No setting in Firefly III can change that.',
'pref_locale_exception' => 'Date input fields will always follow your browser settings, not Firefly III settings. If your browser is set to "English (US)" the date will always be formatted mm/dd/yyyy. No setting in Firefly III can change that.',
'pref_locale_no_demo' => 'This feature won\'t work for the demo user.',
'pref_convert_to_primary' => 'Display amounts in your primary currency',
'pref_convert_to_primary_help' => 'This option will make Firefly III try to display and show your primary currency in as many places as possible, converting amounts where necessary. This sacrifices accuracy for ease of use, because conversion is not always exact. Please verify that Firefly III has the necessary conversion rates on the "exchange rates"-page.',
@@ -1814,13 +1814,13 @@ return [
'options' => 'Options',
// budgets:
'spent_this_period' => 'Spent on this budget',
'spent_this_period_per_day'=> 'Spent on this budget per day (:days day(s))',
'spent_in_budget_limit_outside_period' => 'Spent on this budget, but NOT in this period',
'spent_in_budget_limit_outside_period_per_day' => 'Spent on this budget, but NOT in this period per day (:days day(s))',
'left_in_budget_limit_overview' => 'Left in this budget',
'left_in_budget_limit_per_day'=> 'Left in this budget per day (:days day(s))',
'nothing_left_in_budget' => 'The budget is now empty',
'spent_this_period' => 'Spent on this budget',
'spent_this_period_per_day' => 'Spent on this budget per day (:days day(s))',
'spent_in_budget_limit_outside_period' => 'Spent on this budget, but NOT in this period',
'spent_in_budget_limit_outside_period_per_day' => 'Spent on this budget, but NOT in this period per day (:days day(s))',
'left_in_budget_limit_overview' => 'Left in this budget',
'left_in_budget_limit_per_day' => 'Left in this budget per day (:days day(s))',
'nothing_left_in_budget' => 'The budget is now empty',
'daily_budgets' => 'Daily budgets',
'weekly_budgets' => 'Weekly budgets',
'monthly_budgets' => 'Monthly budgets',