Consolidate savings-rate math into CashflowSummaryService

The net and savings-rate derivation was byte-identical in
DashboardController::calculateCashflowSummary and
CashflowAnalyticsController::cashflowSummaryFromTransactions, so the two
screens could silently diverge if one formula were ever edited.

Extract a single CashflowSummaryService::summarize(income, expense) that
owns the net and savings-rate math, and have both call sites consume it.
Behaviour-preserving: each controller still computes income/expense its
own way (single-currency SQL on the dashboard, multi-currency in-memory
on the analytics endpoint) and only the shared derivation moves.
This commit is contained in:
Víctor Falcón 2026-07-04 20:18:16 +02:00
parent 845f51abb5
commit 722c7d1bef
4 changed files with 61 additions and 16 deletions

View File

@ -8,6 +8,7 @@ use App\Http\Controllers\Api\Concerns\ConvertsTransactionCurrency;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Transaction;
use App\Services\CashflowSummaryService;
use App\Services\CategoryTree;
use App\Services\ExchangeRateService;
use App\Services\PeriodComparator;
@ -208,14 +209,8 @@ class CashflowAnalyticsController extends Controller
$savings = $this->sumOutflowTransactions($transactions, $userCurrency, CategoryType::Savings);
$investments = $this->sumOutflowTransactions($transactions, $userCurrency, CategoryType::Investment);
$net = $income - $expense;
$savingsRate = $income > 0 ? round((($income - $expense) / $income) * 100, 1) : 0;
return [
'income' => $income,
'expense' => $expense,
'net' => $net,
'savings_rate' => $savingsRate,
...CashflowSummaryService::summarize($income, $expense),
'savings' => $savings,
'investments' => $investments,
];

View File

@ -6,6 +6,7 @@ use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\Transaction;
use App\Services\AccountMetricsService;
use App\Services\CashflowSummaryService;
use App\Services\CategoryTree;
use App\Services\PeriodComparator;
use Carbon\Carbon;
@ -135,15 +136,7 @@ class DashboardController extends Controller
$income = max(0, $this->getTransactionSum($userId, $from, $to, CategoryType::Income));
$expense = max(0, -$this->getTransactionSum($userId, $from, $to, CategoryType::Expense));
$net = $income - $expense;
$savingsRate = $income > 0 ? round((($income - $expense) / $income) * 100, 1) : 0;
return [
'income' => $income,
'expense' => $expense,
'net' => $net,
'savings_rate' => $savingsRate,
];
return CashflowSummaryService::summarize($income, $expense);
}
private function getTransactionSum(string $userId, Carbon $from, Carbon $to, CategoryType $type): int

View File

@ -0,0 +1,24 @@
<?php
namespace App\Services;
class CashflowSummaryService
{
/**
* Derive the shared cashflow summary from already-clamped income and
* expense totals (both non-negative, in minor units). Kept in one place so
* the net and savings-rate math can never drift between the dashboard and
* the cashflow analytics endpoints.
*
* @return array{income: int, expense: int, net: int, savings_rate: float|int}
*/
public static function summarize(int $income, int $expense): array
{
return [
'income' => $income,
'expense' => $expense,
'net' => $income - $expense,
'savings_rate' => $income > 0 ? round((($income - $expense) / $income) * 100, 1) : 0,
];
}
}

View File

@ -0,0 +1,33 @@
<?php
use App\Services\CashflowSummaryService;
it('derives net and savings rate from income and expense', function () {
expect(CashflowSummaryService::summarize(100000, 40000))->toBe([
'income' => 100000,
'expense' => 40000,
'net' => 60000,
'savings_rate' => 60.0,
]);
});
it('returns a negative net when expense exceeds income', function () {
expect(CashflowSummaryService::summarize(0, 10000))->toBe([
'income' => 0,
'expense' => 10000,
'net' => -10000,
'savings_rate' => 0,
]);
});
it('avoids division by zero when income is zero', function () {
$summary = CashflowSummaryService::summarize(0, 0);
expect($summary['savings_rate'])->toBe(0)
->and($summary['net'])->toBe(0);
});
it('rounds the savings rate to one decimal place', function () {
// (100000 - 33333) / 100000 * 100 = 66.667 -> 66.7
expect(CashflowSummaryService::summarize(100000, 33333)['savings_rate'])->toBe(66.7);
});