diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php
index d5a4b7a2..e6c03246 100644
--- a/app/Http/Controllers/Api/DashboardAnalyticsController.php
+++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php
@@ -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(),
+ );
}
}
diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php
index a6a3c532..d6edf0d6 100644
--- a/app/Http/Middleware/HandleInertiaRequests.php
+++ b/app/Http/Middleware/HandleInertiaRequests.php
@@ -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(),
+ ],
];
}
diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php
index 8c977ab0..962e9803 100644
--- a/app/Http/Requests/Settings/ProfileUpdateRequest.php
+++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php
@@ -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'])],
];
}
diff --git a/app/Http/Requests/Settings/StoreAccountRequest.php b/app/Http/Requests/Settings/StoreAccountRequest.php
index 099efe40..89d989df 100644
--- a/app/Http/Requests/Settings/StoreAccountRequest.php
+++ b/app/Http/Requests/Settings/StoreAccountRequest.php
@@ -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',
diff --git a/app/Http/Requests/Settings/UpdateAccountRequest.php b/app/Http/Requests/Settings/UpdateAccountRequest.php
index eb0b04f2..3f7bf9c6 100644
--- a/app/Http/Requests/Settings/UpdateAccountRequest.php
+++ b/app/Http/Requests/Settings/UpdateAccountRequest.php
@@ -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',
diff --git a/app/Services/Banking/BinanceBalanceSyncService.php b/app/Services/Banking/BinanceBalanceSyncService.php
index 203e564b..364d8278 100644
--- a/app/Services/Banking/BinanceBalanceSyncService.php
+++ b/app/Services/Banking/BinanceBalanceSyncService.php
@@ -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;
}
/**
diff --git a/app/Services/Banking/BitpandaBalanceSyncService.php b/app/Services/Banking/BitpandaBalanceSyncService.php
index d8986a22..e56ddc22 100644
--- a/app/Services/Banking/BitpandaBalanceSyncService.php
+++ b/app/Services/Banking/BitpandaBalanceSyncService.php
@@ -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;
}
diff --git a/app/Services/CurrencyOptions.php b/app/Services/CurrencyOptions.php
new file mode 100644
index 00000000..406f925e
--- /dev/null
+++ b/app/Services/CurrencyOptions.php
@@ -0,0 +1,70 @@
+
+ */
+ public function all(): array
+ {
+ /** @var list $options */
+ $options = config('currencies.options', []);
+
+ return $options;
+ }
+
+ /**
+ * @return list
+ */
+ 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
+ */
+ 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
+ */
+ public function primaryOptions(): array
+ {
+ return $this->formatOptions($this->all(), 'allows_primary');
+ }
+
+ /**
+ * @return list
+ */
+ public function accountOptions(): array
+ {
+ return $this->formatOptions($this->all(), 'allows_account');
+ }
+
+ /**
+ * @param list $options
+ * @return list
+ */
+ 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])
+ ));
+ }
+}
diff --git a/config/currencies.php b/config/currencies.php
new file mode 100644
index 00000000..1b9f1d19
--- /dev/null
+++ b/config/currencies.php
@@ -0,0 +1,120 @@
+ [
+ [
+ '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,
+ ],
+ ],
+];
diff --git a/lang/es.json b/lang/es.json
index a2ec2301..6fda0a19 100644
--- a/lang/es.json
+++ b/lang/es.json
@@ -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",
diff --git a/resources/js/components/accounts/account-balance-chart.tsx b/resources/js/components/accounts/account-balance-chart.tsx
index 0da6bc75..3efb210f 100644
--- a/resources/js/components/accounts/account-balance-chart.tsx
+++ b/resources/js/components/accounts/account-balance-chart.tsx
@@ -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;
longTrend: ReturnType;
@@ -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('monthly');
+ const [currencyMode, setCurrencyMode] =
+ useState('account');
const [balanceData, setBalanceData] = useState(
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)
+ .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(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"
>
@@ -663,7 +770,7 @@ export function AccountBalanceChart({
>
@@ -672,19 +779,19 @@ export function AccountBalanceChart({
)}
@@ -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 && (
+
+ )}
{showInvestmentBenefits &&
- currentInvestedAmount !== null && (
+ activeCurrentInvestedAmount !==
+ null && (
) : showInvestmentBenefits &&
- currentInvestedAmount !== null ? (
+ activeCurrentInvestedAmount !== null ? (
diff --git a/resources/js/components/accounts/account-form.tsx b/resources/js/components/accounts/account-form.tsx
index b39c8c22..f3547a9c 100644
--- a/resources/js/components/accounts/account-form.tsx
+++ b/resources/js/components/accounts/account-form.tsx
@@ -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;
}
@@ -115,13 +118,18 @@ export function AccountForm({
forceAccountType,
hiddenAccountTypes = [],
availableLoanAccounts = [],
+ usePrimaryCurrenciesOnly = false,
onChange,
errors = {},
}: AccountFormProps) {
+ const { currencies } = usePage().props;
+ const currencyOptions = usePrimaryCurrenciesOnly
+ ? currencies.profile
+ : currencies.accounts;
const [displayName, setDisplayName] = useState(
initialValues?.displayName ?? '',
);
- const [selectedBankId, setSelectedBankId] = useState(
+ const [selectedBankId, setSelectedBankId] = useState(
initialValues?.bank?.id ?? null,
);
const [selectedType, setSelectedType] = useState(
@@ -318,9 +326,12 @@ export function AccountForm({
- {CURRENCY_OPTIONS.map((currency) => (
-
- {currency}
+ {currencyOptions.map((currency: CurrencyOption) => (
+
+ {currency.code} - {currency.name}
))}
diff --git a/resources/js/components/accounts/account-list-card.tsx b/resources/js/components/accounts/account-list-card.tsx
index 0cd9d0a0..3dc35ec9 100644
--- a/resources/js/components/accounts/account-list-card.tsx
+++ b/resources/js/components/accounts/account-list-card.tsx
@@ -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({
@@ -210,7 +213,7 @@ export function AccountListCard({
>
@@ -224,7 +227,7 @@ export function AccountListCard({
previousAmount={displayPreviousBalance}
currentAmount={displayBalance}
tooltipSide="bottom"
- currencyCode={account.currency_code}
+ currencyCode={currencyCode}
/>
@@ -266,7 +269,7 @@ export function AccountListCard({
data.value
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
@@ -282,7 +285,7 @@ export function AccountListCard({
0
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
@@ -297,7 +300,7 @@ export function AccountListCard({
equity
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
@@ -331,7 +334,7 @@ export function AccountListCard({
data.value
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
@@ -344,7 +347,7 @@ export function AccountListCard({
invested
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
@@ -366,7 +369,7 @@ export function AccountListCard({
gain
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
@@ -380,7 +383,7 @@ export function AccountListCard({
data.value
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
diff --git a/resources/js/components/accounts/bank-combobox.tsx b/resources/js/components/accounts/bank-combobox.tsx
index f80fbc78..7eeb3450 100644
--- a/resources/js/components/accounts/bank-combobox.tsx
+++ b/resources/js/components/accounts/bank-combobox.tsx
@@ -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;
}
diff --git a/resources/js/components/accounts/create-account-dialog.tsx b/resources/js/components/accounts/create-account-dialog.tsx
index 95493853..4c1d7bda 100644
--- a/resources/js/components/accounts/create-account-dialog.tsx
+++ b/resources/js/components/accounts/create-account-dialog.tsx
@@ -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(
@@ -302,6 +301,7 @@ export function CreateAccountDialog({
hiddenAccountTypes={
realEstateEnabled ? [] : ['real_estate']
}
+ usePrimaryCurrenciesOnly={isFirstAccount}
/>
diff --git a/resources/js/components/accounts/edit-account-dialog.tsx b/resources/js/components/accounts/edit-account-dialog.tsx
index a31126ee..54169815 100644
--- a/resources/js/components/accounts/edit-account-dialog.tsx
+++ b/resources/js/components/accounts/edit-account-dialog.tsx
@@ -46,7 +46,7 @@ export function EditAccountDialog({
const [errors, setErrors] = useState
>({});
const formDataRef = useRef({
displayName: '',
- bankId: (account.bank?.id ?? null) as number | null,
+ bankId: account.bank?.id ?? null,
type: account.type,
currencyCode: account.currency_code,
customBank: null,
diff --git a/resources/js/components/charts/chart-currency-toggle.tsx b/resources/js/components/charts/chart-currency-toggle.tsx
new file mode 100644
index 00000000..882f94b2
--- /dev/null
+++ b/resources/js/components/charts/chart-currency-toggle.tsx
@@ -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 (
+ {
+ if (v) onValueChange(v as ChartCurrencyMode);
+ }}
+ variant="outline"
+ size="sm"
+ className={cn(className)}
+ >
+ {items.map((item) => {
+ const toggleItem = (
+
+ {item.label}
+
+ );
+
+ if (!showTooltip) {
+ return toggleItem;
+ }
+
+ return (
+
+ {toggleItem}
+
+ {item.tooltip}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/resources/js/components/charts/chart-settings-popover.tsx b/resources/js/components/charts/chart-settings-popover.tsx
index bf579ddd..a8a55668 100644
--- a/resources/js/components/charts/chart-settings-popover.tsx
+++ b/resources/js/components/charts/chart-settings-popover.tsx
@@ -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}
/>
+ {currencyToggle && (
+
+
+ {__('Currency')}
+
+
+
+ )}
{allToggles.length > 0 && }
>
) : null}
diff --git a/resources/js/components/charts/index.ts b/resources/js/components/charts/index.ts
index 46c8bd6b..394b1a13 100644
--- a/resources/js/components/charts/index.ts
+++ b/resources/js/components/charts/index.ts
@@ -1,4 +1,8 @@
export { CashflowTrendChart } from './cashflow-trend-chart';
+export {
+ ChartCurrencyToggle,
+ type ChartCurrencyMode,
+} from './chart-currency-toggle';
export {
ChartGranularityToggle,
type ChartGranularity,
diff --git a/resources/js/components/dashboard/account-balance-card.tsx b/resources/js/components/dashboard/account-balance-card.tsx
index f246f89b..c478e563 100644
--- a/resources/js/components/dashboard/account-balance-card.tsx
+++ b/resources/js/components/dashboard/account-balance-card.tsx
@@ -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({
@@ -183,7 +186,7 @@ export function AccountBalanceCard({
>
@@ -197,7 +200,7 @@ export function AccountBalanceCard({
previousAmount={displayPreviousBalance}
currentAmount={displayBalance}
tooltipSide="bottom"
- currencyCode={account.currency_code}
+ currencyCode={currencyCode}
/>
@@ -237,7 +240,7 @@ export function AccountBalanceCard({
data.value
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
@@ -253,7 +256,7 @@ export function AccountBalanceCard({
0
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
@@ -268,7 +271,7 @@ export function AccountBalanceCard({
equity
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
@@ -302,7 +305,7 @@ export function AccountBalanceCard({
data.value
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
@@ -315,7 +318,7 @@ export function AccountBalanceCard({
invested
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
@@ -337,7 +340,7 @@ export function AccountBalanceCard({
gain
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
@@ -351,7 +354,7 @@ export function AccountBalanceCard({
data.value
}
currencyCode={
- account.currency_code
+ currencyCode
}
/>
diff --git a/resources/js/components/onboarding/step-create-account.tsx b/resources/js/components/onboarding/step-create-account.tsx
index 87d3b8c3..da965997 100644
--- a/resources/js/components/onboarding/step-create-account.tsx
+++ b/resources/js/components/onboarding/step-create-account.tsx
@@ -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"
>
-
+
{error && (
diff --git a/resources/js/hooks/use-dashboard-data.test.ts b/resources/js/hooks/use-dashboard-data.test.ts
index d64c1471..2f5d717e 100644
--- a/resources/js/hooks/use-dashboard-data.test.ts
+++ b/resources/js/hooks/use-dashboard-data.test.ts
@@ -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);
+ });
});
diff --git a/resources/js/pages/Accounts/Index.tsx b/resources/js/pages/Accounts/Index.tsx
index 84e275bd..7bb7bd4e 100644
--- a/resources/js/pages/Accounts/Index.tsx
+++ b/resources/js/pages/Accounts/Index.tsx
@@ -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().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}
/>
));
})}
diff --git a/resources/js/pages/Accounts/Show.tsx b/resources/js/pages/Accounts/Show.tsx
index d2229b66..475f7ec6 100644
--- a/resources/js/pages/Accounts/Show.tsx
+++ b/resources/js/pages/Accounts/Show.tsx
@@ -338,7 +338,7 @@ export default function AccountShow({
currentMortgageBalance={
chartComputedData.currentMortgageBalance
}
- currencyCode={account.currency_code}
+ currencyCode={chartComputedData.currencyCode}
/>
)}
diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx
index b40fa886..0a8ba820 100644
--- a/resources/js/pages/dashboard.tsx
+++ b/resources/js/pages/dashboard.tsx
@@ -214,6 +214,9 @@ export default function Dashboard() {
linkedLoanMetrics={
linkedLoanMetricsMap[account.id]
}
+ displayCurrencyCode={
+ netWorthEvolution.currency_code
+ }
/>
))}
diff --git a/resources/js/pages/settings/account.tsx b/resources/js/pages/settings/account.tsx
index 5640795b..cbfa03bf 100644
--- a/resources/js/pages/settings/account.tsx
+++ b/resources/js/pages/settings/account.tsx
@@ -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
().props;
+ const { auth, currencies } = usePage().props;
const passwordInput = useRef(null);
const currentPasswordInput = useRef(null);
@@ -144,13 +143,14 @@ export default function Account({
/>
- {CURRENCY_OPTIONS.map(
+ {currencies.profile.map(
(currency) => (
- {currency}
+ {currency.code} -{' '}
+ {currency.name}
),
)}
@@ -170,7 +170,9 @@ export default function Account({