From 722c7d1befceedac60f5f5722a89657fbe44285a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sat, 4 Jul 2026 20:18:16 +0200 Subject: [PATCH] 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. --- .../Api/CashflowAnalyticsController.php | 9 ++--- app/Http/Controllers/DashboardController.php | 11 ++----- app/Services/CashflowSummaryService.php | 24 ++++++++++++++ .../Services/CashflowSummaryServiceTest.php | 33 +++++++++++++++++++ 4 files changed, 61 insertions(+), 16 deletions(-) create mode 100644 app/Services/CashflowSummaryService.php create mode 100644 tests/Unit/Services/CashflowSummaryServiceTest.php diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index 367c160a..95943cce 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -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, ]; diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index a69b9f9e..fe234778 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -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 diff --git a/app/Services/CashflowSummaryService.php b/app/Services/CashflowSummaryService.php new file mode 100644 index 00000000..c9726df9 --- /dev/null +++ b/app/Services/CashflowSummaryService.php @@ -0,0 +1,24 @@ + $income, + 'expense' => $expense, + 'net' => $income - $expense, + 'savings_rate' => $income > 0 ? round((($income - $expense) / $income) * 100, 1) : 0, + ]; + } +} diff --git a/tests/Unit/Services/CashflowSummaryServiceTest.php b/tests/Unit/Services/CashflowSummaryServiceTest.php new file mode 100644 index 00000000..5a6cd5f1 --- /dev/null +++ b/tests/Unit/Services/CashflowSummaryServiceTest.php @@ -0,0 +1,33 @@ +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); +});