feat: Add multi-currency conversion for net worth charts (#138)

## Why

### Problem

Users with accounts in multiple currencies see raw balances on the net
worth chart without any conversion. A user with a USD-denominated
profile who also holds EUR and GBP accounts sees misleading totals — the
chart simply sums cents across currencies as if they were the same unit.

## What

### Changes

- **`ExchangeRateService`** — New service that wraps
`CurrencyConversionService` with DB-level caching. Checks the
`exchange_rates` table first, falls back to the external CDN API on
cache miss, and stores the full rate blob for future lookups.
- **`exchange_rates` migration + model** — New table with
`(base_currency, date)` unique constraint storing JSON rate blobs.
Includes factory.
- **`CurrencyConversionService`** — Made `getRatesForCurrency()` public
so `ExchangeRateService` can delegate to it.
- **`DashboardAnalyticsController`** — `netWorthEvolution` and
`netWorthDailyEvolution` endpoints now convert every account balance
from `account.currency_code` to `user.currency_code`. Response includes
`currency_code` field and `_original` data for foreign-currency
accounts.
- **Future date capping** — `ExchangeRateService::getRates()` caps any
future date to today, since the currency API only serves historical
rates. Prevents 500 errors when the chart requests end-of-month rates
for the current (incomplete) month.
- **Frontend charts** — Simplified to single-currency display. Removed
multi-currency `TotalDisplay` component; the chart header and tooltip
now show amounts in the user's currency. Foreign-currency accounts show
their original amount inline in the tooltip (e.g., "€2,000.00 →
$2,222.22").
- **`valueFormatter` type fix** — Changed return type from `string` to
`React.ReactNode` in chart component interfaces to match actual usage
with `AmountDisplay`.

## Screenshot
<img width="1266" height="649" alt="image"
src="https://github.com/user-attachments/assets/6f661840-3f65-49fe-9948-d4cfd6c6ef0e"
/>
This commit is contained in:
Víctor Falcón 2026-02-20 10:45:23 +01:00 committed by GitHub
parent 880b27675c
commit b743cad803
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 658 additions and 134 deletions

View File

@ -2,12 +2,12 @@
namespace App\Http\Controllers\Api;
use App\Enums\AccountType;
use App\Enums\CategoryType;
use App\Http\Controllers\Controller;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Transaction;
use App\Services\ExchangeRateService;
use App\Services\PeriodComparator;
use Carbon\Carbon;
use Illuminate\Http\Request;
@ -15,6 +15,8 @@ use Illuminate\Support\Facades\DB;
class DashboardAnalyticsController extends Controller
{
public function __construct(private ExchangeRateService $exchangeRateService) {}
public function netWorth(Request $request)
{
$validated = $request->validate([
@ -25,9 +27,12 @@ class DashboardAnalyticsController extends Controller
$period = PeriodComparator::fromRequest($validated);
$previousPeriod = $period->previous();
$userCurrency = $request->user()->currency_code;
return response()->json([
'current' => $this->calculateNetWorthAt($period->to),
'previous' => $this->calculateNetWorthAt($previousPeriod->to),
'current' => $this->calculateNetWorthAt($period->to, $userCurrency),
'previous' => $this->calculateNetWorthAt($previousPeriod->to, $userCurrency),
'currency_code' => $userCurrency,
]);
}
@ -73,6 +78,8 @@ class DashboardAnalyticsController extends Controller
$start = Carbon::parse($validated['from']);
$end = Carbon::parse($validated['to']);
$userCurrency = $request->user()->currency_code;
$accounts = Account::query()
->where('user_id', $request->user()->id)
->with(['bank:id,name,logo'])
@ -90,7 +97,22 @@ class DashboardAnalyticsController extends Controller
];
foreach ($accounts as $account) {
$point[$account->id] = $this->getBalanceAt($account->id, $date);
$originalBalance = $this->getBalanceAt($account->id, $date);
$convertedBalance = $this->convertBalance(
$originalBalance,
$account->currency_code,
$userCurrency,
$date->toDateString(),
);
$point[$account->id] = $convertedBalance;
if ($account->currency_code !== $userCurrency) {
$point[$account->id.'_original'] = [
'amount' => $originalBalance,
'currency_code' => $account->currency_code,
];
}
}
$points[] = $point;
@ -114,6 +136,7 @@ class DashboardAnalyticsController extends Controller
return response()->json([
'data' => $points,
'accounts' => $accountsConfig,
'currency_code' => $userCurrency,
]);
}
@ -208,6 +231,8 @@ class DashboardAnalyticsController extends Controller
$start = Carbon::parse($validated['from']);
$end = Carbon::parse($validated['to']);
$userCurrency = $request->user()->currency_code;
$accounts = Account::query()
->where('user_id', $request->user()->id)
->with(['bank:id,name,logo'])
@ -224,7 +249,22 @@ class DashboardAnalyticsController extends Controller
];
foreach ($accounts as $account) {
$point[$account->id] = $this->getBalanceAt($account->id, $date);
$originalBalance = $this->getBalanceAt($account->id, $date);
$convertedBalance = $this->convertBalance(
$originalBalance,
$account->currency_code,
$userCurrency,
$date->toDateString(),
);
$point[$account->id] = $convertedBalance;
if ($account->currency_code !== $userCurrency) {
$point[$account->id.'_original'] = [
'amount' => $originalBalance,
'currency_code' => $account->currency_code,
];
}
}
$points[] = $point;
@ -248,6 +288,7 @@ class DashboardAnalyticsController extends Controller
return response()->json([
'data' => $points,
'accounts' => $accountsConfig,
'currency_code' => $userCurrency,
]);
}
@ -305,52 +346,20 @@ class DashboardAnalyticsController extends Controller
});
}
private function calculateNetWorthAt(Carbon $date): int
private function calculateNetWorthAt(Carbon $date, string $userCurrency): int
{
// Get latest balance for each account on or before date
$accounts = Account::where('user_id', request()->user()->id)->get();
$total = 0;
foreach ($accounts as $account) {
$balance = $this->getBalanceAt($account->id, $date);
// Assets: Checking, Savings, Others
// Liabilities: CreditCard, Loan
if (in_array($account->type, [AccountType::CreditCard, AccountType::Loan])) {
// Liabilities should be subtracted from Net Worth.
// If balance is stored as positive debt (e.g. $1000 debt), we subtract it.
// If balance is stored as negative (e.g. -$1000), we add it.
// Standard convention in this app seems to be signed values based on `account_balances` table.
// Let's assume standard accounting: Credit Card balance of -$500 means I owe $500.
// So I just sum everything up.
// Wait, user option was "Assets minus liabilities".
// If I have $1000 in Checking (Asset) and -$500 in CC (Liability).
// Net Worth = 1000 + (-500) = 500.
// This is effectively "Sum of all accounts".
// User explicitly selected "Assets minus liabilities" vs "Sum of all accounts".
// "Sum of all accounts - This treats all accounts equally. Simple but may include debt as negative values."
// "Assets minus liabilities - ... Net Worth = Total Assets - Total Liabilities."
// If Credit Card balance is stored as POSITIVE (e.g. "I owe $500"), then Net Worth = Assets - Liabilities.
// If Credit Card balance is stored as NEGATIVE (e.g. "-$500"), then Net Worth = Assets + Liabilities (1000 + (-500) = 500).
// Let's check `AccountBalance` table data or assumptions.
// I'll assume balances are stored as their actual signed value (Assets +, Liabilities -).
// If so, simple sum is correct.
// BUT, if the user chose "Assets minus liabilities" distinct from "Sum of all",
// it implies liabilities might be stored as positive numbers?
// OR they just want the breakdown.
// Let's double check how `useDashboardData` calculated it previously.
// `const currentNetWorth = accountMetrics.reduce((sum, acc) => sum + acc.currentBalance, 0);`
// It was just summing them up.
// If I assume signed balances:
$total += $balance;
} else {
$total += $balance;
}
$total += $this->convertBalance(
$balance,
$account->currency_code,
$userCurrency,
$date->toDateString(),
);
}
return $total;
@ -365,6 +374,18 @@ class DashboardAnalyticsController extends Controller
->value('balance') ?? 0;
}
/**
* Convert a balance from one currency to another, skipping conversion when currencies match.
*/
private function convertBalance(int $balance, string $sourceCurrency, string $targetCurrency, string $date): int
{
if (strtolower($sourceCurrency) === strtolower($targetCurrency)) {
return $balance;
}
return $this->exchangeRateService->convert($sourceCurrency, $targetCurrency, $balance, $date);
}
private function calculateSpending(Carbon $from, Carbon $to): int
{
$spending = Transaction::query()

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ExchangeRate extends Model
{
/** @use HasFactory<\Database\Factories\ExchangeRateFactory> */
use HasFactory;
protected $fillable = [
'base_currency',
'date',
'rates',
];
protected function casts(): array
{
return [
'date' => 'date',
'rates' => 'array',
];
}
}

View File

@ -48,12 +48,16 @@ class CurrencyConversionService
}
/**
* Fetch rates for a target currency, using in-memory cache.
* Fetch all rates for a base currency on a given date.
*
* @return array<string, float> Map of currency code => rate (e.g., "btc" => 0.000015)
* Returns a map of currency code => rate relative to the base currency.
* Results are cached in-memory for the duration of the request.
*
* @return array<string, float>
*/
private function getRatesForCurrency(string $currency, string $date): array
public function getRatesForCurrency(string $currency, string $date): array
{
$currency = strtolower($currency);
$cacheKey = "{$currency}:{$date}";
if (isset($this->rateCache[$cacheKey])) {

View File

@ -0,0 +1,86 @@
<?php
namespace App\Services;
use App\Models\ExchangeRate;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
class ExchangeRateService
{
public function __construct(private CurrencyConversionService $currencyApi) {}
/**
* Convert an amount in cents from one currency to another on a given date.
*
* Uses DB-cached exchange rates, falling back to the external API on cache miss.
*
* @param string $source Source currency code (e.g., "EUR")
* @param string $target Target currency code (e.g., "USD")
* @param int $amountInCents Amount in the source currency's smallest unit
* @param string $date Date string (YYYY-MM-DD)
*/
public function convert(string $source, string $target, int $amountInCents, string $date): int
{
$source = strtolower($source);
$target = strtolower($target);
if ($source === $target) {
return $amountInCents;
}
$rates = $this->getRates($target, $date);
if (! isset($rates[$source]) || $rates[$source] == 0) {
Log::warning('Exchange rate not found, returning unconverted amount', [
'source' => $source,
'target' => $target,
'date' => $date,
]);
return $amountInCents;
}
return (int) round($amountInCents / $rates[$source]);
}
/**
* Get all exchange rates for a base currency on a given date.
*
* Checks the DB cache first, then fetches from the external API
* and stores the result for future lookups.
*
* @return array<string, float>
*/
public function getRates(string $baseCurrency, string $date): array
{
$baseCurrency = strtolower($baseCurrency);
// Cap future dates to today — the API only has rates up to the current date.
$today = Carbon::today()->toDateString();
if ($date > $today) {
$date = $today;
}
$cached = ExchangeRate::query()
->where('base_currency', $baseCurrency)
->where('date', $date)
->first();
if ($cached) {
return $cached->rates;
}
$rates = $this->currencyApi->getRatesForCurrency($baseCurrency, $date);
if (! empty($rates)) {
ExchangeRate::query()->create([
'base_currency' => $baseCurrency,
'date' => $date,
'rates' => $rates,
]);
}
return $rates;
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ExchangeRate>
*/
class ExchangeRateFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'base_currency' => 'usd',
'date' => fake()->date(),
'rates' => [
'eur' => fake()->randomFloat(6, 0.8, 1.2),
'gbp' => fake()->randomFloat(6, 0.7, 0.9),
'jpy' => fake()->randomFloat(6, 100, 160),
],
];
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('exchange_rates', function (Blueprint $table) {
$table->id();
$table->string('base_currency', 3);
$table->date('date');
$table->json('rates');
$table->timestamps();
$table->unique(['base_currency', 'date']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('exchange_rates');
}
};

View File

@ -19,7 +19,10 @@ import { ChartConfig } from '@/components/ui/chart';
import { StackedAreaChart } from '@/components/ui/stacked-area-chart';
import { StackedBarChart } from '@/components/ui/stacked-bar-chart';
import { useChartViews } from '@/hooks/use-chart-views';
import { NetWorthEvolutionData } from '@/hooks/use-dashboard-data';
import {
NetWorthEvolutionData,
OriginalAmount,
} from '@/hooks/use-dashboard-data';
import { useLocale } from '@/hooks/use-locale';
import { useIsMobile } from '@/hooks/use-mobile';
import { AccountInfo } from '@/lib/chart-calculations';
@ -65,7 +68,7 @@ function formatXAxisLabel(
}
function calculateTrend(
data: Array<Record<string, string | number>>,
data: Array<Record<string, string | number | OriginalAmount>>,
accountIds: string[],
periodsBack: number,
): TrendData | null {
@ -104,48 +107,6 @@ function EncryptedLabel({ account }: EncryptedLabelProps) {
return <AccountName account={account} length={{ min: 5, max: 20 }} />;
}
interface CurrencyTotal {
currency: string;
total: number;
}
function TotalDisplay({ totals }: { totals: CurrencyTotal[] }) {
if (totals.length === 0) return null;
if (totals.length === 1) {
return (
<AmountDisplay
amountInCents={totals[0].total}
currencyCode={totals[0].currency}
variant="large"
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
);
}
return (
<div className="flex flex-wrap items-baseline justify-end gap-1">
{totals.map((item, index) => (
<span key={item.currency} className="flex items-baseline">
{index > 0 && (
<span className="mx-1 text-lg opacity-50 sm:text-2xl">
+
</span>
)}
<AmountDisplay
amountInCents={item.total}
currencyCode={item.currency}
variant="large"
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
</span>
))}
</div>
);
}
export function NetWorthChart({
data: monthlyData,
loading,
@ -178,9 +139,13 @@ export function NetWorthChart({
date: string;
};
return { ...rest, month: date };
}) as Array<Record<string, string | number>>;
}) as Array<Record<string, string | number | OriginalAmount>>;
setDailyData({ data: normalizedData, accounts: data.accounts });
setDailyData({
data: normalizedData,
accounts: data.accounts,
currency_code: data.currency_code,
});
} catch (error) {
console.error('Failed to fetch daily net worth data:', error);
} finally {
@ -197,15 +162,16 @@ export function NetWorthChart({
const activeData =
granularity === 'daily' && dailyData ? dailyData : monthlyData;
const userCurrency = activeData.currency_code || 'USD';
const {
chartData,
dataKeys,
chartConfig,
shortTrend,
longTrend,
currencyTotals,
totalAmount,
accountCurrencies,
primaryCurrency,
accountsForHook,
} = useMemo(() => {
const accounts = activeData.accounts || {};
@ -233,27 +199,18 @@ export function NetWorthChart({
}
});
const totals: Record<string, number> = {};
// All values are now in the user's currency, so compute a single total
let total = 0;
if (chartDataArray.length > 0) {
const lastDataPoint = chartDataArray[chartDataArray.length - 1];
accountIds.forEach((id) => {
const value = lastDataPoint[id];
const currency = currencies[id] || 'USD';
if (typeof value === 'number') {
totals[currency] = (totals[currency] || 0) + value;
total += value;
}
});
}
const currencyTotalsList: CurrencyTotal[] = Object.entries(totals)
.map(([currency, total]) => ({ currency, total }))
.sort((a, b) => b.total - a.total);
const primary =
currencyTotalsList.length > 0
? currencyTotalsList[0].currency
: 'USD';
return {
chartData: chartDataArray,
dataKeys: accountIds,
@ -264,36 +221,31 @@ export function NetWorthChart({
accountIds,
chartDataArray.length - 1,
),
currencyTotals: currencyTotalsList,
totalAmount: total,
accountCurrencies: currencies,
primaryCurrency: primary,
accountsForHook: hookAccounts,
};
}, [activeData]);
const chartViews = useChartViews({
data: chartData,
data: chartData as Array<Record<string, string | number>>,
accounts: accountsForHook,
initialView: 'stacked',
hasStackedView: true,
});
const valueFormatter = useMemo(() => {
return (value: number, accountId?: string): React.ReactNode => {
const currency =
accountId && accountCurrencies[accountId]
? accountCurrencies[accountId]
: 'USD';
return (value: number): React.ReactNode => {
return (
<AmountDisplay
amountInCents={value}
currencyCode={currency}
currencyCode={userCurrency}
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
);
};
}, [accountCurrencies]);
}, [userCurrency]);
const shortTrendLabel =
granularity === 'daily' ? __('today') : __('this month');
@ -344,14 +296,20 @@ export function NetWorthChart({
<CardTitle>{__('Net Worth Evolution')}</CardTitle>
<CardDescription className="flex flex-col gap-1 text-sm">
<div className="text-foreground">
<TotalDisplay totals={currencyTotals} />
<AmountDisplay
amountInCents={totalAmount}
currencyCode={userCurrency}
variant="large"
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
</div>
<PercentageTrendIndicator
trend={shortTrend?.percentage ?? null}
label={shortTrendLabel}
previousAmount={shortTrend?.previousAmount}
currentAmount={shortTrend?.currentAmount}
currencyCode={primaryCurrency}
currencyCode={userCurrency}
/>
<PercentageTrendIndicator
@ -359,7 +317,7 @@ export function NetWorthChart({
label={longTrendLabel}
previousAmount={longTrend?.previousAmount}
currentAmount={longTrend?.currentAmount}
currencyCode={primaryCurrency}
currencyCode={userCurrency}
/>
</CardDescription>
</div>
@ -401,6 +359,7 @@ export function NetWorthChart({
xAxisFormatter={xAxisFormatter}
valueFormatter={valueFormatter}
accountCurrencies={accountCurrencies}
displayCurrency={userCurrency}
className="h-[300px] w-full"
showLegend={showLegend}
/>
@ -413,6 +372,7 @@ export function NetWorthChart({
xAxisFormatter={xAxisFormatter}
valueFormatter={valueFormatter}
accountCurrencies={accountCurrencies}
displayCurrency={userCurrency}
className="h-[300px] w-full"
showLegend={showLegend}
/>
@ -420,7 +380,7 @@ export function NetWorthChart({
{chartViews.currentView === 'mom' && (
<MoMChart
data={chartViews.deltaSeries}
currencyCode={primaryCurrency}
currencyCode={userCurrency}
xAxisFormatter={xAxisFormatter}
className="h-[300px] w-full"
/>

View File

@ -128,8 +128,9 @@ interface ChartTooltipContentProps {
) => React.ReactNode;
nameKey?: string;
labelKey?: string;
valueFormatter?: (value: number, accountId?: string) => string;
valueFormatter?: (value: number, accountId?: string) => React.ReactNode;
accountCurrencies?: Record<string, string>;
displayCurrency?: string;
}
function formatCurrencyWithCode(value: number, currencyCode: string): string {
@ -160,6 +161,7 @@ const ChartTooltipContent = React.forwardRef<
labelKey,
valueFormatter,
accountCurrencies,
displayCurrency,
},
ref,
) => {
@ -204,7 +206,19 @@ const ChartTooltipContent = React.forwardRef<
]);
const currencyTotals = React.useMemo(() => {
if (!payload?.length || !accountCurrencies) {
if (!payload?.length) {
return null;
}
// When displayCurrency is set, all values are in a single currency
if (displayCurrency) {
const total = payload.reduce((sum, item) => {
return sum + (typeof item.value === 'number' ? item.value : 0);
}, 0);
return [[displayCurrency, total] as [string, number]];
}
if (!accountCurrencies) {
return null;
}
@ -218,7 +232,7 @@ const ChartTooltipContent = React.forwardRef<
});
return Object.entries(totals).sort((a, b) => b[1] - a[1]);
}, [payload, accountCurrencies]);
}, [payload, accountCurrencies, displayCurrency]);
if (!active || !payload?.length) {
return null;
@ -292,6 +306,18 @@ const ChartTooltipContent = React.forwardRef<
</div>
{item.value !== undefined && (
<span className="text-foreground font-mono font-medium tabular-nums">
{(() => {
const originalKey = `${accountId}_original`;
const original = item.payload?.[originalKey] as { amount: number; currency_code: string } | undefined;
if (original) {
return (
<span className="text-muted-foreground mr-1 text-[10px]">
({formatCurrencyWithCode(original.amount, original.currency_code)})
</span>
);
}
return null;
})()}
{valueFormatter
? valueFormatter(
item.value as number,

View File

@ -30,8 +30,9 @@ export interface StackedAreaChartProps<T extends Record<string, unknown>> {
config: ChartConfig;
xAxisKey: string;
xAxisFormatter?: (value: string) => string;
valueFormatter?: (value: number, accountId?: string) => string;
valueFormatter?: (value: number, accountId?: string) => React.ReactNode;
accountCurrencies?: Record<string, string>;
displayCurrency?: string;
className?: string;
showLegend?: boolean;
minBarWidth?: number;
@ -45,6 +46,7 @@ export function StackedAreaChart<T extends Record<string, unknown>>({
xAxisFormatter,
valueFormatter,
accountCurrencies,
displayCurrency,
className,
showLegend = true,
minBarWidth = 20,
@ -121,6 +123,7 @@ export function StackedAreaChart<T extends Record<string, unknown>>({
hideLabel
valueFormatter={valueFormatter}
accountCurrencies={accountCurrencies}
displayCurrency={displayCurrency}
/>
}
/>

View File

@ -122,8 +122,9 @@ export interface StackedBarChartProps<T extends Record<string, unknown>> {
config: ChartConfig;
xAxisKey: string;
xAxisFormatter?: (value: string) => string;
valueFormatter?: (value: number, accountId?: string) => string;
valueFormatter?: (value: number, accountId?: string) => React.ReactNode;
accountCurrencies?: Record<string, string>;
displayCurrency?: string;
className?: string;
showLegend?: boolean;
minBarWidth?: number;
@ -137,6 +138,7 @@ export function StackedBarChart<T extends Record<string, unknown>>({
xAxisFormatter,
valueFormatter,
accountCurrencies,
displayCurrency,
className,
showLegend = true,
minBarWidth = 50,
@ -203,6 +205,7 @@ export function StackedBarChart<T extends Record<string, unknown>>({
hideLabel
valueFormatter={valueFormatter}
accountCurrencies={accountCurrencies}
displayCurrency={displayCurrency}
/>
}
/>

View File

@ -13,9 +13,15 @@ export interface NetWorthEvolutionAccount {
bank: Bank;
}
export interface OriginalAmount {
amount: number;
currency_code: string;
}
export interface NetWorthEvolutionData {
data: Array<Record<string, string | number>>;
data: Array<Record<string, string | number | OriginalAmount>>;
accounts: Record<string, NetWorthEvolutionAccount>;
currency_code: string;
}
export interface AccountWithMetrics extends Account {
@ -87,7 +93,7 @@ function formatMonth(yearMonth: string): string {
export function useDashboardData(): DashboardData & { refetch: () => void } {
const [data, setData] = useState<Omit<DashboardData, 'isLoading'>>({
netWorthEvolution: { data: [], accounts: {} },
netWorthEvolution: { data: [], accounts: {}, currency_code: 'USD' },
accounts: [],
topCategories: [],
});

View File

@ -5,10 +5,15 @@ use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Category;
use App\Models\ExchangeRate;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
// Prevent real HTTP calls to currency API in all tests
Http::fake();
$this->user = User::factory()->create();
$this->actingAs($this->user);
});
@ -18,6 +23,7 @@ test('net worth calculates assets minus liabilities', function () {
$checking = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $checking->id,
@ -29,6 +35,7 @@ test('net worth calculates assets minus liabilities', function () {
$creditCard = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::CreditCard,
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $creditCard->id,
@ -57,9 +64,21 @@ test('net worth calculates assets minus liabilities', function () {
->assertJson([
'current' => 400000, // 5000 - 1000 = 4000
'previous' => 350000, // 4000 - 500 = 3500
'currency_code' => 'USD',
]);
});
test('net worth response includes currency_code', function () {
$response = $this->getJson('/api/dashboard/net-worth?'.http_build_query([
'from' => now()->subDays(29)->toDateString(),
'to' => now()->toDateString(),
]));
$response->assertOk()
->assertJsonStructure(['current', 'previous', 'currency_code'])
->assertJson(['currency_code' => 'USD']);
});
test('monthly spending calculates expenses correctly', function () {
$category = Category::factory()->create([
'user_id' => $this->user->id,
@ -186,7 +205,7 @@ test('net worth evolution returns monthly data points with per-account balances'
'user_id' => $this->user->id,
'type' => AccountType::Savings,
'name' => 'Savings Account',
'currency_code' => 'EUR',
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
@ -218,7 +237,8 @@ test('net worth evolution returns monthly data points with per-account balances'
$response->assertOk();
$data = $response->json();
expect($data)->toHaveKeys(['data', 'accounts']);
expect($data)->toHaveKeys(['data', 'accounts', 'currency_code']);
expect($data['currency_code'])->toBe('USD');
expect($data['data'])->toHaveCount(3);
expect($data['data'][0])->toHaveKeys(['month', 'timestamp', $account1->id, $account2->id]);
expect($data['accounts'])->toHaveKey($account1->id);
@ -226,13 +246,77 @@ test('net worth evolution returns monthly data points with per-account balances'
expect($data['accounts'][$account1->id]['name'])->toBe('Checking Account');
expect($data['accounts'][$account1->id]['currency_code'])->toBe('USD');
expect($data['accounts'][$account2->id]['name'])->toBe('Savings Account');
expect($data['accounts'][$account2->id]['currency_code'])->toBe('EUR');
expect($data['accounts'][$account2->id]['currency_code'])->toBe('USD');
});
test('net worth evolution converts foreign currency accounts using cached exchange rates', function () {
$usdAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'name' => 'USD Checking',
'currency_code' => 'USD',
]);
$eurAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Savings,
'name' => 'EUR Savings',
'currency_code' => 'EUR',
]);
$lastMonth = now()->subMonth();
$endOfMonth = $lastMonth->copy()->endOfMonth();
AccountBalance::factory()->create([
'account_id' => $usdAccount->id,
'balance_date' => $endOfMonth,
'balance' => 100000, // $1,000.00 USD
]);
AccountBalance::factory()->create([
'account_id' => $eurAccount->id,
'balance_date' => $endOfMonth,
'balance' => 200000, // €2,000.00 EUR
]);
// Seed exchange rate: 1 USD = 0.90 EUR, so EUR -> USD = 200000 / 0.90 = 222222
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => $endOfMonth->toDateString(),
'rates' => ['eur' => 0.90],
]);
$response = $this->getJson('/api/dashboard/net-worth-evolution?'.http_build_query([
'from' => $lastMonth->copy()->startOfMonth()->toDateString(),
'to' => $endOfMonth->toDateString(),
]));
$response->assertOk();
$data = $response->json();
// USD account: no conversion, stays 100000
expect($data['data'][0][$usdAccount->id])->toBe(100000);
// EUR account: 200000 / 0.90 = 222222 (rounded)
expect($data['data'][0][$eurAccount->id])->toBe((int) round(200000 / 0.90));
// EUR account should have _original data
$originalKey = $eurAccount->id.'_original';
expect($data['data'][0])->toHaveKey($originalKey);
expect($data['data'][0][$originalKey])->toMatchArray([
'amount' => 200000,
'currency_code' => 'EUR',
]);
// USD account should NOT have _original data (same currency)
$usdOriginalKey = $usdAccount->id.'_original';
expect($data['data'][0])->not->toHaveKey($usdOriginalKey);
Http::assertNothingSent();
});
test('net worth evolution uses last balance of each month per account', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
$lastMonth = now()->subMonth();
@ -270,6 +354,7 @@ test('net worth evolution returns account metadata including bank', function ()
'type' => AccountType::CreditCard,
'name' => 'My Credit Card',
'name_iv' => 'test_iv_1234567',
'currency_code' => 'USD',
]);
$response = $this->getJson('/api/dashboard/net-worth-evolution?'.http_build_query([
@ -337,6 +422,7 @@ test('account daily balance evolution fills gaps with last known balance', funct
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Savings,
'currency_code' => 'USD',
]);
// Balance before the range — carried forward into gap days
@ -396,7 +482,7 @@ test('net worth daily evolution returns daily data points with per-account balan
'user_id' => $this->user->id,
'type' => AccountType::Savings,
'name' => 'Daily Savings',
'currency_code' => 'EUR',
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
@ -428,7 +514,8 @@ test('net worth daily evolution returns daily data points with per-account balan
$response->assertOk();
$data = $response->json();
expect($data)->toHaveKeys(['data', 'accounts']);
expect($data)->toHaveKeys(['data', 'accounts', 'currency_code']);
expect($data['currency_code'])->toBe('USD');
expect($data['data'])->toHaveCount(3);
expect($data['data'][0])->toHaveKeys(['date', 'timestamp', $account1->id, $account2->id]);
expect($data['data'][0]['date'])->toBe(now()->subDays(2)->format('Y-m-d'));
@ -439,13 +526,68 @@ test('net worth daily evolution returns daily data points with per-account balan
expect($data['accounts'])->toHaveKey($account1->id);
expect($data['accounts'])->toHaveKey($account2->id);
expect($data['accounts'][$account1->id]['currency_code'])->toBe('USD');
expect($data['accounts'][$account2->id]['currency_code'])->toBe('EUR');
expect($data['accounts'][$account2->id]['currency_code'])->toBe('USD');
});
test('net worth daily evolution converts foreign currency accounts', function () {
$usdAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'name' => 'USD Account',
'currency_code' => 'USD',
]);
$gbpAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Savings,
'name' => 'GBP Account',
'currency_code' => 'GBP',
]);
$today = now();
AccountBalance::factory()->create([
'account_id' => $usdAccount->id,
'balance_date' => $today,
'balance' => 100000,
]);
AccountBalance::factory()->create([
'account_id' => $gbpAccount->id,
'balance_date' => $today,
'balance' => 50000, // £500.00
]);
// Seed exchange rate: 1 USD = 0.79 GBP
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => $today->toDateString(),
'rates' => ['gbp' => 0.79],
]);
$response = $this->getJson('/api/dashboard/net-worth-daily-evolution?'.http_build_query([
'from' => $today->toDateString(),
'to' => $today->toDateString(),
]));
$response->assertOk();
$data = $response->json();
// USD stays the same
expect($data['data'][0][$usdAccount->id])->toBe(100000);
// GBP: 50000 / 0.79 = 63291 (rounded)
expect($data['data'][0][$gbpAccount->id])->toBe((int) round(50000 / 0.79));
// Original data for GBP account
$originalKey = $gbpAccount->id.'_original';
expect($data['data'][0])->toHaveKey($originalKey);
expect($data['data'][0][$originalKey]['amount'])->toBe(50000);
expect($data['data'][0][$originalKey]['currency_code'])->toBe('GBP');
});
test('net worth daily evolution fills gaps with last known balance', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
// Balance before the range — carried forward into gap days
@ -485,6 +627,7 @@ test('net worth daily evolution returns account metadata including bank', functi
'type' => AccountType::CreditCard,
'name' => 'My Daily CC',
'name_iv' => 'test_iv_daily',
'currency_code' => 'USD',
]);
$response = $this->getJson('/api/dashboard/net-worth-daily-evolution?'.http_build_query([

View File

@ -0,0 +1,185 @@
<?php
use App\Models\ExchangeRate;
use App\Services\ExchangeRateService;
use Illuminate\Support\Facades\Http;
test('convert returns same amount when source equals target', function () {
Http::fake();
$service = app(ExchangeRateService::class);
$result = $service->convert('USD', 'USD', 500000, '2026-01-15');
expect($result)->toBe(500000);
Http::assertNothingSent();
});
test('convert uses cached exchange rates from database', function () {
Http::fake();
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => '2026-01-15',
'rates' => [
'eur' => 0.85,
'gbp' => 0.72,
],
]);
$service = app(ExchangeRateService::class);
// Converting EUR -> USD: 500000 / 0.85 = 588235 (rounded)
$result = $service->convert('EUR', 'USD', 500000, '2026-01-15');
expect($result)->toBe((int) round(500000 / 0.85));
Http::assertNothingSent();
});
test('convert fetches from API on cache miss and stores result', function () {
Http::fake([
'cdn.jsdelivr.net/*currencies/usd*' => Http::response([
'usd' => [
'eur' => 0.92,
'gbp' => 0.79,
],
]),
]);
expect(ExchangeRate::count())->toBe(0);
$service = app(ExchangeRateService::class);
$result = $service->convert('EUR', 'USD', 300000, '2026-02-10');
expect($result)->toBe((int) round(300000 / 0.92));
// Verify the rates were cached in the database
expect(ExchangeRate::count())->toBe(1);
$cached = ExchangeRate::first();
expect($cached->base_currency)->toBe('usd');
expect($cached->date->format('Y-m-d'))->toBe('2026-02-10');
expect($cached->rates)->toHaveKey('eur');
expect($cached->rates['eur'])->toBe(0.92);
});
test('convert uses database cache on second call for same currency and date', function () {
Http::fake([
'cdn.jsdelivr.net/*currencies/usd*' => Http::response([
'usd' => [
'eur' => 0.90,
'gbp' => 0.75,
],
]),
]);
$service = app(ExchangeRateService::class);
// First call — hits API
$service->convert('EUR', 'USD', 100000, '2026-01-20');
Http::assertSentCount(1);
// Second call with a fresh service instance — should use DB cache
$freshService = app(ExchangeRateService::class);
$freshService->convert('GBP', 'USD', 200000, '2026-01-20');
// Still only 1 HTTP request total (second used DB cache)
Http::assertSentCount(1);
});
test('convert returns unconverted amount when rate is missing', function () {
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => '2026-01-15',
'rates' => [
'eur' => 0.85,
],
]);
$service = app(ExchangeRateService::class);
$result = $service->convert('XYZ', 'USD', 100000, '2026-01-15');
expect($result)->toBe(100000);
});
test('convert is case-insensitive for currency codes', function () {
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => '2026-01-15',
'rates' => [
'eur' => 0.85,
],
]);
$service = app(ExchangeRateService::class);
$result1 = $service->convert('EUR', 'USD', 100000, '2026-01-15');
$result2 = $service->convert('eur', 'usd', 100000, '2026-01-15');
expect($result1)->toBe($result2);
});
test('getRates returns cached rates from database', function () {
ExchangeRate::factory()->create([
'base_currency' => 'eur',
'date' => '2026-01-15',
'rates' => [
'usd' => 1.10,
'gbp' => 0.84,
],
]);
Http::fake();
$service = app(ExchangeRateService::class);
$rates = $service->getRates('EUR', '2026-01-15');
expect($rates)->toHaveKey('usd');
expect($rates['usd'])->toBe(1.10);
expect($rates)->toHaveKey('gbp');
Http::assertNothingSent();
});
test('getRates fetches and stores when not cached', function () {
Http::fake([
'cdn.jsdelivr.net/*currencies/eur*' => Http::response([
'eur' => [
'usd' => 1.08,
'jpy' => 158.5,
],
]),
]);
$service = app(ExchangeRateService::class);
$rates = $service->getRates('eur', '2026-01-10');
expect($rates)->toHaveKey('usd');
expect($rates['usd'])->toBe(1.08);
$stored = ExchangeRate::where('base_currency', 'eur')
->where('date', '2026-01-10')
->first();
expect($stored)->not->toBeNull();
expect($stored->rates['usd'])->toBe(1.08);
});
test('getRates caps future dates to today', function () {
$today = now()->toDateString();
ExchangeRate::factory()->create([
'base_currency' => 'eur',
'date' => $today,
'rates' => [
'usd' => 1.12,
],
]);
Http::fake();
$service = app(ExchangeRateService::class);
$rates = $service->getRates('EUR', '2027-06-15');
expect($rates)->toHaveKey('usd');
expect($rates['usd'])->toBe(1.12);
Http::assertNothingSent();
});