Skip to content
Merged
2 changes: 2 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Money/>` 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 `<Money/>` 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.

---
Expand Down
67 changes: 67 additions & 0 deletions frontend/e2e/currency.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
});
27 changes: 22 additions & 5 deletions frontend/src/components/ui/Money.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,10 +28,23 @@ interface MoneyProps {
*
* Prefer `<Money value={uah}/>` 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 <span className={className} style={style}>{nullText}</span>;
}

return (
<span
className={className}
Expand Down
114 changes: 114 additions & 0 deletions frontend/src/hooks/__tests__/useFormatCurrency.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useCurrencyStore } from '../../stores/currencyStore';
import { useFormatCurrency } from '../useFormatCurrency';

/**
* Regression tests for the currency-conversion bug where display labels
* changed without the actual numeric conversion being applied.
*
* Invariants under test:
* 1. uahValue × (1 / rateToUah) == displayValue (i.e. divide by rate).
* 2. On empty rate table → graceful fallback to UAH + console.warn.
* 3. null / undefined value → "—" (never crash, never "NaN", never "0 ₴").
* 4. UAH preference → passthrough with ₴ symbol.
*
* The `date` parameter is accepted but currently advisory — we always use the
* latest cached rate. Historical per-date rates are tracked as tech debt.
* The test case for "rate unknown for date → previous business day" is
* satisfied by the same latest-rate fallback in this PR.
*/

// Helper: stuff the store into a clean, predictable state per test.
const setStore = (patch: Partial<ReturnType<typeof useCurrencyStore.getState>>) => {
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/);
});
});
65 changes: 26 additions & 39 deletions frontend/src/hooks/useFormatCurrency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand All @@ -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:
* <InputNumber suffix={useCurrencySymbol()} />
*/
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);
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const en: Translations = {
deactivate: 'Deactivate',
export: 'Export',
close: 'Close',
storedInUah: 'Amount is stored in hryvnia',
},
auditLog: {
title: 'Audit Log',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/uk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const uk = {
deactivate: 'Деактивувати',
export: 'Експорт',
close: 'Закрити',
storedInUah: 'Сума зберігається в гривнях',
},
auditLog: {
title: 'Журнал аудиту',
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/Economics/BreakEvenCalculator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export default function BreakEvenCalculator() {
step={100}
value={pricePerTonne}
onChange={(v) => setPricePerTonne(v)}
placeholder={`${currencySymbol}/т`}
placeholder="₴/т"
className={s.block7}
/>
</Space>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/Economics/BudgetPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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="₴"
/>
),
},
Expand Down
Loading
Loading