From 4e039962891cfdfb59e48b52de427b3698a41c1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Mon, 11 May 2026 16:49:29 +0100 Subject: [PATCH] Fix cashflow multi-currency totals (#380) ## Summary - convert cashflow transaction amounts to user currency - apply conversion to summary, trend, sankey, and breakdown endpoints - add regression coverage for mixed USD/EUR cashflow analytics ## Tests - vendor/bin/pint --dirty --format agent - php artisan test --compact tests/Feature/CashflowAnalyticsTest.php --- .../Api/CashflowAnalyticsController.php | 342 +++++++++++------- tests/Feature/CashflowAnalyticsTest.php | 119 ++++++ 2 files changed, 337 insertions(+), 124 deletions(-) diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index b15a5a18..1fd847ca 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -7,14 +7,17 @@ use App\Enums\CategoryType; use App\Http\Controllers\Controller; use App\Models\Category; use App\Models\Transaction; +use App\Services\ExchangeRateService; use App\Services\PeriodComparator; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; +use Illuminate\Support\Collection; class CashflowAnalyticsController extends Controller { + public function __construct(private ExchangeRateService $exchangeRateService) {} + public function summary(Request $request): JsonResponse { $validated = $request->validate([ @@ -24,14 +27,11 @@ class CashflowAnalyticsController extends Controller $period = PeriodComparator::fromRequest($validated); $previousPeriod = $period->previous(); + $user = $request->user(); - $current = $this->calculateCashflowSummary($request->user()->id, $period->from, $period->to); - $previous = $this->calculateCashflowSummary($request->user()->id, $previousPeriod->from, $previousPeriod->to); - - return $this->cashflowJson([ - 'current' => $current, - 'previous' => $previous, - ]); + return $this->cashflowJson( + $this->calculateCashflowSummaries($user->id, $user->currency_code, $period, $previousPeriod) + ); } public function sankey(Request $request): JsonResponse @@ -43,12 +43,12 @@ class CashflowAnalyticsController extends Controller $from = Carbon::parse($validated['from']); $to = Carbon::parse($validated['to']); - $userId = $request->user()->id; + $user = $request->user(); // Split by sign, not by category type: a single category can appear on // both sides when it has both incoming and outgoing transactions. - $incomeCategories = $this->getSankeyBreakdown($userId, $from, $to, '>'); - $expenseCategories = $this->getSankeyBreakdown($userId, $from, $to, '<'); + $incomeCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '>'); + $expenseCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '<'); $totalIncome = $incomeCategories->sum('amount'); $totalExpense = $expenseCategories->sum('amount'); @@ -69,13 +69,13 @@ class CashflowAnalyticsController extends Controller ]); $months = $validated['months'] ?? 12; - $userId = $request->user()->id; + $user = $request->user(); $end = isset($validated['to']) ? Carbon::parse($validated['to'])->endOfMonth() : Carbon::now()->endOfMonth(); $start = $end->copy()->subMonthsNoOverflow($months - 1)->startOfMonth(); - $monthlyTotals = $this->getMonthlyTrendTotals($userId, $start, $end); + $monthlyTotals = $this->getMonthlyTrendTotals($user->id, $user->currency_code, $start, $end); $data = []; $current = $start->copy(); @@ -83,8 +83,8 @@ class CashflowAnalyticsController extends Controller while ($current->lte($end)) { $monthKey = $current->format('Y-m'); $totals = $monthlyTotals->get($monthKey); - $income = (int) ($totals->income ?? 0); - $expense = (int) ($totals->expense ?? 0); + $income = (int) ($totals['income'] ?? 0); + $expense = (int) ($totals['expense'] ?? 0); $data[] = [ 'month' => $monthKey, @@ -111,12 +111,12 @@ class CashflowAnalyticsController extends Controller $period = PeriodComparator::fromRequest($validated); $previousPeriod = $period->previous(); - $userId = $request->user()->id; + $user = $request->user(); $categoryType = $validated['type'] === 'income' ? CategoryType::Income : CategoryType::Expense; - $current = $this->getCategoryBreakdown($userId, $period->from, $period->to, $categoryType); - $previous = $this->getCategoryBreakdown($userId, $previousPeriod->from, $previousPeriod->to, $categoryType); + $current = $this->getCategoryBreakdown($user->id, $user->currency_code, $period->from, $period->to, $categoryType); + $previous = $this->getCategoryBreakdown($user->id, $user->currency_code, $previousPeriod->from, $previousPeriod->to, $categoryType); $currentTotal = $current->sum('amount'); $previousTotal = $previous->sum('amount'); @@ -148,10 +148,32 @@ class CashflowAnalyticsController extends Controller ->header('Cache-Control', 'no-store, private'); } - private function calculateCashflowSummary(string $userId, Carbon $from, Carbon $to): array + private function calculateCashflowSummaries(string $userId, string $userCurrency, PeriodComparator $period, PeriodComparator $previousPeriod): array { - $income = $this->getTransactionSum($userId, $from, $to, CategoryType::Income); - $expense = abs($this->getTransactionSum($userId, $from, $to, CategoryType::Expense)); + $transactions = Transaction::query() + ->where('transactions.user_id', $userId) + ->whereBetween('transactions.transaction_date', [$previousPeriod->from, $period->to]) + ->with(['account', 'category']) + ->get(); + + $this->preloadExchangeRates($transactions, $userCurrency); + + return [ + 'current' => $this->cashflowSummaryFromTransactions( + $this->transactionsForPeriod($transactions, $period->from, $period->to), + $userCurrency, + ), + 'previous' => $this->cashflowSummaryFromTransactions( + $this->transactionsForPeriod($transactions, $previousPeriod->from, $previousPeriod->to), + $userCurrency, + ), + ]; + } + + private function cashflowSummaryFromTransactions(Collection $transactions, string $userCurrency): array + { + $income = $this->sumTransactions($transactions, $userCurrency, CategoryType::Income); + $expense = abs($this->sumTransactions($transactions, $userCurrency, CategoryType::Expense)); $net = $income - $expense; $savingsRate = $income > 0 ? round((($income - $expense) / $income) * 100, 1) : 0; @@ -164,90 +186,84 @@ class CashflowAnalyticsController extends Controller ]; } - private function getTransactionSum(string $userId, Carbon $from, Carbon $to, CategoryType $type): int + private function sumTransactions(Collection $transactions, string $userCurrency, CategoryType $type): int { - return Transaction::query() - ->where('transactions.user_id', $userId) - ->whereBetween('transactions.transaction_date', [$from, $to]) - ->where(function ($q) use ($type) { - $q->whereExists(function ($sub) use ($type) { - $sub->select(DB::raw(1)) - ->from('categories') - ->whereColumn('categories.id', 'transactions.category_id') - ->where('categories.type', $type); - }) - ->orWhere(function ($q) use ($type) { - $q->whereNull('transactions.category_id') - ->where('transactions.amount', $type === CategoryType::Income ? '>' : '<', 0); - }); + return $transactions + ->filter(function (Transaction $transaction) use ($type): bool { + if ($this->categoryType($transaction) === $type) { + return true; + } + + return $transaction->category_id === null + && $this->matchesSign($transaction->amount, $type === CategoryType::Income ? '>' : '<'); }) - ->sum('transactions.amount'); + ->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)); } - private function getSankeyBreakdown(string $userId, Carbon $from, Carbon $to, string $operator) + private function getSankeyBreakdown(string $userId, string $userCurrency, Carbon $from, Carbon $to, string $operator): Collection { $isIncome = $operator === '>'; + $transactions = Transaction::query() + ->where('transactions.user_id', $userId) + ->whereBetween('transactions.transaction_date', [$from, $to]) + ->with(['account', 'category']) + ->get(); + + $this->preloadExchangeRates($transactions, $userCurrency); // Non-transfer categories keep the existing sign-based behavior so a // category with mixed signs can appear on both sides of the Sankey. - $regularCategories = Transaction::query() - ->where('transactions.user_id', $userId) - ->whereBetween('transactions.transaction_date', [$from, $to]) - ->where('transactions.amount', $operator, 0) - ->whereNotNull('transactions.category_id') - ->join('categories', function ($join) { - $join->on('transactions.category_id', '=', 'categories.id') - ->where('categories.type', '!=', CategoryType::Transfer); + $regularCategories = $transactions + ->filter(function (Transaction $transaction) use ($operator): bool { + return $transaction->category_id !== null + && $this->categoryType($transaction) !== CategoryType::Transfer + && $this->matchesSign($transaction->amount, $operator); }) - ->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount')) - ->groupBy('transactions.category_id') - ->with('category') - ->get() - ->map(function ($item) { + ->groupBy('category_id') + ->map(function (Collection $transactions) use ($userCurrency): array { + $totalAmount = $transactions->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)); + return [ - 'category_id' => $item->category_id, - 'category' => $item->category, - 'amount' => abs($item->total_amount), + 'category_id' => $transactions->first()->category_id, + 'category' => $transactions->first()->category, + 'amount' => abs($totalAmount), ]; }); - $transferCategories = Transaction::query() - ->where('transactions.user_id', $userId) - ->whereBetween('transactions.transaction_date', [$from, $to]) - ->whereNotNull('transactions.category_id') - ->join('categories', function ($join) use ($isIncome) { - $join->on('transactions.category_id', '=', 'categories.id') - ->where('categories.type', CategoryType::Transfer) - ->where( - 'categories.cashflow_direction', - $isIncome - ? CategoryCashflowDirection::Inflow - : CategoryCashflowDirection::Outflow, - ); + $transferCategories = $transactions + ->filter(function (Transaction $transaction) use ($isIncome): bool { + return $transaction->category_id !== null + && $this->categoryType($transaction) === CategoryType::Transfer + && $this->categoryCashflowDirection($transaction) === ($isIncome + ? CategoryCashflowDirection::Inflow + : CategoryCashflowDirection::Outflow); }) - ->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount')) - ->groupBy('transactions.category_id') - ->with('category') - ->get() - ->filter(function ($item) use ($isIncome) { - return $isIncome ? $item->total_amount > 0 : $item->total_amount < 0; - }) - ->map(function ($item) { + ->groupBy('category_id') + ->map(function (Collection $transactions) use ($userCurrency): array { + $totalAmount = $transactions->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)); + return [ - 'category_id' => $item->category_id, - 'category' => $item->category, - 'amount' => abs($item->total_amount), + 'category_id' => $transactions->first()->category_id, + 'category' => $transactions->first()->category, + 'amount' => abs($totalAmount), + 'total_amount' => $totalAmount, ]; - }); + }) + ->filter(fn (array $item): bool => $isIncome ? $item['total_amount'] > 0 : $item['total_amount'] < 0) + ->map(fn (array $item): array => [ + 'category_id' => $item['category_id'], + 'category' => $item['category'], + 'amount' => $item['amount'], + ]); $categorized = $regularCategories->concat($transferCategories)->values(); - $uncategorized = Transaction::query() - ->where('user_id', $userId) - ->whereBetween('transaction_date', [$from, $to]) - ->whereNull('category_id') - ->where('amount', $operator, 0) - ->sum('amount'); + $uncategorized = $transactions + ->filter(function (Transaction $transaction) use ($operator): bool { + return $transaction->category_id === null + && $this->matchesSign($transaction->amount, $operator); + }) + ->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)); if ($uncategorized != 0) { $categorized->push([ @@ -266,60 +282,79 @@ class CashflowAnalyticsController extends Controller return $categorized; } - private function getMonthlyTrendTotals(string $userId, Carbon $from, Carbon $to) + private function getMonthlyTrendTotals(string $userId, string $userCurrency, Carbon $from, Carbon $to): Collection { - return Transaction::query() + $transactions = Transaction::query() ->where('transactions.user_id', $userId) ->whereBetween('transactions.transaction_date', [$from, $to]) - ->leftJoin('categories', 'transactions.category_id', '=', 'categories.id') - ->selectRaw("DATE_FORMAT(transactions.transaction_date, '%Y-%m') as month") - ->selectRaw( - 'SUM(CASE WHEN ((categories.type = ? AND transactions.amount > 0) OR (transactions.category_id IS NULL AND transactions.amount > 0)) THEN transactions.amount ELSE 0 END) as income', - [CategoryType::Income->value] - ) - ->selectRaw( - 'SUM(CASE WHEN ((categories.type = ? AND transactions.amount < 0) OR (transactions.category_id IS NULL AND transactions.amount < 0)) THEN -transactions.amount ELSE 0 END) as expense', - [CategoryType::Expense->value] - ) - ->groupBy('month') - ->orderBy('month') - ->get() - ->keyBy('month'); + ->with(['account', 'category']) + ->get(); + + $this->preloadExchangeRates($transactions, $userCurrency); + + return $transactions + ->groupBy(fn (Transaction $transaction): string => $transaction->transaction_date->format('Y-m')) + ->map(function (Collection $transactions) use ($userCurrency): array { + $income = 0; + $expense = 0; + + foreach ($transactions as $transaction) { + $amount = $this->convertTransactionAmount($transaction, $userCurrency); + + if (($this->categoryType($transaction) === CategoryType::Income && $transaction->amount > 0) + || ($transaction->category_id === null && $transaction->amount > 0)) { + $income += $amount; + } + + if (($this->categoryType($transaction) === CategoryType::Expense && $transaction->amount < 0) + || ($transaction->category_id === null && $transaction->amount < 0)) { + $expense += abs($amount); + } + } + + return [ + 'income' => $income, + 'expense' => $expense, + ]; + }); } - private function getCategoryBreakdown(string $userId, Carbon $from, Carbon $to, CategoryType $type) + private function getCategoryBreakdown(string $userId, string $userCurrency, Carbon $from, Carbon $to, CategoryType $type): Collection { + $transactions = Transaction::query() + ->where('transactions.user_id', $userId) + ->whereBetween('transactions.transaction_date', [$from, $to]) + ->with(['account', 'category']) + ->get(); + + $this->preloadExchangeRates($transactions, $userCurrency); + // Get categorized transactions — filter by sign so that outgoing payments // in an income category (or refunds in an expense category) are excluded. // This ensures the Sankey shows the actual gross flow for each side, not // the net which could be misleading when categories contain mixed-sign entries. - $categorized = Transaction::query() - ->where('transactions.user_id', $userId) - ->whereBetween('transactions.transaction_date', [$from, $to]) - ->where('transactions.amount', $type === CategoryType::Income ? '>' : '<', 0) - ->join('categories', function ($join) use ($type) { - $join->on('transactions.category_id', '=', 'categories.id') - ->where('categories.type', '=', $type); + $categorized = $transactions + ->filter(function (Transaction $transaction) use ($type): bool { + return $this->categoryType($transaction) === $type + && $this->matchesSign($transaction->amount, $type === CategoryType::Income ? '>' : '<'); }) - ->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount')) - ->groupBy('transactions.category_id') - ->with('category') - ->get() - ->map(function ($item) { + ->groupBy('category_id') + ->map(function (Collection $transactions) use ($userCurrency): array { + $totalAmount = $transactions->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)); + return [ - 'category_id' => $item->category_id, - 'category' => $item->category, - 'amount' => abs($item->total_amount), + 'category_id' => $transactions->first()->category_id, + 'category' => $transactions->first()->category, + 'amount' => abs($totalAmount), ]; }); - // Get uncategorized transactions - $uncategorized = Transaction::query() - ->where('user_id', $userId) - ->whereBetween('transaction_date', [$from, $to]) - ->whereNull('category_id') - ->where('amount', $type === CategoryType::Income ? '>' : '<', 0) - ->sum('amount'); + $uncategorized = $transactions + ->filter(function (Transaction $transaction) use ($type): bool { + return $transaction->category_id === null + && $this->matchesSign($transaction->amount, $type === CategoryType::Income ? '>' : '<'); + }) + ->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)); // Add uncategorized as a special category if there are any if ($uncategorized != 0) { @@ -338,4 +373,63 @@ class CashflowAnalyticsController extends Controller return $categorized; } + + private function transactionsForPeriod(Collection $transactions, Carbon $from, Carbon $to): Collection + { + return $transactions->filter(function (Transaction $transaction) use ($from, $to): bool { + return $transaction->transaction_date->betweenIncluded($from, $to); + }); + } + + private function convertTransactionAmount(Transaction $transaction, string $userCurrency): int + { + return $this->exchangeRateService->convert( + $transaction->currency_code ?: $transaction->account?->currency_code ?: $userCurrency, + $userCurrency, + $transaction->amount, + $transaction->transaction_date->toDateString(), + ); + } + + private function preloadExchangeRates(Collection $transactions, string $userCurrency): void + { + $dates = $transactions + ->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $userCurrency, $userCurrency) !== 0) + ->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString()) + ->unique() + ->values(); + + if ($dates->isEmpty()) { + return; + } + + $this->exchangeRateService->preloadRates($userCurrency, $dates); + } + + private function categoryType(Transaction $transaction): ?CategoryType + { + $type = $transaction->category?->getAttribute('type'); + + if ($type instanceof CategoryType) { + return $type; + } + + return is_string($type) ? CategoryType::tryFrom($type) : null; + } + + private function categoryCashflowDirection(Transaction $transaction): ?CategoryCashflowDirection + { + $direction = $transaction->category?->getAttribute('cashflow_direction'); + + if ($direction instanceof CategoryCashflowDirection) { + return $direction; + } + + return is_string($direction) ? CategoryCashflowDirection::tryFrom($direction) : null; + } + + private function matchesSign(int $amount, string $operator): bool + { + return $operator === '>' ? $amount > 0 : $amount < 0; + } } diff --git a/tests/Feature/CashflowAnalyticsTest.php b/tests/Feature/CashflowAnalyticsTest.php index fbab4d78..8a1cfd8b 100644 --- a/tests/Feature/CashflowAnalyticsTest.php +++ b/tests/Feature/CashflowAnalyticsTest.php @@ -4,10 +4,14 @@ use App\Enums\CategoryCashflowDirection; use App\Enums\CategoryType; use App\Models\Account; use App\Models\Category; +use App\Models\ExchangeRate; use App\Models\Transaction; use App\Models\User; +use Illuminate\Support\Facades\Http; beforeEach(function () { + Http::fake(); + $this->user = User::factory()->create(); $this->actingAs($this->user); }); @@ -68,6 +72,121 @@ test('cashflow summary returns income, expense, net, and savings rate', function ]); }); +test('cashflow analytics convert foreign currency transactions to user currency', function () { + $date = now()->startOfMonth()->addDays(4); + $from = $date->copy()->startOfMonth()->toDateString(); + $to = $date->copy()->endOfMonth()->toDateString(); + + $incomeCategory = Category::factory()->create([ + 'user_id' => $this->user->id, + 'type' => CategoryType::Income, + ]); + $expenseCategory = Category::factory()->create([ + 'user_id' => $this->user->id, + 'type' => CategoryType::Expense, + ]); + + $usdAccount = Account::factory()->create([ + 'user_id' => $this->user->id, + 'currency_code' => 'USD', + ]); + $eurAccount = Account::factory()->create([ + 'user_id' => $this->user->id, + 'currency_code' => 'EUR', + ]); + + ExchangeRate::factory()->create([ + 'base_currency' => 'usd', + 'date' => $date->toDateString(), + 'rates' => ['eur' => 0.80], + ]); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $usdAccount->id, + 'category_id' => $incomeCategory->id, + 'amount' => 10000, + 'currency_code' => 'USD', + 'transaction_date' => $date, + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $eurAccount->id, + 'category_id' => $incomeCategory->id, + 'amount' => 8000, + 'currency_code' => 'EUR', + 'transaction_date' => $date, + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $usdAccount->id, + 'category_id' => $expenseCategory->id, + 'amount' => -5000, + 'currency_code' => 'USD', + 'transaction_date' => $date, + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $eurAccount->id, + 'category_id' => $expenseCategory->id, + 'amount' => -4000, + 'currency_code' => 'EUR', + 'transaction_date' => $date, + ]); + + $summary = $this->getJson('/api/cashflow/summary?'.http_build_query([ + 'from' => $from, + 'to' => $to, + ])); + $trend = $this->getJson('/api/cashflow/trend?'.http_build_query([ + 'months' => 1, + 'to' => $to, + ])); + $sankey = $this->getJson('/api/cashflow/sankey?'.http_build_query([ + 'from' => $from, + 'to' => $to, + ])); + $incomeBreakdown = $this->getJson('/api/cashflow/breakdown?'.http_build_query([ + 'from' => $from, + 'to' => $to, + 'type' => 'income', + ])); + $expenseBreakdown = $this->getJson('/api/cashflow/breakdown?'.http_build_query([ + 'from' => $from, + 'to' => $to, + 'type' => 'expense', + ])); + + $summary->assertOk() + ->assertJsonPath('current.income', 20000) + ->assertJsonPath('current.expense', 10000) + ->assertJsonPath('current.net', 10000) + ->assertJsonPath('current.savings_rate', 50); + + $trend->assertOk() + ->assertJsonPath('data.0.income', 20000) + ->assertJsonPath('data.0.expense', 10000) + ->assertJsonPath('data.0.net', 10000); + + $sankey->assertOk() + ->assertJsonPath('total_income', 20000) + ->assertJsonPath('total_expense', 10000) + ->assertJsonPath('income_categories.0.amount', 20000) + ->assertJsonPath('expense_categories.0.amount', 10000); + + $incomeBreakdown->assertOk() + ->assertJsonPath('total', 20000) + ->assertJsonPath('data.0.amount', 20000) + ->assertJsonPath('data.0.percentage', 100); + + $expenseBreakdown->assertOk() + ->assertJsonPath('total', 10000) + ->assertJsonPath('data.0.amount', 10000) + ->assertJsonPath('data.0.percentage', 100); + + Http::assertNothingSent(); +}); + test('cashflow summary handles zero income for savings rate', function () { $expenseCategory = Category::factory()->create([ 'user_id' => $this->user->id,