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
This commit is contained in:
Víctor Falcón 2026-05-11 16:49:29 +01:00 committed by GitHub
parent 5b8e7e86d7
commit 4e03996289
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 337 additions and 124 deletions

View File

@ -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;
}
}

View File

@ -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,