feat(settings): centralize currency options and split profile/account support (#256)

## Summary

- **Bug fix:** Dashboard and accounts index cards displayed the
account's original currency code (e.g. `BTC`) even though balances were
already converted to the user's currency (e.g. `EUR`). Now passes
`displayCurrencyCode` to card components so the label matches the
converted amount.
- **Feature:** Added a currency toggle on the account detail chart to
switch between the account's native currency and the user's main
currency. Extends the balance evolution APIs with `display_*` fields
when conversion applies.
- **First-account restriction:** Restricts first-account creation to
primary (fiat) currencies only, ensuring the user's base currency is
always widely supported.

## Changes

### Bug fix — currency label on cards
- `AccountBalanceCard` / `AccountListCard`: Added `displayCurrencyCode`
prop; all amount renders now use it instead of `account.currency_code`
- `dashboard.tsx`: Passes `netWorthEvolution.currency_code` as
`displayCurrencyCode`
- `Accounts/Index.tsx`: Passes `auth.user.currency_code` as
`displayCurrencyCode`

### Backend — API extension
- `DashboardAnalyticsController`: Both `accountBalanceEvolution()` and
`accountDailyBalanceEvolution()` now return `display_value`,
`display_invested_amount`, `display_mortgage_balance` per data point and
a top-level `display_currency_code` when the account currency differs
from the user's

### Frontend — currency toggle
- New `ChartCurrencyToggle` component with `ToggleGroup` showing
currency code labels (e.g. `BTC` / `EUR`)
- `ChartSettingsPopover`: Extended with optional `currencyToggle` prop
for mobile
- `AccountBalanceChart`: Full integration — all amounts, trends,
tooltips, MoM chart, and equity swap to `display_*` values when toggle
is set to user currency

### First-account currency restriction
- `StoreAccountRequest`: First account limited to primary currency codes
- `AccountForm` / `CreateAccountDialog` / `StepCreateAccount`: Pass
`usePrimaryCurrenciesOnly` when applicable

### Tests
- 5 new Pest tests for `display_*` fields in balance evolution endpoints
- 3 new Vitest tests for `deriveAccountMetrics` (mixed-currency, empty
data, invested amounts)
- 1 new Pest test for first-account BTC rejection
This commit is contained in:
Víctor Falcón 2026-04-02 18:23:10 +01:00 committed by GitHub
parent 259a9a9712
commit 3d5823728a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1615 additions and 140 deletions

View File

@ -112,29 +112,67 @@ class DashboardAnalyticsController extends Controller
$start = Carbon::parse($validated['from']);
$end = Carbon::parse($validated['to']);
$linkedLoanId = $this->getLinkedLoanAccountId($account);
$linkedLoanAccount = $this->getLinkedLoanAccount($account);
$linkedLoanId = $linkedLoanAccount?->id;
$accountIds = $linkedLoanId ? [$account->id, $linkedLoanId] : [$account->id];
$lookup = BalanceLookup::forAccounts($accountIds, $start->copy()->startOfMonth(), $end);
$userCurrency = $request->user()->currency_code;
$displayCurrencyCode = strcasecmp($account->currency_code, $userCurrency) !== 0
? $userCurrency
: null;
$points = [];
$current = $start->copy()->startOfMonth();
$endMonth = $end->copy()->startOfMonth();
while ($current->lte($endMonth)) {
$date = $current->copy()->endOfMonth();
$value = $lookup->getBalanceAt($account->id, $date);
$point = [
'month' => $date->format('Y-m'),
'timestamp' => $date->timestamp,
'value' => $lookup->getBalanceAt($account->id, $date),
'value' => $value,
];
if ($account->type->supportsInvestedAmount()) {
$point['invested_amount'] = $lookup->getInvestedAmountAt($account->id, $date);
$investedAmount = $lookup->getInvestedAmountAt($account->id, $date);
$point['invested_amount'] = $investedAmount;
if ($displayCurrencyCode !== null) {
$point['display_invested_amount'] = $investedAmount !== null
? $this->convertBalanceForDate($account->currency_code, $displayCurrencyCode, $investedAmount, $date)
: null;
}
}
if ($linkedLoanId) {
$point['mortgage_balance'] = $lookup->getBalanceAt($linkedLoanId, $date);
$mortgageBalance = $lookup->getBalanceAt($linkedLoanId, $date);
$point['mortgage_balance'] = $this->convertBalanceForDate(
$linkedLoanAccount->currency_code,
$account->currency_code,
$mortgageBalance,
$date,
);
if ($displayCurrencyCode !== null) {
$point['display_mortgage_balance'] = $this->convertBalanceForDate(
$linkedLoanAccount->currency_code,
$displayCurrencyCode,
$mortgageBalance,
$date,
);
}
}
if ($displayCurrencyCode !== null) {
$point['display_value'] = $this->convertBalanceForDate(
$account->currency_code,
$displayCurrencyCode,
$value,
$date,
);
}
$points[] = $point;
@ -157,17 +195,28 @@ class DashboardAnalyticsController extends Controller
continue;
}
$points[] = [
$projectedPoint = [
'month' => $yearMonth,
'timestamp' => $projectedDate->timestamp,
'value' => $balanceCents,
'projected' => true,
];
if ($displayCurrencyCode !== null) {
$projectedPoint['display_value'] = $this->convertBalanceForDate(
$account->currency_code,
$displayCurrencyCode,
$balanceCents,
Carbon::today(),
);
}
$points[] = $projectedPoint;
}
}
}
return response()->json([
$response = [
'data' => $points,
'account' => [
'id' => $account->id,
@ -177,7 +226,13 @@ class DashboardAnalyticsController extends Controller
'type' => $account->type,
'currency_code' => $account->currency_code,
],
]);
];
if ($displayCurrencyCode !== null) {
$response['display_currency_code'] = $displayCurrencyCode;
}
return response()->json($response);
}
public function accountDailyBalanceEvolution(Request $request, Account $account)
@ -194,35 +249,73 @@ class DashboardAnalyticsController extends Controller
$start = Carbon::parse($validated['from']);
$end = Carbon::parse($validated['to']);
$linkedLoanId = $this->getLinkedLoanAccountId($account);
$linkedLoanAccount = $this->getLinkedLoanAccount($account);
$linkedLoanId = $linkedLoanAccount?->id;
$accountIds = $linkedLoanId ? [$account->id, $linkedLoanId] : [$account->id];
$lookup = BalanceLookup::forAccounts($accountIds, $start, $end);
$userCurrency = $request->user()->currency_code;
$displayCurrencyCode = strcasecmp($account->currency_code, $userCurrency) !== 0
? $userCurrency
: null;
$points = [];
$current = $start->copy();
while ($current->lte($end)) {
$date = $current->copy();
$value = $lookup->getBalanceAt($account->id, $date);
$point = [
'date' => $date->format('Y-m-d'),
'timestamp' => $date->endOfDay()->timestamp,
'value' => $lookup->getBalanceAt($account->id, $date),
'value' => $value,
];
if ($account->type->supportsInvestedAmount()) {
$point['invested_amount'] = $lookup->getInvestedAmountAt($account->id, $date);
$investedAmount = $lookup->getInvestedAmountAt($account->id, $date);
$point['invested_amount'] = $investedAmount;
if ($displayCurrencyCode !== null) {
$point['display_invested_amount'] = $investedAmount !== null
? $this->convertBalanceForDate($account->currency_code, $displayCurrencyCode, $investedAmount, $date)
: null;
}
}
if ($linkedLoanId) {
$point['mortgage_balance'] = $lookup->getBalanceAt($linkedLoanId, $date);
$mortgageBalance = $lookup->getBalanceAt($linkedLoanId, $date);
$point['mortgage_balance'] = $this->convertBalanceForDate(
$linkedLoanAccount->currency_code,
$account->currency_code,
$mortgageBalance,
$date,
);
if ($displayCurrencyCode !== null) {
$point['display_mortgage_balance'] = $this->convertBalanceForDate(
$linkedLoanAccount->currency_code,
$displayCurrencyCode,
$mortgageBalance,
$date,
);
}
}
if ($displayCurrencyCode !== null) {
$point['display_value'] = $this->convertBalanceForDate(
$account->currency_code,
$displayCurrencyCode,
$value,
$date,
);
}
$points[] = $point;
$current->addDay();
}
return response()->json([
$response = [
'data' => $points,
'account' => [
'id' => $account->id,
@ -232,7 +325,13 @@ class DashboardAnalyticsController extends Controller
'type' => $account->type,
'currency_code' => $account->currency_code,
],
]);
];
if ($displayCurrencyCode !== null) {
$response['display_currency_code'] = $displayCurrencyCode;
}
return response()->json($response);
}
public function netWorthDailyEvolution(Request $request)
@ -381,14 +480,28 @@ class DashboardAnalyticsController extends Controller
}
/**
* Get the linked loan account ID for a real estate account, if any.
* Get the linked loan account for a real estate account, if any.
*/
private function getLinkedLoanAccountId(Account $account): ?string
private function getLinkedLoanAccount(Account $account): ?Account
{
if ($account->type !== AccountType::RealEstate) {
return null;
}
return $account->realEstateDetail?->linked_loan_account_id;
return $account->realEstateDetail?->linkedLoanAccount;
}
private function convertBalanceForDate(string $sourceCurrency, string $targetCurrency, int $amount, Carbon $date): int
{
if (strcasecmp($sourceCurrency, $targetCurrency) === 0) {
return $amount;
}
return $this->exchangeRateService->convert(
$sourceCurrency,
$targetCurrency,
$amount,
$date->toDateString(),
);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Services\CurrencyOptions;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
@ -9,6 +10,8 @@ use Laravel\Pennant\Feature;
class HandleInertiaRequests extends Middleware
{
public function __construct(private CurrencyOptions $currencyOptions) {}
/**
* The root template that's loaded on the first page visit.
*
@ -117,6 +120,10 @@ class HandleInertiaRequests extends Middleware
'hasEncryptedTransactions' => $hasEncryptedTransactions,
'locale' => app()->getLocale(),
'translations' => $this->getTranslations(),
'currencies' => [
'profile' => $this->currencyOptions->primaryOptions(),
'accounts' => $this->currencyOptions->accountOptions(),
],
];
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Requests\Settings;
use App\Models\User;
use App\Services\CurrencyOptions;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -16,6 +17,8 @@ class ProfileUpdateRequest extends FormRequest
*/
public function rules(): array
{
$currencyOptions = app(CurrencyOptions::class);
return [
'name' => ['required', 'string', 'max:255'],
@ -27,7 +30,7 @@ class ProfileUpdateRequest extends FormRequest
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
'currency_code' => ['required', 'string', 'max:3', Rule::in(['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'CNY', 'INR', 'MXN'])],
'currency_code' => ['required', 'string', 'max:3', Rule::in($currencyOptions->primaryCodes())],
'locale' => ['nullable', 'string', Rule::in(['en', 'es'])],
];
}

View File

@ -4,6 +4,7 @@ namespace App\Http\Requests\Settings;
use App\Enums\AccountType;
use App\Enums\PropertyType;
use App\Services\CurrencyOptions;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -31,6 +32,10 @@ class StoreAccountRequest extends FormRequest
public function rules(): array
{
$isRealEstate = $this->input('type') === AccountType::RealEstate->value;
$currencyOptions = app(CurrencyOptions::class);
$allowedCurrencyCodes = $this->user()->accounts()->exists()
? $currencyOptions->accountCodes()
: $currencyOptions->primaryCodes();
$rules = [
'name' => ['required', 'string'],
@ -40,7 +45,7 @@ class StoreAccountRequest extends FormRequest
'currency_code' => [
'required',
'string',
Rule::in(['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'CNY', 'INR', 'MXN']),
Rule::in($allowedCurrencyCodes),
],
'type' => [
'required',

View File

@ -4,6 +4,7 @@ namespace App\Http\Requests\Settings;
use App\Enums\AccountType;
use App\Enums\PropertyType;
use App\Services\CurrencyOptions;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -26,6 +27,7 @@ class UpdateAccountRequest extends FormRequest
public function rules(): array
{
$isRealEstate = $this->input('type') === AccountType::RealEstate->value;
$currencyOptions = app(CurrencyOptions::class);
$rules = [
'name' => ['required', 'string'],
@ -35,7 +37,7 @@ class UpdateAccountRequest extends FormRequest
'currency_code' => [
'required',
'string',
Rule::in(['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'CNY', 'INR', 'MXN']),
Rule::in($currencyOptions->accountCodes()),
],
'type' => [
'required',

View File

@ -34,6 +34,8 @@ class BinanceBalanceSyncService
/** Seconds to wait between API calls to avoid hitting Binance rate limits */
private const THROTTLE_SECONDS = 1;
private const USD_CURRENCY = 'USD';
public function __construct(private CurrencyConversionService $currencyConverter) {}
/**
@ -228,7 +230,6 @@ class BinanceBalanceSyncService
*/
private function calculateTotalValue(array $balances, array $priceMap, string $targetCurrency): int
{
$quoteAsset = self::FIAT_QUOTE_MAP[$targetCurrency] ?? 'USDT';
$totalValue = 0.0;
foreach ($balances as $balance) {
@ -239,7 +240,7 @@ class BinanceBalanceSyncService
continue;
}
$value = $this->convertAssetToFiat($asset, $quantity, $priceMap, $targetCurrency, $quoteAsset);
$value = $this->convertAssetToFiat($asset, $quantity, $priceMap, $targetCurrency);
$totalValue += $value;
}
@ -254,13 +255,64 @@ class BinanceBalanceSyncService
float $quantity,
array $priceMap,
string $targetCurrency,
string $quoteAsset,
): float {
// Asset IS the target currency (e.g., EUR balance when target is EUR)
if ($asset === $targetCurrency) {
return $quantity;
}
$quoteAsset = self::FIAT_QUOTE_MAP[$targetCurrency] ?? null;
if ($quoteAsset !== null) {
$tickerValue = $this->convertAssetUsingTickerPairs($asset, $quantity, $priceMap, $targetCurrency, $quoteAsset);
if ($tickerValue !== null) {
return $tickerValue;
}
}
$usdValue = $this->convertAssetToUsd($asset, $quantity, $priceMap);
if ($usdValue !== null) {
if ($targetCurrency === self::USD_CURRENCY) {
return $usdValue;
}
return $this->currencyConverter->convert(
self::USD_CURRENCY,
$targetCurrency,
$usdValue,
now()->toDateString(),
);
}
$converted = $this->currencyConverter->convert(
$asset,
$targetCurrency,
$quantity,
now()->toDateString(),
);
if ($converted > 0) {
return $converted;
}
Log::warning('Could not convert Binance asset to fiat', [
'asset' => $asset,
'target_currency' => $targetCurrency,
]);
return 0.0;
}
private function convertAssetUsingTickerPairs(
string $asset,
float $quantity,
array $priceMap,
string $targetCurrency,
string $quoteAsset,
): ?float {
// USD stablecoins when target is USD → 1:1
if ($targetCurrency === 'USD' && in_array($asset, self::USD_STABLECOINS, true)) {
return $quantity;
@ -290,12 +342,29 @@ class BinanceBalanceSyncService
}
}
Log::warning('Could not convert Binance asset to fiat', [
'asset' => $asset,
'target_currency' => $targetCurrency,
]);
return null;
}
return 0.0;
private function convertAssetToUsd(string $asset, float $quantity, array $priceMap): ?float
{
if ($asset === self::USD_CURRENCY || in_array($asset, self::USD_STABLECOINS, true)) {
return $quantity;
}
$usdtPair = $asset.'USDT';
if (isset($priceMap[$usdtPair])) {
return $quantity * $priceMap[$usdtPair];
}
$converted = $this->currencyConverter->convert(
$asset,
self::USD_CURRENCY,
$quantity,
now()->toDateString(),
);
return $converted > 0 ? $converted : null;
}
/**

View File

@ -3,10 +3,13 @@
namespace App\Services\Banking;
use App\Models\Account;
use App\Services\CurrencyConversionService;
use Illuminate\Support\Facades\Log;
class BitpandaBalanceSyncService
{
public function __construct(private CurrencyConversionService $currencyConverter) {}
/**
* Sync the total portfolio value for a Bitpanda account.
* Uses Bitpanda's own ticker prices to match the values shown in the Bitpanda dashboard.
@ -103,12 +106,12 @@ class BitpandaBalanceSyncService
if ($symbol === $targetCurrency) {
$total += $balance;
} else {
// Fiat-to-fiat conversion is rare on Bitpanda; use simple rate if available
Log::warning('Bitpanda fiat wallet in different currency than target', [
'fiat_symbol' => $symbol,
'target_currency' => $targetCurrency,
'balance' => $balance,
]);
$total += $this->currencyConverter->convert(
$symbol,
$targetCurrency,
$balance,
now()->toDateString(),
);
}
}
@ -172,11 +175,12 @@ class BitpandaBalanceSyncService
$fiatId = strtoupper($attributes['fiat_id'] ?? '');
if ($fiatId !== $targetCurrency) {
Log::warning('Bitpanda fiat transaction in different currency than target', [
'fiat_id' => $fiatId,
'target_currency' => $targetCurrency,
'amount' => $amount,
]);
$total += $this->currencyConverter->convert(
$fiatId,
$targetCurrency,
$amount,
now()->toDateString(),
);
continue;
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Services;
class CurrencyOptions
{
/**
* @return list<array{code: string, name: string, allows_primary: bool, allows_account: bool}>
*/
public function all(): array
{
/** @var list<array{code: string, name: string, allows_primary: bool, allows_account: bool}> $options */
$options = config('currencies.options', []);
return $options;
}
/**
* @return list<string>
*/
public function primaryCodes(): array
{
return array_values(array_map(
fn (array $currency): string => $currency['code'],
array_filter($this->all(), fn (array $currency): bool => $currency['allows_primary'])
));
}
/**
* @return list<string>
*/
public function accountCodes(): array
{
return array_values(array_map(
fn (array $currency): string => $currency['code'],
array_filter($this->all(), fn (array $currency): bool => $currency['allows_account'])
));
}
/**
* @return list<array{code: string, name: string}>
*/
public function primaryOptions(): array
{
return $this->formatOptions($this->all(), 'allows_primary');
}
/**
* @return list<array{code: string, name: string}>
*/
public function accountOptions(): array
{
return $this->formatOptions($this->all(), 'allows_account');
}
/**
* @param list<array{code: string, name: string, allows_primary: bool, allows_account: bool}> $options
* @return list<array{code: string, name: string}>
*/
private function formatOptions(array $options, string $capability): array
{
return array_values(array_map(
fn (array $currency): array => [
'code' => $currency['code'],
'name' => __($currency['name']),
],
array_filter($options, fn (array $currency): bool => $currency[$capability])
));
}
}

120
config/currencies.php Normal file
View File

@ -0,0 +1,120 @@
<?php
return [
'options' => [
[
'code' => 'USD',
'name' => 'US Dollar',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'EUR',
'name' => 'Euro',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'GBP',
'name' => 'British Pound',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'JPY',
'name' => 'Japanese Yen',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'CHF',
'name' => 'Swiss Franc',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'CAD',
'name' => 'Canadian Dollar',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'AUD',
'name' => 'Australian Dollar',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'CNY',
'name' => 'Chinese Yuan',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'INR',
'name' => 'Indian Rupee',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'MXN',
'name' => 'Mexican Peso',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'ARS',
'name' => 'Argentine Peso',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'BOB',
'name' => 'Bolivian Boliviano',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'CLP',
'name' => 'Chilean Peso',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'COP',
'name' => 'Colombian Peso',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'PYG',
'name' => 'Paraguayan Guarani',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'PEN',
'name' => 'Peruvian Sol',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'UYU',
'name' => 'Uruguayan Peso',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'VES',
'name' => 'Venezuelan Bolívar',
'allows_primary' => true,
'allows_account' => true,
],
[
'code' => 'BTC',
'name' => 'Bitcoin',
'allows_primary' => false,
'allows_account' => true,
],
],
];

View File

@ -177,6 +177,7 @@
"Are you sure you want to delete this transaction? This action cannot be undone.": "\u00bfEst\u00e1s seguro de que quieres eliminar esta transacci\u00f3n? Esta acci\u00f3n no se puede deshacer.",
"Are you sure you want to delete your account?": "\u00bfEst\u00e1s seguro de que deseas eliminar tu cuenta?",
"Area": "Superficie",
"Argentine Peso": "Peso argentino",
"As a developer, I appreciate the security architecture. This is how finance apps should be built.": "Como desarrollador, aprecio la arquitectura de seguridad. As\u00ed es como deber\u00edan construirse las apps de finanzas.",
"As a solo founder, your feedback directly shapes what I build next. I personally read every response and take your suggestions seriously. When you subscribe, you're not just supporting some big corporation - you're helping me continue building something I'm passionate about.": "Como fundador en solitario, tus comentarios moldean directamente lo que construyo a continuaci\u00f3n. Leo personalmente cada respuesta y tomo tus sugerencias en serio. Cuando te suscribes, no est\u00e1s apoyando a una gran corporaci\u00f3n, est\u00e1s ayud\u00e1ndome a seguir construyendo algo que me apasiona.",
"As a user in the European Union, you have the following rights regarding your personal data:": "Como usuario en la Uni\u00f3n Europea, tienes los siguientes derechos con respecto a tus datos personales:",
@ -186,6 +187,7 @@
"As the founders, your feedback directly shapes what we build next. We personally read every response and take your suggestions seriously. When you subscribe, you're not just supporting some big corporation - you're helping us continue building something we're passionate about.": "Como fundadores, tus comentarios moldean directamente lo que construimos a continuaci\u00f3n. Leemos personalmente cada respuesta y tomamos tus sugerencias en serio. Cuando te suscribes, no est\u00e1s apoyando a una gran corporaci\u00f3n, est\u00e1s ayud\u00e1ndonos a seguir construyendo algo que nos apasiona.",
"Assign a new category": "Asignar una nueva categor\u00eda",
"At least one action is required": "Se requiere al menos una acci\u00f3n",
"Australian Dollar": "D\u00f3lar australiano",
"Authentication failed. Your credentials may have expired or been revoked.": "Autenticaci\u00f3n fallida. Tus credenciales pueden haber expirado o sido revocadas.",
"Auto-detect": "Detectar autom\u00e1ticamente",
"Auto-sync transactions directly from your bank": "Sincroniza autom\u00e1ticamente las transacciones desde tu banco",
@ -229,7 +231,10 @@
"Billed annually at $": "Facturado anualmente a $",
"Billing management is not available on the demo account.": "La gesti\u00f3n de facturaci\u00f3n no est\u00e1 disponible en la cuenta demo.",
"Billing management is not available on the demo\\n account.": "La gesti\u00f3n de facturaci\u00f3n no est\u00e1 disponible en la cuenta de demostraci\u00f3n.",
"Bitcoin": "Bitcoin",
"Blue": "Azul",
"Bolivian Boliviano": "Boliviano",
"British Pound": "Libra esterlina",
"Browse Files": "Explorar Archivos",
"Budget Name": "Nombre del Presupuesto",
"Budget Spending": "Gasto del Presupuesto",
@ -248,6 +253,7 @@
"CSV, XLS, XLSX files": "Archivos CSV, XLS, XLSX",
"Cafes, restaurants, bars": "Cafeter\u00edas, restaurantes, bares",
"Can I export or delete my data?": "\u00bfPuedo exportar o eliminar mis datos?",
"Canadian Dollar": "D\u00f3lar canadiense",
"Cancel": "Cancelar",
"Carry Over": "Acumular",
"Cash inflow": "Entrada de efectivo",
@ -276,6 +282,8 @@
"Check that everything looks correct and click \"Import\". Your transactions will be encrypted and stored securely.": "Verifica que todo se vea correcto y haz clic en \"Importar\". Tus transacciones ser\u00e1n encriptadas y almacenadas de forma segura.",
"Check your inbox \u2014 we've sent you an email.": "Revisa tu bandeja de entrada \u2014 te hemos enviado un correo.",
"Checking": "Cuenta Corriente",
"Chilean Peso": "Peso chileno",
"Chinese Yuan": "Yuan chino",
"Choose how to handle each account from :bank. You can create new accounts, link to existing ones, or skip.": "Elige c\u00f3mo gestionar cada cuenta de :bank. Puedes crear nuevas cuentas, vincularlas a las existentes u omitirlas.",
"Choose how to report this transfer": "Elige como mostrar esta transferencia",
"Choose how you want to add your account.": "Elige c\u00f3mo quieres a\u00f1adir tu cuenta.",
@ -298,6 +306,7 @@
"Client-Side Encryption": "Encriptaci\u00f3n del Lado del Cliente",
"Close": "Cerrar",
"Coffee Lab": "Coffee Lab",
"Colombian Peso": "Peso colombiano",
"Color": "Color",
"Colorful": "Colorido",
"Columns": "Columnas",
@ -499,6 +508,7 @@
"Error": "Error",
"Errors (": "Errores (",
"Espa\u00f1ol": "Espa\u00f1ol",
"Euro": "Euro",
"Even we can't access your data. It's truly private.": "Ni siquiera nosotros podemos acceder a tus datos. Es verdaderamente privado.",
"Every Transaction": "Cada Transacci\u00f3n",
"Every person who joins through your link moves you **10 positions forward** in the queue.": "Cada persona que se una a trav\u00e9s de tu enlace te adelanta **10 posiciones** en la fila.",
@ -668,6 +678,7 @@
"Income (Salary, Freelance, Investments)": "Ingresos (Salario, Freelance, Inversiones)",
"Income Sources": "Fuentes de Ingresos",
"Income minus expenses": "Ingresos menos gastos",
"Indian Rupee": "Rupia india",
"Information about how you use our service, including access times and features used": "Informaci\u00f3n sobre c\u00f3mo usas nuestro servicio, incluyendo horarios de acceso y funciones utilizadas",
"Information about how you use our service,\\n including access times and features used": "Informaci\u00f3n sobre c\u00f3mo usas nuestro servicio, incluyendo tiempos de acceso y funciones utilizadas",
"Install App": "Instalar App",
@ -684,6 +695,7 @@
"Is the encryption confusing?": "\u00bfLa encriptaci\u00f3n es confusa?",
"Is there anything confusing or frustrating?": "\u00bfHay algo confuso o frustrante?",
"It's personal finance, but actually private.": "Son tus finanzas personales, pero realmente privadas.",
"Japanese Yen": "Yen japon\u00e9s",
"Jessica P.": "Jessica P.",
"Join Waitlist": "Unirse a la lista",
"Join our Discord": "\u00danete a nuestro Discord",
@ -784,6 +796,7 @@
"Max": "M\u00e1x",
"Maybe later": "Quiz\u00e1s m\u00e1s tarde",
"Metro Transit": "Metro Transit",
"Mexican Peso": "Peso mexicano",
"Michael R.": "Michael R.",
"Min": "M\u00edn",
"Mobile app (iOS & Android)": "App m\u00f3vil (iOS y Android)",
@ -897,6 +910,7 @@
"Owed Amounts": "Montos Adeudados",
"Owed amount evolution": "Evoluci\u00f3n del monto adeudado",
"Page": "P\u00e1gina",
"Paraguayan Guarani": "Guaran\u00ed paraguayo",
"Password": "Contrase\u00f1a",
"Password changes are disabled on the demo account.": "Los cambios de contrase\u00f1a est\u00e1n deshabilitados en la cuenta demo.",
"Password changes are disabled on the demo\\n account.": "Los cambios de contrase\u00f1a est\u00e1n desactivados en la cuenta de demostraci\u00f3n.",
@ -920,6 +934,7 @@
"Period": "Per\u00edodo",
"Period Duration (days)": "Duraci\u00f3n del Per\u00edodo (d\u00edas)",
"Period Type": "Tipo de Per\u00edodo",
"Peruvian Sol": "Sol peruano",
"Personal transfers": "Transferencias personales",
"Pink": "Rosa",
"Platform": "Plataforma",
@ -1124,6 +1139,8 @@
"Shopping (Clothing, Electronics, Gifts)": "Compras (Ropa, Electr\u00f3nica, Regalos)",
"Show as cash inflow": "Mostrar como entrada de efectivo",
"Show as cash outflow": "Mostrar como salida de efectivo",
"Show in account currency (:currency)": "Mostrar en moneda de la cuenta (:currency)",
"Show in your currency (:currency)": "Mostrar en tu moneda (:currency)",
"Sign up": "Crear cuenta",
"Simple, transparent pricing": "Precios simples y transparentes",
"Skip": "Saltar",
@ -1169,6 +1186,7 @@
"Subscription fees are billed in advance on a\\n recurring basis until cancelled": "Las tarifas de suscripci\u00f3n se facturan por adelantado de forma recurrente hasta que se cancelen",
"Supported formats": "Formatos soportados",
"Supports CSV, XLS, and XLSX files": "Soporta archivos CSV, XLS y XLSX",
"Swiss Franc": "Franco suizo",
"Switch to daily view": "Cambiar a vista diaria",
"Switch to monthly view": "Cambiar a vista mensual",
"Sync Now": "Sincronizar Ahora",
@ -1369,9 +1387,12 @@
"Use code **CONTINUE50** to get **50% off** all current and future payments - works for both monthly and yearly subscriptions.": "Usa el c\u00f3digo **CONTINUE50** para obtener **50% de descuento** en todos los pagos actuales y futuros, funciona para suscripciones mensuales y anuales.",
"Use the service only for lawful purposes and in compliance with all applicable laws": "Usa el servicio solo para fines legales y en cumplimiento de todas las leyes aplicables",
"Use the service only for lawful purposes and\\n in compliance with all applicable laws": "Usa el servicio solo para fines legales y de conformidad con todas las leyes aplicables",
"US Dollar": "D\u00f3lar estadounidense",
"User account": "Cuenta de usuario",
"Uruguayan Peso": "Peso uruguayo",
"Vacation": "Vacacional",
"Value": "Valor",
"Venezuelan Bol\u00edvar": "Bol\u00edvar venezolano",
"Verify Email Address": "Verificar Correo Electr\u00f3nico",
"Verify Your Email - Whisper Money": "Verifica Tu Email - Whisper Money",
"Verify email": "Verificar correo electr\u00f3nico",

View File

@ -1,6 +1,8 @@
import { AccountName } from '@/components/accounts/account-name';
import {
type ChartCurrencyMode,
type ChartGranularity,
ChartCurrencyToggle,
ChartGranularityToggle,
ChartSettingsPopover,
ChartViewToggle,
@ -227,6 +229,9 @@ export interface BalanceDataPoint {
invested_amount?: number | null;
mortgage_balance?: number | null;
projected?: boolean;
display_value?: number;
display_invested_amount?: number | null;
display_mortgage_balance?: number;
}
interface AccountBalanceData {
@ -238,6 +243,7 @@ interface AccountBalanceData {
type: string;
currency_code: string;
};
display_currency_code?: string;
}
interface DailyBalanceDataPoint {
@ -246,6 +252,9 @@ interface DailyBalanceDataPoint {
value: number;
invested_amount?: number | null;
mortgage_balance?: number | null;
display_value?: number;
display_invested_amount?: number | null;
display_mortgage_balance?: number;
}
interface AccountDailyBalanceData {
@ -257,12 +266,14 @@ interface AccountDailyBalanceData {
type: string;
currency_code: string;
};
display_currency_code?: string;
}
export interface ChartComputedData {
chartData: BalanceDataPoint[];
currentBalance: number;
currentMortgageBalance: number | null;
currencyCode: string;
hasMortgageData: boolean;
shortTrend: ReturnType<typeof calculateTrend>;
longTrend: ReturnType<typeof calculateTrend>;
@ -330,6 +341,9 @@ function normalizeDailyData(data: DailyBalanceDataPoint[]): BalanceDataPoint[] {
value: point.value,
invested_amount: point.invested_amount,
mortgage_balance: point.mortgage_balance,
display_value: point.display_value,
display_invested_amount: point.display_invested_amount,
display_mortgage_balance: point.display_mortgage_balance,
}));
}
@ -345,6 +359,8 @@ export function AccountBalanceChart({
const isLoan = account.type === 'loan';
const isRealEstate = isRealEstateAccount(account);
const [granularity, setGranularity] = useState<ChartGranularity>('monthly');
const [currencyMode, setCurrencyMode] =
useState<ChartCurrencyMode>('account');
const [balanceData, setBalanceData] = useState<AccountBalanceData | null>(
null,
);
@ -369,6 +385,7 @@ export function AccountBalanceChart({
setBalanceData({
data: normalizeDailyData(data.data),
account: data.account,
display_currency_code: data.display_currency_code,
});
} else {
const from = format(subMonths(now, 12), 'yyyy-MM-dd');
@ -488,36 +505,126 @@ export function AccountBalanceChart({
};
}, [balanceData]);
// Determine if currency toggle is available
const displayCurrencyCode = balanceData?.display_currency_code ?? null;
const hasCurrencyToggle = displayCurrencyCode !== null;
// When in user-currency mode, swap display_* values into the primary fields
const activeCurrencyCode =
currencyMode === 'user' && displayCurrencyCode
? displayCurrencyCode
: account.currency_code;
const activeChartData = useMemo(() => {
if (currencyMode !== 'user' || !hasCurrencyToggle) {
return chartData;
}
return chartData.map((point) => ({
...point,
value: point.display_value ?? point.value,
invested_amount:
point.display_invested_amount !== undefined
? point.display_invested_amount
: point.invested_amount,
mortgage_balance:
point.display_mortgage_balance !== undefined
? point.display_mortgage_balance
: point.mortgage_balance,
projected_value:
'projected_value' in point && point.display_value !== undefined
? point.display_value
: ((point as unknown as Record<string, unknown>)
.projected_value as number | undefined),
}));
}, [chartData, currencyMode, hasCurrencyToggle]);
const activeCurrentBalance = useMemo(() => {
if (currencyMode !== 'user' || !hasCurrencyToggle) {
return currentBalance;
}
const historicalData = activeChartData.filter((d) => !d.projected);
return historicalData[historicalData.length - 1]?.value ?? 0;
}, [activeChartData, currentBalance, currencyMode, hasCurrencyToggle]);
const activeCurrentMortgageBalance = useMemo(() => {
if (currencyMode !== 'user' || !hasCurrencyToggle) {
return currentMortgageBalance;
}
if (!hasMortgageData) return null;
for (let i = activeChartData.length - 1; i >= 0; i--) {
const mb = activeChartData[i].mortgage_balance;
if (mb !== null && mb !== undefined) return mb;
}
return null;
}, [
activeChartData,
currentMortgageBalance,
currencyMode,
hasCurrencyToggle,
hasMortgageData,
]);
const activeCurrentInvestedAmount = useMemo(() => {
if (currencyMode !== 'user' || !hasCurrencyToggle) {
return currentInvestedAmount;
}
for (let i = activeChartData.length - 1; i >= 0; i--) {
const ia = activeChartData[i].invested_amount;
if (ia !== null && ia !== undefined) return ia;
}
return null;
}, [
activeChartData,
currentInvestedAmount,
currencyMode,
hasCurrencyToggle,
]);
const activeShortTrend = useMemo(() => {
if (currencyMode !== 'user' || !hasCurrencyToggle) return shortTrend;
const historicalData = activeChartData.filter((d) => !d.projected);
return calculateTrend(historicalData, 1);
}, [activeChartData, currencyMode, hasCurrencyToggle, shortTrend]);
const activeLongTrend = useMemo(() => {
if (currencyMode !== 'user' || !hasCurrencyToggle) return longTrend;
const historicalData = activeChartData.filter((d) => !d.projected);
return calculateTrend(historicalData, historicalData.length - 1);
}, [activeChartData, currencyMode, hasCurrencyToggle, longTrend]);
useEffect(() => {
if (onDataLoaded && chartData.length > 0) {
if (onDataLoaded && activeChartData.length > 0) {
onDataLoaded({
chartData,
currentBalance,
currentMortgageBalance,
chartData: activeChartData,
currentBalance: activeCurrentBalance,
currentMortgageBalance: activeCurrentMortgageBalance,
currencyCode: activeCurrencyCode,
hasMortgageData,
shortTrend,
longTrend,
shortTrend: activeShortTrend,
longTrend: activeLongTrend,
});
}
}, [
chartData,
currentBalance,
currentMortgageBalance,
activeChartData,
activeCurrentBalance,
activeCurrentMortgageBalance,
activeCurrencyCode,
hasMortgageData,
shortTrend,
longTrend,
activeShortTrend,
activeLongTrend,
onDataLoaded,
]);
// Convert data for useChartViews hook
const { data: hookData, accounts: hookAccounts } = useMemo(() => {
return convertSingleAccountData(
chartData,
activeChartData,
account.id,
account.type,
account.currency_code,
activeCurrencyCode,
);
}, [chartData, account.id, account.type, account.currency_code]);
}, [activeChartData, account.id, account.type, activeCurrencyCode]);
const chartViews = useChartViews({
data: hookData,
@ -568,19 +675,19 @@ export function AccountBalanceChart({
);
const valueFormatter = (value: number): string => {
return formatChartCurrency(value, account.currency_code, locale);
return formatChartCurrency(value, activeCurrencyCode, locale);
};
const scrollContainerRef = useRef<HTMLDivElement>(null);
const minBarWidth = granularity === 'daily' ? 20 : 50;
const minChartWidth = chartData.length * minBarWidth;
const minChartWidth = activeChartData.length * minBarWidth;
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollLeft =
scrollContainerRef.current.scrollWidth;
}
}, [chartData]);
}, [activeChartData]);
const shortTrendLabel =
granularity === 'daily' ? __('today') : __('this month');
@ -600,8 +707,8 @@ export function AccountBalanceChart({
? __('No owed amount data available')
: __('No balance data available');
const currentEquity =
hasMortgageData && currentMortgageBalance !== null
? currentBalance - currentMortgageBalance
hasMortgageData && activeCurrentMortgageBalance !== null
? activeCurrentBalance - activeCurrentMortgageBalance
: null;
if (initialLoading || isLoading) {
@ -647,8 +754,8 @@ export function AccountBalanceChart({
className="-ml-3 cursor-pointer rounded-md px-2 py-1 text-left text-4xl font-semibold tabular-nums transition-colors hover:bg-muted"
>
<AmountDisplay
amountInCents={currentBalance}
currencyCode={account.currency_code}
amountInCents={activeCurrentBalance}
currencyCode={activeCurrencyCode}
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
@ -663,7 +770,7 @@ export function AccountBalanceChart({
>
<AmountDisplay
amountInCents={currentEquity}
currencyCode={account.currency_code}
currencyCode={activeCurrencyCode}
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
@ -672,19 +779,19 @@ export function AccountBalanceChart({
)}
<CardDescription className="flex flex-col gap-1 text-sm">
<PercentageTrendIndicator
trend={shortTrend?.percentage ?? null}
trend={activeShortTrend?.percentage ?? null}
label={shortTrendLabel}
previousAmount={shortTrend?.previousValue}
currentAmount={shortTrend?.currentValue}
currencyCode={account.currency_code}
previousAmount={activeShortTrend?.previousValue}
currentAmount={activeShortTrend?.currentValue}
currencyCode={activeCurrencyCode}
/>
<PercentageTrendIndicator
trend={longTrend?.percentage ?? null}
trend={activeLongTrend?.percentage ?? null}
label={longTrendLabel}
previousAmount={longTrend?.previousValue}
currentAmount={longTrend?.currentValue}
currencyCode={account.currency_code}
previousAmount={activeLongTrend?.previousValue}
currentAmount={activeLongTrend?.currentValue}
currencyCode={activeCurrencyCode}
/>
</CardDescription>
</div>
@ -696,9 +803,31 @@ export function AccountBalanceChart({
currentView={chartViews.currentView}
onViewChange={chartViews.setCurrentView}
availableViews={chartViews.availableViews}
currencyToggle={
hasCurrencyToggle
? {
value: currencyMode,
onValueChange: setCurrencyMode,
accountCurrencyCode:
account.currency_code,
userCurrencyCode:
displayCurrencyCode!,
}
: undefined
}
/>
) : (
<>
{hasCurrencyToggle && (
<ChartCurrencyToggle
value={currencyMode}
onValueChange={setCurrencyMode}
accountCurrencyCode={
account.currency_code
}
userCurrencyCode={displayCurrencyCode!}
/>
)}
<ChartGranularityToggle
value={granularity}
onValueChange={setGranularity}
@ -728,7 +857,7 @@ export function AccountBalanceChart({
{hasMortgageData ? (
<ComposedChart
accessibilityLayer
data={chartData.slice(1)}
data={activeChartData.slice(1)}
>
<defs>
<linearGradient
@ -788,7 +917,7 @@ export function AccountBalanceChart({
) : granularity === 'daily' ? (
<AreaChart
accessibilityLayer
data={chartData.slice(1)}
data={activeChartData.slice(1)}
>
<defs>
<linearGradient
@ -846,7 +975,8 @@ export function AccountBalanceChart({
fillOpacity={1}
/>
{showInvestmentBenefits &&
currentInvestedAmount !== null && (
activeCurrentInvestedAmount !==
null && (
<Line
dataKey="invested_amount"
type="monotone"
@ -860,10 +990,10 @@ export function AccountBalanceChart({
)}
</AreaChart>
) : showInvestmentBenefits &&
currentInvestedAmount !== null ? (
activeCurrentInvestedAmount !== null ? (
<ComposedChart
accessibilityLayer
data={chartData.slice(1)}
data={activeChartData.slice(1)}
>
<XAxis
dataKey="month"
@ -898,7 +1028,7 @@ export function AccountBalanceChart({
) : hasProjectedData ? (
<ComposedChart
accessibilityLayer
data={chartData.slice(1)}
data={activeChartData.slice(1)}
>
<defs>
<linearGradient
@ -959,7 +1089,7 @@ export function AccountBalanceChart({
) : (
<BarChart
accessibilityLayer
data={chartData.slice(1)}
data={activeChartData.slice(1)}
>
<XAxis
dataKey="month"
@ -989,7 +1119,7 @@ export function AccountBalanceChart({
{chartViews.currentView === 'mom' && (
<MoMChart
data={chartViews.deltaSeries}
currencyCode={account.currency_code}
currencyCode={activeCurrencyCode}
xAxisFormatter={formatXAxisLabel}
className="h-[300px] w-full"
/>

View File

@ -10,11 +10,12 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { SharedData } from '@/types';
import {
ACCOUNT_TYPES,
AREA_UNITS,
Account,
CURRENCY_OPTIONS,
CurrencyOption,
PROPERTY_TYPES,
balanceTermCapitalized,
formatAccountType,
@ -27,6 +28,7 @@ import {
type PropertyType,
} from '@/types/account';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { useCallback, useEffect, useState } from 'react';
import { BankCombobox } from './bank-combobox';
import { CustomBankData, CustomBankForm } from './custom-bank-form';
@ -60,7 +62,7 @@ export interface LoanFormData {
export interface AccountFormData {
displayName: string;
bankId: number | null;
bankId: string | null;
type: AccountType | null;
currencyCode: CurrencyCode | null;
customBank: CustomBankData | null;
@ -81,6 +83,7 @@ interface AccountFormProps {
forceAccountType?: AccountType;
hiddenAccountTypes?: AccountType[];
availableLoanAccounts?: Account[];
usePrimaryCurrenciesOnly?: boolean;
onChange: (data: AccountFormData) => void;
errors?: Record<string, string>;
}
@ -115,13 +118,18 @@ export function AccountForm({
forceAccountType,
hiddenAccountTypes = [],
availableLoanAccounts = [],
usePrimaryCurrenciesOnly = false,
onChange,
errors = {},
}: AccountFormProps) {
const { currencies } = usePage<SharedData>().props;
const currencyOptions = usePrimaryCurrenciesOnly
? currencies.profile
: currencies.accounts;
const [displayName, setDisplayName] = useState(
initialValues?.displayName ?? '',
);
const [selectedBankId, setSelectedBankId] = useState<number | null>(
const [selectedBankId, setSelectedBankId] = useState<string | null>(
initialValues?.bank?.id ?? null,
);
const [selectedType, setSelectedType] = useState<AccountType | null>(
@ -318,9 +326,12 @@ export function AccountForm({
<SelectValue placeholder={__('Select currency')} />
</SelectTrigger>
<SelectContent>
{CURRENCY_OPTIONS.map((currency) => (
<SelectItem key={currency} value={currency}>
{currency}
{currencyOptions.map((currency: CurrencyOption) => (
<SelectItem
key={currency.code}
value={currency.code}
>
{currency.code} - {currency.name}
</SelectItem>
))}
</SelectContent>

View File

@ -33,6 +33,7 @@ interface AccountListCardProps {
loading?: boolean;
onBalanceUpdated?: () => void;
linkedLoanMetrics?: LinkedLoanMetrics;
displayCurrencyCode?: string;
}
export function AccountListCard({
@ -40,7 +41,9 @@ export function AccountListCard({
loading,
onBalanceUpdated,
linkedLoanMetrics,
displayCurrencyCode,
}: AccountListCardProps) {
const currencyCode = displayCurrencyCode ?? account.currency_code;
const { accountMainLineColor, accountGainLineColor, mortgageLineColor } =
useChartColors();
const [updateBalanceOpen, setUpdateBalanceOpen] = useState(false);
@ -197,7 +200,7 @@ export function AccountListCard({
<div className="-mr-2 px-2 py-1">
<AmountDisplay
amountInCents={displayBalance}
currencyCode={account.currency_code}
currencyCode={currencyCode}
size="2xl"
weight="bold"
/>
@ -210,7 +213,7 @@ export function AccountListCard({
>
<AmountDisplay
amountInCents={displayBalance}
currencyCode={account.currency_code}
currencyCode={currencyCode}
size="2xl"
weight="bold"
/>
@ -224,7 +227,7 @@ export function AccountListCard({
previousAmount={displayPreviousBalance}
currentAmount={displayBalance}
tooltipSide="bottom"
currencyCode={account.currency_code}
currencyCode={currencyCode}
/>
</div>
</div>
@ -266,7 +269,7 @@ export function AccountListCard({
data.value
}
currencyCode={
account.currency_code
currencyCode
}
/>
</span>
@ -282,7 +285,7 @@ export function AccountListCard({
0
}
currencyCode={
account.currency_code
currencyCode
}
/>
</span>
@ -297,7 +300,7 @@ export function AccountListCard({
equity
}
currencyCode={
account.currency_code
currencyCode
}
/>
</span>
@ -331,7 +334,7 @@ export function AccountListCard({
data.value
}
currencyCode={
account.currency_code
currencyCode
}
/>
</span>
@ -344,7 +347,7 @@ export function AccountListCard({
invested
}
currencyCode={
account.currency_code
currencyCode
}
/>
</span>
@ -366,7 +369,7 @@ export function AccountListCard({
gain
}
currencyCode={
account.currency_code
currencyCode
}
/>
</span>
@ -380,7 +383,7 @@ export function AccountListCard({
data.value
}
currencyCode={
account.currency_code
currencyCode
}
/>
</p>

View File

@ -22,8 +22,8 @@ import { Check, ChevronsUpDown, Plus } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
interface BankComboboxProps {
value: number | null;
onValueChange: (value: number | null) => void;
value: string | null;
onValueChange: (value: string | null) => void;
defaultBank?: Bank;
onCreateCustomBank?: (searchQuery: string) => void;
}

View File

@ -38,13 +38,12 @@ export function CreateAccountDialog({
const openBankingEnabled = features['open-banking'];
const realEstateEnabled = features['real-estate'];
const isFreePlan = subscriptionsEnabled && !auth?.hasProPlan;
const sharedAccountsList = (sharedAccounts as Account[]) || [];
const availableLoanAccounts = useMemo(
() =>
((sharedAccounts as Account[]) || []).filter(
(a) => a.type === 'loan',
),
() => sharedAccountsList.filter((a) => a.type === 'loan'),
[sharedAccounts],
);
const isFirstAccount = sharedAccountsList.length === 0;
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<Mode>(
@ -302,6 +301,7 @@ export function CreateAccountDialog({
hiddenAccountTypes={
realEstateEnabled ? [] : ['real_estate']
}
usePrimaryCurrenciesOnly={isFirstAccount}
/>
<div className="flex justify-end gap-2 pt-4">

View File

@ -46,7 +46,7 @@ export function EditAccountDialog({
const [errors, setErrors] = useState<Record<string, string>>({});
const formDataRef = useRef<AccountFormData>({
displayName: '',
bankId: (account.bank?.id ?? null) as number | null,
bankId: account.bank?.id ?? null,
type: account.type,
currencyCode: account.currency_code,
customBank: null,

View File

@ -0,0 +1,88 @@
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { __ } from '@/utils/i18n';
export type ChartCurrencyMode = 'account' | 'user';
interface ChartCurrencyToggleProps {
value: ChartCurrencyMode;
onValueChange: (value: ChartCurrencyMode) => void;
accountCurrencyCode: string;
userCurrencyCode: string;
className?: string;
showTooltip?: boolean;
}
export function ChartCurrencyToggle({
value,
onValueChange,
accountCurrencyCode,
userCurrencyCode,
className,
showTooltip = true,
}: ChartCurrencyToggleProps) {
const items: Array<{
mode: ChartCurrencyMode;
label: string;
tooltip: string;
}> = [
{
mode: 'account',
label: accountCurrencyCode,
tooltip: __('Show in account currency (:currency)', {
currency: accountCurrencyCode,
}),
},
{
mode: 'user',
label: userCurrencyCode,
tooltip: __('Show in your currency (:currency)', {
currency: userCurrencyCode,
}),
},
];
return (
<ToggleGroup
type="single"
value={value}
onValueChange={(v) => {
if (v) onValueChange(v as ChartCurrencyMode);
}}
variant="outline"
size="sm"
className={cn(className)}
>
{items.map((item) => {
const toggleItem = (
<ToggleGroupItem
key={item.mode}
value={item.mode}
aria-label={item.tooltip}
className="cursor-pointer px-2 text-xs aria-checked:bg-primary/10"
>
{item.label}
</ToggleGroupItem>
);
if (!showTooltip) {
return toggleItem;
}
return (
<Tooltip key={item.mode}>
<TooltipTrigger asChild>{toggleItem}</TooltipTrigger>
<TooltipContent side="bottom">
{item.tooltip}
</TooltipContent>
</Tooltip>
);
})}
</ToggleGroup>
);
}

View File

@ -10,6 +10,10 @@ import { ChartViewType } from '@/hooks/use-chart-views';
import { __ } from '@/utils/i18n';
import { Settings2 } from 'lucide-react';
import { Separator } from '../ui/separator';
import {
type ChartCurrencyMode,
ChartCurrencyToggle,
} from './chart-currency-toggle';
import {
type ChartGranularity,
ChartGranularityToggle,
@ -24,6 +28,13 @@ interface ToggleOption {
onChange: (value: boolean) => void;
}
interface CurrencyToggleConfig {
value: ChartCurrencyMode;
onValueChange: (value: ChartCurrencyMode) => void;
accountCurrencyCode: string;
userCurrencyCode: string;
}
interface ChartSettingsPopoverProps {
granularity: ChartGranularity;
onGranularityChange: (value: ChartGranularity) => void;
@ -35,6 +46,7 @@ interface ChartSettingsPopoverProps {
includeLoans?: boolean;
onIncludeLoansChange?: (value: boolean) => void;
toggles?: ToggleOption[];
currencyToggle?: CurrencyToggleConfig;
}
export function ChartSettingsPopover({
@ -48,6 +60,7 @@ export function ChartSettingsPopover({
includeLoans,
onIncludeLoansChange,
toggles = [],
currencyToggle,
}: ChartSettingsPopoverProps) {
// Build the effective list of toggles: legacy loan prop + explicit toggles
const allToggles: ToggleOption[] = [];
@ -103,6 +116,26 @@ export function ChartSettingsPopover({
showTooltip={false}
/>
</div>
{currencyToggle && (
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">
{__('Currency')}
</span>
<ChartCurrencyToggle
value={currencyToggle.value}
onValueChange={
currencyToggle.onValueChange
}
accountCurrencyCode={
currencyToggle.accountCurrencyCode
}
userCurrencyCode={
currencyToggle.userCurrencyCode
}
showTooltip={false}
/>
</div>
)}
{allToggles.length > 0 && <Separator />}
</>
) : null}

View File

@ -1,4 +1,8 @@
export { CashflowTrendChart } from './cashflow-trend-chart';
export {
ChartCurrencyToggle,
type ChartCurrencyMode,
} from './chart-currency-toggle';
export {
ChartGranularityToggle,
type ChartGranularity,

View File

@ -33,6 +33,7 @@ interface AccountBalanceCardProps {
loading?: boolean;
onBalanceUpdated?: () => void;
linkedLoanMetrics?: LinkedLoanMetrics;
displayCurrencyCode?: string;
}
export function AccountBalanceCard({
@ -40,7 +41,9 @@ export function AccountBalanceCard({
loading,
onBalanceUpdated,
linkedLoanMetrics,
displayCurrencyCode,
}: AccountBalanceCardProps) {
const currencyCode = displayCurrencyCode ?? account.currency_code;
const { accountMainLineColor, accountGainLineColor, mortgageLineColor } =
useChartColors();
const [updateBalanceOpen, setUpdateBalanceOpen] = useState(false);
@ -170,7 +173,7 @@ export function AccountBalanceCard({
<div className="-ml-2 px-2 py-1">
<AmountDisplay
amountInCents={displayBalance}
currencyCode={account.currency_code}
currencyCode={currencyCode}
size="2xl"
weight="medium"
/>
@ -183,7 +186,7 @@ export function AccountBalanceCard({
>
<AmountDisplay
amountInCents={displayBalance}
currencyCode={account.currency_code}
currencyCode={currencyCode}
size="2xl"
weight="medium"
/>
@ -197,7 +200,7 @@ export function AccountBalanceCard({
previousAmount={displayPreviousBalance}
currentAmount={displayBalance}
tooltipSide="bottom"
currencyCode={account.currency_code}
currencyCode={currencyCode}
/>
</div>
<div className="h-[70px] w-full max-w-[250px] flex-1">
@ -237,7 +240,7 @@ export function AccountBalanceCard({
data.value
}
currencyCode={
account.currency_code
currencyCode
}
/>
</span>
@ -253,7 +256,7 @@ export function AccountBalanceCard({
0
}
currencyCode={
account.currency_code
currencyCode
}
/>
</span>
@ -268,7 +271,7 @@ export function AccountBalanceCard({
equity
}
currencyCode={
account.currency_code
currencyCode
}
/>
</span>
@ -302,7 +305,7 @@ export function AccountBalanceCard({
data.value
}
currencyCode={
account.currency_code
currencyCode
}
/>
</span>
@ -315,7 +318,7 @@ export function AccountBalanceCard({
invested
}
currencyCode={
account.currency_code
currencyCode
}
/>
</span>
@ -337,7 +340,7 @@ export function AccountBalanceCard({
gain
}
currencyCode={
account.currency_code
currencyCode
}
/>
</span>
@ -351,7 +354,7 @@ export function AccountBalanceCard({
data.value
}
currencyCode={
account.currency_code
currencyCode
}
/>
</p>

View File

@ -87,6 +87,9 @@ export function StepCreateAccount({
type: null,
currencyCode: null,
customBank: null,
balance: null,
realEstate: null,
loan: null,
});
// Compute cheapest monthly equivalent across all plans
@ -483,7 +486,14 @@ export function StepCreateAccount({
autoFocus
className="w-full max-w-md space-y-4"
>
<AccountForm onChange={handleFormChange} />
<AccountForm
onChange={handleFormChange}
usePrimaryCurrenciesOnly={
isFirstAccount &&
existingAccounts.length === 0 &&
createdAccounts.length === 0
}
/>
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">

View File

@ -42,4 +42,124 @@ describe('deriveAccountMetrics', () => {
expect.objectContaining({ value: -100000 }),
]);
});
it('preserves original account currency_code even when net worth uses a different user currency', () => {
const netWorthEvolution: NetWorthEvolutionData = {
currency_code: 'EUR',
accounts: {
btc_1: {
id: 'btc_1',
name: 'Bitcoin Wallet',
name_iv: null,
encrypted: false,
type: 'investment',
currency_code: 'BTC',
bank: {
id: 'bank_1',
user_id: null,
name: 'Crypto',
logo: null,
},
banking_connection_id: null,
},
eur_1: {
id: 'eur_1',
name: 'Savings',
name_iv: null,
encrypted: false,
type: 'savings',
currency_code: 'EUR',
bank: {
id: 'bank_2',
user_id: null,
name: 'Bank',
logo: null,
},
banking_connection_id: null,
},
},
data: [
{ month: '2025-01', btc_1: 4000000, eur_1: 500000 },
{ month: '2025-02', btc_1: 5000000, eur_1: 600000 },
],
};
const accounts = deriveAccountMetrics(netWorthEvolution, 'en-US');
const btcAccount = accounts.find((a) => a.id === 'btc_1')!;
const eurAccount = accounts.find((a) => a.id === 'eur_1')!;
// currency_code on derived metrics preserves the original account currency
// (the UI layer uses displayCurrencyCode from netWorthEvolution.currency_code to fix display)
expect(btcAccount.currency_code).toBe('BTC');
expect(eurAccount.currency_code).toBe('EUR');
// Balances are the converted amounts from the net worth evolution data
expect(btcAccount.currentBalance).toBe(5000000);
expect(btcAccount.previousBalance).toBe(4000000);
expect(btcAccount.diff).toBe(1000000);
expect(eurAccount.currentBalance).toBe(600000);
expect(eurAccount.previousBalance).toBe(500000);
});
it('returns empty array when data or accounts are empty', () => {
expect(
deriveAccountMetrics(
{ currency_code: 'USD', accounts: {}, data: [] },
'en-US',
),
).toEqual([]);
expect(
deriveAccountMetrics(
{
currency_code: 'USD',
accounts: {},
data: [{ month: '2025-01' }],
},
'en-US',
),
).toEqual([]);
});
it('includes invested amount data when present', () => {
const netWorthEvolution: NetWorthEvolutionData = {
currency_code: 'USD',
accounts: {
inv_1: {
id: 'inv_1',
name: 'Portfolio',
name_iv: null,
encrypted: false,
type: 'investment',
currency_code: 'USD',
bank: {
id: 'bank_1',
user_id: null,
name: 'Broker',
logo: null,
},
banking_connection_id: null,
invested_amount: 300000,
},
},
data: [
{
month: '2025-01',
inv_1: 400000,
inv_1_invested: 250000,
},
{
month: '2025-02',
inv_1: 500000,
inv_1_invested: 300000,
},
],
};
const [account] = deriveAccountMetrics(netWorthEvolution, 'en-US');
expect(account.investedAmount).toBe(300000);
expect(account.history[0].investedAmount).toBe(250000);
expect(account.history[1].investedAmount).toBe(300000);
});
});

View File

@ -5,10 +5,10 @@ import HeadingSmall from '@/components/heading-small';
import { Card, CardContent } from '@/components/ui/card';
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { BreadcrumbItem } from '@/types';
import { BreadcrumbItem, SharedData } from '@/types';
import { Account, AccountType } from '@/types/account';
import { __ } from '@/utils/i18n';
import { Head, router } from '@inertiajs/react';
import { Head, router, usePage } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useCallback, useMemo } from 'react';
@ -48,6 +48,7 @@ interface Props {
}
export default function AccountsIndex({ accounts, accountMetrics }: Props) {
const { auth } = usePage<SharedData>().props;
const isLoading = !accountMetrics;
// Identify loan account IDs that are linked to a real estate account
@ -145,7 +146,7 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) {
}, []);
const handleAccountCreated = useCallback(() => {
router.reload({ only: ['accounts'] });
router.reload();
}, []);
return (
@ -175,6 +176,7 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) {
linkedLoanMetrics={
linkedLoanMetricsMap[account.id]
}
displayCurrencyCode={auth.user.currency_code}
/>
));
})}

View File

@ -338,7 +338,7 @@ export default function AccountShow({
currentMortgageBalance={
chartComputedData.currentMortgageBalance
}
currencyCode={account.currency_code}
currencyCode={chartComputedData.currencyCode}
/>
)}

View File

@ -214,6 +214,9 @@ export default function Dashboard() {
linkedLoanMetrics={
linkedLoanMetricsMap[account.id]
}
displayCurrencyCode={
netWorthEvolution.currency_code
}
/>
))}
</div>

View File

@ -23,7 +23,6 @@ import { edit as accountEdit } from '@/routes/account';
import { disable, enable } from '@/routes/two-factor';
import { send } from '@/routes/verification';
import { type BreadcrumbItem, type SharedData } from '@/types';
import { CURRENCY_OPTIONS } from '@/types/account';
import { LANGUAGE_OPTIONS } from '@/types/language';
import { __ } from '@/utils/i18n';
import { Transition } from '@headlessui/react';
@ -49,7 +48,7 @@ export default function Account({
requiresConfirmation?: boolean;
twoFactorEnabled?: boolean;
}) {
const { auth } = usePage<SharedData>().props;
const { auth, currencies } = usePage<SharedData>().props;
const passwordInput = useRef<HTMLInputElement>(null);
const currentPasswordInput = useRef<HTMLInputElement>(null);
@ -144,13 +143,14 @@ export default function Account({
/>
</SelectTrigger>
<SelectContent>
{CURRENCY_OPTIONS.map(
{currencies.profile.map(
(currency) => (
<SelectItem
key={currency}
value={currency}
key={currency.code}
value={currency.code}
>
{currency}
{currency.code} -{' '}
{currency.name}
</SelectItem>
),
)}
@ -170,7 +170,9 @@ export default function Account({
<Select
name="locale"
defaultValue={auth.user.locale}
defaultValue={
auth.user.locale ?? undefined
}
>
<SelectTrigger className="mt-1 w-full">
<SelectValue

View File

@ -192,7 +192,7 @@ export default function Accounts({ accounts }: AccountsPageProps) {
];
const handleAccountCreated = () => {
router.reload({ only: ['accounts'] });
router.reload();
};
const columns: ColumnDef<Account>[] = [

View File

@ -26,20 +26,12 @@ export const ACCOUNT_TYPES = [
export type AccountType = (typeof ACCOUNT_TYPES)[number];
export const CURRENCY_OPTIONS = [
'USD',
'EUR',
'GBP',
'JPY',
'CHF',
'CAD',
'AUD',
'CNY',
'INR',
'MXN',
] as const;
export type CurrencyCode = string;
export type CurrencyCode = (typeof CURRENCY_OPTIONS)[number];
export interface CurrencyOption {
code: CurrencyCode;
name: string;
}
export interface Bank {
id: UUID;

View File

@ -1,7 +1,7 @@
import { InertiaLinkProps } from '@inertiajs/react';
import { LucideIcon } from 'lucide-react';
import { ReactNode } from 'react';
import { CurrencyCode } from './account';
import { CurrencyCode, CurrencyOption } from './account';
import { PricingConfig } from './pricing';
import { UUID } from './uuid';
@ -71,6 +71,10 @@ export interface SharedData {
hasEncryptionSetup: boolean;
locale: string;
translations: Record<string, string>;
currencies: {
profile: CurrencyOption[];
accounts: CurrencyOption[];
};
[key: string]: unknown;
}

View File

@ -105,3 +105,18 @@ test('different dates make separate requests', function () {
Http::assertSentCount(2);
});
test('converts new latam fiat currency using CDN rates', function () {
Http::fake([
'cdn.jsdelivr.net/*currencies/usd*' => Http::response([
'usd' => [
'ars' => 1400.0,
],
]),
]);
$service = new CurrencyConversionService;
$result = $service->convert('ARS', 'USD', 2800.0, '2026-01-15');
expect($result)->toBe(2.0);
});

View File

@ -6,6 +6,7 @@ use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Category;
use App\Models\ExchangeRate;
use App\Models\RealEstateDetail;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Http;
@ -913,3 +914,365 @@ test('net worth daily evolution returns account metadata including bank', functi
expect($data['accounts'][$account->id])->toHaveKey('bank');
expect($data['accounts'][$account->id]['bank'])->toHaveKeys(['id', 'name', 'logo']);
});
test('account balance evolution includes display_* fields when account currency differs from user currency', function () {
// User currency is USD (default from factory)
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'name' => 'EUR Checking',
'currency_code' => 'EUR',
]);
$lastMonth = now()->subMonthNoOverflow();
$endOfMonth = $lastMonth->copy()->endOfMonth();
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => $endOfMonth,
'balance' => 100000, // €1,000.00
]);
// Seed exchange rate: 1 USD = 0.90 EUR, so EUR -> USD = 100000 / 0.90
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => $endOfMonth->toDateString(),
'rates' => ['eur' => 0.90],
]);
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/balance-evolution?'.http_build_query([
'from' => $lastMonth->copy()->startOfMonth()->toDateString(),
'to' => $endOfMonth->toDateString(),
]));
$response->assertOk();
$data = $response->json();
// Original value stays in EUR
expect($data['data'][0]['value'])->toBe(100000);
// Converted display value in USD
expect($data['data'][0])->toHaveKey('display_value');
expect($data['data'][0]['display_value'])->toBe((int) round(100000 / 0.90));
// Top-level display_currency_code is present
expect($data)->toHaveKey('display_currency_code');
expect($data['display_currency_code'])->toBe('USD');
// Account metadata still shows original currency
expect($data['account']['currency_code'])->toBe('EUR');
});
test('account balance evolution does not include display_* fields when currencies match', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'name' => 'USD Checking',
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now()->endOfMonth(),
'balance' => 100000,
]);
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/balance-evolution?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['data'][0])->not->toHaveKey('display_value');
expect($data)->not->toHaveKey('display_currency_code');
});
test('account daily balance evolution includes display_* fields when account currency differs from user currency', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Savings,
'name' => 'GBP Savings',
'currency_code' => 'GBP',
]);
$today = now();
$yesterday = now()->subDays(1);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => $yesterday,
'balance' => 50000, // £500.00
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => $today,
'balance' => 60000, // £600.00
]);
// Seed exchange rates for both days
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => $yesterday->toDateString(),
'rates' => ['gbp' => 0.79],
]);
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => $today->toDateString(),
'rates' => ['gbp' => 0.80],
]);
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/daily-balance-evolution?'.http_build_query([
'from' => $yesterday->toDateString(),
'to' => $today->toDateString(),
]));
$response->assertOk();
$data = $response->json();
// Original values stay in GBP
expect($data['data'][0]['value'])->toBe(50000);
expect($data['data'][1]['value'])->toBe(60000);
// Converted display values in USD
expect($data['data'][0])->toHaveKey('display_value');
expect($data['data'][0]['display_value'])->toBe((int) round(50000 / 0.79));
expect($data['data'][1])->toHaveKey('display_value');
expect($data['data'][1]['display_value'])->toBe((int) round(60000 / 0.80));
// Top-level display_currency_code
expect($data)->toHaveKey('display_currency_code');
expect($data['display_currency_code'])->toBe('USD');
expect($data['account']['currency_code'])->toBe('GBP');
});
test('account daily balance evolution does not include display_* fields when currencies match', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'name' => 'USD Daily',
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now(),
'balance' => 100000,
]);
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/daily-balance-evolution?'.http_build_query([
'from' => now()->toDateString(),
'to' => now()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['data'][0])->not->toHaveKey('display_value');
expect($data)->not->toHaveKey('display_currency_code');
});
test('account balance evolution includes display_invested_amount for foreign currency investment accounts', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Investment,
'name' => 'EUR Portfolio',
'currency_code' => 'EUR',
]);
$lastMonth = now()->subMonthNoOverflow();
$endOfMonth = $lastMonth->copy()->endOfMonth();
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => $endOfMonth,
'balance' => 500000,
'invested_amount' => 400000,
]);
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => $endOfMonth->toDateString(),
'rates' => ['eur' => 0.90],
]);
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/balance-evolution?'.http_build_query([
'from' => $lastMonth->copy()->startOfMonth()->toDateString(),
'to' => $endOfMonth->toDateString(),
]));
$response->assertOk();
$data = $response->json();
// Original invested_amount in EUR
expect($data['data'][0]['invested_amount'])->toBe(400000);
// Converted display values
expect($data['data'][0])->toHaveKey('display_value');
expect($data['data'][0])->toHaveKey('display_invested_amount');
expect($data['data'][0]['display_invested_amount'])->toBe((int) round(400000 / 0.90));
expect($data['display_currency_code'])->toBe('USD');
});
test('account balance evolution converts mortgage using loan currency when it differs from property currency', function () {
// User currency is USD, property is USD, loan is EUR — only the mortgage needs conversion
$property = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::RealEstate,
'currency_code' => 'USD',
]);
$loan = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Loan,
'currency_code' => 'EUR',
]);
RealEstateDetail::factory()->create([
'account_id' => $property->id,
'linked_loan_account_id' => $loan->id,
]);
$lastMonth = now()->subMonthNoOverflow();
$endOfMonth = $lastMonth->copy()->endOfMonth();
AccountBalance::factory()->create([
'account_id' => $property->id,
'balance_date' => $endOfMonth,
'balance' => 50000000, // $500,000.00 USD
]);
AccountBalance::factory()->create([
'account_id' => $loan->id,
'balance_date' => $endOfMonth,
'balance' => 18000000, // €180,000.00 EUR
]);
// 1 USD = 0.90 EUR, so EUR -> USD = 18000000 / 0.90
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => $endOfMonth->toDateString(),
'rates' => ['eur' => 0.90],
]);
$response = $this->getJson('/api/dashboard/account/'.$property->id.'/balance-evolution?'.http_build_query([
'from' => $lastMonth->copy()->startOfMonth()->toDateString(),
'to' => $endOfMonth->toDateString(),
]));
$response->assertOk();
$data = $response->json();
// Property value stays in USD and no alternate display currency is exposed.
expect($data['data'][0]['value'])->toBe(50000000);
expect($data['data'][0])->not->toHaveKey('display_value');
// Mortgage balance is normalized into the property's account currency.
expect($data['data'][0]['mortgage_balance'])->toBe((int) round(18000000 / 0.90));
expect($data['data'][0])->not->toHaveKey('display_mortgage_balance');
expect($data)->not->toHaveKey('display_currency_code');
});
test('account daily balance evolution converts mortgage using loan currency when it differs from property currency', function () {
$property = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::RealEstate,
'currency_code' => 'USD',
]);
$loan = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Loan,
'currency_code' => 'EUR',
]);
RealEstateDetail::factory()->create([
'account_id' => $property->id,
'linked_loan_account_id' => $loan->id,
]);
$today = now();
AccountBalance::factory()->create([
'account_id' => $property->id,
'balance_date' => $today,
'balance' => 50000000,
]);
AccountBalance::factory()->create([
'account_id' => $loan->id,
'balance_date' => $today,
'balance' => 18000000,
]);
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => $today->toDateString(),
'rates' => ['eur' => 0.90],
]);
$response = $this->getJson('/api/dashboard/account/'.$property->id.'/daily-balance-evolution?'.http_build_query([
'from' => $today->toDateString(),
'to' => $today->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['data'][0]['value'])->toBe(50000000);
expect($data['data'][0])->not->toHaveKey('display_value');
expect($data['data'][0]['mortgage_balance'])->toBe((int) round(18000000 / 0.90));
expect($data['data'][0])->not->toHaveKey('display_mortgage_balance');
expect($data)->not->toHaveKey('display_currency_code');
});
test('account balance evolution exposes alternate user-currency mortgage values when property and user currencies differ', function () {
$this->user->update(['currency_code' => 'USD']);
$property = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::RealEstate,
'currency_code' => 'EUR',
]);
$loan = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Loan,
'currency_code' => 'GBP',
]);
RealEstateDetail::factory()->create([
'account_id' => $property->id,
'linked_loan_account_id' => $loan->id,
]);
$endOfMonth = now()->subMonthNoOverflow()->endOfMonth();
AccountBalance::factory()->create([
'account_id' => $property->id,
'balance_date' => $endOfMonth,
'balance' => 50000000,
]);
AccountBalance::factory()->create([
'account_id' => $loan->id,
'balance_date' => $endOfMonth,
'balance' => 18000000,
]);
ExchangeRate::factory()->create([
'base_currency' => 'eur',
'date' => $endOfMonth->toDateString(),
'rates' => ['gbp' => 0.80],
]);
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => $endOfMonth->toDateString(),
'rates' => ['eur' => 0.90, 'gbp' => 0.72],
]);
$response = $this->getJson('/api/dashboard/account/'.$property->id.'/balance-evolution?'.http_build_query([
'from' => $endOfMonth->copy()->startOfMonth()->toDateString(),
'to' => $endOfMonth->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['data'][0]['value'])->toBe(50000000);
expect($data['data'][0]['display_value'])->toBe((int) round(50000000 / 0.90));
expect($data['data'][0]['mortgage_balance'])->toBe((int) round(18000000 / 0.80));
expect($data['data'][0]['display_mortgage_balance'])->toBe((int) round(18000000 / 0.72));
expect($data['display_currency_code'])->toBe('USD');
});

View File

@ -31,3 +31,18 @@ test('all pages receive app url in shared props', function () {
->where('appUrl', config('app.url'))
);
});
test('shared currency options split profile and account currencies', function () {
$response = $this->get(route('home'));
$response->assertInertia(fn (Assert $page) => $page
->where('currencies.profile.0.code', 'USD')
->where('currencies.accounts.0.code', 'USD')
);
$props = $response->viewData('page')['props'];
expect(collect($props['currencies']['profile'])->pluck('code'))->toContain('ARS');
expect(collect($props['currencies']['profile'])->pluck('code'))->not->toContain('BTC');
expect(collect($props['currencies']['accounts'])->pluck('code'))->toContain('BTC');
});

View File

@ -88,6 +88,47 @@ test('syncs binance balance using USDT fallback conversion', function () {
expect($balance->balance)->toBe(90909); // 909.09 EUR * 100
});
test('syncs binance balance for unsupported quote currencies via usd conversion', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'ARS']);
$connection = BankingConnection::factory()->binance()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'binance-portfolio',
'currency_code' => 'ARS',
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
['asset' => 'SOL', 'free' => '10.0', 'locked' => '0.0'],
],
]),
'api.binance.com/api/v3/ticker/price' => Http::response([
['symbol' => 'SOLUSDT', 'price' => '100.00'],
]),
'cdn.jsdelivr.net/*currencies/ars*' => Http::response([
'ars' => [
'usd' => 0.0007142857,
],
]),
]);
$client = new BinanceClient('test-key', 'test-secret');
$service = app(BinanceBalanceSyncService::class);
$service->sync($account, $client);
expect($account->balances()->count())->toBe(1);
$balance = $account->balances()->first();
expect($balance->balance)->toBe((int) round((1000 / 0.0007142857) * 100));
});
test('handles USD stablecoins as 1:1 when target is USD', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'USD']);
$connection = BankingConnection::factory()->binance()->create([

View File

@ -125,6 +125,74 @@ test('syncs bitpanda balance with crypto only', function () {
expect($balance->balance)->toBe(1000000);
});
test('syncs bitpanda balance by converting fiat wallets to newly allowed primary currency', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'ARS']);
$connection = BankingConnection::factory()->bitpanda()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'bitpanda-portfolio',
'currency_code' => 'ARS',
]);
Http::fake([
'api.bitpanda.com/v1/ticker' => Http::response([
'BTC' => ['ARS' => '70000000.00'],
]),
'api.bitpanda.com/v1/wallets' => Http::response([
'data' => [
[
'type' => 'wallet',
'attributes' => [
'cryptocoin_id' => '1',
'cryptocoin_symbol' => 'BTC',
'balance' => '0.01000000',
'is_default' => true,
'name' => 'BTC wallet',
'deleted' => false,
],
'id' => 'wallet-uuid-1',
],
],
]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([
'data' => [],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response([
'data' => [
[
'type' => 'fiat_wallet',
'attributes' => [
'fiat_id' => '1',
'fiat_symbol' => 'USD',
'balance' => '500.00000000',
'name' => 'USD Wallet',
],
'id' => 'fiat-wallet-uuid-1',
],
],
]),
'cdn.jsdelivr.net/*currencies/ars*' => Http::response([
'ars' => [
'usd' => 0.0007142857,
],
]),
]);
$client = new BitpandaClient('test-key');
$service = app(BitpandaBalanceSyncService::class);
$service->sync($account, $client);
expect($account->balances()->count())->toBe(1);
$balance = $account->balances()->first();
expect($balance->balance)->toBe((int) round((700000 + (500 / 0.0007142857)) * 100));
});
test('syncs bitpanda balance including bitpanda-specific indices', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->bitpanda()->create([
@ -618,7 +686,7 @@ test('only counts finished fiat transactions for invested_amount', function () {
expect($balance->invested_amount)->toBe(100000); // 1000 EUR → 100000 cents
});
test('only counts fiat transactions matching target currency for invested_amount', function () {
test('converts bitpanda fiat transactions that do not match the target currency for invested_amount', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->bitpanda()->create([
'user_id' => $user->id,
@ -673,6 +741,11 @@ test('only counts fiat transactions matching target currency for invested_amount
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]),
'cdn.jsdelivr.net/*currencies/eur*' => Http::response([
'eur' => [
'usd' => 1.10,
],
]),
]);
$client = new BitpandaClient('test-key');
@ -680,9 +753,69 @@ test('only counts fiat transactions matching target currency for invested_amount
$service->sync($account, $client);
$balance = $account->balances()->first();
// Only EUR transactions should count: 1000 + 250 = 1250 EUR → 125000 cents
// The 500 USD deposit should be excluded
expect($balance->invested_amount)->toBe(125000);
// 1000 EUR + 250 EUR + (500 USD / 1.10) = 1704.55 EUR → 170455 cents
expect($balance->invested_amount)->toBe(170455);
});
test('converts bitpanda fiat transactions to newly allowed primary currency for invested amount', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'ARS']);
$connection = BankingConnection::factory()->bitpanda()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'bitpanda-portfolio',
'currency_code' => 'ARS',
]);
Http::fake([
'api.bitpanda.com/v1/ticker' => Http::response([]),
'api.bitpanda.com/v1/wallets' => Http::response(['data' => []]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::sequence()
->push([
'data' => [
[
'type' => 'fiat_wallet_transaction',
'id' => 'tx-1',
'attributes' => [
'amount' => '1000.00',
'status' => 'finished',
'fiat_id' => 'USD',
],
],
[
'type' => 'fiat_wallet_transaction',
'id' => 'tx-2',
'attributes' => [
'amount' => '250.00',
'status' => 'finished',
'fiat_id' => 'ARS',
],
],
],
'meta' => ['next_cursor' => null],
'links' => [],
])
->push([
'data' => [],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]),
'cdn.jsdelivr.net/*currencies/ars*' => Http::response([
'ars' => [
'usd' => 0.0007142857,
],
]),
]);
$client = new BitpandaClient('test-key');
$service = app(BitpandaBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
expect($balance->invested_amount)->toBe((int) round(((1000 / 0.0007142857) + 250) * 100));
});
test('returns null invested_amount when no fiat transactions exist', function () {

View File

@ -76,6 +76,63 @@ it('validates currency_code must be in allowed list', function () {
$response->assertSessionHasErrors(['currency_code']);
});
it('accepts new latam currency when creating account', function () {
actingAs($this->user);
$response = $this->post(route('accounts.store'), [
'name' => 'Argentina Account',
'bank_id' => $this->bank->id,
'currency_code' => 'ARS',
'type' => AccountType::Checking->value,
]);
$response->assertRedirect();
assertDatabaseHas('accounts', [
'user_id' => $this->user->id,
'bank_id' => $this->bank->id,
'currency_code' => 'ARS',
]);
});
it('accepts bitcoin when creating account', function () {
actingAs($this->user);
Account::factory()->create([
'user_id' => $this->user->id,
'bank_id' => $this->bank->id,
'currency_code' => 'USD',
]);
$response = $this->post(route('accounts.store'), [
'name' => 'Bitcoin Account',
'bank_id' => $this->bank->id,
'currency_code' => 'BTC',
'type' => AccountType::Investment->value,
]);
$response->assertRedirect();
assertDatabaseHas('accounts', [
'user_id' => $this->user->id,
'bank_id' => $this->bank->id,
'currency_code' => 'BTC',
]);
});
it('rejects bitcoin when creating a first account', function () {
actingAs($this->user);
$response = $this->post(route('accounts.store'), [
'name' => 'Bitcoin Account',
'bank_id' => $this->bank->id,
'currency_code' => 'BTC',
'type' => AccountType::Investment->value,
]);
$response->assertSessionHasErrors(['currency_code']);
});
it('validates type must be valid AccountType', function () {
actingAs($this->user);

View File

@ -35,6 +35,38 @@ test('profile information can be updated', function () {
expect($user->currency_code)->toBe('EUR');
});
test('profile accepts new latam primary currency', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => 'test@example.com',
'currency_code' => 'ARS',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('account.edit'));
expect($user->refresh()->currency_code)->toBe('ARS');
});
test('profile rejects bitcoin as primary currency', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => 'test@example.com',
'currency_code' => 'BTC',
]);
$response->assertSessionHasErrors(['currency_code']);
});
test('email verification status is unchanged when the email address is unchanged', function () {
$user = User::factory()->create();