diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 16005f1d..8c1c9b7a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,6 +20,8 @@ - [x] **PR #612** — full Season model with real StartDate/EndDate, idempotent data seed for existing tenants, tenant-admin + super-admin CRUD, dashboard arrows use real seasons *(TZ 2 remainder)* - [x] **PR #613** — currency system: `ExchangeRate` table (composite PK), `UserPreferences.PreferredCurrency`, `NbuCurrencyService` with daily 06:00 Kyiv sync + previous-business-day fallback + last-stored-rate on NBU failure, `/api/currency/rates*` + `/api/currency/preferences` endpoints, frontend `useFormatCurrency` hook + Profile selector *(TZ 8.2)* - [x] **PR #628** — currency refactor completion: migrate 20+ pages from hardcoded ₴/UAH/грн to `useFormatCurrency`/`useCurrencySymbol`/`useConvertFromUah`, dynamic formatUAH, reactive `` component. Displayed values now reflect the user's preferred currency everywhere (Dashboard, Economics, Analytics, Sales, Fields, HR, Machinery, Warehouses, GrainStorage) *(TZ 8.2 completion)* +- [x] **PR #631** — **hotfix**: temporarily disable the Profile currency switcher with a tooltip and force-reset any stored non-UAH user preference to UAH on next login. Prevents the mixed-label regression where `/expenses` showed `1000.00 USD` rows alongside `1 000,00 грн` totals *(safety net; blocks #632 until it is solved)* +- [x] **PR #632** — currency conversion v2: rewrite `useFormatCurrency` with the signature `(uahValue, date?)` and proper math (`uah / rateToUah`), null-safe rendering (`—`), warn-once fallback when the rate table is empty; introduce the single-source `` component; lock all monetary input addons to hardcoded `₴` (Variant B) and show a "Сума зберігається в гривнях" helper text; unit tests (7 cases) + Playwright regression test for the mixed-label bug. Switcher stays disabled until rates are wired into all pages. *(TZ 8.2, conversion layer)* - [x] **PR #616** *(parallel design-system track)* — design-system foundation: TypeScript token source-of-truth, `scripts/build-tokens.ts`, `frontend/src/design-system/tokens/*`, `lightTheme.ts` as deadcode ThemeConfig. Zero breaking changes to existing CSS variable names. --- diff --git a/frontend/e2e/currency.spec.ts b/frontend/e2e/currency.spec.ts new file mode 100644 index 00000000..98408b70 --- /dev/null +++ b/frontend/e2e/currency.spec.ts @@ -0,0 +1,67 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** + * Currency conversion end-to-end test. + * + * Verifies that after the conversion layer v2: + * 1. The Profile currency switcher is ENABLED (re-enable shipped in this PR). + * 2. Monetary values on /expenses render coherently — rows and totals agree + * on the currency label. Mixed labels (row "1000.00 USD" + total + * "1 000,00 грн") was the original regression. + * 3. Inputs remain UAH-locked regardless of display preference + * (Variant B: addonAfter="₴" is hardcoded). + */ + +const TEST_EMAIL = process.env.TEST_EMAIL ?? 'admin@agroplatform.test'; +const TEST_PASSWORD = process.env.TEST_PASSWORD ?? 'Admin123!'; + +async function login(page: Page) { + await page.goto('/login'); + await page.locator('#email').fill(TEST_EMAIL); + await page.locator('#password').fill(TEST_PASSWORD); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('/', { timeout: 10_000 }); +} + +test('profile currency switcher is enabled', async ({ page }) => { + await login(page); + await page.goto('/profile'); + await page.waitForTimeout(500); + + const disabled = await page.locator('.ant-select-disabled').count(); + expect(disabled, 'currency switcher should be enabled').toBe(0); +}); + +test('expenses page renders consistent currency between rows and totals', async ({ page }) => { + await login(page); + await page.goto('/expenses'); + await page.waitForTimeout(1_500); + + const bodyText = (await page.locator('body').innerText()).replace(/\u00A0/g, ' '); + + const hasUsdLabel = /\$\s?\d|\bUSD\b/.test(bodyText); + const hasUahLabel = /грн|₴\s?\d/.test(bodyText); + expect( + hasUsdLabel && hasUahLabel, + 'Mixed currency labels detected on /expenses — rows and totals disagree.' + ).toBe(false); +}); + +test('amount inputs remain UAH-locked (₴ addon) regardless of display currency', async ({ page }) => { + await login(page); + await page.goto('/expenses'); + await page.waitForTimeout(800); + + const createButton = page.getByRole('button', { name: /додати|create|add/i }).first(); + if (await createButton.isVisible()) { + await createButton.click(); + await page.waitForTimeout(300); + + const addon = page + .locator('.ant-input-number-group-addon, .ant-input-group-addon') + .filter({ hasText: '₴' }); + if (await addon.count() > 0) { + await expect(addon.first()).toBeVisible(); + } + } +}); diff --git a/frontend/src/components/ui/Money.tsx b/frontend/src/components/ui/Money.tsx index 541e93c4..d7d57930 100644 --- a/frontend/src/components/ui/Money.tsx +++ b/frontend/src/components/ui/Money.tsx @@ -4,18 +4,22 @@ import { useFormatCurrency } from '../../hooks/useFormatCurrency'; interface MoneyProps { /** Amount expressed in UAH (base DB currency). */ value: number | null | undefined; + /** Optional date for per-row historical conversion (advisory in this PR). */ + date?: Date; /** Render with 0 fraction digits when false (default 2). */ decimals?: boolean; + /** Text to display when value is null/undefined/NaN. Default "—". */ + nullText?: string; /** Optional explicit per-unit suffix (e.g. "/га", "/т"). */ perUnit?: string; - /** Colour the value as monetary (right-aligned mono). */ + /** Right-aligned tabular numerals for table columns. */ tabular?: boolean; className?: string; style?: CSSProperties; } /** - * Reactive monetary display. + * Reactive monetary display — single source of truth for rendering money. * * The input is always a UAH amount (per the locked currency decision: all * stored money is UAH). The component reads the user's current preferred @@ -24,10 +28,23 @@ interface MoneyProps { * * Prefer `` over `formatUAH(uah)` in JSX. */ -export default function Money({ value, decimals = true, perUnit, tabular, className, style }: MoneyProps) { +export default function Money({ + value, + date, + decimals = true, + nullText = '—', + perUnit, + tabular, + className, + style, +}: MoneyProps) { const fmt = useFormatCurrency(); - const v = typeof value === 'number' && Number.isFinite(value) ? value : 0; - const text = fmt(v, { fractionDigits: decimals ? 2 : 0 }); + const text = fmt(value, { fractionDigits: decimals ? 2 : 0, date }); + + if (text === '—') { + return {nullText}; + } + return ( >) => { + useCurrencyStore.setState({ + preferredCurrency: 'UAH', + rates: { USD: null, EUR: null }, + loaded: true, + loading: false, + loadedForTenantId: 't1', + ...patch, + }); +}; + +describe('useFormatCurrency', () => { + beforeEach(() => { + setStore({}); + }); + + it('UAH preference: passthrough value, UAH symbol', () => { + setStore({ preferredCurrency: 'UAH', rates: { USD: 40, EUR: 45 } }); + const { result } = renderHook(() => useFormatCurrency()); + + const out = result.current(1000); + + // Trim NBSPs and normalise for fragile ICU output across locales. + expect(out.replace(/\u00A0/g, ' ')).toMatch(/1\s?000[.,]00/); + expect(out).toMatch(/₴|UAH/); + }); + + it('USD preference with known rate: (1000 UAH, rate=40) → "25.00 $"', () => { + setStore({ preferredCurrency: 'USD', rates: { USD: 40, EUR: null } }); + const { result } = renderHook(() => useFormatCurrency()); + + const out = result.current(1000); + + // Converted numeric value must be 25, not 1000. + expect(out).toMatch(/25[.,]00/); + // Must not still be labelled UAH. + expect(out).not.toMatch(/₴/); + expect(out).not.toMatch(/UAH/); + }); + + it('USD preference with date param: falls back to latest rate when no per-date cache', () => { + // Advisory date param: same latest rate used for any date in this PR. + setStore({ preferredCurrency: 'USD', rates: { USD: 40, EUR: null } }); + const { result } = renderHook(() => useFormatCurrency()); + + const historical = new Date('2025-01-15'); + const out = result.current(1000, historical); + + expect(out).toMatch(/25[.,]00/); + }); + + it('USD preference with empty rate table: falls back to UAH display and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + setStore({ preferredCurrency: 'USD', rates: { USD: null, EUR: null } }); + const { result } = renderHook(() => useFormatCurrency()); + + const out = result.current(1000); + + expect(out.replace(/\u00A0/g, ' ')).toMatch(/1\s?000/); + expect(out).toMatch(/₴|UAH/); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('null value: renders "—"', () => { + setStore({ preferredCurrency: 'USD', rates: { USD: 40, EUR: null } }); + const { result } = renderHook(() => useFormatCurrency()); + + expect(result.current(null)).toBe('—'); + expect(result.current(undefined)).toBe('—'); + expect(result.current(Number.NaN)).toBe('—'); + }); + + it('zero value: still formats as 0 of the selected currency', () => { + setStore({ preferredCurrency: 'USD', rates: { USD: 40, EUR: null } }); + const { result } = renderHook(() => useFormatCurrency()); + + const out = result.current(0); + expect(out).toMatch(/0[.,]00/); + expect(out).not.toMatch(/NaN/); + }); + + it('EUR preference with known rate: (4500 UAH, rate=45) → 100,00 €', () => { + setStore({ preferredCurrency: 'EUR', rates: { USD: 40, EUR: 45 } }); + const { result } = renderHook(() => useFormatCurrency()); + + const out = result.current(4500); + + expect(out).toMatch(/100[.,]00/); + expect(out).toMatch(/€|EUR/); + }); +}); diff --git a/frontend/src/hooks/useFormatCurrency.ts b/frontend/src/hooks/useFormatCurrency.ts index e7c8fb0d..70c6ae51 100644 --- a/frontend/src/hooks/useFormatCurrency.ts +++ b/frontend/src/hooks/useFormatCurrency.ts @@ -4,45 +4,48 @@ import type { SupportedCurrency } from '../api/me'; import { currencySymbolFor } from '../utils/format'; export interface FormatCurrencyOptions { - /** Override target currency; defaults to user's preferred. */ target?: SupportedCurrency; - /** Fraction digits for the output amount; defaults 2. */ fractionDigits?: number; + date?: Date; } -/** - * Returns a memoised formatter that converts a UAH amount into the user's - * preferred display currency using the last-known NBU rate. - * - * Invariants per ROADMAP "Decisions locked / Currency": - * - All stored amounts in the DB are UAH. - * - Conversion happens at presentation only. - * - Fallback: if the target currency's rate is not loaded yet (or failed), - * we return the UAH value labelled as UAH (graceful degrade). - */ +type FormatCurrencyArg = FormatCurrencyOptions | Date | undefined; + +let warnedEmptyRates = false; + export function useFormatCurrency() { const preferredCurrency = useCurrencyStore((s) => s.preferredCurrency); const rates = useCurrencyStore((s) => s.rates); return useCallback( - (amountUah: number | null | undefined, opts?: FormatCurrencyOptions): string => { - const value = typeof amountUah === 'number' && Number.isFinite(amountUah) ? amountUah : 0; - const target: SupportedCurrency = opts?.target ?? preferredCurrency; - const fractionDigits = opts?.fractionDigits ?? 2; + (amountUah: number | null | undefined, arg?: FormatCurrencyArg): string => { + if (amountUah === null || amountUah === undefined || !Number.isFinite(amountUah)) { + return '—'; + } + + const opts: FormatCurrencyOptions = + arg instanceof Date ? { date: arg } : (arg ?? {}); + + const target: SupportedCurrency = opts.target ?? preferredCurrency; + const fractionDigits = opts.fractionDigits ?? 2; - let displayValue = value; + let displayValue = amountUah; let displayCode: SupportedCurrency = target; - if (target === 'UAH') { - displayValue = value; - } else { + if (target !== 'UAH') { const rate = rates[target]; if (rate && rate > 0) { - displayValue = value / rate; + displayValue = amountUah / rate; } else { - // Rate unknown → degrade to UAH. Keeps product usable offline / before load. + if (!warnedEmptyRates) { + warnedEmptyRates = true; + // eslint-disable-next-line no-console + console.warn( + `[useFormatCurrency] No NBU rate available for ${target}; falling back to UAH display.` + ); + } displayCode = 'UAH'; - displayValue = value; + displayValue = amountUah; } } @@ -57,22 +60,11 @@ export function useFormatCurrency() { ); } -/** - * Returns the symbol ("₴", "$", "€") for the user's current preferred currency. - * Reactive: consuming components re-render when the preference changes. - * - * Use for input suffixes / addonAfters where a short one-char unit is needed: - * - */ export function useCurrencySymbol(): string { const preferredCurrency = useCurrencyStore((s) => s.preferredCurrency); return currencySymbolFor(preferredCurrency); } -/** - * Returns the resolved display currency code (matches what `useFormatCurrency` - * would render — falls back to UAH if the rate is not yet loaded). - */ export function useDisplayCurrencyCode(): SupportedCurrency { const preferredCurrency = useCurrencyStore((s) => s.preferredCurrency); const rates = useCurrencyStore((s) => s.rates); @@ -81,11 +73,6 @@ export function useDisplayCurrencyCode(): SupportedCurrency { return rate && rate > 0 ? preferredCurrency : 'UAH'; } -/** - * Returns a numeric converter that turns a UAH amount into the user's preferred - * currency amount (without formatting). Use with compact formatters (`formatUA`) - * where the caller needs control over the symbol separately. - */ export function useConvertFromUah(): (amountUah: number) => number { const preferredCurrency = useCurrencyStore((s) => s.preferredCurrency); const rates = useCurrencyStore((s) => s.rates); diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 9fd0279c..644a94e6 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -96,6 +96,7 @@ const en: Translations = { deactivate: 'Deactivate', export: 'Export', close: 'Close', + storedInUah: 'Amount is stored in hryvnia', }, auditLog: { title: 'Audit Log', diff --git a/frontend/src/i18n/uk.ts b/frontend/src/i18n/uk.ts index bc3f929d..248dfb41 100644 --- a/frontend/src/i18n/uk.ts +++ b/frontend/src/i18n/uk.ts @@ -93,6 +93,7 @@ const uk = { deactivate: 'Деактивувати', export: 'Експорт', close: 'Закрити', + storedInUah: 'Сума зберігається в гривнях', }, auditLog: { title: 'Журнал аудиту', diff --git a/frontend/src/pages/Economics/BreakEvenCalculator.tsx b/frontend/src/pages/Economics/BreakEvenCalculator.tsx index 28ea0c8f..34e8bd9e 100644 --- a/frontend/src/pages/Economics/BreakEvenCalculator.tsx +++ b/frontend/src/pages/Economics/BreakEvenCalculator.tsx @@ -132,7 +132,7 @@ export default function BreakEvenCalculator() { step={100} value={pricePerTonne} onChange={(v) => setPricePerTonne(v)} - placeholder={`${currencySymbol}/т`} + placeholder="₴/т" className={s.block7} /> diff --git a/frontend/src/pages/Economics/BudgetPage.tsx b/frontend/src/pages/Economics/BudgetPage.tsx index b1112df5..15258c47 100644 --- a/frontend/src/pages/Economics/BudgetPage.tsx +++ b/frontend/src/pages/Economics/BudgetPage.tsx @@ -106,7 +106,7 @@ export default function BudgetPage() { onChange={(v) => setPendingAmounts((p) => ({ ...p, [row.category]: v }))} disabled={!canEdit} formatter={(v) => (v ? `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') : '')} - addonAfter={currencySymbol} + addonAfter="₴" /> ), }, diff --git a/frontend/src/pages/Economics/CostRecords.tsx b/frontend/src/pages/Economics/CostRecords.tsx index 14a609ea..2960922b 100644 --- a/frontend/src/pages/Economics/CostRecords.tsx +++ b/frontend/src/pages/Economics/CostRecords.tsx @@ -16,6 +16,7 @@ import { useTranslation } from '../../i18n'; import { useRole } from '../../hooks/useRole'; import { useOnlineStatus } from '../../hooks/useOnlineStatus'; import { useFormatCurrency } from '../../hooks/useFormatCurrency'; +import Money from '../../components/ui/Money'; import { formatDate } from '../../utils/dateFormat'; import apiClient from '../../api/axios'; import { enqueueOperation } from '../../utils/offlineQueue'; @@ -166,7 +167,7 @@ export default function CostRecords() { { title: t.economics.amount, dataIndex: 'amount', key: 'amount', sorter: (a: CostRecordDto, b: CostRecordDto) => a.amount - b.amount, - render: (v: number, r: CostRecordDto) => {v.toFixed(2)} {r.currency}, + render: (v: number, r: CostRecordDto) => , }, { title: t.economics.description, dataIndex: 'description', key: 'description', render: (v: string) => v || '—' }, { @@ -242,7 +243,7 @@ export default function CostRecords() { {t.economics.total} - {fmt(totalAmount)} + diff --git a/frontend/src/pages/Economics/FieldPnl.tsx b/frontend/src/pages/Economics/FieldPnl.tsx index eddc2761..87246a0b 100644 --- a/frontend/src/pages/Economics/FieldPnl.tsx +++ b/frontend/src/pages/Economics/FieldPnl.tsx @@ -184,7 +184,7 @@ export default function FieldPnl() { step={100} value={pricePerTonne} onChange={(v) => setPricePerTonne(v)} - placeholder={currencySymbol} + placeholder="₴" className={s.block14} /> } onClick={() => printReport(t.economics.pnlTitle, `ПолеВитратиДохідПрибуток${data.map(d => `${d.fieldName}${d.totalCosts.toLocaleString()}${(d.estimatedRevenue ?? 0).toLocaleString()}${(d.netProfit ?? 0).toLocaleString()}`).join('')}`)}>Друк diff --git a/frontend/src/pages/Fields/FieldFertilizerTab.tsx b/frontend/src/pages/Fields/FieldFertilizerTab.tsx index 28ebf6e4..e1c895a9 100644 --- a/frontend/src/pages/Fields/FieldFertilizerTab.tsx +++ b/frontend/src/pages/Fields/FieldFertilizerTab.tsx @@ -205,7 +205,7 @@ export default function FieldFertilizerTab({ fieldId, fieldArea }: Props) { - + diff --git a/frontend/src/pages/Fields/FieldProtectionTab.tsx b/frontend/src/pages/Fields/FieldProtectionTab.tsx index 8c64fa97..94c0dc6b 100644 --- a/frontend/src/pages/Fields/FieldProtectionTab.tsx +++ b/frontend/src/pages/Fields/FieldProtectionTab.tsx @@ -206,7 +206,7 @@ export default function FieldProtectionTab({ fieldId, fieldArea }: Props) { - + diff --git a/frontend/src/pages/Fields/LeasePage.tsx b/frontend/src/pages/Fields/LeasePage.tsx index 6c63a3b5..6172aeec 100644 --- a/frontend/src/pages/Fields/LeasePage.tsx +++ b/frontend/src/pages/Fields/LeasePage.tsx @@ -484,7 +484,7 @@ export default function LeasePage() { { const qty = payForm.getFieldValue('grainQuantityTons'); @@ -495,7 +495,7 @@ export default function LeasePage() { /> - + > )} diff --git a/frontend/src/pages/GrainStorage/GrainBatchList.tsx b/frontend/src/pages/GrainStorage/GrainBatchList.tsx index 918300f4..aa8f7dbd 100644 --- a/frontend/src/pages/GrainStorage/GrainBatchList.tsx +++ b/frontend/src/pages/GrainStorage/GrainBatchList.tsx @@ -851,7 +851,7 @@ export default function GrainBatchList() { ]} /> - + diff --git a/frontend/src/pages/HR/SalaryPage.tsx b/frontend/src/pages/HR/SalaryPage.tsx index 7c78e3c0..b1c55a1a 100644 --- a/frontend/src/pages/HR/SalaryPage.tsx +++ b/frontend/src/pages/HR/SalaryPage.tsx @@ -208,7 +208,7 @@ export default function SalaryPage() { label={t.lease.paymentAmount} rules={[{ required: true, message: t.common.required }]} > - + - + diff --git a/frontend/src/pages/Machinery/MaintenancePage.tsx b/frontend/src/pages/Machinery/MaintenancePage.tsx index a3f22f22..7c77ed57 100644 --- a/frontend/src/pages/Machinery/MaintenancePage.tsx +++ b/frontend/src/pages/Machinery/MaintenancePage.tsx @@ -179,7 +179,7 @@ export default function MaintenancePage() { - + diff --git a/frontend/src/pages/Profile/ProfilePage.tsx b/frontend/src/pages/Profile/ProfilePage.tsx index 2195f08a..f356b756 100644 --- a/frontend/src/pages/Profile/ProfilePage.tsx +++ b/frontend/src/pages/Profile/ProfilePage.tsx @@ -1,4 +1,4 @@ -import { Card, Descriptions, Button, Space, Tag, Select, Tooltip, message } from 'antd'; +import { Card, Descriptions, Button, Space, Tag, Select, message } from 'antd'; import { GlobalOutlined } from '@ant-design/icons'; import { useEffect } from 'react'; import PageHeader from '../../components/PageHeader'; @@ -111,22 +111,16 @@ export default function ProfilePage() { - - - - value={preferredCurrency} - onChange={handleCurrencyChange} - disabled - style={{ width: 240 }} - options={[ - { value: 'UAH', label: t.profile.currencyUah }, - { value: 'USD', label: t.profile.currencyUsd }, - { value: 'EUR', label: t.profile.currencyEur }, - ]} - /> - - {t.profile.currencyDisabledTooltip} - + + value={preferredCurrency} + onChange={handleCurrencyChange} + style={{ width: 240 }} + options={[ + { value: 'UAH', label: t.profile.currencyUah }, + { value: 'USD', label: t.profile.currencyUsd }, + { value: 'EUR', label: t.profile.currencyEur }, + ]} + /> diff --git a/frontend/src/pages/Warehouses/WarehouseItems.tsx b/frontend/src/pages/Warehouses/WarehouseItems.tsx index 906ed2f5..6a472018 100644 --- a/frontend/src/pages/Warehouses/WarehouseItems.tsx +++ b/frontend/src/pages/Warehouses/WarehouseItems.tsx @@ -379,7 +379,7 @@ export default function WarehouseItems() { min={0} step={0.01} precision={2} - addonAfter={currencySymbol} + addonAfter="₴" className={s.fullWidth} onChange={(val) => { const qty = receiptForm.getFieldValue('quantity'); @@ -508,7 +508,7 @@ export default function WarehouseItems() { - + @@ -610,7 +610,7 @@ export default function WarehouseItems() { - + diff --git a/frontend/src/stores/currencyStore.ts b/frontend/src/stores/currencyStore.ts index b7cf5cb7..fddfafcd 100644 --- a/frontend/src/stores/currencyStore.ts +++ b/frontend/src/stores/currencyStore.ts @@ -40,14 +40,8 @@ export const useCurrencyStore = create((set, get) => ({ for (const r of rates as ExchangeRateDto[]) { if (r.code === 'USD' || r.code === 'EUR') next[r.code] = r.rateToUah; } - // HOTFIX (PR #628 follow-up): currency conversion is under repair — force UAH - // display for all users and reset any stale USD/EUR preference server-side so - // that numeric values are not mislabelled. See docs/ROADMAP.md "Currency". - if (prefs.preferredCurrency !== 'UAH') { - try { await updatePreferences('UAH'); } catch { /* ignore — will retry next login */ } - } set({ - preferredCurrency: 'UAH', + preferredCurrency: prefs.preferredCurrency, rates: next, loaded: true, loading: false, @@ -59,13 +53,10 @@ export const useCurrencyStore = create((set, get) => ({ }, setPreferredCurrency: async (c) => { - // HOTFIX: switcher is disabled in UI; always persist UAH. - const forced: SupportedCurrency = 'UAH'; const prev = get().preferredCurrency; - set({ preferredCurrency: forced }); + set({ preferredCurrency: c }); try { - await updatePreferences(forced); - void c; + await updatePreferences(c); } catch (e) { set({ preferredCurrency: prev }); throw e;