feat(settings): centralize currency options and split profile/account support (#256)
## Summary - **Bug fix:** Dashboard and accounts index cards displayed the account's original currency code (e.g. `BTC`) even though balances were already converted to the user's currency (e.g. `EUR`). Now passes `displayCurrencyCode` to card components so the label matches the converted amount. - **Feature:** Added a currency toggle on the account detail chart to switch between the account's native currency and the user's main currency. Extends the balance evolution APIs with `display_*` fields when conversion applies. - **First-account restriction:** Restricts first-account creation to primary (fiat) currencies only, ensuring the user's base currency is always widely supported. ## Changes ### Bug fix — currency label on cards - `AccountBalanceCard` / `AccountListCard`: Added `displayCurrencyCode` prop; all amount renders now use it instead of `account.currency_code` - `dashboard.tsx`: Passes `netWorthEvolution.currency_code` as `displayCurrencyCode` - `Accounts/Index.tsx`: Passes `auth.user.currency_code` as `displayCurrencyCode` ### Backend — API extension - `DashboardAnalyticsController`: Both `accountBalanceEvolution()` and `accountDailyBalanceEvolution()` now return `display_value`, `display_invested_amount`, `display_mortgage_balance` per data point and a top-level `display_currency_code` when the account currency differs from the user's ### Frontend — currency toggle - New `ChartCurrencyToggle` component with `ToggleGroup` showing currency code labels (e.g. `BTC` / `EUR`) - `ChartSettingsPopover`: Extended with optional `currencyToggle` prop for mobile - `AccountBalanceChart`: Full integration — all amounts, trends, tooltips, MoM chart, and equity swap to `display_*` values when toggle is set to user currency ### First-account currency restriction - `StoreAccountRequest`: First account limited to primary currency codes - `AccountForm` / `CreateAccountDialog` / `StepCreateAccount`: Pass `usePrimaryCurrenciesOnly` when applicable ### Tests - 5 new Pest tests for `display_*` fields in balance evolution endpoints - 3 new Vitest tests for `deriveAccountMetrics` (mixed-currency, empty data, invested amounts) - 1 new Pest test for first-account BTC rejection
This commit is contained in:
parent
259a9a9712
commit
3d5823728a
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'])],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class CurrencyOptions
|
||||
{
|
||||
/**
|
||||
* @return list<array{code: string, name: string, allows_primary: bool, allows_account: bool}>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
/** @var list<array{code: string, name: string, allows_primary: bool, allows_account: bool}> $options */
|
||||
$options = config('currencies.options', []);
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function primaryCodes(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
fn (array $currency): string => $currency['code'],
|
||||
array_filter($this->all(), fn (array $currency): bool => $currency['allows_primary'])
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function accountCodes(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
fn (array $currency): string => $currency['code'],
|
||||
array_filter($this->all(), fn (array $currency): bool => $currency['allows_account'])
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{code: string, name: string}>
|
||||
*/
|
||||
public function primaryOptions(): array
|
||||
{
|
||||
return $this->formatOptions($this->all(), 'allows_primary');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{code: string, name: string}>
|
||||
*/
|
||||
public function accountOptions(): array
|
||||
{
|
||||
return $this->formatOptions($this->all(), 'allows_account');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{code: string, name: string, allows_primary: bool, allows_account: bool}> $options
|
||||
* @return list<array{code: string, name: string}>
|
||||
*/
|
||||
private function formatOptions(array $options, string $capability): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
fn (array $currency): array => [
|
||||
'code' => $currency['code'],
|
||||
'name' => __($currency['name']),
|
||||
],
|
||||
array_filter($options, fn (array $currency): bool => $currency[$capability])
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'options' => [
|
||||
[
|
||||
'code' => 'USD',
|
||||
'name' => 'US Dollar',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'EUR',
|
||||
'name' => 'Euro',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'GBP',
|
||||
'name' => 'British Pound',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'JPY',
|
||||
'name' => 'Japanese Yen',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'CHF',
|
||||
'name' => 'Swiss Franc',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'CAD',
|
||||
'name' => 'Canadian Dollar',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'AUD',
|
||||
'name' => 'Australian Dollar',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'CNY',
|
||||
'name' => 'Chinese Yuan',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'INR',
|
||||
'name' => 'Indian Rupee',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'MXN',
|
||||
'name' => 'Mexican Peso',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'ARS',
|
||||
'name' => 'Argentine Peso',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'BOB',
|
||||
'name' => 'Bolivian Boliviano',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'CLP',
|
||||
'name' => 'Chilean Peso',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'COP',
|
||||
'name' => 'Colombian Peso',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'PYG',
|
||||
'name' => 'Paraguayan Guarani',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'PEN',
|
||||
'name' => 'Peruvian Sol',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'UYU',
|
||||
'name' => 'Uruguayan Peso',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'VES',
|
||||
'name' => 'Venezuelan Bolívar',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'BTC',
|
||||
'name' => 'Bitcoin',
|
||||
'allows_primary' => false,
|
||||
'allows_account' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
21
lang/es.json
21
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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { AccountName } from '@/components/accounts/account-name';
|
||||
import {
|
||||
type ChartCurrencyMode,
|
||||
type ChartGranularity,
|
||||
ChartCurrencyToggle,
|
||||
ChartGranularityToggle,
|
||||
ChartSettingsPopover,
|
||||
ChartViewToggle,
|
||||
|
|
@ -227,6 +229,9 @@ export interface BalanceDataPoint {
|
|||
invested_amount?: number | null;
|
||||
mortgage_balance?: number | null;
|
||||
projected?: boolean;
|
||||
display_value?: number;
|
||||
display_invested_amount?: number | null;
|
||||
display_mortgage_balance?: number;
|
||||
}
|
||||
|
||||
interface AccountBalanceData {
|
||||
|
|
@ -238,6 +243,7 @@ interface AccountBalanceData {
|
|||
type: string;
|
||||
currency_code: string;
|
||||
};
|
||||
display_currency_code?: string;
|
||||
}
|
||||
|
||||
interface DailyBalanceDataPoint {
|
||||
|
|
@ -246,6 +252,9 @@ interface DailyBalanceDataPoint {
|
|||
value: number;
|
||||
invested_amount?: number | null;
|
||||
mortgage_balance?: number | null;
|
||||
display_value?: number;
|
||||
display_invested_amount?: number | null;
|
||||
display_mortgage_balance?: number;
|
||||
}
|
||||
|
||||
interface AccountDailyBalanceData {
|
||||
|
|
@ -257,12 +266,14 @@ interface AccountDailyBalanceData {
|
|||
type: string;
|
||||
currency_code: string;
|
||||
};
|
||||
display_currency_code?: string;
|
||||
}
|
||||
|
||||
export interface ChartComputedData {
|
||||
chartData: BalanceDataPoint[];
|
||||
currentBalance: number;
|
||||
currentMortgageBalance: number | null;
|
||||
currencyCode: string;
|
||||
hasMortgageData: boolean;
|
||||
shortTrend: ReturnType<typeof calculateTrend>;
|
||||
longTrend: ReturnType<typeof calculateTrend>;
|
||||
|
|
@ -330,6 +341,9 @@ function normalizeDailyData(data: DailyBalanceDataPoint[]): BalanceDataPoint[] {
|
|||
value: point.value,
|
||||
invested_amount: point.invested_amount,
|
||||
mortgage_balance: point.mortgage_balance,
|
||||
display_value: point.display_value,
|
||||
display_invested_amount: point.display_invested_amount,
|
||||
display_mortgage_balance: point.display_mortgage_balance,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -345,6 +359,8 @@ export function AccountBalanceChart({
|
|||
const isLoan = account.type === 'loan';
|
||||
const isRealEstate = isRealEstateAccount(account);
|
||||
const [granularity, setGranularity] = useState<ChartGranularity>('monthly');
|
||||
const [currencyMode, setCurrencyMode] =
|
||||
useState<ChartCurrencyMode>('account');
|
||||
const [balanceData, setBalanceData] = useState<AccountBalanceData | null>(
|
||||
null,
|
||||
);
|
||||
|
|
@ -369,6 +385,7 @@ export function AccountBalanceChart({
|
|||
setBalanceData({
|
||||
data: normalizeDailyData(data.data),
|
||||
account: data.account,
|
||||
display_currency_code: data.display_currency_code,
|
||||
});
|
||||
} else {
|
||||
const from = format(subMonths(now, 12), 'yyyy-MM-dd');
|
||||
|
|
@ -488,36 +505,126 @@ export function AccountBalanceChart({
|
|||
};
|
||||
}, [balanceData]);
|
||||
|
||||
// Determine if currency toggle is available
|
||||
const displayCurrencyCode = balanceData?.display_currency_code ?? null;
|
||||
const hasCurrencyToggle = displayCurrencyCode !== null;
|
||||
|
||||
// When in user-currency mode, swap display_* values into the primary fields
|
||||
const activeCurrencyCode =
|
||||
currencyMode === 'user' && displayCurrencyCode
|
||||
? displayCurrencyCode
|
||||
: account.currency_code;
|
||||
|
||||
const activeChartData = useMemo(() => {
|
||||
if (currencyMode !== 'user' || !hasCurrencyToggle) {
|
||||
return chartData;
|
||||
}
|
||||
|
||||
return chartData.map((point) => ({
|
||||
...point,
|
||||
value: point.display_value ?? point.value,
|
||||
invested_amount:
|
||||
point.display_invested_amount !== undefined
|
||||
? point.display_invested_amount
|
||||
: point.invested_amount,
|
||||
mortgage_balance:
|
||||
point.display_mortgage_balance !== undefined
|
||||
? point.display_mortgage_balance
|
||||
: point.mortgage_balance,
|
||||
projected_value:
|
||||
'projected_value' in point && point.display_value !== undefined
|
||||
? point.display_value
|
||||
: ((point as unknown as Record<string, unknown>)
|
||||
.projected_value as number | undefined),
|
||||
}));
|
||||
}, [chartData, currencyMode, hasCurrencyToggle]);
|
||||
|
||||
const activeCurrentBalance = useMemo(() => {
|
||||
if (currencyMode !== 'user' || !hasCurrencyToggle) {
|
||||
return currentBalance;
|
||||
}
|
||||
const historicalData = activeChartData.filter((d) => !d.projected);
|
||||
return historicalData[historicalData.length - 1]?.value ?? 0;
|
||||
}, [activeChartData, currentBalance, currencyMode, hasCurrencyToggle]);
|
||||
|
||||
const activeCurrentMortgageBalance = useMemo(() => {
|
||||
if (currencyMode !== 'user' || !hasCurrencyToggle) {
|
||||
return currentMortgageBalance;
|
||||
}
|
||||
if (!hasMortgageData) return null;
|
||||
for (let i = activeChartData.length - 1; i >= 0; i--) {
|
||||
const mb = activeChartData[i].mortgage_balance;
|
||||
if (mb !== null && mb !== undefined) return mb;
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
activeChartData,
|
||||
currentMortgageBalance,
|
||||
currencyMode,
|
||||
hasCurrencyToggle,
|
||||
hasMortgageData,
|
||||
]);
|
||||
|
||||
const activeCurrentInvestedAmount = useMemo(() => {
|
||||
if (currencyMode !== 'user' || !hasCurrencyToggle) {
|
||||
return currentInvestedAmount;
|
||||
}
|
||||
for (let i = activeChartData.length - 1; i >= 0; i--) {
|
||||
const ia = activeChartData[i].invested_amount;
|
||||
if (ia !== null && ia !== undefined) return ia;
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
activeChartData,
|
||||
currentInvestedAmount,
|
||||
currencyMode,
|
||||
hasCurrencyToggle,
|
||||
]);
|
||||
|
||||
const activeShortTrend = useMemo(() => {
|
||||
if (currencyMode !== 'user' || !hasCurrencyToggle) return shortTrend;
|
||||
const historicalData = activeChartData.filter((d) => !d.projected);
|
||||
return calculateTrend(historicalData, 1);
|
||||
}, [activeChartData, currencyMode, hasCurrencyToggle, shortTrend]);
|
||||
|
||||
const activeLongTrend = useMemo(() => {
|
||||
if (currencyMode !== 'user' || !hasCurrencyToggle) return longTrend;
|
||||
const historicalData = activeChartData.filter((d) => !d.projected);
|
||||
return calculateTrend(historicalData, historicalData.length - 1);
|
||||
}, [activeChartData, currencyMode, hasCurrencyToggle, longTrend]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onDataLoaded && chartData.length > 0) {
|
||||
if (onDataLoaded && activeChartData.length > 0) {
|
||||
onDataLoaded({
|
||||
chartData,
|
||||
currentBalance,
|
||||
currentMortgageBalance,
|
||||
chartData: activeChartData,
|
||||
currentBalance: activeCurrentBalance,
|
||||
currentMortgageBalance: activeCurrentMortgageBalance,
|
||||
currencyCode: activeCurrencyCode,
|
||||
hasMortgageData,
|
||||
shortTrend,
|
||||
longTrend,
|
||||
shortTrend: activeShortTrend,
|
||||
longTrend: activeLongTrend,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
chartData,
|
||||
currentBalance,
|
||||
currentMortgageBalance,
|
||||
activeChartData,
|
||||
activeCurrentBalance,
|
||||
activeCurrentMortgageBalance,
|
||||
activeCurrencyCode,
|
||||
hasMortgageData,
|
||||
shortTrend,
|
||||
longTrend,
|
||||
activeShortTrend,
|
||||
activeLongTrend,
|
||||
onDataLoaded,
|
||||
]);
|
||||
|
||||
// Convert data for useChartViews hook
|
||||
const { data: hookData, accounts: hookAccounts } = useMemo(() => {
|
||||
return convertSingleAccountData(
|
||||
chartData,
|
||||
activeChartData,
|
||||
account.id,
|
||||
account.type,
|
||||
account.currency_code,
|
||||
activeCurrencyCode,
|
||||
);
|
||||
}, [chartData, account.id, account.type, account.currency_code]);
|
||||
}, [activeChartData, account.id, account.type, activeCurrencyCode]);
|
||||
|
||||
const chartViews = useChartViews({
|
||||
data: hookData,
|
||||
|
|
@ -568,19 +675,19 @@ export function AccountBalanceChart({
|
|||
);
|
||||
|
||||
const valueFormatter = (value: number): string => {
|
||||
return formatChartCurrency(value, account.currency_code, locale);
|
||||
return formatChartCurrency(value, activeCurrencyCode, locale);
|
||||
};
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const minBarWidth = granularity === 'daily' ? 20 : 50;
|
||||
const minChartWidth = chartData.length * minBarWidth;
|
||||
const minChartWidth = activeChartData.length * minBarWidth;
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollLeft =
|
||||
scrollContainerRef.current.scrollWidth;
|
||||
}
|
||||
}, [chartData]);
|
||||
}, [activeChartData]);
|
||||
|
||||
const shortTrendLabel =
|
||||
granularity === 'daily' ? __('today') : __('this month');
|
||||
|
|
@ -600,8 +707,8 @@ export function AccountBalanceChart({
|
|||
? __('No owed amount data available')
|
||||
: __('No balance data available');
|
||||
const currentEquity =
|
||||
hasMortgageData && currentMortgageBalance !== null
|
||||
? currentBalance - currentMortgageBalance
|
||||
hasMortgageData && activeCurrentMortgageBalance !== null
|
||||
? activeCurrentBalance - activeCurrentMortgageBalance
|
||||
: null;
|
||||
|
||||
if (initialLoading || isLoading) {
|
||||
|
|
@ -647,8 +754,8 @@ export function AccountBalanceChart({
|
|||
className="-ml-3 cursor-pointer rounded-md px-2 py-1 text-left text-4xl font-semibold tabular-nums transition-colors hover:bg-muted"
|
||||
>
|
||||
<AmountDisplay
|
||||
amountInCents={currentBalance}
|
||||
currencyCode={account.currency_code}
|
||||
amountInCents={activeCurrentBalance}
|
||||
currencyCode={activeCurrencyCode}
|
||||
minimumFractionDigits={0}
|
||||
maximumFractionDigits={0}
|
||||
/>
|
||||
|
|
@ -663,7 +770,7 @@ export function AccountBalanceChart({
|
|||
>
|
||||
<AmountDisplay
|
||||
amountInCents={currentEquity}
|
||||
currencyCode={account.currency_code}
|
||||
currencyCode={activeCurrencyCode}
|
||||
minimumFractionDigits={0}
|
||||
maximumFractionDigits={0}
|
||||
/>
|
||||
|
|
@ -672,19 +779,19 @@ export function AccountBalanceChart({
|
|||
)}
|
||||
<CardDescription className="flex flex-col gap-1 text-sm">
|
||||
<PercentageTrendIndicator
|
||||
trend={shortTrend?.percentage ?? null}
|
||||
trend={activeShortTrend?.percentage ?? null}
|
||||
label={shortTrendLabel}
|
||||
previousAmount={shortTrend?.previousValue}
|
||||
currentAmount={shortTrend?.currentValue}
|
||||
currencyCode={account.currency_code}
|
||||
previousAmount={activeShortTrend?.previousValue}
|
||||
currentAmount={activeShortTrend?.currentValue}
|
||||
currencyCode={activeCurrencyCode}
|
||||
/>
|
||||
|
||||
<PercentageTrendIndicator
|
||||
trend={longTrend?.percentage ?? null}
|
||||
trend={activeLongTrend?.percentage ?? null}
|
||||
label={longTrendLabel}
|
||||
previousAmount={longTrend?.previousValue}
|
||||
currentAmount={longTrend?.currentValue}
|
||||
currencyCode={account.currency_code}
|
||||
previousAmount={activeLongTrend?.previousValue}
|
||||
currentAmount={activeLongTrend?.currentValue}
|
||||
currencyCode={activeCurrencyCode}
|
||||
/>
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
|
@ -696,9 +803,31 @@ export function AccountBalanceChart({
|
|||
currentView={chartViews.currentView}
|
||||
onViewChange={chartViews.setCurrentView}
|
||||
availableViews={chartViews.availableViews}
|
||||
currencyToggle={
|
||||
hasCurrencyToggle
|
||||
? {
|
||||
value: currencyMode,
|
||||
onValueChange: setCurrencyMode,
|
||||
accountCurrencyCode:
|
||||
account.currency_code,
|
||||
userCurrencyCode:
|
||||
displayCurrencyCode!,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{hasCurrencyToggle && (
|
||||
<ChartCurrencyToggle
|
||||
value={currencyMode}
|
||||
onValueChange={setCurrencyMode}
|
||||
accountCurrencyCode={
|
||||
account.currency_code
|
||||
}
|
||||
userCurrencyCode={displayCurrencyCode!}
|
||||
/>
|
||||
)}
|
||||
<ChartGranularityToggle
|
||||
value={granularity}
|
||||
onValueChange={setGranularity}
|
||||
|
|
@ -728,7 +857,7 @@ export function AccountBalanceChart({
|
|||
{hasMortgageData ? (
|
||||
<ComposedChart
|
||||
accessibilityLayer
|
||||
data={chartData.slice(1)}
|
||||
data={activeChartData.slice(1)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
|
|
@ -788,7 +917,7 @@ export function AccountBalanceChart({
|
|||
) : granularity === 'daily' ? (
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={chartData.slice(1)}
|
||||
data={activeChartData.slice(1)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
|
|
@ -846,7 +975,8 @@ export function AccountBalanceChart({
|
|||
fillOpacity={1}
|
||||
/>
|
||||
{showInvestmentBenefits &&
|
||||
currentInvestedAmount !== null && (
|
||||
activeCurrentInvestedAmount !==
|
||||
null && (
|
||||
<Line
|
||||
dataKey="invested_amount"
|
||||
type="monotone"
|
||||
|
|
@ -860,10 +990,10 @@ export function AccountBalanceChart({
|
|||
)}
|
||||
</AreaChart>
|
||||
) : showInvestmentBenefits &&
|
||||
currentInvestedAmount !== null ? (
|
||||
activeCurrentInvestedAmount !== null ? (
|
||||
<ComposedChart
|
||||
accessibilityLayer
|
||||
data={chartData.slice(1)}
|
||||
data={activeChartData.slice(1)}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
|
|
@ -898,7 +1028,7 @@ export function AccountBalanceChart({
|
|||
) : hasProjectedData ? (
|
||||
<ComposedChart
|
||||
accessibilityLayer
|
||||
data={chartData.slice(1)}
|
||||
data={activeChartData.slice(1)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
|
|
@ -959,7 +1089,7 @@ export function AccountBalanceChart({
|
|||
) : (
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData.slice(1)}
|
||||
data={activeChartData.slice(1)}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
|
|
@ -989,7 +1119,7 @@ export function AccountBalanceChart({
|
|||
{chartViews.currentView === 'mom' && (
|
||||
<MoMChart
|
||||
data={chartViews.deltaSeries}
|
||||
currencyCode={account.currency_code}
|
||||
currencyCode={activeCurrencyCode}
|
||||
xAxisFormatter={formatXAxisLabel}
|
||||
className="h-[300px] w-full"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { SharedData } from '@/types';
|
||||
import {
|
||||
ACCOUNT_TYPES,
|
||||
AREA_UNITS,
|
||||
Account,
|
||||
CURRENCY_OPTIONS,
|
||||
CurrencyOption,
|
||||
PROPERTY_TYPES,
|
||||
balanceTermCapitalized,
|
||||
formatAccountType,
|
||||
|
|
@ -27,6 +28,7 @@ import {
|
|||
type PropertyType,
|
||||
} from '@/types/account';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { BankCombobox } from './bank-combobox';
|
||||
import { CustomBankData, CustomBankForm } from './custom-bank-form';
|
||||
|
|
@ -60,7 +62,7 @@ export interface LoanFormData {
|
|||
|
||||
export interface AccountFormData {
|
||||
displayName: string;
|
||||
bankId: number | null;
|
||||
bankId: string | null;
|
||||
type: AccountType | null;
|
||||
currencyCode: CurrencyCode | null;
|
||||
customBank: CustomBankData | null;
|
||||
|
|
@ -81,6 +83,7 @@ interface AccountFormProps {
|
|||
forceAccountType?: AccountType;
|
||||
hiddenAccountTypes?: AccountType[];
|
||||
availableLoanAccounts?: Account[];
|
||||
usePrimaryCurrenciesOnly?: boolean;
|
||||
onChange: (data: AccountFormData) => void;
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
|
@ -115,13 +118,18 @@ export function AccountForm({
|
|||
forceAccountType,
|
||||
hiddenAccountTypes = [],
|
||||
availableLoanAccounts = [],
|
||||
usePrimaryCurrenciesOnly = false,
|
||||
onChange,
|
||||
errors = {},
|
||||
}: AccountFormProps) {
|
||||
const { currencies } = usePage<SharedData>().props;
|
||||
const currencyOptions = usePrimaryCurrenciesOnly
|
||||
? currencies.profile
|
||||
: currencies.accounts;
|
||||
const [displayName, setDisplayName] = useState(
|
||||
initialValues?.displayName ?? '',
|
||||
);
|
||||
const [selectedBankId, setSelectedBankId] = useState<number | null>(
|
||||
const [selectedBankId, setSelectedBankId] = useState<string | null>(
|
||||
initialValues?.bank?.id ?? null,
|
||||
);
|
||||
const [selectedType, setSelectedType] = useState<AccountType | null>(
|
||||
|
|
@ -318,9 +326,12 @@ export function AccountForm({
|
|||
<SelectValue placeholder={__('Select currency')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CURRENCY_OPTIONS.map((currency) => (
|
||||
<SelectItem key={currency} value={currency}>
|
||||
{currency}
|
||||
{currencyOptions.map((currency: CurrencyOption) => (
|
||||
<SelectItem
|
||||
key={currency.code}
|
||||
value={currency.code}
|
||||
>
|
||||
{currency.code} - {currency.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ interface AccountListCardProps {
|
|||
loading?: boolean;
|
||||
onBalanceUpdated?: () => void;
|
||||
linkedLoanMetrics?: LinkedLoanMetrics;
|
||||
displayCurrencyCode?: string;
|
||||
}
|
||||
|
||||
export function AccountListCard({
|
||||
|
|
@ -40,7 +41,9 @@ export function AccountListCard({
|
|||
loading,
|
||||
onBalanceUpdated,
|
||||
linkedLoanMetrics,
|
||||
displayCurrencyCode,
|
||||
}: AccountListCardProps) {
|
||||
const currencyCode = displayCurrencyCode ?? account.currency_code;
|
||||
const { accountMainLineColor, accountGainLineColor, mortgageLineColor } =
|
||||
useChartColors();
|
||||
const [updateBalanceOpen, setUpdateBalanceOpen] = useState(false);
|
||||
|
|
@ -197,7 +200,7 @@ export function AccountListCard({
|
|||
<div className="-mr-2 px-2 py-1">
|
||||
<AmountDisplay
|
||||
amountInCents={displayBalance}
|
||||
currencyCode={account.currency_code}
|
||||
currencyCode={currencyCode}
|
||||
size="2xl"
|
||||
weight="bold"
|
||||
/>
|
||||
|
|
@ -210,7 +213,7 @@ export function AccountListCard({
|
|||
>
|
||||
<AmountDisplay
|
||||
amountInCents={displayBalance}
|
||||
currencyCode={account.currency_code}
|
||||
currencyCode={currencyCode}
|
||||
size="2xl"
|
||||
weight="bold"
|
||||
/>
|
||||
|
|
@ -224,7 +227,7 @@ export function AccountListCard({
|
|||
previousAmount={displayPreviousBalance}
|
||||
currentAmount={displayBalance}
|
||||
tooltipSide="bottom"
|
||||
currencyCode={account.currency_code}
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -266,7 +269,7 @@ export function AccountListCard({
|
|||
data.value
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -282,7 +285,7 @@ export function AccountListCard({
|
|||
0
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -297,7 +300,7 @@ export function AccountListCard({
|
|||
equity
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -331,7 +334,7 @@ export function AccountListCard({
|
|||
data.value
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -344,7 +347,7 @@ export function AccountListCard({
|
|||
invested
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -366,7 +369,7 @@ export function AccountListCard({
|
|||
gain
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -380,7 +383,7 @@ export function AccountListCard({
|
|||
data.value
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,13 +38,12 @@ export function CreateAccountDialog({
|
|||
const openBankingEnabled = features['open-banking'];
|
||||
const realEstateEnabled = features['real-estate'];
|
||||
const isFreePlan = subscriptionsEnabled && !auth?.hasProPlan;
|
||||
const sharedAccountsList = (sharedAccounts as Account[]) || [];
|
||||
const availableLoanAccounts = useMemo(
|
||||
() =>
|
||||
((sharedAccounts as Account[]) || []).filter(
|
||||
(a) => a.type === 'loan',
|
||||
),
|
||||
() => sharedAccountsList.filter((a) => a.type === 'loan'),
|
||||
[sharedAccounts],
|
||||
);
|
||||
const isFirstAccount = sharedAccountsList.length === 0;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<Mode>(
|
||||
|
|
@ -302,6 +301,7 @@ export function CreateAccountDialog({
|
|||
hiddenAccountTypes={
|
||||
realEstateEnabled ? [] : ['real_estate']
|
||||
}
|
||||
usePrimaryCurrenciesOnly={isFirstAccount}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function EditAccountDialog({
|
|||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const formDataRef = useRef<AccountFormData>({
|
||||
displayName: '',
|
||||
bankId: (account.bank?.id ?? null) as number | null,
|
||||
bankId: account.bank?.id ?? null,
|
||||
type: account.type,
|
||||
currencyCode: account.currency_code,
|
||||
customBank: null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
||||
export type ChartCurrencyMode = 'account' | 'user';
|
||||
|
||||
interface ChartCurrencyToggleProps {
|
||||
value: ChartCurrencyMode;
|
||||
onValueChange: (value: ChartCurrencyMode) => void;
|
||||
accountCurrencyCode: string;
|
||||
userCurrencyCode: string;
|
||||
className?: string;
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
export function ChartCurrencyToggle({
|
||||
value,
|
||||
onValueChange,
|
||||
accountCurrencyCode,
|
||||
userCurrencyCode,
|
||||
className,
|
||||
showTooltip = true,
|
||||
}: ChartCurrencyToggleProps) {
|
||||
const items: Array<{
|
||||
mode: ChartCurrencyMode;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
}> = [
|
||||
{
|
||||
mode: 'account',
|
||||
label: accountCurrencyCode,
|
||||
tooltip: __('Show in account currency (:currency)', {
|
||||
currency: accountCurrencyCode,
|
||||
}),
|
||||
},
|
||||
{
|
||||
mode: 'user',
|
||||
label: userCurrencyCode,
|
||||
tooltip: __('Show in your currency (:currency)', {
|
||||
currency: userCurrencyCode,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={value}
|
||||
onValueChange={(v) => {
|
||||
if (v) onValueChange(v as ChartCurrencyMode);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(className)}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const toggleItem = (
|
||||
<ToggleGroupItem
|
||||
key={item.mode}
|
||||
value={item.mode}
|
||||
aria-label={item.tooltip}
|
||||
className="cursor-pointer px-2 text-xs aria-checked:bg-primary/10"
|
||||
>
|
||||
{item.label}
|
||||
</ToggleGroupItem>
|
||||
);
|
||||
|
||||
if (!showTooltip) {
|
||||
return toggleItem;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip key={item.mode}>
|
||||
<TooltipTrigger asChild>{toggleItem}</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{item.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</ToggleGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,10 @@ import { ChartViewType } from '@/hooks/use-chart-views';
|
|||
import { __ } from '@/utils/i18n';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { Separator } from '../ui/separator';
|
||||
import {
|
||||
type ChartCurrencyMode,
|
||||
ChartCurrencyToggle,
|
||||
} from './chart-currency-toggle';
|
||||
import {
|
||||
type ChartGranularity,
|
||||
ChartGranularityToggle,
|
||||
|
|
@ -24,6 +28,13 @@ interface ToggleOption {
|
|||
onChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface CurrencyToggleConfig {
|
||||
value: ChartCurrencyMode;
|
||||
onValueChange: (value: ChartCurrencyMode) => void;
|
||||
accountCurrencyCode: string;
|
||||
userCurrencyCode: string;
|
||||
}
|
||||
|
||||
interface ChartSettingsPopoverProps {
|
||||
granularity: ChartGranularity;
|
||||
onGranularityChange: (value: ChartGranularity) => void;
|
||||
|
|
@ -35,6 +46,7 @@ interface ChartSettingsPopoverProps {
|
|||
includeLoans?: boolean;
|
||||
onIncludeLoansChange?: (value: boolean) => void;
|
||||
toggles?: ToggleOption[];
|
||||
currencyToggle?: CurrencyToggleConfig;
|
||||
}
|
||||
|
||||
export function ChartSettingsPopover({
|
||||
|
|
@ -48,6 +60,7 @@ export function ChartSettingsPopover({
|
|||
includeLoans,
|
||||
onIncludeLoansChange,
|
||||
toggles = [],
|
||||
currencyToggle,
|
||||
}: ChartSettingsPopoverProps) {
|
||||
// Build the effective list of toggles: legacy loan prop + explicit toggles
|
||||
const allToggles: ToggleOption[] = [];
|
||||
|
|
@ -103,6 +116,26 @@ export function ChartSettingsPopover({
|
|||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
{currencyToggle && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{__('Currency')}
|
||||
</span>
|
||||
<ChartCurrencyToggle
|
||||
value={currencyToggle.value}
|
||||
onValueChange={
|
||||
currencyToggle.onValueChange
|
||||
}
|
||||
accountCurrencyCode={
|
||||
currencyToggle.accountCurrencyCode
|
||||
}
|
||||
userCurrencyCode={
|
||||
currencyToggle.userCurrencyCode
|
||||
}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{allToggles.length > 0 && <Separator />}
|
||||
</>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export { CashflowTrendChart } from './cashflow-trend-chart';
|
||||
export {
|
||||
ChartCurrencyToggle,
|
||||
type ChartCurrencyMode,
|
||||
} from './chart-currency-toggle';
|
||||
export {
|
||||
ChartGranularityToggle,
|
||||
type ChartGranularity,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ interface AccountBalanceCardProps {
|
|||
loading?: boolean;
|
||||
onBalanceUpdated?: () => void;
|
||||
linkedLoanMetrics?: LinkedLoanMetrics;
|
||||
displayCurrencyCode?: string;
|
||||
}
|
||||
|
||||
export function AccountBalanceCard({
|
||||
|
|
@ -40,7 +41,9 @@ export function AccountBalanceCard({
|
|||
loading,
|
||||
onBalanceUpdated,
|
||||
linkedLoanMetrics,
|
||||
displayCurrencyCode,
|
||||
}: AccountBalanceCardProps) {
|
||||
const currencyCode = displayCurrencyCode ?? account.currency_code;
|
||||
const { accountMainLineColor, accountGainLineColor, mortgageLineColor } =
|
||||
useChartColors();
|
||||
const [updateBalanceOpen, setUpdateBalanceOpen] = useState(false);
|
||||
|
|
@ -170,7 +173,7 @@ export function AccountBalanceCard({
|
|||
<div className="-ml-2 px-2 py-1">
|
||||
<AmountDisplay
|
||||
amountInCents={displayBalance}
|
||||
currencyCode={account.currency_code}
|
||||
currencyCode={currencyCode}
|
||||
size="2xl"
|
||||
weight="medium"
|
||||
/>
|
||||
|
|
@ -183,7 +186,7 @@ export function AccountBalanceCard({
|
|||
>
|
||||
<AmountDisplay
|
||||
amountInCents={displayBalance}
|
||||
currencyCode={account.currency_code}
|
||||
currencyCode={currencyCode}
|
||||
size="2xl"
|
||||
weight="medium"
|
||||
/>
|
||||
|
|
@ -197,7 +200,7 @@ export function AccountBalanceCard({
|
|||
previousAmount={displayPreviousBalance}
|
||||
currentAmount={displayBalance}
|
||||
tooltipSide="bottom"
|
||||
currencyCode={account.currency_code}
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-[70px] w-full max-w-[250px] flex-1">
|
||||
|
|
@ -237,7 +240,7 @@ export function AccountBalanceCard({
|
|||
data.value
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -253,7 +256,7 @@ export function AccountBalanceCard({
|
|||
0
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -268,7 +271,7 @@ export function AccountBalanceCard({
|
|||
equity
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -302,7 +305,7 @@ export function AccountBalanceCard({
|
|||
data.value
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -315,7 +318,7 @@ export function AccountBalanceCard({
|
|||
invested
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -337,7 +340,7 @@ export function AccountBalanceCard({
|
|||
gain
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -351,7 +354,7 @@ export function AccountBalanceCard({
|
|||
data.value
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
currencyCode
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ export function StepCreateAccount({
|
|||
type: null,
|
||||
currencyCode: null,
|
||||
customBank: null,
|
||||
balance: null,
|
||||
realEstate: null,
|
||||
loan: null,
|
||||
});
|
||||
|
||||
// Compute cheapest monthly equivalent across all plans
|
||||
|
|
@ -483,7 +486,14 @@ export function StepCreateAccount({
|
|||
autoFocus
|
||||
className="w-full max-w-md space-y-4"
|
||||
>
|
||||
<AccountForm onChange={handleFormChange} />
|
||||
<AccountForm
|
||||
onChange={handleFormChange}
|
||||
usePrimaryCurrenciesOnly={
|
||||
isFirstAccount &&
|
||||
existingAccounts.length === 0 &&
|
||||
createdAccounts.length === 0
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import HeadingSmall from '@/components/heading-small';
|
|||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
|
||||
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
||||
import { BreadcrumbItem } from '@/types';
|
||||
import { BreadcrumbItem, SharedData } from '@/types';
|
||||
import { Account, AccountType } from '@/types/account';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Head, router, usePage } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
|
|
@ -48,6 +48,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export default function AccountsIndex({ accounts, accountMetrics }: Props) {
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
const isLoading = !accountMetrics;
|
||||
|
||||
// Identify loan account IDs that are linked to a real estate account
|
||||
|
|
@ -145,7 +146,7 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) {
|
|||
}, []);
|
||||
|
||||
const handleAccountCreated = useCallback(() => {
|
||||
router.reload({ only: ['accounts'] });
|
||||
router.reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
@ -175,6 +176,7 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) {
|
|||
linkedLoanMetrics={
|
||||
linkedLoanMetricsMap[account.id]
|
||||
}
|
||||
displayCurrencyCode={auth.user.currency_code}
|
||||
/>
|
||||
));
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -338,7 +338,7 @@ export default function AccountShow({
|
|||
currentMortgageBalance={
|
||||
chartComputedData.currentMortgageBalance
|
||||
}
|
||||
currencyCode={account.currency_code}
|
||||
currencyCode={chartComputedData.currencyCode}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -214,6 +214,9 @@ export default function Dashboard() {
|
|||
linkedLoanMetrics={
|
||||
linkedLoanMetricsMap[account.id]
|
||||
}
|
||||
displayCurrencyCode={
|
||||
netWorthEvolution.currency_code
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import { edit as accountEdit } from '@/routes/account';
|
|||
import { disable, enable } from '@/routes/two-factor';
|
||||
import { send } from '@/routes/verification';
|
||||
import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
import { CURRENCY_OPTIONS } from '@/types/account';
|
||||
import { LANGUAGE_OPTIONS } from '@/types/language';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Transition } from '@headlessui/react';
|
||||
|
|
@ -49,7 +48,7 @@ export default function Account({
|
|||
requiresConfirmation?: boolean;
|
||||
twoFactorEnabled?: boolean;
|
||||
}) {
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
const { auth, currencies } = usePage<SharedData>().props;
|
||||
const passwordInput = useRef<HTMLInputElement>(null);
|
||||
const currentPasswordInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
|
|
@ -144,13 +143,14 @@ export default function Account({
|
|||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CURRENCY_OPTIONS.map(
|
||||
{currencies.profile.map(
|
||||
(currency) => (
|
||||
<SelectItem
|
||||
key={currency}
|
||||
value={currency}
|
||||
key={currency.code}
|
||||
value={currency.code}
|
||||
>
|
||||
{currency}
|
||||
{currency.code} -{' '}
|
||||
{currency.name}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
|
|
@ -170,7 +170,9 @@ export default function Account({
|
|||
|
||||
<Select
|
||||
name="locale"
|
||||
defaultValue={auth.user.locale}
|
||||
defaultValue={
|
||||
auth.user.locale ?? undefined
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 w-full">
|
||||
<SelectValue
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ export default function Accounts({ accounts }: AccountsPageProps) {
|
|||
];
|
||||
|
||||
const handleAccountCreated = () => {
|
||||
router.reload({ only: ['accounts'] });
|
||||
router.reload();
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Account>[] = [
|
||||
|
|
|
|||
|
|
@ -26,20 +26,12 @@ export const ACCOUNT_TYPES = [
|
|||
|
||||
export type AccountType = (typeof ACCOUNT_TYPES)[number];
|
||||
|
||||
export const CURRENCY_OPTIONS = [
|
||||
'USD',
|
||||
'EUR',
|
||||
'GBP',
|
||||
'JPY',
|
||||
'CHF',
|
||||
'CAD',
|
||||
'AUD',
|
||||
'CNY',
|
||||
'INR',
|
||||
'MXN',
|
||||
] as const;
|
||||
export type CurrencyCode = string;
|
||||
|
||||
export type CurrencyCode = (typeof CURRENCY_OPTIONS)[number];
|
||||
export interface CurrencyOption {
|
||||
code: CurrencyCode;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Bank {
|
||||
id: UUID;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { InertiaLinkProps } from '@inertiajs/react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { CurrencyCode } from './account';
|
||||
import { CurrencyCode, CurrencyOption } from './account';
|
||||
import { PricingConfig } from './pricing';
|
||||
import { UUID } from './uuid';
|
||||
|
||||
|
|
@ -71,6 +71,10 @@ export interface SharedData {
|
|||
hasEncryptionSetup: boolean;
|
||||
locale: string;
|
||||
translations: Record<string, string>;
|
||||
currencies: {
|
||||
profile: CurrencyOption[];
|
||||
accounts: CurrencyOption[];
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -105,3 +105,18 @@ test('different dates make separate requests', function () {
|
|||
|
||||
Http::assertSentCount(2);
|
||||
});
|
||||
|
||||
test('converts new latam fiat currency using CDN rates', function () {
|
||||
Http::fake([
|
||||
'cdn.jsdelivr.net/*currencies/usd*' => Http::response([
|
||||
'usd' => [
|
||||
'ars' => 1400.0,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = new CurrencyConversionService;
|
||||
$result = $service->convert('ARS', 'USD', 2800.0, '2026-01-15');
|
||||
|
||||
expect($result)->toBe(2.0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Models\Account;
|
|||
use App\Models\AccountBalance;
|
||||
use App\Models\Category;
|
||||
use App\Models\ExchangeRate;
|
||||
use App\Models\RealEstateDetail;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
|
@ -913,3 +914,365 @@ test('net worth daily evolution returns account metadata including bank', functi
|
|||
expect($data['accounts'][$account->id])->toHaveKey('bank');
|
||||
expect($data['accounts'][$account->id]['bank'])->toHaveKeys(['id', 'name', 'logo']);
|
||||
});
|
||||
|
||||
test('account balance evolution includes display_* fields when account currency differs from user currency', function () {
|
||||
// User currency is USD (default from factory)
|
||||
$account = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Checking,
|
||||
'name' => 'EUR Checking',
|
||||
'currency_code' => 'EUR',
|
||||
]);
|
||||
|
||||
$lastMonth = now()->subMonthNoOverflow();
|
||||
$endOfMonth = $lastMonth->copy()->endOfMonth();
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $endOfMonth,
|
||||
'balance' => 100000, // €1,000.00
|
||||
]);
|
||||
|
||||
// Seed exchange rate: 1 USD = 0.90 EUR, so EUR -> USD = 100000 / 0.90
|
||||
ExchangeRate::factory()->create([
|
||||
'base_currency' => 'usd',
|
||||
'date' => $endOfMonth->toDateString(),
|
||||
'rates' => ['eur' => 0.90],
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/balance-evolution?'.http_build_query([
|
||||
'from' => $lastMonth->copy()->startOfMonth()->toDateString(),
|
||||
'to' => $endOfMonth->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
// Original value stays in EUR
|
||||
expect($data['data'][0]['value'])->toBe(100000);
|
||||
// Converted display value in USD
|
||||
expect($data['data'][0])->toHaveKey('display_value');
|
||||
expect($data['data'][0]['display_value'])->toBe((int) round(100000 / 0.90));
|
||||
// Top-level display_currency_code is present
|
||||
expect($data)->toHaveKey('display_currency_code');
|
||||
expect($data['display_currency_code'])->toBe('USD');
|
||||
// Account metadata still shows original currency
|
||||
expect($data['account']['currency_code'])->toBe('EUR');
|
||||
});
|
||||
|
||||
test('account balance evolution does not include display_* fields when currencies match', function () {
|
||||
$account = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Checking,
|
||||
'name' => 'USD Checking',
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => now()->endOfMonth(),
|
||||
'balance' => 100000,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/balance-evolution?'.http_build_query([
|
||||
'from' => now()->startOfMonth()->toDateString(),
|
||||
'to' => now()->endOfMonth()->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
expect($data['data'][0])->not->toHaveKey('display_value');
|
||||
expect($data)->not->toHaveKey('display_currency_code');
|
||||
});
|
||||
|
||||
test('account daily balance evolution includes display_* fields when account currency differs from user currency', function () {
|
||||
$account = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Savings,
|
||||
'name' => 'GBP Savings',
|
||||
'currency_code' => 'GBP',
|
||||
]);
|
||||
|
||||
$today = now();
|
||||
$yesterday = now()->subDays(1);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $yesterday,
|
||||
'balance' => 50000, // £500.00
|
||||
]);
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $today,
|
||||
'balance' => 60000, // £600.00
|
||||
]);
|
||||
|
||||
// Seed exchange rates for both days
|
||||
ExchangeRate::factory()->create([
|
||||
'base_currency' => 'usd',
|
||||
'date' => $yesterday->toDateString(),
|
||||
'rates' => ['gbp' => 0.79],
|
||||
]);
|
||||
ExchangeRate::factory()->create([
|
||||
'base_currency' => 'usd',
|
||||
'date' => $today->toDateString(),
|
||||
'rates' => ['gbp' => 0.80],
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/daily-balance-evolution?'.http_build_query([
|
||||
'from' => $yesterday->toDateString(),
|
||||
'to' => $today->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
// Original values stay in GBP
|
||||
expect($data['data'][0]['value'])->toBe(50000);
|
||||
expect($data['data'][1]['value'])->toBe(60000);
|
||||
|
||||
// Converted display values in USD
|
||||
expect($data['data'][0])->toHaveKey('display_value');
|
||||
expect($data['data'][0]['display_value'])->toBe((int) round(50000 / 0.79));
|
||||
expect($data['data'][1])->toHaveKey('display_value');
|
||||
expect($data['data'][1]['display_value'])->toBe((int) round(60000 / 0.80));
|
||||
|
||||
// Top-level display_currency_code
|
||||
expect($data)->toHaveKey('display_currency_code');
|
||||
expect($data['display_currency_code'])->toBe('USD');
|
||||
expect($data['account']['currency_code'])->toBe('GBP');
|
||||
});
|
||||
|
||||
test('account daily balance evolution does not include display_* fields when currencies match', function () {
|
||||
$account = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Checking,
|
||||
'name' => 'USD Daily',
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => now(),
|
||||
'balance' => 100000,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/daily-balance-evolution?'.http_build_query([
|
||||
'from' => now()->toDateString(),
|
||||
'to' => now()->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
expect($data['data'][0])->not->toHaveKey('display_value');
|
||||
expect($data)->not->toHaveKey('display_currency_code');
|
||||
});
|
||||
|
||||
test('account balance evolution includes display_invested_amount for foreign currency investment accounts', function () {
|
||||
$account = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Investment,
|
||||
'name' => 'EUR Portfolio',
|
||||
'currency_code' => 'EUR',
|
||||
]);
|
||||
|
||||
$lastMonth = now()->subMonthNoOverflow();
|
||||
$endOfMonth = $lastMonth->copy()->endOfMonth();
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $endOfMonth,
|
||||
'balance' => 500000,
|
||||
'invested_amount' => 400000,
|
||||
]);
|
||||
|
||||
ExchangeRate::factory()->create([
|
||||
'base_currency' => 'usd',
|
||||
'date' => $endOfMonth->toDateString(),
|
||||
'rates' => ['eur' => 0.90],
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/balance-evolution?'.http_build_query([
|
||||
'from' => $lastMonth->copy()->startOfMonth()->toDateString(),
|
||||
'to' => $endOfMonth->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
// Original invested_amount in EUR
|
||||
expect($data['data'][0]['invested_amount'])->toBe(400000);
|
||||
// Converted display values
|
||||
expect($data['data'][0])->toHaveKey('display_value');
|
||||
expect($data['data'][0])->toHaveKey('display_invested_amount');
|
||||
expect($data['data'][0]['display_invested_amount'])->toBe((int) round(400000 / 0.90));
|
||||
expect($data['display_currency_code'])->toBe('USD');
|
||||
});
|
||||
|
||||
test('account balance evolution converts mortgage using loan currency when it differs from property currency', function () {
|
||||
// User currency is USD, property is USD, loan is EUR — only the mortgage needs conversion
|
||||
$property = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::RealEstate,
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
$loan = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Loan,
|
||||
'currency_code' => 'EUR',
|
||||
]);
|
||||
|
||||
RealEstateDetail::factory()->create([
|
||||
'account_id' => $property->id,
|
||||
'linked_loan_account_id' => $loan->id,
|
||||
]);
|
||||
|
||||
$lastMonth = now()->subMonthNoOverflow();
|
||||
$endOfMonth = $lastMonth->copy()->endOfMonth();
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $property->id,
|
||||
'balance_date' => $endOfMonth,
|
||||
'balance' => 50000000, // $500,000.00 USD
|
||||
]);
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $loan->id,
|
||||
'balance_date' => $endOfMonth,
|
||||
'balance' => 18000000, // €180,000.00 EUR
|
||||
]);
|
||||
|
||||
// 1 USD = 0.90 EUR, so EUR -> USD = 18000000 / 0.90
|
||||
ExchangeRate::factory()->create([
|
||||
'base_currency' => 'usd',
|
||||
'date' => $endOfMonth->toDateString(),
|
||||
'rates' => ['eur' => 0.90],
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/account/'.$property->id.'/balance-evolution?'.http_build_query([
|
||||
'from' => $lastMonth->copy()->startOfMonth()->toDateString(),
|
||||
'to' => $endOfMonth->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
// Property value stays in USD and no alternate display currency is exposed.
|
||||
expect($data['data'][0]['value'])->toBe(50000000);
|
||||
expect($data['data'][0])->not->toHaveKey('display_value');
|
||||
|
||||
// Mortgage balance is normalized into the property's account currency.
|
||||
expect($data['data'][0]['mortgage_balance'])->toBe((int) round(18000000 / 0.90));
|
||||
expect($data['data'][0])->not->toHaveKey('display_mortgage_balance');
|
||||
expect($data)->not->toHaveKey('display_currency_code');
|
||||
});
|
||||
|
||||
test('account daily balance evolution converts mortgage using loan currency when it differs from property currency', function () {
|
||||
$property = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::RealEstate,
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
$loan = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Loan,
|
||||
'currency_code' => 'EUR',
|
||||
]);
|
||||
|
||||
RealEstateDetail::factory()->create([
|
||||
'account_id' => $property->id,
|
||||
'linked_loan_account_id' => $loan->id,
|
||||
]);
|
||||
|
||||
$today = now();
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $property->id,
|
||||
'balance_date' => $today,
|
||||
'balance' => 50000000,
|
||||
]);
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $loan->id,
|
||||
'balance_date' => $today,
|
||||
'balance' => 18000000,
|
||||
]);
|
||||
|
||||
ExchangeRate::factory()->create([
|
||||
'base_currency' => 'usd',
|
||||
'date' => $today->toDateString(),
|
||||
'rates' => ['eur' => 0.90],
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/account/'.$property->id.'/daily-balance-evolution?'.http_build_query([
|
||||
'from' => $today->toDateString(),
|
||||
'to' => $today->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
expect($data['data'][0]['value'])->toBe(50000000);
|
||||
expect($data['data'][0])->not->toHaveKey('display_value');
|
||||
expect($data['data'][0]['mortgage_balance'])->toBe((int) round(18000000 / 0.90));
|
||||
expect($data['data'][0])->not->toHaveKey('display_mortgage_balance');
|
||||
expect($data)->not->toHaveKey('display_currency_code');
|
||||
});
|
||||
|
||||
test('account balance evolution exposes alternate user-currency mortgage values when property and user currencies differ', function () {
|
||||
$this->user->update(['currency_code' => 'USD']);
|
||||
|
||||
$property = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::RealEstate,
|
||||
'currency_code' => 'EUR',
|
||||
]);
|
||||
$loan = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Loan,
|
||||
'currency_code' => 'GBP',
|
||||
]);
|
||||
|
||||
RealEstateDetail::factory()->create([
|
||||
'account_id' => $property->id,
|
||||
'linked_loan_account_id' => $loan->id,
|
||||
]);
|
||||
|
||||
$endOfMonth = now()->subMonthNoOverflow()->endOfMonth();
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $property->id,
|
||||
'balance_date' => $endOfMonth,
|
||||
'balance' => 50000000,
|
||||
]);
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $loan->id,
|
||||
'balance_date' => $endOfMonth,
|
||||
'balance' => 18000000,
|
||||
]);
|
||||
|
||||
ExchangeRate::factory()->create([
|
||||
'base_currency' => 'eur',
|
||||
'date' => $endOfMonth->toDateString(),
|
||||
'rates' => ['gbp' => 0.80],
|
||||
]);
|
||||
ExchangeRate::factory()->create([
|
||||
'base_currency' => 'usd',
|
||||
'date' => $endOfMonth->toDateString(),
|
||||
'rates' => ['eur' => 0.90, 'gbp' => 0.72],
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/account/'.$property->id.'/balance-evolution?'.http_build_query([
|
||||
'from' => $endOfMonth->copy()->startOfMonth()->toDateString(),
|
||||
'to' => $endOfMonth->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
expect($data['data'][0]['value'])->toBe(50000000);
|
||||
expect($data['data'][0]['display_value'])->toBe((int) round(50000000 / 0.90));
|
||||
expect($data['data'][0]['mortgage_balance'])->toBe((int) round(18000000 / 0.80));
|
||||
expect($data['data'][0]['display_mortgage_balance'])->toBe((int) round(18000000 / 0.72));
|
||||
expect($data['display_currency_code'])->toBe('USD');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,3 +31,18 @@ test('all pages receive app url in shared props', function () {
|
|||
->where('appUrl', config('app.url'))
|
||||
);
|
||||
});
|
||||
|
||||
test('shared currency options split profile and account currencies', function () {
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('currencies.profile.0.code', 'USD')
|
||||
->where('currencies.accounts.0.code', 'USD')
|
||||
);
|
||||
|
||||
$props = $response->viewData('page')['props'];
|
||||
|
||||
expect(collect($props['currencies']['profile'])->pluck('code'))->toContain('ARS');
|
||||
expect(collect($props['currencies']['profile'])->pluck('code'))->not->toContain('BTC');
|
||||
expect(collect($props['currencies']['accounts'])->pluck('code'))->toContain('BTC');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -88,6 +88,47 @@ test('syncs binance balance using USDT fallback conversion', function () {
|
|||
expect($balance->balance)->toBe(90909); // 909.09 EUR * 100
|
||||
});
|
||||
|
||||
test('syncs binance balance for unsupported quote currencies via usd conversion', function () {
|
||||
$user = User::factory()->onboarded()->create(['currency_code' => 'ARS']);
|
||||
$connection = BankingConnection::factory()->binance()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
$account = Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'binance-portfolio',
|
||||
'currency_code' => 'ARS',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
|
||||
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
|
||||
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
|
||||
'api.binance.com/api/v3/account*' => Http::response([
|
||||
'balances' => [
|
||||
['asset' => 'SOL', 'free' => '10.0', 'locked' => '0.0'],
|
||||
],
|
||||
]),
|
||||
'api.binance.com/api/v3/ticker/price' => Http::response([
|
||||
['symbol' => 'SOLUSDT', 'price' => '100.00'],
|
||||
]),
|
||||
'cdn.jsdelivr.net/*currencies/ars*' => Http::response([
|
||||
'ars' => [
|
||||
'usd' => 0.0007142857,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$client = new BinanceClient('test-key', 'test-secret');
|
||||
$service = app(BinanceBalanceSyncService::class);
|
||||
$service->sync($account, $client);
|
||||
|
||||
expect($account->balances()->count())->toBe(1);
|
||||
|
||||
$balance = $account->balances()->first();
|
||||
expect($balance->balance)->toBe((int) round((1000 / 0.0007142857) * 100));
|
||||
});
|
||||
|
||||
test('handles USD stablecoins as 1:1 when target is USD', function () {
|
||||
$user = User::factory()->onboarded()->create(['currency_code' => 'USD']);
|
||||
$connection = BankingConnection::factory()->binance()->create([
|
||||
|
|
|
|||
|
|
@ -125,6 +125,74 @@ test('syncs bitpanda balance with crypto only', function () {
|
|||
expect($balance->balance)->toBe(1000000);
|
||||
});
|
||||
|
||||
test('syncs bitpanda balance by converting fiat wallets to newly allowed primary currency', function () {
|
||||
$user = User::factory()->onboarded()->create(['currency_code' => 'ARS']);
|
||||
$connection = BankingConnection::factory()->bitpanda()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
$account = Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'bitpanda-portfolio',
|
||||
'currency_code' => 'ARS',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'api.bitpanda.com/v1/ticker' => Http::response([
|
||||
'BTC' => ['ARS' => '70000000.00'],
|
||||
]),
|
||||
'api.bitpanda.com/v1/wallets' => Http::response([
|
||||
'data' => [
|
||||
[
|
||||
'type' => 'wallet',
|
||||
'attributes' => [
|
||||
'cryptocoin_id' => '1',
|
||||
'cryptocoin_symbol' => 'BTC',
|
||||
'balance' => '0.01000000',
|
||||
'is_default' => true,
|
||||
'name' => 'BTC wallet',
|
||||
'deleted' => false,
|
||||
],
|
||||
'id' => 'wallet-uuid-1',
|
||||
],
|
||||
],
|
||||
]),
|
||||
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([
|
||||
'data' => [],
|
||||
'meta' => ['next_cursor' => null],
|
||||
'links' => [],
|
||||
]),
|
||||
'api.bitpanda.com/v1/fiatwallets' => Http::response([
|
||||
'data' => [
|
||||
[
|
||||
'type' => 'fiat_wallet',
|
||||
'attributes' => [
|
||||
'fiat_id' => '1',
|
||||
'fiat_symbol' => 'USD',
|
||||
'balance' => '500.00000000',
|
||||
'name' => 'USD Wallet',
|
||||
],
|
||||
'id' => 'fiat-wallet-uuid-1',
|
||||
],
|
||||
],
|
||||
]),
|
||||
'cdn.jsdelivr.net/*currencies/ars*' => Http::response([
|
||||
'ars' => [
|
||||
'usd' => 0.0007142857,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$client = new BitpandaClient('test-key');
|
||||
$service = app(BitpandaBalanceSyncService::class);
|
||||
$service->sync($account, $client);
|
||||
|
||||
expect($account->balances()->count())->toBe(1);
|
||||
|
||||
$balance = $account->balances()->first();
|
||||
expect($balance->balance)->toBe((int) round((700000 + (500 / 0.0007142857)) * 100));
|
||||
});
|
||||
|
||||
test('syncs bitpanda balance including bitpanda-specific indices', function () {
|
||||
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
|
||||
$connection = BankingConnection::factory()->bitpanda()->create([
|
||||
|
|
@ -618,7 +686,7 @@ test('only counts finished fiat transactions for invested_amount', function () {
|
|||
expect($balance->invested_amount)->toBe(100000); // 1000 EUR → 100000 cents
|
||||
});
|
||||
|
||||
test('only counts fiat transactions matching target currency for invested_amount', function () {
|
||||
test('converts bitpanda fiat transactions that do not match the target currency for invested_amount', function () {
|
||||
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
|
||||
$connection = BankingConnection::factory()->bitpanda()->create([
|
||||
'user_id' => $user->id,
|
||||
|
|
@ -673,6 +741,11 @@ test('only counts fiat transactions matching target currency for invested_amount
|
|||
'links' => [],
|
||||
]),
|
||||
'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]),
|
||||
'cdn.jsdelivr.net/*currencies/eur*' => Http::response([
|
||||
'eur' => [
|
||||
'usd' => 1.10,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$client = new BitpandaClient('test-key');
|
||||
|
|
@ -680,9 +753,69 @@ test('only counts fiat transactions matching target currency for invested_amount
|
|||
$service->sync($account, $client);
|
||||
|
||||
$balance = $account->balances()->first();
|
||||
// Only EUR transactions should count: 1000 + 250 = 1250 EUR → 125000 cents
|
||||
// The 500 USD deposit should be excluded
|
||||
expect($balance->invested_amount)->toBe(125000);
|
||||
// 1000 EUR + 250 EUR + (500 USD / 1.10) = 1704.55 EUR → 170455 cents
|
||||
expect($balance->invested_amount)->toBe(170455);
|
||||
});
|
||||
|
||||
test('converts bitpanda fiat transactions to newly allowed primary currency for invested amount', function () {
|
||||
$user = User::factory()->onboarded()->create(['currency_code' => 'ARS']);
|
||||
$connection = BankingConnection::factory()->bitpanda()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
$account = Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'bitpanda-portfolio',
|
||||
'currency_code' => 'ARS',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'api.bitpanda.com/v1/ticker' => Http::response([]),
|
||||
'api.bitpanda.com/v1/wallets' => Http::response(['data' => []]),
|
||||
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::sequence()
|
||||
->push([
|
||||
'data' => [
|
||||
[
|
||||
'type' => 'fiat_wallet_transaction',
|
||||
'id' => 'tx-1',
|
||||
'attributes' => [
|
||||
'amount' => '1000.00',
|
||||
'status' => 'finished',
|
||||
'fiat_id' => 'USD',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'fiat_wallet_transaction',
|
||||
'id' => 'tx-2',
|
||||
'attributes' => [
|
||||
'amount' => '250.00',
|
||||
'status' => 'finished',
|
||||
'fiat_id' => 'ARS',
|
||||
],
|
||||
],
|
||||
],
|
||||
'meta' => ['next_cursor' => null],
|
||||
'links' => [],
|
||||
])
|
||||
->push([
|
||||
'data' => [],
|
||||
'meta' => ['next_cursor' => null],
|
||||
'links' => [],
|
||||
]),
|
||||
'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]),
|
||||
'cdn.jsdelivr.net/*currencies/ars*' => Http::response([
|
||||
'ars' => [
|
||||
'usd' => 0.0007142857,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$client = new BitpandaClient('test-key');
|
||||
$service = app(BitpandaBalanceSyncService::class);
|
||||
$service->sync($account, $client);
|
||||
|
||||
$balance = $account->balances()->first();
|
||||
expect($balance->invested_amount)->toBe((int) round(((1000 / 0.0007142857) + 250) * 100));
|
||||
});
|
||||
|
||||
test('returns null invested_amount when no fiat transactions exist', function () {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,63 @@ it('validates currency_code must be in allowed list', function () {
|
|||
$response->assertSessionHasErrors(['currency_code']);
|
||||
});
|
||||
|
||||
it('accepts new latam currency when creating account', function () {
|
||||
actingAs($this->user);
|
||||
|
||||
$response = $this->post(route('accounts.store'), [
|
||||
'name' => 'Argentina Account',
|
||||
'bank_id' => $this->bank->id,
|
||||
'currency_code' => 'ARS',
|
||||
'type' => AccountType::Checking->value,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
assertDatabaseHas('accounts', [
|
||||
'user_id' => $this->user->id,
|
||||
'bank_id' => $this->bank->id,
|
||||
'currency_code' => 'ARS',
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts bitcoin when creating account', function () {
|
||||
actingAs($this->user);
|
||||
|
||||
Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'bank_id' => $this->bank->id,
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
|
||||
$response = $this->post(route('accounts.store'), [
|
||||
'name' => 'Bitcoin Account',
|
||||
'bank_id' => $this->bank->id,
|
||||
'currency_code' => 'BTC',
|
||||
'type' => AccountType::Investment->value,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
assertDatabaseHas('accounts', [
|
||||
'user_id' => $this->user->id,
|
||||
'bank_id' => $this->bank->id,
|
||||
'currency_code' => 'BTC',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects bitcoin when creating a first account', function () {
|
||||
actingAs($this->user);
|
||||
|
||||
$response = $this->post(route('accounts.store'), [
|
||||
'name' => 'Bitcoin Account',
|
||||
'bank_id' => $this->bank->id,
|
||||
'currency_code' => 'BTC',
|
||||
'type' => AccountType::Investment->value,
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['currency_code']);
|
||||
});
|
||||
|
||||
it('validates type must be valid AccountType', function () {
|
||||
actingAs($this->user);
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,38 @@ test('profile information can be updated', function () {
|
|||
expect($user->currency_code)->toBe('EUR');
|
||||
});
|
||||
|
||||
test('profile accepts new latam primary currency', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch(route('profile.update'), [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'currency_code' => 'ARS',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('account.edit'));
|
||||
|
||||
expect($user->refresh()->currency_code)->toBe('ARS');
|
||||
});
|
||||
|
||||
test('profile rejects bitcoin as primary currency', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch(route('profile.update'), [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'currency_code' => 'BTC',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['currency_code']);
|
||||
});
|
||||
|
||||
test('email verification status is unchanged when the email address is unchanged', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue