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({