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:
parent
880b27675c
commit
b743cad803
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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])) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue