diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php index d2d56942..c44163fa 100644 --- a/app/Http/Controllers/Api/DashboardAnalyticsController.php +++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php @@ -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() diff --git a/app/Models/ExchangeRate.php b/app/Models/ExchangeRate.php new file mode 100644 index 00000000..a3864a6f --- /dev/null +++ b/app/Models/ExchangeRate.php @@ -0,0 +1,26 @@ + */ + use HasFactory; + + protected $fillable = [ + 'base_currency', + 'date', + 'rates', + ]; + + protected function casts(): array + { + return [ + 'date' => 'date', + 'rates' => 'array', + ]; + } +} diff --git a/app/Services/CurrencyConversionService.php b/app/Services/CurrencyConversionService.php index c50d7d50..1c6724ae 100644 --- a/app/Services/CurrencyConversionService.php +++ b/app/Services/CurrencyConversionService.php @@ -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 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 */ - 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])) { diff --git a/app/Services/ExchangeRateService.php b/app/Services/ExchangeRateService.php new file mode 100644 index 00000000..4dc5f74a --- /dev/null +++ b/app/Services/ExchangeRateService.php @@ -0,0 +1,86 @@ +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 + */ + 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; + } +} diff --git a/database/factories/ExchangeRateFactory.php b/database/factories/ExchangeRateFactory.php new file mode 100644 index 00000000..b681251d --- /dev/null +++ b/database/factories/ExchangeRateFactory.php @@ -0,0 +1,29 @@ + + */ +class ExchangeRateFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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), + ], + ]; + } +} diff --git a/database/migrations/2026_02_20_074846_create_exchange_rates_table.php b/database/migrations/2026_02_20_074846_create_exchange_rates_table.php new file mode 100644 index 00000000..5ae331ae --- /dev/null +++ b/database/migrations/2026_02_20_074846_create_exchange_rates_table.php @@ -0,0 +1,32 @@ +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'); + } +}; diff --git a/resources/js/components/dashboard/net-worth-chart.tsx b/resources/js/components/dashboard/net-worth-chart.tsx index 3b8d1cfd..2f4b63ed 100644 --- a/resources/js/components/dashboard/net-worth-chart.tsx +++ b/resources/js/components/dashboard/net-worth-chart.tsx @@ -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>, + data: Array>, accountIds: string[], periodsBack: number, ): TrendData | null { @@ -104,48 +107,6 @@ function EncryptedLabel({ account }: EncryptedLabelProps) { return ; } -interface CurrencyTotal { - currency: string; - total: number; -} - -function TotalDisplay({ totals }: { totals: CurrencyTotal[] }) { - if (totals.length === 0) return null; - - if (totals.length === 1) { - return ( - - ); - } - - return ( -
- {totals.map((item, index) => ( - - {index > 0 && ( - - + - - )} - - - ))} -
- ); -} - export function NetWorthChart({ data: monthlyData, loading, @@ -178,9 +139,13 @@ export function NetWorthChart({ date: string; }; return { ...rest, month: date }; - }) as Array>; + }) as Array>; - 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 = {}; + // 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>, 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 ( ); }; - }, [accountCurrencies]); + }, [userCurrency]); const shortTrendLabel = granularity === 'daily' ? __('today') : __('this month'); @@ -344,14 +296,20 @@ export function NetWorthChart({ {__('Net Worth Evolution')}
- +
@@ -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' && ( diff --git a/resources/js/components/ui/chart.tsx b/resources/js/components/ui/chart.tsx index 63ff1c57..4602e25e 100644 --- a/resources/js/components/ui/chart.tsx +++ b/resources/js/components/ui/chart.tsx @@ -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; + 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< {item.value !== undefined && ( + {(() => { + const originalKey = `${accountId}_original`; + const original = item.payload?.[originalKey] as { amount: number; currency_code: string } | undefined; + if (original) { + return ( + + ({formatCurrencyWithCode(original.amount, original.currency_code)}) + + ); + } + return null; + })()} {valueFormatter ? valueFormatter( item.value as number, diff --git a/resources/js/components/ui/stacked-area-chart.tsx b/resources/js/components/ui/stacked-area-chart.tsx index e395ebf5..ca269dd5 100644 --- a/resources/js/components/ui/stacked-area-chart.tsx +++ b/resources/js/components/ui/stacked-area-chart.tsx @@ -30,8 +30,9 @@ export interface StackedAreaChartProps> { config: ChartConfig; xAxisKey: string; xAxisFormatter?: (value: string) => string; - valueFormatter?: (value: number, accountId?: string) => string; + valueFormatter?: (value: number, accountId?: string) => React.ReactNode; accountCurrencies?: Record; + displayCurrency?: string; className?: string; showLegend?: boolean; minBarWidth?: number; @@ -45,6 +46,7 @@ export function StackedAreaChart>({ xAxisFormatter, valueFormatter, accountCurrencies, + displayCurrency, className, showLegend = true, minBarWidth = 20, @@ -121,6 +123,7 @@ export function StackedAreaChart>({ hideLabel valueFormatter={valueFormatter} accountCurrencies={accountCurrencies} + displayCurrency={displayCurrency} /> } /> diff --git a/resources/js/components/ui/stacked-bar-chart.tsx b/resources/js/components/ui/stacked-bar-chart.tsx index 3a11ed89..72d397fa 100644 --- a/resources/js/components/ui/stacked-bar-chart.tsx +++ b/resources/js/components/ui/stacked-bar-chart.tsx @@ -122,8 +122,9 @@ export interface StackedBarChartProps> { config: ChartConfig; xAxisKey: string; xAxisFormatter?: (value: string) => string; - valueFormatter?: (value: number, accountId?: string) => string; + valueFormatter?: (value: number, accountId?: string) => React.ReactNode; accountCurrencies?: Record; + displayCurrency?: string; className?: string; showLegend?: boolean; minBarWidth?: number; @@ -137,6 +138,7 @@ export function StackedBarChart>({ xAxisFormatter, valueFormatter, accountCurrencies, + displayCurrency, className, showLegend = true, minBarWidth = 50, @@ -203,6 +205,7 @@ export function StackedBarChart>({ hideLabel valueFormatter={valueFormatter} accountCurrencies={accountCurrencies} + displayCurrency={displayCurrency} /> } /> diff --git a/resources/js/hooks/use-dashboard-data.ts b/resources/js/hooks/use-dashboard-data.ts index 40464eba..84d891c0 100644 --- a/resources/js/hooks/use-dashboard-data.ts +++ b/resources/js/hooks/use-dashboard-data.ts @@ -13,9 +13,15 @@ export interface NetWorthEvolutionAccount { bank: Bank; } +export interface OriginalAmount { + amount: number; + currency_code: string; +} + export interface NetWorthEvolutionData { - data: Array>; + data: Array>; accounts: Record; + 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>({ - netWorthEvolution: { data: [], accounts: {} }, + netWorthEvolution: { data: [], accounts: {}, currency_code: 'USD' }, accounts: [], topCategories: [], }); diff --git a/tests/Feature/DashboardAnalyticsTest.php b/tests/Feature/DashboardAnalyticsTest.php index ec34e228..35032e99 100644 --- a/tests/Feature/DashboardAnalyticsTest.php +++ b/tests/Feature/DashboardAnalyticsTest.php @@ -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([ diff --git a/tests/Feature/ExchangeRateServiceTest.php b/tests/Feature/ExchangeRateServiceTest.php new file mode 100644 index 00000000..514fe561 --- /dev/null +++ b/tests/Feature/ExchangeRateServiceTest.php @@ -0,0 +1,185 @@ +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(); +});