From 74aa8a920d85e88d9142880011e93ea265c9d996 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:53:46 +0100 Subject: [PATCH 1/2] feat(currency): support travel currencies across expense flows Add GEL, OMR, THB, MYR, KHR and TRY to all input and reporting paths. Share Unicode aliases with expense, budget, calculator and AI guards. Keep native totals precise without changing EUR-cent storage or legacy reporting; preserve OMR baisa and two-decimal ringgit, riel and lira. Cover settings, receipts, Sheets, Mini App and live/offline conversions. Refs #250 --- CLAUDE.md | 14 +- README.md | 36 +- miniapp/package.json | 3 +- miniapp/src/widgets/StatCard.tsx | 8 +- miniapp/src/widgets/Ticker.tsx | 2 +- miniapp/test/currency-precision.test.ts | 46 +++ src/bot/commands/budget.test.ts | 17 + src/bot/commands/budget.ts | 82 +--- src/bot/commands/stats.test.ts | 43 +- src/bot/commands/stats.ts | 14 +- src/bot/commands/sum.output.test.ts | 23 ++ src/bot/commands/sum.ts | 7 +- src/bot/keyboards.test.ts | 6 + src/bot/services/budget-prompt.ts | 1 + src/config/constants.ts | 319 ++++++++++++++- .../new-currencies.integration.test.ts | 369 ++++++++++++++++++ .../ai/agent-helpers.currencies.test.ts | 76 ++++ src/services/ai/agent-helpers.ts | 33 +- src/services/ai/stats.test.ts | 57 +++ src/services/ai/stats.ts | 53 ++- src/services/ai/system-prompt.ts | 3 +- .../ai/tool-executor.expenses.test.ts | 135 +++++++ src/services/ai/tool-executor.test.ts | 9 + src/services/ai/tool-executor.ts | 23 +- .../ai/tool-executor/expenses-report.ts | 35 +- src/services/analytics/formatters.ts | 2 +- src/services/bank/panel-builder.ts | 5 +- src/services/currency/budget-amount-parser.ts | 52 ++- src/services/currency/calculator.ts | 28 +- src/services/currency/converter.test.ts | 7 +- src/services/currency/converter.ts | 40 +- src/services/currency/currency-rates.test.ts | 86 ++++ .../currency/currency-support.test.ts | 229 +++++++++++ src/services/currency/parser.test.ts | 11 +- src/services/currency/parser.ts | 73 ++-- src/services/currency/tokens.ts | 29 ++ src/services/currency/totals.test.ts | 74 ++++ src/services/currency/totals.ts | 44 +++ src/services/google/sheets-delete.test.ts | 25 ++ src/services/google/sheets-rows.ts | 15 +- src/services/receipt/receipt-parser.test.ts | 67 ++++ src/services/receipt/receipt-parser.ts | 26 +- .../settings/group-settings-registry.test.ts | 26 +- .../settings/group-settings-registry.ts | 3 +- src/web/miniapp-api.dashboard.test.ts | 80 +++- src/web/miniapp-api.ts | 34 +- 46 files changed, 2087 insertions(+), 283 deletions(-) create mode 100644 miniapp/test/currency-precision.test.ts create mode 100644 src/integration/new-currencies.integration.test.ts create mode 100644 src/services/ai/agent-helpers.currencies.test.ts create mode 100644 src/services/currency/currency-rates.test.ts create mode 100644 src/services/currency/currency-support.test.ts create mode 100644 src/services/currency/tokens.ts create mode 100644 src/services/currency/totals.test.ts create mode 100644 src/services/currency/totals.ts diff --git a/CLAUDE.md b/CLAUDE.md index b9177d6e..40f147fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -313,8 +313,18 @@ Telegram Mini App for expense input with receipt scanning. Located in `miniapp/` 1. Add aliases to `CURRENCY_ALIASES` in [src/config/constants.ts](src/config/constants.ts) 2. Add code to `SUPPORTED_CURRENCIES` array -3. Add symbol to `CURRENCY_SYMBOLS` map -4. Add exchange rate in [src/services/currency/converter.ts](src/services/currency/converter.ts) +3. Add names, symbol, shortcuts and non-default `decimalPlaces` to `CURRENCY_INFO` +4. Add a dated offline exchange rate in [src/services/currency/converter.ts](src/services/currency/converter.ts) +5. Currency tokens are shared in `src/services/currency/tokens.ts`; expense, budget and + calculator parsing must consume complete aliases, including multiword/native names. +6. OMR has three decimal places. Use `getCurrencyPrecision`/`formatAmount` for original + amounts; keep receipt sums, original-currency reports and Sheets row matching precise. + A fixed half-cent match can delete an adjacent OMR row. Preserve the established + EUR normalization contract. Native GEL/OMR/THB/MYR/KHR/TRY reports use original + amounts plus converted foreign EUR subtotals, via `usesOriginalCurrencyAmounts`; + do not reconstruct these amounts from rounded EUR cents (even 1 KHR can become + zero EUR). Older currencies retain their existing reporting semantics. MYR, KHR + and TRY have two fractional digits; KHR cash habits must not erase fractions. ### Google Sheets Structure diff --git a/README.md b/README.md index 19197957..cd228851 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Telegram bot for tracking expenses and syncing them to Google Sheets. - πŸ’° Track expenses via Telegram messages - πŸ“Š Automatic sync to Google Sheets -- πŸ’± Multi-currency support (USD, EUR, RUB, RSD, GBP, CHF, JPY, CNY, INR) +- πŸ’± Multi-currency support (USD, EUR, RUB, RSD, GBP, BYN, CHF, JPY, CNY, INR, LKR, AED, EGP, GEL, OMR, THB, MYR, KHR, TRY) - 🏷️ Automatic category detection with confirmation - πŸ“ˆ Statistics and expense analysis - πŸ” Secure OAuth2 authentication with Google @@ -35,6 +35,33 @@ $ 190 Π΅Π²Ρ€ΠΎ Alex hobby Format: `[amount] [currency?] [category] [comment]` +### Georgian lari, Omani rial, and Thai baht + +GEL, OMR, THB, MYR, KHR, and TRY are available in the initial currency picker and group settings, for +manual expenses, budgets, receipts, bank imports, Google Sheets, and the AI calculator. +Codes are case-insensitive; symbols and complete names work before or after the amount, +with or without a space. + +| Currency | Accepted examples | Expense example | +| --- | --- | --- | +| GEL β€” Georgian lari | `GEL`, `β‚Ύ`, `Π»Π°Ρ€ΠΈ`, `грузинских Π»Π°Ρ€ΠΈ`, `lari`, `Georgian lari`, `αƒšαƒαƒ αƒ˜`, `αƒ₯αƒαƒ αƒ—αƒ£αƒšαƒ˜ αƒšαƒαƒ αƒ˜` | `β‚Ύ12.34 food lunch` | +| OMR β€” Omani rial | `OMR`, `RO`, `R.O.`, `оманский Ρ€ΠΈΠ°Π»`, `оманских Ρ€ΠΈΠ°Π»ΠΎΠ²`, `Ρ€ΠΈΠ°Π»Π° ΠΎΠΌΠ°Π½Π°`, `Omani rial`, `rial omani`, `Ψ±.ΨΉ.`, `Ψ±ΩŠΨ§Ω„ ΨΉΩ…Ψ§Ω†ΩŠ` | `1,234 OMR food lunch` | +| THB β€” Thai baht | `THB`, `ΰΈΏ`, `Π±Π°Ρ‚`, `Π±Π°Ρ‚Π°`, `Π±Π°Ρ‚ΠΎΠ²`, `тайских Π±Π°Ρ‚ΠΎΠ²`, `baht`, `Thai baht`, `ΰΈšΰΈ²ΰΈ—`, `ΰΈšΰΈ²ΰΈ—ΰΉ„ΰΈ—ΰΈ’` | `100ΰΈΏ food lunch` | +| MYR β€” Malaysian ringgit | `MYR`, `RM`, `Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚`, `Ρ€ΠΈΠ½Π³ΠΈΡ‚`, `малайзийских Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚ΠΎΠ²`, `ringgit`, `Malaysian ringgit`, `Ringgit Malaysia` | `RM12.34 food lunch` | +| KHR β€” Cambodian riel | `KHR`, `αŸ›`, `Ρ€ΠΈΠ΅Π»ΡŒ`, `Ρ€ΠΈΡΠ»ΡŒ`, `Ρ€ΠΈΠ΅Π»Π΅ΠΉ`, `камбодТийских Ρ€ΠΈΠ΅Π»Π΅ΠΉ`, `riel`, `Cambodian riel`, `αžšαŸ€αž›` | `1 234,56αŸ› food lunch` | +| TRY β€” Turkish lira | `TRY`, `TL`, `β‚Ί`, `Π»ΠΈΡ€Π°`, `Π»ΠΈΡ€`, `Ρ‚ΡƒΡ€Π΅Ρ†ΠΊΠΈΡ… Π»ΠΈΡ€`, `Turkish lira`, `TΓΌrk lirasΔ±`, `TÜRK LΔ°RASI` | `β‚Ί12.34 food lunch` | + + +Russian case/count forms are accepted, including `таиландский` and `тайландский` baht +spellings. OMR retains three fractional digits: `0.001 OMR` is one baisa; `1,234 OMR` +means 1.234 OMR. GEL, THB, MYR, KHR, and TRY use two fractional digits, including KHR fractional amounts. Bare `rial`/`Ρ€ΠΈΠ°Π»`/`Ψ±ΩŠΨ§Ω„` +are ambiguous and are not OMR or KHR aliases; subunit names do not stand for whole currency units. TRY uses `β‚Ί`, while `Β£` remains GBP; historical `TRL`/`YTL` codes are not treated as TRY. Native totals for the six currencies above sum original amounts directly and convert only foreign stored-EUR subtotals, so small KHR or OMR expenses are not lost to EUR-cent rounding. Older currencies retain their existing EUR-based reporting semantics. + +Original amounts remain numeric in SQLite and Sheets. Currency rates refresh through +the existing daily API/cache flow; an unavailable or missing quote uses that currency's +dated offline fallback. EUR remains the internal normalization currency. + + ## Setup ### 1. Install Dependencies @@ -296,8 +323,11 @@ Edit [src/config/constants.ts](src/config/constants.ts): 1. Add aliases to `CURRENCY_ALIASES` 2. Add code to `SUPPORTED_CURRENCIES` -3. Add symbol to `CURRENCY_SYMBOLS` -4. Add exchange rate in [src/services/currency/converter.ts](src/services/currency/converter.ts) +3. Add display names, symbol, shortcuts, and any non-default decimal precision to `CURRENCY_INFO` +4. Add a dated offline exchange rate in [src/services/currency/converter.ts](src/services/currency/converter.ts) +5. Exercise full/native names, attached symbols, boundaries, minor units, settings, recording, + receipts, summaries, and Sheets deletion; the shared token pattern and AI currency lists + derive from these constants. ### Adding New Bot Commands diff --git a/miniapp/package.json b/miniapp/package.json index e0c0430c..797294a5 100644 --- a/miniapp/package.json +++ b/miniapp/package.json @@ -4,7 +4,8 @@ "private": true, "scripts": { "dev": "vite", - "build": "tsc && vite build", + "test": "bun test ./test", + "build": "bun run test && tsc && vite build", "preview": "vite preview" }, "dependencies": { diff --git a/miniapp/src/widgets/StatCard.tsx b/miniapp/src/widgets/StatCard.tsx index 4ffc6043..96daf14c 100644 --- a/miniapp/src/widgets/StatCard.tsx +++ b/miniapp/src/widgets/StatCard.tsx @@ -9,13 +9,13 @@ interface Props { export function StatCard({ value, label, currency, comparison }: Props) { const delta = comparison != null ? value - comparison : null; - const pct = comparison && comparison !== 0 ? (delta! / Math.abs(comparison)) * 100 : null; + const pct = comparison && comparison !== 0 ? ((value - comparison) / Math.abs(comparison)) * 100 : null; return (
{label &&
{label}
}
- {formatNum(value)} + {formatNum(value, currency)} {currency ? ` ${currency}` : ''}
{delta != null && ( @@ -28,8 +28,8 @@ export function StatCard({ value, label, currency, comparison }: Props) { ); } -function formatNum(n: number): string { +function formatNum(n: number, currency?: string): string { if (Math.abs(n) >= 1e9) return (n / 1e9).toFixed(1) + ' ΠΌΠ»Ρ€Π΄'; if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(1) + ' ΠΌΠ»Π½'; - return n.toLocaleString('ru-RU', { maximumFractionDigits: 0 }); + return n.toLocaleString('ru-RU', { maximumFractionDigits: currency === 'OMR' ? 3 : ['GEL', 'THB', 'MYR', 'KHR', 'TRY'].includes(currency ?? '') ? 2 : 0 }); } diff --git a/miniapp/src/widgets/Ticker.tsx b/miniapp/src/widgets/Ticker.tsx index 2234bba6..9243f325 100644 --- a/miniapp/src/widgets/Ticker.tsx +++ b/miniapp/src/widgets/Ticker.tsx @@ -14,7 +14,7 @@ export function Ticker({ value, label, currency, series }: Props) {
{label &&
{label}
}
- {value.toLocaleString('ru-RU', { maximumFractionDigits: 0 })} + {value.toLocaleString('ru-RU', { maximumFractionDigits: currency === 'OMR' ? 3 : ['GEL', 'THB', 'MYR', 'KHR', 'TRY'].includes(currency ?? '') ? 2 : 0 })} {currency ? ` ${currency}` : ''}
diff --git a/miniapp/test/currency-precision.test.ts b/miniapp/test/currency-precision.test.ts new file mode 100644 index 00000000..fa9b5fca --- /dev/null +++ b/miniapp/test/currency-precision.test.ts @@ -0,0 +1,46 @@ +/** Server-rendered widgets preserve native travel amounts and retain legacy compact display. */ +import { describe, expect, test } from 'bun:test'; +import { type ComponentType, createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { StatCard } from '../src/widgets/StatCard'; +import { Ticker } from '../src/widgets/Ticker'; + +const widgets: Array<[string, ComponentType<{ value: number; currency?: string }>]> = [ + ['StatCard', StatCard], + ['Ticker', Ticker], +]; + +for (const [name, Widget] of widgets) { + describe(name, () => { + test.each([ + { value: 0.001, text: '0,001 OMR' }, + { value: 1.234, text: '1,234 OMR' }, + ])('renders $value OMR without losing baisa', ({ value, text }) => { + const markup = renderToStaticMarkup(createElement(Widget, { value, currency: 'OMR' })); + expect(markup.replace(/<[^>]*>/g, '')).toBe(text); + }); + + test.each( + ['GEL', 'THB', 'MYR', 'KHR', 'TRY'].flatMap((currency) => [ + { currency, value: 0.01, text: `0,01 ${currency}` }, + { currency, value: 12.5, text: `12,5 ${currency}` }, + ]), + )('renders $value $currency without rounding away hundredths', ({ currency, value, text }) => { + const markup = renderToStaticMarkup(createElement(Widget, { value, currency })); + expect(markup.replace(/<[^>]*>/g, '')).toBe(text); + }); + + test('retains the compact display for other currencies', () => { + const markup = renderToStaticMarkup(createElement(Widget, { value: 1.234, currency: 'EUR' })); + expect(markup.replace(/<[^>]*>/g, '')).toBe('1 EUR'); + }); + }); +} + +test.each([ + { value: 1_200_000, text: '1.2 ΠΌΠ»Π½ OMR' }, + { value: 1_200_000_000, text: '1.2 ΠΌΠ»Ρ€Π΄ OMR' }, +])('StatCard retains large-value abbreviations for $value', ({ value, text }) => { + const markup = renderToStaticMarkup(createElement(StatCard, { value, currency: 'OMR' })); + expect(markup.replace(/<[^>]*>/g, '')).toBe(text); +}); diff --git a/src/bot/commands/budget.test.ts b/src/bot/commands/budget.test.ts index dcdfe1f8..709792e8 100644 --- a/src/bot/commands/budget.test.ts +++ b/src/bot/commands/budget.test.ts @@ -436,3 +436,20 @@ describe('/budget invalid usage', () => { expect(msg).toContain('НСвСрный Ρ„ΠΎΡ€ΠΌΠ°Ρ‚'); }); }); + +describe('/budget set new currencies', () => { + test.each([ + ['β‚Ύ12.34', 12.34, 'GEL'], + ['RM12.34', 12.34, 'MYR'], + ['1\u00a0234,56 Ρ€ΠΈΠ΅Π»Π΅ΠΉ', 1234.56, 'KHR'], + ['12.34 TÜRK LΔ°RASI', 12.34, 'TRY'], + ['12.34 тайских Π±Π°Ρ‚ΠΎΠ²', 12.34, 'THB'], + ['R.O.1.234', 1.234, 'OMR'], + ['1,234 оманских Ρ€ΠΈΠ°Π»ΠΎΠ²', 1.234, 'OMR'], + ] as const)('records %s with its actual currency', async (input, amount, currency) => { + await handleBudgetCommand(fakeCtx(`/budget set Food ${input}`), fakeGroup()); + expect(budgetManagerMock.set.mock.calls[0]?.[0]).toMatchObject({ amount, currency }); + expect(logMock.warn).not.toHaveBeenCalled(); + expect(logMock.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/bot/commands/budget.ts b/src/bot/commands/budget.ts index 2e82a084..98a120d1 100644 --- a/src/bot/commands/budget.ts +++ b/src/bot/commands/budget.ts @@ -1,11 +1,13 @@ /** /budget command handler β€” create, view, and edit spending budgets per category */ import { format } from 'date-fns'; import { InlineKeyboard } from 'gramio'; -import { CURRENCY_ALIASES, type CurrencyCode, getCurrencySymbol } from '../../config/constants'; +import { type CurrencyCode, getCurrencySymbol } from '../../config/constants'; import { database } from '../../database'; import { sendMessage } from '../../services/bank/telegram-sender'; import { getBudgetManager } from '../../services/budget-manager'; +import { parseBudgetInputAmount } from '../../services/currency/budget-amount-parser'; +import { normalizeCurrencyAlias } from '../../services/currency/tokens'; import { monthAbbrFromDate } from '../../services/google/month-abbr'; import { createEmptyMonthTab, @@ -28,81 +30,15 @@ const logger = createLogger('budget'); const NO_BUDGET_HINT = 'Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉ:\nβ€’ /budget set <ΠšΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΡ> <Π‘ΡƒΠΌΠΌΠ°>\nβ€’ /budget sync β€” ΡΠΈΠ½Ρ…Ρ€ΠΎΠ½ΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ с Google Sheets'; -function parsePositiveAmount(numStr: string): number | null { - const amount = parseFloat(numStr.replace(/[\s,]/g, '')); - if (Number.isNaN(amount) || amount <= 0) return null; - return amount; +/** Parse the same strict amount syntax accepted by budget prompt replies. */ +function parseBudgetAmount(amountStr: string, defaultCurrency: CurrencyCode) { + const parsed = parseBudgetInputAmount(amountStr, defaultCurrency); + return parsed ? { amount: parsed.amount, currency: parsed.currency ?? defaultCurrency } : null; } -type ParsedBudgetAmount = { amount: number; currency: CurrencyCode }; - -function parseSymbolPrefixed(trimmed: string): ParsedBudgetAmount | null | undefined { - const match = trimmed.match(/^([$€£₽Β₯])\s*([\d\s,.]+)$/); - if (!match) return undefined; - const [, currencySymbol, numStr] = match; - if (!currencySymbol || !numStr) return null; - - const normalized = normalizeCurrency(currencySymbol); - if (!normalized) return null; - - const amount = parsePositiveAmount(numStr); - return amount === null ? null : { amount, currency: normalized }; -} - -function parseCurrencySuffixed( - trimmed: string, - defaultCurrency: CurrencyCode, -): ParsedBudgetAmount | null | undefined { - const match = trimmed.match(/^([\d\s,.]+)\s*([$€£₽Β₯]|[a-zA-ZΠ°-яА-Π―]+)$/); - if (!match) return undefined; - const [, numStr, currencyStr] = match; - if (!numStr) return null; - - const amount = parsePositiveAmount(numStr); - if (amount === null) return null; - - const normalized = currencyStr ? normalizeCurrency(currencyStr) : null; - return { amount, currency: normalized ?? defaultCurrency }; -} - -function parseBareAmount( - trimmed: string, - defaultCurrency: CurrencyCode, -): ParsedBudgetAmount | null | undefined { - const match = trimmed.match(/^([\d\s,.]+)$/); - if (!match) return undefined; - const [, numStr] = match; - if (!numStr) return null; - - const amount = parsePositiveAmount(numStr); - return amount === null ? null : { amount, currency: defaultCurrency }; -} - -/** - * Parse budget amount with optional currency - * Supports: 500, 500$, $500, 500 EUR, 500 Π΅Π²Ρ€ΠΎ, etc. - * - * Each pattern parser returns undefined when its pattern does not match (try the - * next one), null when it matched but the value is invalid. - */ -function parseBudgetAmount( - amountStr: string, - defaultCurrency: CurrencyCode, -): ParsedBudgetAmount | null { - const trimmed = amountStr.trim(); - const symbolPrefixed = parseSymbolPrefixed(trimmed); - if (symbolPrefixed !== undefined) return symbolPrefixed; - const currencySuffixed = parseCurrencySuffixed(trimmed, defaultCurrency); - if (currencySuffixed !== undefined) return currencySuffixed; - return parseBareAmount(trimmed, defaultCurrency) ?? null; -} - -/** - * Normalize currency code from alias - */ +/** Normalize currency spellings shared with expense and calculator input. */ export function normalizeCurrency(currencyStr: string): CurrencyCode | null { - const normalized = currencyStr.toLowerCase().trim(); - return (CURRENCY_ALIASES[normalized] as CurrencyCode) || null; + return normalizeCurrencyAlias(currencyStr); } /** diff --git a/src/bot/commands/stats.test.ts b/src/bot/commands/stats.test.ts index c9618a88..6caa8298 100644 --- a/src/bot/commands/stats.test.ts +++ b/src/bot/commands/stats.test.ts @@ -22,8 +22,14 @@ const mockExpenses = { getTotalInEUR: mock((_groupId: number): number => 0), }; +const queryAllMock = mock( + ( + _sql: string, + ..._params: unknown[] + ): Array<{ currency: string; amount: number; eur_amount: number }> => [], +); mock.module('../../database', () => ({ - database: { expenses: mockExpenses }, + database: { expenses: mockExpenses, queryAll: queryAllMock }, })); // ── Currency converter (identity-ish) ───────────────────────────────────── @@ -116,6 +122,7 @@ beforeEach(() => { mockExpenses.findByGroupId.mockReset().mockReturnValue([]); mockExpenses.getTotalsByCurrency.mockReset().mockReturnValue({}); mockExpenses.getTotalInEUR.mockReset().mockReturnValue(0); + queryAllMock.mockReset().mockReturnValue([]); buildMiniAppUrlMock.mockReset().mockReturnValue(null); maybeSmartAdviceMock.mockReset().mockResolvedValue(undefined); logMock.error.mockReset(); @@ -124,6 +131,40 @@ beforeEach(() => { // ── Tests ───────────────────────────────────────────────────────────────── describe('/stats', () => { + test('OMR grand total includes native baisa and foreign stored EUR beyond recent rows', async () => { + mockExpenses.findByGroupId.mockReturnValue([ + makeExpense({ amount: 0.001, currency: 'OMR', eur_amount: 0 }), + ]); + mockExpenses.getTotalsByCurrency.mockReturnValue({ OMR: 1.235, USD: 999 }); + mockExpenses.getTotalInEUR.mockReturnValue(4.46); + queryAllMock.mockReturnValue([ + { currency: 'OMR', amount: 1.235, eur_amount: 2.46 }, + { currency: 'USD', amount: 999, eur_amount: 2 }, + ]); + + await handleStatsCommand(fakeCtx(), fakeGroup({ default_currency: 'OMR' })); + const msg = sendMessageMock.mock.calls[0]?.[0]; + expect(msg).toContain('ВсСго: 3.235 OMR'); + expect(logMock.error).not.toHaveBeenCalled(); + expect(logMock.warn).not.toHaveBeenCalled(); + }); + + test('KHR grand total combines native riels and foreign stored EUR beyond recent rows', async () => { + mockExpenses.findByGroupId.mockReturnValue([ + makeExpense({ amount: 1, currency: 'KHR', eur_amount: 0 }), + ]); + mockExpenses.getTotalsByCurrency.mockReturnValue({ KHR: 1001.25, USD: 999 }); + mockExpenses.getTotalInEUR.mockReturnValue(2.22); + queryAllMock.mockReturnValue([ + { currency: 'KHR', amount: 1001.25, eur_amount: 0.22 }, + { currency: 'USD', amount: 999, eur_amount: 2 }, + ]); + await handleStatsCommand(fakeCtx(), fakeGroup({ default_currency: 'KHR' })); + expect(sendMessageMock.mock.calls[0]?.[0]).toContain('ВсСго: 1003.25 KHR'); + expect(logMock.error).not.toHaveBeenCalled(); + expect(logMock.warn).not.toHaveBeenCalled(); + }); + test('empty state β€” no expenses, no totals', async () => { mockExpenses.findByGroupId.mockReturnValue([]); mockExpenses.getTotalsByCurrency.mockReturnValue({}); diff --git a/src/bot/commands/stats.ts b/src/bot/commands/stats.ts index 4cf7abeb..f8697d2f 100644 --- a/src/bot/commands/stats.ts +++ b/src/bot/commands/stats.ts @@ -2,9 +2,10 @@ import { InlineKeyboard } from 'gramio'; import { BASE_CURRENCY, type CurrencyCode, getCurrencySymbol } from '../../config/constants'; import { database } from '../../database'; -import type { Group } from '../../database/types'; +import type { Expense, Group } from '../../database/types'; import { sendMessage } from '../../services/bank/telegram-sender'; import { convertCurrency, formatAmount } from '../../services/currency/converter'; +import { sumExpensesInCurrency, usesOriginalCurrencyAmounts } from '../../services/currency/totals'; import { createLogger } from '../../utils/logger.ts'; import { buildMiniAppUrl } from '../../utils/miniapp-url'; import { pluralize } from '../../utils/pluralize'; @@ -33,7 +34,16 @@ export async function handleStatsCommand(ctx: Ctx['Command'], group: Group): Pro message += `β€’ ${formatAmount(total, currency as CurrencyCode)}\n`; } - const totalDisplay = convertCurrency(totalEUR, BASE_CURRENCY, group.default_currency); + // Aggregate all rows, not the recent-expense page: native amounts cannot be recovered from EUR cents. + const totalDisplay = usesOriginalCurrencyAmounts(group.default_currency) + ? sumExpensesInCurrency( + database.queryAll>( + 'SELECT currency, SUM(amount) AS amount, SUM(eur_amount) AS eur_amount FROM expenses WHERE group_id = ? GROUP BY currency', + group.id, + ), + group.default_currency, + ) + : convertCurrency(totalEUR, BASE_CURRENCY, group.default_currency); message += `\nВсСго: ${formatAmount(totalDisplay, group.default_currency)}\n`; message += `\nПослСдниС ${recentExpenses.length} ${pluralize(recentExpenses.length, 'расход', 'расхода', 'расходов')}:\n`; diff --git a/src/bot/commands/sum.output.test.ts b/src/bot/commands/sum.output.test.ts index 476c866c..24d7b4cc 100644 --- a/src/bot/commands/sum.output.test.ts +++ b/src/bot/commands/sum.output.test.ts @@ -124,6 +124,29 @@ describe('/sum message text', () => { snapshotMock.mockReset().mockReturnValue({ technicalAnalysis: null }); }); + test('OMR current-month total retains original baisa instead of rounded EUR cents', async () => { + mockExpensesRepo.findByGroupId.mockReturnValue([ + { ...exp(MONTH_START, 'Food', 0), amount: 0.001, currency: 'OMR' }, + { ...exp(MONTH_START, 'Food', 2.46), amount: 1.234, currency: 'OMR' }, + ]); + await handleSumCommand(ctx, { ...group, default_currency: 'OMR' }); + const message = sendMessageMock.mock.calls.at(-1)?.[0]; + expect(message).toContain('πŸ’° ВсСго: 1.235'); + expect(logMock.error).not.toHaveBeenCalled(); + expect(logMock.warn).not.toHaveBeenCalled(); + }); + + test('KHR total retains original whole and fractional riels', async () => { + mockExpensesRepo.findByGroupId.mockReturnValue([ + { ...exp(MONTH_START, 'Food', 0), amount: 1, currency: 'KHR' }, + { ...exp(MONTH_START, 'Food', 0.22), amount: 1000.25, currency: 'KHR' }, + ]); + await handleSumCommand(ctx, { ...group, default_currency: 'KHR' }); + expect(sendMessageMock.mock.calls.at(-1)?.[0]).toContain('πŸ’° ВсСго: 1001.25'); + expect(logMock.error).not.toHaveBeenCalled(); + expect(logMock.warn).not.toHaveBeenCalled(); + }); + test('only current month: total line only, nothing else', async () => { mockExpensesRepo.findByGroupId.mockReturnValue([ exp(MONTH_START, 'Food', 10), diff --git a/src/bot/commands/sum.ts b/src/bot/commands/sum.ts index 3b74f430..0560552b 100644 --- a/src/bot/commands/sum.ts +++ b/src/bot/commands/sum.ts @@ -11,6 +11,7 @@ import type { CategoryTaAnalysis } from '../../services/analytics/ta/analyzer'; import type { TechnicalAnalysis } from '../../services/analytics/types'; import { sendMessage } from '../../services/bank/telegram-sender'; import { convertCurrency, formatAmount } from '../../services/currency/converter'; +import { sumExpensesInCurrency } from '../../services/currency/totals'; import { googleConn } from '../../services/google/sheets'; import { truncateForTelegram } from '../../utils/html'; import { createLogger } from '../../utils/logger.ts'; @@ -253,11 +254,7 @@ export async function handleSumCommand(ctx: Ctx['Command'], group: Group): Promi // Convert EUR totals to group's display currency for user output const displayCurrency = group.default_currency; - const currentMonthTotalDisplay = convertCurrency( - currentMonthTotal, - BASE_CURRENCY, - displayCurrency, - ); + const currentMonthTotalDisplay = sumExpensesInCurrency(currentMonthExpenses, displayCurrency); let message = `πŸ“Š Расходы Π·Π° ${format(now, 'LLLL yyyy')}\n\n`; message += `πŸ’° ВсСго: ${formatAmount(currentMonthTotalDisplay, displayCurrency)}\n`; diff --git a/src/bot/keyboards.test.ts b/src/bot/keyboards.test.ts index 1d5ad651..438136d3 100644 --- a/src/bot/keyboards.test.ts +++ b/src/bot/keyboards.test.ts @@ -66,6 +66,12 @@ describe('createCurrencyKeyboard', () => { expect(texts).toContain('EUR'); expect(texts).toContain('USD'); expect(texts).toContain('RSD'); + expect(texts).toContain('GEL'); + expect(texts).toContain('OMR'); + expect(texts).toContain('THB'); + expect(texts).toContain('MYR'); + expect(texts).toContain('KHR'); + expect(texts).toContain('TRY'); }); it('selected currencies get checkmark prefix', () => { diff --git a/src/bot/services/budget-prompt.ts b/src/bot/services/budget-prompt.ts index 6dfb2b43..e541bdef 100644 --- a/src/bot/services/budget-prompt.ts +++ b/src/bot/services/budget-prompt.ts @@ -232,6 +232,7 @@ export async function handleBudgetPromptText(input: PromptTextInput): Promise = { @@ -89,6 +91,14 @@ export const CURRENCY_INFO: Record = { nameEn: 'UAE Dirham', shortcuts: ['Π΄ΠΈΡ€Ρ…Π°ΠΌ'], }, + OMR: { + code: 'OMR', + symbol: 'OMR', + nameRu: 'Оманский Ρ€ΠΈΠ°Π»', + nameEn: 'Omani Rial', + shortcuts: ['оманский Ρ€ΠΈΠ°Π»', 'RO'], + decimalPlaces: 3, + }, GEL: { code: 'GEL', symbol: 'β‚Ύ', @@ -136,7 +146,7 @@ export const CURRENCY_INFO: Record = { symbol: 'β‚Ί', nameRu: 'ВурСцкая Π»ΠΈΡ€Π°', nameEn: 'Turkish Lira', - shortcuts: ['Π»ΠΈΡ€Π°'], + shortcuts: ['Π»ΠΈΡ€Π°', 'TL'], }, ILS: { code: 'ILS', @@ -314,12 +324,19 @@ export const CURRENCY_INFO: Record = { nameEn: 'Indonesian Rupiah', shortcuts: [], }, + KHR: { + code: 'KHR', + symbol: 'αŸ›', + nameRu: 'КамбодТийский Ρ€ΠΈΠ΅Π»ΡŒ', + nameEn: 'Cambodian Riel', + shortcuts: ['Ρ€ΠΈΠ΅Π»ΡŒ'], + }, MYR: { code: 'MYR', symbol: 'RM', nameRu: 'Малайзийский Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚', nameEn: 'Malaysian Ringgit', - shortcuts: [], + shortcuts: ['Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚'], }, VND: { code: 'VND', @@ -452,6 +469,11 @@ export function getCurrencySymbol(code: string): string { return info ? info.symbol : code; } +/** Decimal places used for amounts in their own currency (OMR has 1,000 baisa). */ +export function getCurrencyPrecision(code: string): number { + return CURRENCY_INFO[code.toUpperCase()]?.decimalPlaces ?? 2; +} + /** * Currency codes and their aliases */ @@ -546,6 +568,293 @@ export const CURRENCY_ALIASES: Record = { Π΄ΠΈΡ€Ρ…Π°ΠΌΠ°: 'AED', Π΄ΠΈΡ€Ρ…Π°ΠΌΠΎΠ²: 'AED', + // GEL β€” unambiguous names, local spelling, and symbols + gel: 'GEL', + Π³Π΅Π»: 'GEL', + 'β‚Ύ': 'GEL', + lari: 'GEL', + laris: 'GEL', + 'georgian lari': 'GEL', + 'georgian laris': 'GEL', + Π»Π°Ρ€ΠΈ: 'GEL', + 'грузинский Π»Π°Ρ€ΠΈ': 'GEL', + 'грузинского Π»Π°Ρ€ΠΈ': 'GEL', + 'грузинскому Π»Π°Ρ€ΠΈ': 'GEL', + 'грузинским Π»Π°Ρ€ΠΈ': 'GEL', + 'грузинском Π»Π°Ρ€ΠΈ': 'GEL', + 'грузинскиС Π»Π°Ρ€ΠΈ': 'GEL', + 'грузинских Π»Π°Ρ€ΠΈ': 'GEL', + 'грузинскими Π»Π°Ρ€ΠΈ': 'GEL', + αƒšαƒαƒ αƒ˜: 'GEL', + αƒšαƒαƒ αƒ˜αƒ‘: 'GEL', + 'αƒ₯αƒαƒ αƒ—αƒ£αƒšαƒ˜ αƒšαƒαƒ αƒ˜': 'GEL', + + // OMR β€” unambiguous names, local spelling, and symbols + omr: 'OMR', + ΠΎΠΌΡ€: 'OMR', + ro: 'OMR', + 'r.o.': 'OMR', + 'r.o': 'OMR', + 'Ρ€.ΠΎ.': 'OMR', + 'omani rial': 'OMR', + 'omani rials': 'OMR', + 'rial omani': 'OMR', + 'rials omani': 'OMR', + 'omani riyal': 'OMR', + 'omani riyals': 'OMR', + 'оманский Ρ€ΠΈΠ°Π»': 'OMR', + 'оманского Ρ€ΠΈΠ°Π»Π°': 'OMR', + 'оманскому Ρ€ΠΈΠ°Π»Ρƒ': 'OMR', + 'оманским Ρ€ΠΈΠ°Π»ΠΎΠΌ': 'OMR', + 'оманском Ρ€ΠΈΠ°Π»Π΅': 'OMR', + 'оманскиС Ρ€ΠΈΠ°Π»Ρ‹': 'OMR', + 'оманских Ρ€ΠΈΠ°Π»ΠΎΠ²': 'OMR', + 'оманским Ρ€ΠΈΠ°Π»Π°ΠΌ': 'OMR', + 'оманскими Ρ€ΠΈΠ°Π»Π°ΠΌΠΈ': 'OMR', + 'оманских Ρ€ΠΈΠ°Π»Π°Ρ…': 'OMR', + 'Ρ€ΠΈΠ°Π» ΠΎΠΌΠ°Π½Π°': 'OMR', + 'Ρ€ΠΈΠ°Π»Π° ΠΎΠΌΠ°Π½Π°': 'OMR', + 'Ρ€ΠΈΠ°Π»ΠΎΠ² ΠΎΠΌΠ°Π½Π°': 'OMR', + 'Ρ€ΠΈΠ°Π»Ρ‹ ΠΎΠΌΠ°Π½Π°': 'OMR', + 'Ρ€ΠΈΠ°Π»Ρƒ ΠΎΠΌΠ°Π½Π°': 'OMR', + 'Ρ€ΠΈΠ°Π»ΠΎΠΌ ΠΎΠΌΠ°Π½Π°': 'OMR', + 'Ρ€ΠΈΠ°Π»Π°ΠΌΠΈ ΠΎΠΌΠ°Π½Π°': 'OMR', + 'Ρ€ΠΈΠ°Π»Π°Ρ… ΠΎΠΌΠ°Π½Π°': 'OMR', + 'Ψ±.ΨΉ.': 'OMR', + 'Ψ±.ΨΉ': 'OMR', + 'Ψ±ΩŠΨ§Ω„ ΨΉΩ…Ψ§Ω†ΩŠ': 'OMR', + 'Ψ±ΩŠΨ§Ω„Ψ§Ψͺ ΨΉΩ…Ψ§Ω†ΩŠΨ©': 'OMR', + 'Ψ§Ω„Ψ±ΩŠΨ§Ω„ Ψ§Ω„ΨΉΩ…Ψ§Ω†ΩŠ': 'OMR', + 'Ψ§Ω„Ψ±ΩŠΨ§Ω„Ψ§Ψͺ Ψ§Ω„ΨΉΩ…Ψ§Ω†ΩŠΨ©': 'OMR', + '⃄': 'OMR', + + // THB β€” unambiguous names, local spelling, and symbols + thb: 'THB', + Ρ‚Ρ…Π±: 'THB', + 'ΰΈΏ': 'THB', + baht: 'THB', + bahts: 'THB', + 'thai baht': 'THB', + 'thai bahts': 'THB', + Π±Π°Ρ‚: 'THB', + Π±Π°Ρ‚Π°: 'THB', + Π±Π°Ρ‚ΠΎΠ²: 'THB', + Π±Π°Ρ‚Ρ‹: 'THB', + Π±Π°Ρ‚Ρƒ: 'THB', + Π±Π°Ρ‚ΠΎΠΌ: 'THB', + Π±Π°Ρ‚Π΅: 'THB', + Π±Π°Ρ‚Π°ΠΌ: 'THB', + Π±Π°Ρ‚Π°ΠΌΠΈ: 'THB', + Π±Π°Ρ‚Π°Ρ…: 'THB', + ΰΈšΰΈ²ΰΈ—: 'THB', + ΰΈšΰΈ²ΰΈ—ΰΉ„ΰΈ—ΰΈ’: 'THB', + 'тайский Π±Π°Ρ‚': 'THB', + 'тайского Π±Π°Ρ‚Π°': 'THB', + 'тайскому Π±Π°Ρ‚Ρƒ': 'THB', + 'тайским Π±Π°Ρ‚ΠΎΠΌ': 'THB', + 'тайском Π±Π°Ρ‚Π΅': 'THB', + 'тайскиС Π±Π°Ρ‚Ρ‹': 'THB', + 'тайских Π±Π°Ρ‚ΠΎΠ²': 'THB', + 'тайским Π±Π°Ρ‚Π°ΠΌ': 'THB', + 'тайскими Π±Π°Ρ‚Π°ΠΌΠΈ': 'THB', + 'тайских Π±Π°Ρ‚Π°Ρ…': 'THB', + 'таиландский Π±Π°Ρ‚': 'THB', + 'таиландского Π±Π°Ρ‚Π°': 'THB', + 'таиландскому Π±Π°Ρ‚Ρƒ': 'THB', + 'таиландским Π±Π°Ρ‚ΠΎΠΌ': 'THB', + 'таиландском Π±Π°Ρ‚Π΅': 'THB', + 'таиландскиС Π±Π°Ρ‚Ρ‹': 'THB', + 'таиландских Π±Π°Ρ‚ΠΎΠ²': 'THB', + 'таиландским Π±Π°Ρ‚Π°ΠΌ': 'THB', + 'таиландскими Π±Π°Ρ‚Π°ΠΌΠΈ': 'THB', + 'таиландских Π±Π°Ρ‚Π°Ρ…': 'THB', + 'тайландский Π±Π°Ρ‚': 'THB', + 'тайландского Π±Π°Ρ‚Π°': 'THB', + 'тайландскому Π±Π°Ρ‚Ρƒ': 'THB', + 'тайландским Π±Π°Ρ‚ΠΎΠΌ': 'THB', + 'тайландском Π±Π°Ρ‚Π΅': 'THB', + 'тайландскиС Π±Π°Ρ‚Ρ‹': 'THB', + 'тайландских Π±Π°Ρ‚ΠΎΠ²': 'THB', + 'тайландским Π±Π°Ρ‚Π°ΠΌ': 'THB', + 'тайландскими Π±Π°Ρ‚Π°ΠΌΠΈ': 'THB', + 'тайландских Π±Π°Ρ‚Π°Ρ…': 'THB', + + // MYR β€” codes, symbols, and qualified/local names + myr: 'MYR', + rm: 'MYR', + ΠΌΠΉΡ€: 'MYR', + ringgit: 'MYR', + ringgits: 'MYR', + 'malaysian ringgit': 'MYR', + 'malaysian ringgits': 'MYR', + 'ringgit malaysia': 'MYR', + Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚: 'MYR', + Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π°: 'MYR', + Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚ΠΎΠ²: 'MYR', + Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Ρ‹: 'MYR', + Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Ρƒ: 'MYR', + Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚ΠΎΠΌ: 'MYR', + Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π΅: 'MYR', + Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π°ΠΌ: 'MYR', + Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π°ΠΌΠΈ: 'MYR', + Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π°Ρ…: 'MYR', + 'малайзийский Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚': 'MYR', + 'малайзийского Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π°': 'MYR', + 'малайзийскому Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Ρƒ': 'MYR', + 'малайзийским Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚ΠΎΠΌ': 'MYR', + 'малайзийском Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π΅': 'MYR', + 'малайзийскиС Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Ρ‹': 'MYR', + 'малайзийских Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚ΠΎΠ²': 'MYR', + 'малайзийским Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π°ΠΌ': 'MYR', + 'малайзийскими Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π°ΠΌΠΈ': 'MYR', + 'малайзийских Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π°Ρ…': 'MYR', + 'малазийский Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚': 'MYR', + 'малазийского Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π°': 'MYR', + 'малазийскому Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Ρƒ': 'MYR', + 'малазийским Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚ΠΎΠΌ': 'MYR', + 'малазийском Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π΅': 'MYR', + 'малазийскиС Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Ρ‹': 'MYR', + 'малазийских Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚ΠΎΠ²': 'MYR', + 'малазийским Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π°ΠΌ': 'MYR', + 'малазийскими Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π°ΠΌΠΈ': 'MYR', + 'малазийских Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚Π°Ρ…': 'MYR', + Ρ€ΠΈΠ½Π³ΠΈΡ‚: 'MYR', + Ρ€ΠΈΠ½Π³ΠΈΡ‚Π°: 'MYR', + Ρ€ΠΈΠ½Π³ΠΈΡ‚ΠΎΠ²: 'MYR', + Ρ€ΠΈΠ½Π³ΠΈΡ‚Ρ‹: 'MYR', + Ρ€ΠΈΠ½Π³ΠΈΡ‚Ρƒ: 'MYR', + Ρ€ΠΈΠ½Π³ΠΈΡ‚ΠΎΠΌ: 'MYR', + Ρ€ΠΈΠ½Π³ΠΈΡ‚Π΅: 'MYR', + Ρ€ΠΈΠ½Π³ΠΈΡ‚Π°ΠΌ: 'MYR', + Ρ€ΠΈΠ½Π³ΠΈΡ‚Π°ΠΌΠΈ: 'MYR', + Ρ€ΠΈΠ½Π³ΠΈΡ‚Π°Ρ…: 'MYR', + 'малайзийский Ρ€ΠΈΠ½Π³ΠΈΡ‚': 'MYR', + 'малайзийского Ρ€ΠΈΠ½Π³ΠΈΡ‚Π°': 'MYR', + 'малайзийскому Ρ€ΠΈΠ½Π³ΠΈΡ‚Ρƒ': 'MYR', + 'малайзийским Ρ€ΠΈΠ½Π³ΠΈΡ‚ΠΎΠΌ': 'MYR', + 'малайзийском Ρ€ΠΈΠ½Π³ΠΈΡ‚Π΅': 'MYR', + 'малайзийскиС Ρ€ΠΈΠ½Π³ΠΈΡ‚Ρ‹': 'MYR', + 'малайзийских Ρ€ΠΈΠ½Π³ΠΈΡ‚ΠΎΠ²': 'MYR', + 'малайзийским Ρ€ΠΈΠ½Π³ΠΈΡ‚Π°ΠΌ': 'MYR', + 'малайзийскими Ρ€ΠΈΠ½Π³ΠΈΡ‚Π°ΠΌΠΈ': 'MYR', + 'малайзийских Ρ€ΠΈΠ½Π³ΠΈΡ‚Π°Ρ…': 'MYR', + 'малазийский Ρ€ΠΈΠ½Π³ΠΈΡ‚': 'MYR', + 'малазийского Ρ€ΠΈΠ½Π³ΠΈΡ‚Π°': 'MYR', + 'малазийскому Ρ€ΠΈΠ½Π³ΠΈΡ‚Ρƒ': 'MYR', + 'малазийским Ρ€ΠΈΠ½Π³ΠΈΡ‚ΠΎΠΌ': 'MYR', + 'малазийском Ρ€ΠΈΠ½Π³ΠΈΡ‚Π΅': 'MYR', + 'малазийскиС Ρ€ΠΈΠ½Π³ΠΈΡ‚Ρ‹': 'MYR', + 'малазийских Ρ€ΠΈΠ½Π³ΠΈΡ‚ΠΎΠ²': 'MYR', + 'малазийским Ρ€ΠΈΠ½Π³ΠΈΡ‚Π°ΠΌ': 'MYR', + 'малазийскими Ρ€ΠΈΠ½Π³ΠΈΡ‚Π°ΠΌΠΈ': 'MYR', + 'малазийских Ρ€ΠΈΠ½Π³ΠΈΡ‚Π°Ρ…': 'MYR', + + // KHR β€” codes, symbols, and qualified/local names + khr: 'KHR', + ΠΊΡ…Ρ€: 'KHR', + 'αŸ›': 'KHR', + riel: 'KHR', + riels: 'KHR', + 'cambodian riel': 'KHR', + 'cambodian riels': 'KHR', + 'khmer riel': 'KHR', + 'khmer riels': 'KHR', + αžšαŸ€αž›: 'KHR', + Ρ€ΠΈΠ΅Π»ΡŒ: 'KHR', + риСля: 'KHR', + Ρ€ΠΈΠ΅Π»ΡŽ: 'KHR', + Ρ€ΠΈΠ΅Π»Π΅ΠΌ: 'KHR', + Ρ€ΠΈΠ΅Π»Π΅: 'KHR', + Ρ€ΠΈΠ΅Π»ΠΈ: 'KHR', + Ρ€ΠΈΠ΅Π»Π΅ΠΉ: 'KHR', + риСлям: 'KHR', + риСлями: 'KHR', + риСлях: 'KHR', + 'камбодТийский Ρ€ΠΈΠ΅Π»ΡŒ': 'KHR', + 'камбодТийского риСля': 'KHR', + 'камбодТийскому Ρ€ΠΈΠ΅Π»ΡŽ': 'KHR', + 'камбодТийским Ρ€ΠΈΠ΅Π»Π΅ΠΌ': 'KHR', + 'камбодТийском Ρ€ΠΈΠ΅Π»Π΅': 'KHR', + 'камбодТийскиС Ρ€ΠΈΠ΅Π»ΠΈ': 'KHR', + 'камбодТийских Ρ€ΠΈΠ΅Π»Π΅ΠΉ': 'KHR', + 'камбодТийским риСлям': 'KHR', + 'камбодТийскими риСлями': 'KHR', + 'камбодТийских риСлях': 'KHR', + 'Ρ€ΠΈΠ΅Π»ΡŒ ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'риСля ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'Ρ€ΠΈΠ΅Π»ΡŽ ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'Ρ€ΠΈΠ΅Π»Π΅ΠΌ ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'Ρ€ΠΈΠ΅Π»Π΅ ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'Ρ€ΠΈΠ΅Π»ΠΈ ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'Ρ€ΠΈΠ΅Π»Π΅ΠΉ ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'риСлям ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'риСлями ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'риСлях ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + Ρ€ΠΈΡΠ»ΡŒ: 'KHR', + риэля: 'KHR', + Ρ€ΠΈΡΠ»ΡŽ: 'KHR', + риэлСм: 'KHR', + риэлС: 'KHR', + риэли: 'KHR', + риэлСй: 'KHR', + риэлям: 'KHR', + риэлями: 'KHR', + риэлях: 'KHR', + 'камбодТийский Ρ€ΠΈΡΠ»ΡŒ': 'KHR', + 'камбодТийского риэля': 'KHR', + 'камбодТийскому Ρ€ΠΈΡΠ»ΡŽ': 'KHR', + 'камбодТийским риэлСм': 'KHR', + 'камбодТийском риэлС': 'KHR', + 'камбодТийскиС риэли': 'KHR', + 'камбодТийских риэлСй': 'KHR', + 'камбодТийским риэлям': 'KHR', + 'камбодТийскими риэлями': 'KHR', + 'камбодТийских риэлях': 'KHR', + 'Ρ€ΠΈΡΠ»ΡŒ ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'риэля ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'Ρ€ΠΈΡΠ»ΡŽ ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'риэлСм ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'риэлС ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'риэли ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'риэлСй ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'риэлям ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'риэлями ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + 'риэлях ΠΊΠ°ΠΌΠ±ΠΎΠ΄ΠΆΠΈ': 'KHR', + + // TRY β€” codes, symbols, and qualified/local names + try: 'TRY', + tl: 'TRY', + Ρ‚Π»: 'TRY', + 'β‚Ί': 'TRY', + lira: 'TRY', + liras: 'TRY', + 'turkish lira': 'TRY', + 'turkish liras': 'TRY', + 'tΓΌrk lirasΔ±': 'TRY', + 'tΓΌrk lirasi': 'TRY', + 'turk lirasi': 'TRY', + Π»ΠΈΡ€Π°: 'TRY', + Π»ΠΈΡ€Ρ‹: 'TRY', + Π»ΠΈΡ€Π΅: 'TRY', + Π»ΠΈΡ€Ρƒ: 'TRY', + Π»ΠΈΡ€ΠΎΠΉ: 'TRY', + Π»ΠΈΡ€ΠΎΡŽ: 'TRY', + Π»ΠΈΡ€: 'TRY', + Π»ΠΈΡ€Π°ΠΌ: 'TRY', + Π»ΠΈΡ€Π°ΠΌΠΈ: 'TRY', + Π»ΠΈΡ€Π°Ρ…: 'TRY', + 'турСцкая Π»ΠΈΡ€Π°': 'TRY', + 'Ρ‚ΡƒΡ€Π΅Ρ†ΠΊΠΎΠΉ Π»ΠΈΡ€Ρ‹': 'TRY', + 'Ρ‚ΡƒΡ€Π΅Ρ†ΠΊΠΎΠΉ Π»ΠΈΡ€Π΅': 'TRY', + 'Ρ‚ΡƒΡ€Π΅Ρ†ΠΊΠΎΠΉ Π»ΠΈΡ€ΠΎΠΉ': 'TRY', + 'Ρ‚ΡƒΡ€Π΅Ρ†ΠΊΠΎΠΉ Π»ΠΈΡ€ΠΎΡŽ': 'TRY', + 'Ρ‚ΡƒΡ€Π΅Ρ†ΠΊΡƒΡŽ Π»ΠΈΡ€Ρƒ': 'TRY', + 'Ρ‚ΡƒΡ€Π΅Ρ†ΠΊΠΈΠ΅ Π»ΠΈΡ€Ρ‹': 'TRY', + 'Ρ‚ΡƒΡ€Π΅Ρ†ΠΊΠΈΡ… Π»ΠΈΡ€': 'TRY', + 'Ρ‚ΡƒΡ€Π΅Ρ†ΠΊΠΈΡ… Π»ΠΈΡ€Π°Ρ…': 'TRY', + 'Ρ‚ΡƒΡ€Π΅Ρ†ΠΊΠΈΠΌ Π»ΠΈΡ€Π°ΠΌ': 'TRY', + 'Ρ‚ΡƒΡ€Π΅Ρ†ΠΊΠΈΠΌΠΈ Π»ΠΈΡ€Π°ΠΌΠΈ': 'TRY', + // Egyptian Pound egp: 'EGP', Π΅Π³ΠΏ: 'EGP', @@ -568,6 +877,12 @@ export const SUPPORTED_CURRENCIES = [ 'LKR', 'AED', 'EGP', + 'GEL', + 'OMR', + 'THB', + 'MYR', + 'KHR', + 'TRY', ] as const; export type CurrencyCode = (typeof SUPPORTED_CURRENCIES)[number]; diff --git a/src/integration/new-currencies.integration.test.ts b/src/integration/new-currencies.integration.test.ts new file mode 100644 index 00000000..b8bf8818 --- /dev/null +++ b/src/integration/new-currencies.integration.test.ts @@ -0,0 +1,369 @@ +// New currency entries traverse the real message handler, pending repository and batch saver. +// SQLite stays in memory; only Google, Telegram and unrelated conversation flows are stubbed. +import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; +import type { CurrencyCode } from '../config/constants'; +import type { PendingExpense } from '../database/types'; +import type { ExpenseRowData, GoogleConn } from '../services/google/sheets'; +import { clearTestDb } from '../test-utils/db'; +import { createMockLogger } from '../test-utils/mocks/logger'; + +const log = createMockLogger(); +mock.module('../utils/logger.ts', () => ({ createLogger: () => log, logger: log })); + +const sentMessages: string[] = []; +mock.module('../services/bank/telegram-sender', () => ({ + sendMessage: async (text: string) => { + sentMessages.push(text); + return { message_id: sentMessages.length }; + }, + createInviteLink: async () => null, + removeInlineKeyboard: async () => {}, +})); + +interface SheetWrite { + spreadsheetId: string; + rows: ExpenseRowData[]; + pending: PendingExpense[]; +} +const sheetWrites: SheetWrite[] = []; +let userId: number; +mock.module('../services/google/sheets', () => ({ + appendExpenseRows: async (conn: GoogleConn, spreadsheetId: string, rows: ExpenseRowData[]) => { + expect(conn.refreshToken).toBe('synthetic-token'); + sheetWrites.push({ + spreadsheetId, + rows, + pending: database.pendingExpenses.findByUserId(userId), + }); + }, + googleConn: (group: { google_refresh_token: string; oauth_client: string }) => ({ + refreshToken: group.google_refresh_token, + oauthClient: group.oauth_client, + }), + writeMonthBudgetRow: async () => true, +})); +mock.module('../services/google/oauth', () => ({ isTokenExpiredError: () => false })); +mock.module('../bot/services/budget-sync', () => ({ silentSyncBudgets: async () => 0 })); +mock.module('../bot/commands/ask', () => ({ + maybeSmartAdvice: async () => {}, + handleAskQuestion: async () => {}, +})); +mock.module('../bot/commands/dev', () => ({ + consumePendingDesignEdit: () => null, + getPipelineInstance: () => null, +})); +mock.module('../bot/commands/feedback', () => ({ + consumePendingFeedback: () => null, + submitFeedback: async () => {}, +})); +mock.module('../bot/commands/connect', () => ({ + isAwaitingCustomCurrency: () => false, + handleCustomCurrencyInput: async () => true, +})); +mock.module('../bot/commands/bank', () => ({ + handleWizardInput: async () => false, + handleBankEditReply: async () => false, +})); +mock.module('../services/bank/otp-manager', () => ({ resolveOtpForGroup: () => false })); +mock.module('../services/receipt/link-analyzer', () => ({ + extractURLsFromText: () => [], + processPaymentLinks: async () => false, +})); + +// The test runner sets NODE_ENV=test, which makes the actual singleton use :memory:. +if (process.env['NODE_ENV'] !== 'test') throw new Error('Run with bun test; no on-disk DB allowed'); +const network = spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Unexpected network call')); +const { database } = await import('../database'); +const { handleExpenseMessage } = await import('../bot/handlers/message.handler'); + +beforeEach(() => { + clearTestDb(database.getDb()); + sentMessages.length = 0; + sheetWrites.length = 0; + log.error.mockClear(); + log.warn.mockClear(); + network.mockClear(); +}); +afterAll(() => { + database.close(); + network.mockRestore(); +}); + +function seed(defaultCurrency: CurrencyCode = 'EUR'): number { + const group = database.groups.create({ + telegram_group_id: -100250, + default_currency: defaultCurrency, + }); + database.groups.update(-100250, { + enabled_currencies: ['EUR', 'USD', 'RSD', 'GEL', 'OMR', 'THB', 'MYR', 'KHR', 'TRY'], + google_refresh_token: 'synthetic-token', + spreadsheet_id: 'sheet-currency-test', + }); + userId = database.users.create({ telegram_id: 250, group_id: group.id }).id; + database.categories.create({ group_id: group.id, name: 'Π•Π΄Π°' }); + return group.id; +} + +async function sendExpenseText(text: string): Promise { + const ctx = { + id: 250, + chat: { id: -100250, type: 'group', title: 'Currency integration' }, + from: { id: 250, firstName: 'Test' }, + text, + update: { message: {} }, + } as unknown as Parameters[0]; + const bot = { + api: { setMessageReaction: async () => true, sendChatAction: async () => {} }, + } as unknown as Parameters[1]; + return handleExpenseMessage(ctx, bot); +} + +const cases = [ + { + name: 'MYR symbol', + text: 'RM12.34 Π΅Π΄Π° ΠΏΠΎΠ΅Π·Π΄ΠΊΠ°', + currency: 'MYR', + amount: 12.34, + display: '12.34 RM', + }, + { + name: 'MYR Russian name', + text: '12,34 малайзийских Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚ΠΎΠ² Π΅Π΄Π° ΠΏΠΎΠ΅Π·Π΄ΠΊΠ°', + currency: 'MYR', + amount: 12.34, + display: '12.34 RM', + }, + { + name: 'KHR symbol', + text: '1\u00a0234,56αŸ› Π΅Π΄Π° ΠΏΠΎΠ΅Π·Π΄ΠΊΠ°', + currency: 'KHR', + amount: 1234.56, + display: '1234.56 αŸ›', + }, + { + name: 'KHR local name', + text: 'αžšαŸ€αž›12.34 Π΅Π΄Π° ΠΏΠΎΠ΅Π·Π΄ΠΊΠ°', + currency: 'KHR', + amount: 12.34, + display: '12.34 αŸ›', + }, + { + name: 'TRY symbol', + text: 'β‚Ί12.34 Π΅Π΄Π° ΠΏΠΎΠ΅Π·Π΄ΠΊΠ°', + currency: 'TRY', + amount: 12.34, + display: '12.34 β‚Ί', + }, + { + name: 'TRY local uppercase name', + text: '12.34 TÜRK LΔ°RASI Π΅Π΄Π° ΠΏΠΎΠ΅Π·Π΄ΠΊΠ°', + currency: 'TRY', + amount: 12.34, + display: '12.34 β‚Ί', + }, + { + name: 'GEL symbol', + text: 'β‚Ύ12.34 Π΅Π΄Π° ΠΏΠΎΠ΅Π·Π΄ΠΊΠ°', + currency: 'GEL', + amount: 12.34, + display: '12.34 β‚Ύ', + }, + { + name: 'GEL Russian name', + text: '12.34 Π»Π°Ρ€ΠΈ Π΅Π΄Π° ΠΏΠΎΠ΅Π·Π΄ΠΊΠ°', + currency: 'GEL', + amount: 12.34, + display: '12.34 β‚Ύ', + }, + { + name: 'THB symbol', + text: '56.78ΰΈΏ Π΅Π΄Π° ΠΏΠΎΠ΅Π·Π΄ΠΊΠ°', + currency: 'THB', + amount: 56.78, + display: '56.78 ΰΈΏ', + }, + { + name: 'THB English name', + text: '56.78 baht Π΅Π΄Π° ΠΏΠΎΠ΅Π·Π΄ΠΊΠ°', + currency: 'THB', + amount: 56.78, + display: '56.78 ΰΈΏ', + }, + { + name: 'OMR code', + text: '1.234 OMR Π΅Π΄Π° ΠΏΠΎΠ΅Π·Π΄ΠΊΠ°', + currency: 'OMR', + amount: 1.234, + display: '1.234 OMR', + }, + { + name: 'OMR one baisa', + text: '0.001 Ψ±.ΨΉ. Π΅Π΄Π° ΠΏΠΎΠ΅Π·Π΄ΠΊΠ°', + currency: 'OMR', + amount: 0.001, + display: '0.001 OMR', + }, + { + name: 'OMR Russian name and decimal comma', + text: '1,234 оманских Ρ€ΠΈΠ°Π»ΠΎΠ² Π΅Π΄Π° ΠΏΠΎΠ΅Π·Π΄ΠΊΠ°', + currency: 'OMR', + amount: 1.234, + display: '1.234 OMR', + }, +]; + +describe('new currencies through pending expense and batch save', () => { + test.each(cases)('$name retains its currency and amount without changing the default', async ({ + text, + currency, + amount, + display, + }) => { + const groupId = seed(); + expect(await sendExpenseText(`${text}\n2.50 Π΅Π΄Π° Π±Π΅Π· Π²Π°Π»ΡŽΡ‚Ρ‹`)).toBe(true); + + const saved = database.expenses.findByGroupId(groupId, 10); + expect(saved).toHaveLength(2); + expect(saved).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + currency, + amount, + category: 'Π•Π΄Π°', + comment: 'ПоСздка', + user_id: userId, + }), + expect.objectContaining({ + currency: 'EUR', + amount: 2.5, + category: 'Π•Π΄Π°', + comment: 'Π‘Π΅Π· Π²Π°Π»ΡŽΡ‚Ρ‹', + }), + ]), + ); + expect(sheetWrites).toHaveLength(1); + const write = sheetWrites[0]; + expect(write?.spreadsheetId).toBe('sheet-currency-test'); + expect(write?.rows).toHaveLength(2); + expect(write?.rows[0]?.amounts).toMatchObject({ [currency]: amount, EUR: null }); + expect(write?.rows[1]?.amounts).toMatchObject({ [currency]: null, EUR: 2.5 }); + expect(write?.rows[0]?.rate).toBeGreaterThan(0); + expect(write?.pending).toHaveLength(2); + expect(write?.pending).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + parsed_currency: currency, + parsed_amount: amount, + status: 'confirmed', + }), + ]), + ); + expect(database.pendingExpenses.findByUserId(userId)).toHaveLength(0); + expect(database.groups.findById(groupId)?.default_currency).toBe('EUR'); + expect(sentMessages.join('\n')).toContain(display); + expect(sentMessages.join('\n')).toContain('2.50 €'); + expect(log.error).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + expect(network).not.toHaveBeenCalled(); + }); + + test.each([ + '100 USD', + '100 GEL', + '100 OMR', + '0.001 OMR', + '100 THB', + '100 MYR', + 'RM100', + '100 KHR', + 'αŸ›100', + '100 TRY', + '100β‚Ί', + 'β‚Ύ100', + '100ΰΈΏ', + 'Ψ±.ΨΉ. 0.001', + ])('incomplete entry %s does not create a currency-named category or financial records', async (text) => { + const groupId = seed(); + expect(await sendExpenseText(text)).toBe(false); + expect(database.expenses.findByGroupId(groupId, 10)).toHaveLength(0); + expect(database.pendingExpenses.findByUserId(userId)).toHaveLength(0); + expect(database.categories.getCategoryNames(groupId)).toEqual(['Π•Π΄Π°']); + expect(sheetWrites).toEqual([]); + expect(sentMessages).toEqual([]); + expect(log.error).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + expect(network).not.toHaveBeenCalled(); + }); + + test('records travel currencies alongside EUR, USD and RSD in one batch', async () => { + const groupId = seed(); + const currencyAmounts = [ + ['EUR', 1.11], + ['USD', 2.22], + ['RSD', 333.33], + ['GEL', 4.44], + ['OMR', 5.555], + ['THB', 66.66], + ['MYR', 7.77], + ['KHR', 8888.88], + ['TRY', 99.99], + ] as const; + const text = currencyAmounts + .map(([code, amount]) => `${amount} ${code} Π΅Π΄Π° synthetic`) + .join('\n'); + expect(await sendExpenseText(text)).toBe(true); + const saved = database.expenses.findByGroupId(groupId, 20); + expect(saved).toHaveLength(currencyAmounts.length); + expect(sheetWrites).toHaveLength(1); + for (const [currency, amount] of currencyAmounts) { + expect(saved).toContainEqual(expect.objectContaining({ currency, amount, category: 'Π•Π΄Π°' })); + expect(sheetWrites[0]?.rows).toContainEqual( + expect.objectContaining({ amounts: expect.objectContaining({ [currency]: amount }) }), + ); + } + expect(database.pendingExpenses.findByUserId(userId)).toHaveLength(0); + expect(database.groups.findById(groupId)?.default_currency).toBe('EUR'); + expect(log.error).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + expect(network).not.toHaveBeenCalled(); + }); + + test.each([ + 'MYR', + 'KHR', + 'TRY', + ] as const)('%s default keeps original hundredths when the currency is omitted', async (currency) => { + const groupId = seed(currency); + expect(await sendExpenseText('0.01 Π΅Π΄Π° fee\n1.23 Π΅Π΄Π° lunch')).toBe(true); + const saved = database.expenses.findByGroupId(groupId, 10); + expect(saved).toHaveLength(2); + expect(saved).toContainEqual(expect.objectContaining({ currency, amount: 0.01 })); + expect(saved).toContainEqual(expect.objectContaining({ currency, amount: 1.23 })); + expect(sheetWrites[0]?.rows.map((row) => row.amounts[currency])).toEqual([0.01, 1.23]); + expect(log.error).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + expect(network).not.toHaveBeenCalled(); + }); + + test('an OMR default preserves one baisa and three decimals when no code is typed', async () => { + const groupId = seed('OMR'); + expect(await sendExpenseText('0.001 Π΅Π΄Π° байса\n1.234 Π΅Π΄Π° ΠΎΠ±Π΅Π΄')).toBe(true); + + const saved = database.expenses.findByGroupId(groupId, 10); + expect(saved).toHaveLength(2); + expect(saved).toEqual( + expect.arrayContaining([ + expect.objectContaining({ currency: 'OMR', amount: 0.001, comment: 'Байса' }), + expect.objectContaining({ currency: 'OMR', amount: 1.234, comment: 'ОбСд' }), + ]), + ); + expect(sheetWrites).toHaveLength(1); + expect(sheetWrites[0]?.rows.map((row) => row.amounts['OMR'])).toEqual([0.001, 1.234]); + expect(database.pendingExpenses.findByUserId(userId)).toHaveLength(0); + expect(sentMessages.join('\n')).toContain('0.001 OMR'); + expect(sentMessages.join('\n')).toContain('1.234 OMR'); + expect(log.error).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + expect(network).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/ai/agent-helpers.currencies.test.ts b/src/services/ai/agent-helpers.currencies.test.ts new file mode 100644 index 00000000..5763a7fc --- /dev/null +++ b/src/services/ai/agent-helpers.currencies.test.ts @@ -0,0 +1,76 @@ +/** Currency invention guard recognizes travel aliases without matching words or unbounded digits. */ +import { describe, expect, test } from 'bun:test'; +import { amountCurrencies } from './agent-helpers'; + +describe('amountCurrencies travel aliases', () => { + test.each([ + ['MYR', 'MYR'], + ['myr', 'MYR'], + ['RM', 'MYR'], + ['rm', 'MYR'], + ['ringgit malaysia', 'MYR'], + ['Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚ΠΎΠ²', 'MYR'], + ['KHR', 'KHR'], + ['αŸ›', 'KHR'], + ['Ρ€ΠΈΠ΅Π»Π΅ΠΉ', 'KHR'], + ['αžšαŸ€αž›', 'KHR'], + ['TRY', 'TRY'], + ['try', 'TRY'], + ['TL', 'TRY'], + ['β‚Ί', 'TRY'], + ['Ρ‚ΡƒΡ€Π΅Ρ†ΠΊΠΈΡ… Π»ΠΈΡ€', 'TRY'], + ['TÜRK LΔ°RASI', 'TRY'], + ['GEL', 'GEL'], + ['β‚Ύ', 'GEL'], + ['αƒ₯αƒαƒ αƒ—αƒ£αƒšαƒ˜ αƒšαƒαƒ αƒ˜', 'GEL'], + ['OMR', 'OMR'], + ['RO', 'OMR'], + ['R.O.', 'OMR'], + ['Ψ±.ΨΉ.\u200f', 'OMR'], + ['THB', 'THB'], + ['ΰΈΏ', 'THB'], + ['ΰΈšΰΈ²ΰΈ—', 'THB'], + ['USD', 'USD'], + ['$', 'USD'], + ['EUR', 'EUR'], + ['€', 'EUR'], + ['RSD', 'RSD'], + ['Β£', 'GBP'], + ])('spots %s next to an amount on either side', (alias, code) => { + expect(amountCurrencies(`${alias}12.34; 12.34 ${alias}; ${alias} βˆ’12`)).toEqual([code]); + }); + + test.each([ + 'farm', + 'tryhard', + 'RMX', + 'KHRx', + 'Ρ€ΠΈΠ΅Π»Ρ‚ΠΎΡ€', + 'Ρ€ΠΈΠ½Π³ΠΈΡ‚ΠΎΠ½', + 'TLD', + 'TRL', + 'YTL', + 'β‚€', + ])('does not invent a currency from %s', (word) => { + expect(amountCurrencies(`12 ${word}; ${word}12`)).toEqual([]); + }); + + test('does not count currency names without adjacent amounts', () => { + expect(amountCurrencies('MYR and KHR; TL, RM, αŸ› and β‚Ί; 12 ordinary words')).toEqual([]); + }); + + test('keeps USD, KHR, MYR, TRY and GBP distinct in one response', () => { + expect(amountCurrencies('$12, 10αŸ›, RM4, β‚Ί50, Β£3, 2omr')).toEqual([ + 'USD', + 'KHR', + 'MYR', + 'TRY', + 'GBP', + 'OMR', + ]); + }); + + test('scans a long digit run without consuming the whole number at every position', () => { + expect(amountCurrencies(`${'1'.repeat(100_000)} RM`)).toEqual(['MYR']); + }); +}); diff --git a/src/services/ai/agent-helpers.ts b/src/services/ai/agent-helpers.ts index f236b159..72432fe0 100644 --- a/src/services/ai/agent-helpers.ts +++ b/src/services/ai/agent-helpers.ts @@ -4,27 +4,30 @@ */ import type OpenAI from 'openai'; -import { CURRENCY_ALIASES, SUPPORTED_CURRENCIES } from '../../config/constants'; +import { isSupportedCurrency } from '../../config/constants'; +import { + CURRENCY_TOKEN_PATTERN, + normalizeCurrencyAlias, + normalizeCurrencyText, +} from '../currency/tokens'; import type { ToolResult } from './types'; +/** The amount side consumes one digit and bounded spacing, avoiding repeated long-number scans. */ +const amountCurrencyPattern = new RegExp( + String.raw`\d[\s\u00a0\u202f]{0,3}(${CURRENCY_TOKEN_PATTERN})(?![\p{L}\p{M}])|(?(); - const add = (token: string | undefined) => { - const code = token && (CURRENCY_ALIASES[token] ?? token); - if (code && SUPPORTED_CURRENCIES.some((c) => c === code)) codes.add(code); - }; - // Up to 3 spaces of any kind (non-breaking ones included) between amount and currency, and an - // optional sign before a prefixed amount ("USD -700", "USD βˆ’700"). - for (const m of text.matchAll( - /\d[\s\u00a0\u202f]{0,3}([A-Z]{3}|[$€₽£])(?![A-Za-z])|(? ({ @@ -15,6 +16,62 @@ import type { ExpenseStats } from './stats'; import { computeExpenseStats, formatStats, formatStatsDiff, formatStatsTrend } from './stats'; describe('computeExpenseStats', () => { + test('keeps native KHR in totals, average, median, minimum and maximum', () => { + const expenses = [ + { + amount: 1, + currency: 'KHR' as const, + eur_amount: 0, + category: 'Food', + comment: 'fee', + date: '2026-04-01', + }, + { + amount: 1000, + currency: 'KHR' as const, + eur_amount: 0.22, + category: 'Food', + comment: 'lunch', + date: '2026-04-02', + }, + ]; + const stats = computeExpenseStats(expenses, 'KHR'); + expect(stats.total).toBe(1001); + expect(stats.avg).toBe(500.5); + expect(stats.median).toBe(500.5); + expect(stats.min?.amount).toBe(1); + expect(stats.max?.amount).toBe(1000); + }); + + test('keeps original OMR amounts when their stored EUR values lose a baisa', () => { + const expenses = [ + { + amount: 0.001, + currency: 'OMR' as CurrencyCode, + eur_amount: 0, + category: 'Food', + comment: 'one baisa', + date: '2026-01-01', + }, + { + amount: 1.234, + currency: 'OMR' as CurrencyCode, + eur_amount: 2.46, + category: 'Food', + comment: 'lunch', + date: '2026-01-02', + }, + ]; + + const stats = computeExpenseStats(expenses, 'OMR' as CurrencyCode); + expect(stats.total).toBe(1.235); + expect(stats.avg).toBe(0.6175); + expect(stats.median).toBe(0.6175); + expect(stats.min?.amount).toBe(0.001); + expect(stats.min?.comment).toBe('one baisa'); + expect(stats.max?.amount).toBe(1.234); + }); + test('returns zeroed stats for empty array', () => { const stats = computeExpenseStats([], 'RSD'); expect(stats.count).toBe(0); diff --git a/src/services/ai/stats.ts b/src/services/ai/stats.ts index d64120e5..f54cb3d2 100644 --- a/src/services/ai/stats.ts +++ b/src/services/ai/stats.ts @@ -1,8 +1,14 @@ /** Pre-calculated statistics for expense data β€” avg, median, min, max, and trend/diff formatters */ +import Big from 'big.js'; import type { CurrencyCode } from '../../config/constants'; import { BASE_CURRENCY } from '../../config/constants'; import type { Expense } from '../../database/types'; import { convertCurrency, formatAmount } from '../currency/converter'; +import { + expenseAmountInCurrency, + sumExpensesInCurrency, + usesOriginalCurrencyAmounts, +} from '../currency/totals'; type ExpenseRecord = Pick< Expense, @@ -18,6 +24,24 @@ export interface ExpenseStats { max: { amount: number; comment: string; category: string; date: string } | null; } +function medianAmount(amounts: number[], displayCurrency: CurrencyCode): number { + const sorted = [...amounts].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + let median: number; + if (sorted.length % 2 === 0) { + median = usesOriginalCurrencyAmounts(displayCurrency) + ? new Big(sorted[mid - 1] ?? 0) + .plus(sorted[mid] ?? 0) + .div(2) + .toNumber() + : ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2; + } else { + median = sorted[mid] ?? 0; + } + + return median; +} + export function computeExpenseStats( expenses: ExpenseRecord[], displayCurrency: CurrencyCode, @@ -27,25 +51,20 @@ export function computeExpenseStats( } const converted = expenses.map((e) => ({ - displayAmount: convertCurrency(e.eur_amount, BASE_CURRENCY, displayCurrency), + displayAmount: expenseAmountInCurrency(e, displayCurrency), comment: e.comment, category: e.category, date: e.date, })); const amounts = converted.map((e) => e.displayAmount); - const total = amounts.reduce((s, a) => s + a, 0); + const total = usesOriginalCurrencyAmounts(displayCurrency) + ? sumExpensesInCurrency(expenses, displayCurrency) + : amounts.reduce((s, a) => s + a, 0); const count = amounts.length; const avg = total / count; - const sorted = [...amounts].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - let median: number; - if (sorted.length % 2 === 0) { - median = ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2; - } else { - median = sorted[mid] ?? 0; - } + const median = medianAmount(amounts, displayCurrency); // Non-null assertion safe here: we checked expenses.length > 0 at the top // biome-ignore lint/style/noNonNullAssertion: guarded by early return on empty array @@ -133,6 +152,20 @@ function aggregateByCategory( expenses: ExpenseRecord[], displayCurrency: CurrencyCode, ): Record { + if (usesOriginalCurrencyAmounts(displayCurrency)) { + const grouped = new Map(); + for (const expense of expenses) { + const items = grouped.get(expense.category) ?? []; + items.push(expense); + grouped.set(expense.category, items); + } + return Object.fromEntries( + [...grouped].map(([category, items]) => [ + category, + sumExpensesInCurrency(items, displayCurrency), + ]), + ); + } const result: Record = {}; for (const e of expenses) { const displayAmount = convertCurrency(e.eur_amount, BASE_CURRENCY, displayCurrency); diff --git a/src/services/ai/system-prompt.ts b/src/services/ai/system-prompt.ts index 16310f21..41cef807 100644 --- a/src/services/ai/system-prompt.ts +++ b/src/services/ai/system-prompt.ts @@ -7,6 +7,7 @@ import { format } from 'date-fns'; import { formatCommandsForPrompt } from '../../bot/command-descriptions'; +import { SUPPORTED_CURRENCIES } from '../../config/constants'; /** Separator between the blocks of the combined system prompt. */ const BLOCK_SEPARATOR = '\n\n'; @@ -72,7 +73,7 @@ If an expense has no comment in the tool result, show nothing β€” do NOT invent 12. User asks you to remember, note, or save ANYTHING β€” a fact about a person, an account, a rule, a preference, any context β€” β†’ call set_custom_prompt with mode="append". NEVER say "got it", "noted", "Π·Π°ΠΏΠΎΠΌΠ½ΠΈΠ»", or "remembered" without calling the tool first. This includes phrases like "Π·Π°ΠΏΠΎΠΌΠ½ΠΈ Ρ‡Ρ‚ΠΎ", "note that", "keep in mind", "ΡƒΡ‡Ρ‚ΠΈ Ρ‡Ρ‚ΠΎ". 13. Recurring patterns β†’ call get_recurring_patterns. To manage (pause/resume/dismiss/delete) β†’ call manage_recurring_pattern. 14. Changing a group setting (default currency, enabled currencies, bank-transaction cards) β†’ call update_group_setting with setting= and value=. You CAN change these β€” never reply that you can't, and never tell the user to open /settings instead. Call get_group_settings first if you need current values, then apply and confirm the new value. - - default_currency accepts ONLY the built-in supported codes (USD, EUR, RUB, RSD, GBP, BYN, CHF, JPY, CNY, INR, LKR, AED, EGP). + - default_currency accepts ONLY the built-in supported codes (${SUPPORTED_CURRENCIES.join(', ')}). - TOPIC: to BIND the bot to a topic the user must run /topic INSIDE that topic β€” update_group_setting active_topic_id can only CLEAR the binding ("сброс"), never set an id. Tell the user to use /topic for binding. - custom_prompt via update_group_setting OVERWRITES the entire prompt β€” use it ONLY for an explicit full rewrite or clear ("ΠΏΠ΅Ρ€Π΅ΠΏΠΈΡˆΠΈ ΠΏΡ€ΠΎΠΌΠΏΡ‚", "очисти ΠΏΡ€ΠΎΠΌΠΏΡ‚"). To remember/add a note WITHOUT wiping existing notes, use set_custom_prompt (append) per rule 12. diff --git a/src/services/ai/tool-executor.expenses.test.ts b/src/services/ai/tool-executor.expenses.test.ts index b9e2f550..2b7a997c 100644 --- a/src/services/ai/tool-executor.expenses.test.ts +++ b/src/services/ai/tool-executor.expenses.test.ts @@ -665,3 +665,138 @@ describe('executeGetExpenses β€” batch periods and stats', () => { expect(result.output).toContain('=== Overall ==='); }); }); + +describe('get_expenses preserves original OMR precision', () => { + beforeEach(() => { + resetAllMocks(); + mockExpenses.findByDateRange.mockReturnValue([ + makeExpense({ + id: 1, + date: '2026-01-02', + amount: 0.001, + currency: 'OMR', + eur_amount: 0, + comment: 'one baisa', + }), + makeExpense({ + id: 2, + date: '2026-01-01', + amount: 1.234, + currency: 'OMR', + eur_amount: 2.46, + comment: 'lunch', + }), + ]); + }); + + test('summary and category retain baisa instead of reconstructing rounded EUR', async () => { + const result = await executeTool( + 'get_expenses', + { period: '2026-01', summary_only: true, currency: 'OMR' }, + ctx, + ); + expect(result.success).toBe(true); + expect(result.output).toContain('Total: 1.235 OMR'); + expect(result.output).toContain('Food: 1.235 OMR (2 ops)'); + expect(result.output).toContain('total: 1.235 OMR'); + expect(result.output).toContain('min: 0.001 OMR'); + }); + + test('detail pages carry the precise whole-set total and native page statistics', async () => { + const result = await executeTool( + 'get_expenses', + { period: '2026-01', currency: 'OMR', page_size: 1 }, + ctx, + ); + expect(result.success).toBe(true); + expect(result.output).toContain('Total for all 2 expenses: 1.235 OMR (= 2.46 EUR)'); + expect(result.output).toContain('total: 1.235 OMR'); + expect(result.output).toContain('total: 0.001 OMR'); + }); + + test('requested EUR keeps stored cents while the OMR group total remains exact', async () => { + const group = mockGroups.findById(); + mockGroups.findById.mockReturnValue(group && { ...group, default_currency: 'OMR' }); + const result = await executeTool('get_expenses', { period: '2026-01', currency: 'EUR' }, ctx); + expect(result.success).toBe(true); + expect(result.output).toContain('Total for all 2 expenses: 2.46 EUR (= 1.235 OMR)'); + expect(result.output).toContain('total: 2.46 EUR'); + }); + + test('batch overall totals preserve native precision across periods', async () => { + const tiny = makeExpense({ date: '2026-01-02', amount: 0.001, currency: 'OMR', eur_amount: 0 }); + const lunch = makeExpense({ + date: '2026-02-01', + amount: 1.234, + currency: 'OMR', + eur_amount: 2.46, + }); + mockExpenses.findByDateRange.mockReturnValueOnce([tiny]).mockReturnValueOnce([lunch]); + const result = await executeTool( + 'get_expenses', + { period: ['2026-01', '2026-02'], summary_only: true, currency: 'OMR' }, + ctx, + ); + expect(result.success).toBe(true); + expect(result.output).toContain('=== Overall ===\nTotal: 1.235 OMR'); + }); +}); + +describe('get_expenses native travel currency reports', () => { + beforeEach(resetAllMocks); + + test.each([ + 'GEL', + 'THB', + 'MYR', + 'KHR', + 'TRY', + ] as const)('%s summaries and pagination use original totals', async (currency) => { + mockExpenses.findByDateRange.mockReturnValue([ + makeExpense({ + id: 1, + amount: 1.21, + currency, + eur_amount: 0, + comment: 'small', + date: '2026-04-01', + }), + makeExpense({ + id: 2, + amount: 2.34, + currency, + eur_amount: 0.01, + comment: 'large', + date: '2026-04-02', + }), + ]); + const summary = await executeTool( + 'get_expenses', + { period: '2026-04', summary_only: true, currency }, + ctx, + ); + expect(summary.success).toBe(true); + expect(summary.output).toContain(`Total: 3.55 ${currency}`); + expect(summary.output).toContain(`Food: 3.55 ${currency} (2 ops)`); + expect(summary.output).toContain(`min: 1.21 ${currency}`); + const detail = await executeTool( + 'get_expenses', + { period: '2026-04', currency, page_size: 1 }, + ctx, + ); + expect(detail.success).toBe(true); + expect(detail.output).toContain(`Total for all 2 expenses: 3.55 ${currency} (= 0.01 EUR)`); + // Detail rows sort newest first, so page 1 contains the second dated expense. + expect(detail.output).toContain('[id:2]'); + expect(detail.output).toContain(`total: 2.34 ${currency}`); + const group = mockGroups.findById(); + mockGroups.findById.mockReturnValue(group && { ...group, default_currency: currency }); + const normalized = await executeTool( + 'get_expenses', + { period: '2026-04', currency: 'EUR' }, + ctx, + ); + expect(normalized.success).toBe(true); + expect(normalized.output).toContain(`Total for all 2 expenses: 0.01 EUR (= 3.55 ${currency})`); + }); +}); diff --git a/src/services/ai/tool-executor.test.ts b/src/services/ai/tool-executor.test.ts index bcfc230a..9d93801c 100644 --- a/src/services/ai/tool-executor.test.ts +++ b/src/services/ai/tool-executor.test.ts @@ -433,6 +433,15 @@ describe('calculate tool', () => { expect(result.output).toBe('33.33'); // pure math β€” no currency suffix }); + test('currency arithmetic preserves OMR milli-rials and accepts lowercase target codes', async () => { + const result = await executeTool( + 'calculate', + { expression: '1.234 OMR + 0.001 omani rial', target_currency: 'omr' }, + ctx, + ); + expect(result).toMatchObject({ success: true, output: '1.235 OMR' }); + }); + test('pure math omits currency from output', async () => { const result = await executeTool('calculate', { expression: '50 + 50' }, ctx); expect(result.success).toBe(true); diff --git a/src/services/ai/tool-executor.ts b/src/services/ai/tool-executor.ts index 8cdef92b..cc210977 100644 --- a/src/services/ai/tool-executor.ts +++ b/src/services/ai/tool-executor.ts @@ -5,7 +5,12 @@ import type Big from 'big.js'; import { format } from 'date-fns'; import { marked } from 'marked'; -import { BASE_CURRENCY, type CurrencyCode, SUPPORTED_CURRENCIES } from '../../config/constants'; +import { + BASE_CURRENCY, + type CurrencyCode, + getCurrencyPrecision, + SUPPORTED_CURRENCIES, +} from '../../config/constants'; import { database } from '../../database'; import type { BankTransaction, BankTransactionFilters } from '../../database/types'; import { getErrorMessage } from '../../utils/error'; @@ -584,7 +589,7 @@ function executeManageCategorySingle(action: string, name: string, ctx: AgentCon * Values >= 1,000,000 use scientific notation (e.g. 1.26e8). * Smaller values are rounded to 2 decimal places with trailing zeros stripped. */ -function formatCalculatorResult(n: Big): string { +function formatCalculatorResult(n: Big, precision = 2): string { if (n.abs().gte(1_000_000)) { // 4 significant figures, strip trailing zeros and normalize exponent sign return n @@ -592,7 +597,7 @@ function formatCalculatorResult(n: Big): string { .replace(/\.?0+(e)/, '$1') .replace('e+', 'e'); } - return n.toFixed(2).replace(/\.?0+$/, ''); + return n.toFixed(precision).replace(/\.?0+$/, ''); } function executeCalculate(input: Record, ctx: AgentContext): ToolResult { @@ -607,10 +612,11 @@ function executeCalculate(input: Record, ctx: AgentContext): To } const rawCurrency = (input['target_currency'] as string | undefined) || group.default_currency || BASE_CURRENCY; - if (!SUPPORTED_CURRENCIES.includes(rawCurrency as CurrencyCode)) { + const currencyCode = rawCurrency.trim().toUpperCase(); + if (!SUPPORTED_CURRENCIES.includes(currencyCode as CurrencyCode)) { return { success: false, error: `Unknown currency: "${rawCurrency}"` }; } - const targetCurrency = rawCurrency as CurrencyCode; + const targetCurrency = currencyCode as CurrencyCode; const result = evaluateCurrencyExpression(expression, targetCurrency); if (result === null) { @@ -624,7 +630,7 @@ function executeCalculate(input: Record, ctx: AgentContext): To // Single number with optional currency β€” might just need no operator hint = 'single value with no operator; if this is a currency conversion (e.g. "90000 RSD"), it should work β€” check currency spelling'; - } else if (/[^0-9+\-*/Γ—Γ·.,% A-Za-zА-Π―Π°-яёЁ$€£Β₯β‚½β‚Έβ‚΄β‚ΌΰΈΏ]/.test(expression)) { + } else if (/[^0-9+\-*/Γ—Γ·.,% \p{L}\p{M}$€£Β₯β‚½β‚Έβ‚΄β‚ΌΰΈΏβ‚Ύ\u20c4]/u.test(expression)) { hint = 'expression contains unsupported characters'; } else { hint = @@ -633,7 +639,10 @@ function executeCalculate(input: Record, ctx: AgentContext): To return { success: false, error: `Cannot evaluate: "${expression}" β€” ${hint}` }; } - const formatted = formatCalculatorResult(result.value); + const formatted = formatCalculatorResult( + result.value, + result.hasCurrency ? getCurrencyPrecision(targetCurrency) : 2, + ); const output = result.hasCurrency ? `${formatted} ${targetCurrency}` : formatted; return { success: true, output }; } diff --git a/src/services/ai/tool-executor/expenses-report.ts b/src/services/ai/tool-executor/expenses-report.ts index 0c77be4a..912c413f 100644 --- a/src/services/ai/tool-executor/expenses-report.ts +++ b/src/services/ai/tool-executor/expenses-report.ts @@ -14,7 +14,8 @@ import type { Expense } from '../../../database/types'; import { createLogger } from '../../../utils/logger.ts'; import { normalizeArrayParam, resolvePeriodDates } from '../../../utils/period'; import { formatReceiptCommentForTelegram } from '../../../utils/receipt-display'; -import { convertCurrency, formatAmount } from '../../currency/converter'; +import { formatAmount } from '../../currency/converter'; +import { sumExpensesInCurrency, usesOriginalCurrencyAmounts } from '../../currency/totals'; import { computeExpenseStats, type ExpenseStats, @@ -36,6 +37,7 @@ interface PeriodData { } interface CategoryTotals { + expenses: Expense[]; count: number; eur_total: number; amounts: Record; @@ -104,9 +106,10 @@ function aggregateByCategory(expenses: Expense[]): Record = {}; for (const e of expenses) { const existing = totals[e.category]; - const cat = existing ?? { count: 0, eur_total: 0, amounts: {} }; + const cat = existing ?? { expenses: [], count: 0, eur_total: 0, amounts: {} }; totals[e.category] = cat; cat.count++; + cat.expenses.push(e); cat.eur_total += e.eur_amount; cat.amounts[e.currency] = (cat.amounts[e.currency] || 0) + e.amount; } @@ -129,18 +132,26 @@ function renderPeriodSummary( } const totals = aggregateByCategory(pd.expenses); - const totalEur = Object.values(totals).reduce((s, c) => s + c.eur_total, 0); - const totalDisplay = convertCurrency(totalEur, BASE_CURRENCY, displayCurrency); + const totalDisplay = sumExpensesInCurrency(pd.expenses, displayCurrency); lines.push(`Total: ${formatAmount(totalDisplay, displayCurrency, true)}`, ''); - const sorted = Object.entries(totals).sort((a, b) => b[1].eur_total - a[1].eur_total); - for (const [cat, data] of sorted) { + const sorted = Object.entries(totals) + .map(([category, data]) => ({ + category, + data, + displayTotal: sumExpensesInCurrency(data.expenses, displayCurrency), + })) + .sort((a, b) => + usesOriginalCurrencyAmounts(displayCurrency) + ? b.displayTotal - a.displayTotal + : b.data.eur_total - a.data.eur_total, + ); + for (const { category: cat, data, displayTotal } of sorted) { const amountParts = Object.entries(data.amounts) .map(([c, a]) => formatAmount(a, c as CurrencyCode, true)) .join(', '); - const catDisplay = convertCurrency(data.eur_total, BASE_CURRENCY, displayCurrency); lines.push( - `${cat}: ${formatAmount(catDisplay, displayCurrency, true)} (${data.count} ops) [${amountParts}]`, + `${cat}: ${formatAmount(displayTotal, displayCurrency, true)} (${data.count} ops) [${amountParts}]`, ); } @@ -191,8 +202,7 @@ function renderBatchOverall( displayCurrency: CurrencyCode, ): string[] { const overallStats = computeExpenseStats(allExpenses, displayCurrency); - const overallEur = allExpenses.reduce((s, e) => s + e.eur_amount, 0); - const overallDisplay = convertCurrency(overallEur, BASE_CURRENCY, displayCurrency); + const overallDisplay = sumExpensesInCurrency(allExpenses, displayCurrency); return [ '=== Overall ===', `Total: ${formatAmount(overallDisplay, displayCurrency, true)}`, @@ -244,14 +254,13 @@ function buildDetailOutput( const offset = (page - 1) * pageSize; const pageItems = allExpenses.slice(offset, offset + pageSize); - const totalEur = allExpenses.reduce((s, e) => s + e.eur_amount, 0); - const totalDisplay = convertCurrency(totalEur, BASE_CURRENCY, displayCurrency); + const totalDisplay = sumExpensesInCurrency(allExpenses, displayCurrency); // A requested currency other than the group's also gets the total in the group currency, so // the model never has to convert the authoritative total itself. const totalInDefault = displayCurrency === defaultCurrency ? '' - : ` (= ${formatAmount(convertCurrency(totalEur, BASE_CURRENCY, defaultCurrency), defaultCurrency, true)})`; + : ` (= ${formatAmount(sumExpensesInCurrency(allExpenses, defaultCurrency), defaultCurrency, true)})`; const firstPeriod = periodData[0]; const lastPeriod = periodData[periodData.length - 1]; diff --git a/src/services/analytics/formatters.ts b/src/services/analytics/formatters.ts index 509c46d3..46422f33 100644 --- a/src/services/analytics/formatters.ts +++ b/src/services/analytics/formatters.ts @@ -70,7 +70,7 @@ export function formatSnapshotForPrompt( const bankAccounts = database.bankAccounts.findByGroupId(groupId); if (bankAccounts.length > 0) { const balanceLines = bankAccounts - .map((a) => `${a.title}: ${a.balance.toFixed(2)} ${a.currency}`) + .map((a) => `${a.title}: ${formatAmount(a.balance, a.currency, true)}`) .join('\n'); result += `\n\n## БанковскиС балансы\n${balanceLines}`; } diff --git a/src/services/bank/panel-builder.ts b/src/services/bank/panel-builder.ts index f8f0e87d..86fc7a2e 100644 --- a/src/services/bank/panel-builder.ts +++ b/src/services/bank/panel-builder.ts @@ -1,6 +1,7 @@ // Builds bank status panel text and keyboard β€” shared between bot commands and sync service. import { database } from '../../database'; import type { BankConnection } from '../../database/types'; +import { formatAmount } from '../currency/converter'; export interface PanelButton { text: string; @@ -41,7 +42,7 @@ function renderBankSection(conn: BankConnection, cardsEnabled: boolean): string const balanceLine = accounts.length > 0 - ? accounts.map((a) => `${a.balance.toFixed(2)} ${a.currency}`).join(', ') + ? accounts.map((a) => formatAmount(a.balance, a.currency, true)).join(', ') : conn.last_sync_at ? 'балансы Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Ρ‹' : 'балансы загрузятся послС ΠΏΠ΅Ρ€Π²ΠΎΠΉ синхронизации'; @@ -55,7 +56,7 @@ function renderBankSection(conn: BankConnection, cardsEnabled: boolean): string pendingTxs .map( (tx) => - `β€’ ${tx.amount.toFixed(2)} ${tx.currency} β€” ${tx.merchant_normalized ?? tx.merchant ?? 'β€”'} Β· ⏳ ΠΎΠΆΠΈΠ΄Π°Π΅Ρ‚`, + `β€’ ${formatAmount(tx.amount, tx.currency, true)} β€” ${tx.merchant_normalized ?? tx.merchant ?? 'β€”'} Β· ⏳ ΠΎΠΆΠΈΠ΄Π°Π΅Ρ‚`, ) .join('\n') : ''; diff --git a/src/services/currency/budget-amount-parser.ts b/src/services/currency/budget-amount-parser.ts index cbbfea08..2a2d1dac 100644 --- a/src/services/currency/budget-amount-parser.ts +++ b/src/services/currency/budget-amount-parser.ts @@ -1,6 +1,7 @@ // Strict parser for a lone budget amount ("150", "150 eur", "1 500 RSD", "$150") typed by the user -import { CURRENCY_ALIASES, type CurrencyCode } from '../../config/constants'; +import { type CurrencyCode, getCurrencyPrecision } from '../../config/constants'; +import { CURRENCY_TOKEN_PATTERN, normalizeCurrencyAlias, normalizeCurrencyText } from './tokens'; export interface ParsedBudgetAmount { amount: number; @@ -14,15 +15,14 @@ export const BUDGET_MAX_AMOUNT = 1_000_000_000_000; const MAX_AMOUNT = BUDGET_MAX_AMOUNT; // Space or NBSP separates thousands groups ("1 500", "1\u00A0500"); \d cannot stand in for it. -const NUMBER = String.raw`(\d{1,3}(?:[ ${'\u00A0'}]\d{3})+(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?)`; -const PREFIX_FORM = new RegExp(String.raw`^([$€£₽Β₯])\s*${NUMBER}$`); -const SUFFIX_FORM = new RegExp(String.raw`^${NUMBER}\s*([$€£₽Β₯]|[\p{L}]+)?$`, 'u'); - -function toCurrency(token: string | undefined): CurrencyCode | null | undefined { - if (!token) return null; - const code = CURRENCY_ALIASES[token.toLowerCase()]; - // undefined = unknown word, so the whole input is prose, not an amount - return code ? (code as CurrencyCode) : undefined; +const NUMBER = String.raw`(\d{1,3}(?:[ ${'\u00A0'}]\d{3})+(?:[.,]\d{1,3})?|\d+(?:[.,]\d{1,3})?)`; +const PREFIX_FORM = new RegExp(`^(${CURRENCY_TOKEN_PATTERN})\\s*${NUMBER}$`, 'iu'); +const SUFFIX_FORM = new RegExp(`^${NUMBER}\\s*(${CURRENCY_TOKEN_PATTERN})?$`, 'iu'); + +function validPrecision(raw: string, currency: CurrencyCode | null): boolean { + const decimal = raw.match(/[.,](\d+)$/)?.[1]; + // Currency-free amounts are resolved against the active prompt by the caller. + return !decimal || decimal.length <= (currency ? getCurrencyPrecision(currency) : 3); } function toAmount(raw: string): number | null { @@ -32,26 +32,40 @@ function toAmount(raw: string): number | null { return amount; } +function parseMatchedAmount( + rawAmount: string, + rawCurrency: string, + defaultCurrency: CurrencyCode | null, +): ParsedBudgetAmount | null { + const amount = toAmount(rawAmount); + const currency = rawCurrency ? normalizeCurrencyAlias(rawCurrency) : null; + if ( + amount === null || + (rawCurrency && !currency) || + !validPrecision(rawAmount, currency ?? defaultCurrency) + ) + return null; + return { amount, currency }; +} + /** * Parse text that consists ONLY of an amount with an optional currency. * Anything else (extra words, unknown currency words, several numbers) returns null so that * ordinary expense lines and prose are never swallowed by a budget prompt. */ -export function parseBudgetInputAmount(text: string): ParsedBudgetAmount | null { - const trimmed = text.trim(); +export function parseBudgetInputAmount( + text: string, + defaultCurrency: CurrencyCode | null = null, +): ParsedBudgetAmount | null { + const trimmed = normalizeCurrencyText(text).trim(); if (!trimmed || trimmed.includes('\n')) return null; const prefix = PREFIX_FORM.exec(trimmed); if (prefix) { - const amount = toAmount(prefix[2] ?? ''); - const currency = toCurrency(prefix[1]); - return amount !== null && currency ? { amount, currency } : null; + return parseMatchedAmount(prefix[2] ?? '', prefix[1] ?? '', defaultCurrency); } const suffix = SUFFIX_FORM.exec(trimmed); if (!suffix) return null; - const amount = toAmount(suffix[1] ?? ''); - const currency = toCurrency(suffix[2]); - if (amount === null || currency === undefined) return null; - return { amount, currency }; + return parseMatchedAmount(suffix[1] ?? '', suffix[2] ?? '', defaultCurrency); } diff --git a/src/services/currency/calculator.ts b/src/services/currency/calculator.ts index 41ed3640..8b9191b1 100644 --- a/src/services/currency/calculator.ts +++ b/src/services/currency/calculator.ts @@ -2,30 +2,18 @@ * Currency-aware expression evaluator β€” used by the AI calculate tool */ import Big from 'big.js'; -import { CURRENCY_ALIASES, type CurrencyCode } from '../../config/constants'; +import type { CurrencyCode } from '../../config/constants'; import { convertCurrencyBig } from './converter'; import { evaluateMathExpressionBig } from './parser'; +import { CURRENCY_TOKEN_PATTERN, normalizeCurrencyAlias, normalizeCurrencyText } from './tokens'; -// All currency aliases, sorted longest-first (prevents partial matches on shorter entries) -const SORTED_ALIASES: Array<[string, CurrencyCode]> = ( - Object.entries(CURRENCY_ALIASES) as Array<[string, string]> -) - .filter(([alias]) => !/\s/.test(alias)) // skip multi-word aliases (e.g. "бСлорусский Ρ€ΡƒΠ±Π»ΡŒ") - .sort(([a], [b]) => b.length - a.length) - .map(([alias, code]) => [alias, code as CurrencyCode]); - -// Number pattern: digits with optional decimal (dot or comma) +// A complete token must border a number/operator or whitespace, never part of another word. const NUM = '\\d+(?:[.,]\\d+)?'; - -// Currency identifier pattern (longest aliases first, all special chars escaped) -const CURR = SORTED_ALIASES.map(([alias]) => alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join( - '|', +const AMOUNT_RE = new RegExp( + `(? { const replacement = convertAmountMatch( diff --git a/src/services/currency/converter.test.ts b/src/services/currency/converter.test.ts index 7007654a..18ad49c3 100644 --- a/src/services/currency/converter.test.ts +++ b/src/services/currency/converter.test.ts @@ -358,9 +358,9 @@ describe('formatAmount (aiContext=true)', () => { }); describe('getAllExchangeRates', () => { - it('returns object with all 13 currencies', () => { + it('returns object with all 19 currencies', () => { const rates = getAllExchangeRates(); - expect(Object.keys(rates)).toHaveLength(13); + expect(Object.keys(rates)).toHaveLength(19); }); it('EUR rate is 1.0', () => { @@ -400,6 +400,9 @@ describe('getAllExchangeRates', () => { expect(keys).toContain('LKR'); expect(keys).toContain('AED'); expect(keys).toContain('EGP'); + expect(keys).toContain('GEL'); + expect(keys).toContain('OMR'); + expect(keys).toContain('THB'); }); }); diff --git a/src/services/currency/converter.ts b/src/services/currency/converter.ts index 02d1328f..e417ae78 100644 --- a/src/services/currency/converter.ts +++ b/src/services/currency/converter.ts @@ -3,6 +3,7 @@ import Big from 'big.js'; import { BASE_CURRENCY, type CurrencyCode, + getCurrencyPrecision, getCurrencySymbol, SUPPORTED_CURRENCIES, } from '../../config/constants'; @@ -27,6 +28,13 @@ const FALLBACK_RATES_STR: Record = { INR: '0.011', // 1 INR = 0.011 EUR (approx 90 INR = 1 EUR) LKR: '0.0028', // 1 LKR = 0.0028 EUR (approx 360 LKR = 1 EUR) AED: '0.25', // 1 AED = 0.25 EUR (approx 4 AED = 1 EUR) + // Offline fallback snapshot: open.er-api.com/v6/latest/EUR, 2026-11-10 00:02:31 UTC. + GEL: '0.34475340478462563', // 1 EUR = 2.900624 GEL + OMR: '2.3211011303762503', // 1 EUR = 0.43083 OMR + THB: '0.02667649713370375', // 1 EUR = 37.486181 THB + MYR: '0.21989200224201885', // 1 EUR = 4.547687 MYR + KHR: '0.000221004757402775', // 1 EUR = 4524.789474 KHR + TRY: '0.017772842456903522', // 1 EUR = 56.26562 TRY EGP: '0.018', // 1 EGP = 0.018 EUR (approx 55.5 EGP = 1 EUR) }; @@ -39,13 +47,11 @@ const FALLBACK_RATES: Record = Object.fromEntries( * These are approximate values for when the API is unavailable. */ const BANK_FALLBACK_RATES: Record = { - GEL: 1 / 2.94, // Georgian Lari (~2.94 GEL = 1 EUR) KZT: 1 / 514, // Kazakh Tenge AMD: 1 / 408, // Armenian Dram UAH: 1 / 44, // Ukrainian Hryvnia UZS: 1 / 13500, // Uzbek Som AZN: 1 / 1.84, // Azerbaijani Manat - TRY: 1 / 35, // Turkish Lira ILS: 1 / 3.96, // Israeli Shekel PLN: 1 / 4.28, // Polish Zloty CZK: 1 / 25.2, // Czech Koruna @@ -99,6 +105,12 @@ const LOGGED_RATE_DECIMALS: ReadonlyArray<[CurrencyCode, number]> = [ ['LKR', 6], ['AED', 4], ['EGP', 6], + ['GEL', 4], + ['OMR', 4], + ['THB', 6], + ['MYR', 4], + ['KHR', 6], + ['TRY', 6], ]; /** Supported currencies other than the base, in the order rate tables are built. */ @@ -106,11 +118,15 @@ const NON_BASE_CURRENCIES = SUPPORTED_CURRENCIES.filter( (currency): currency is Exclude => currency !== 'EUR', ); -/** API gives "1 EUR = X currency"; invert to "1 currency = 1/X EUR" (missing rate β†’ 1). */ +/** Invert API quotes; missing or invalid individual quotes retain the offline rate. */ function buildEurRates(apiRates: Record): Record { const rates = { EUR: 1.0 } as Record; for (const currency of NON_BASE_CURRENCIES) { - rates[currency] = 1 / (apiRates[currency] || 1); + const quote = apiRates[currency]; + rates[currency] = + typeof quote === 'number' && Number.isFinite(quote) && quote > 0 + ? 1 / quote + : FALLBACK_RATES[currency]; } return rates; } @@ -122,7 +138,11 @@ function buildEurRates(apiRates: Record): Record): Record { const rates = { EUR: '1' } as Record; for (const currency of NON_BASE_CURRENCIES) { - rates[currency] = new Big(1).div(new Big(String(apiRates[currency] || 1))).toFixed(15); + const quote = apiRates[currency]; + rates[currency] = + typeof quote === 'number' && Number.isFinite(quote) && quote > 0 + ? new Big(1).div(String(quote)).toFixed(15) + : FALLBACK_RATES_STR[currency]; } return rates; } @@ -132,7 +152,7 @@ function buildKnownRates(apiRates: Record): Record = { EUR: 1.0 }; for (const currency of knownCurrencies) { const apiRate = apiRates[currency]; - if (typeof apiRate === 'number' && apiRate > 0) { + if (typeof apiRate === 'number' && Number.isFinite(apiRate) && apiRate > 0) { allRates[currency] = 1 / apiRate; } } @@ -254,7 +274,8 @@ export function convertCurrency( // rates[X] = "1 X = Y EUR" // from -> EUR -> to const eurAmount = amount * fromRate; - return Math.round((eurAmount / toRate) * 100) / 100; + const factor = 10 ** getCurrencyPrecision(toCurrency); + return Math.round((eurAmount / toRate) * factor) / factor; } /** @@ -296,9 +317,10 @@ export function getExchangeRate(currency: CurrencyCode): number { */ export function formatAmount(amount: number, currency: string, aiContext = false): string { const abs = Math.abs(amount); + const precision = getCurrencyPrecision(currency); if (aiContext) { - const exact = amount.toFixed(2); + const exact = amount.toFixed(precision); if (abs >= 1_000_000_000) { return `${exact} (${+(amount / 1_000_000_000).toFixed(2)} ΠΌΠ»Ρ€Π΄) ${currency}`; } @@ -315,7 +337,7 @@ export function formatAmount(amount: number, currency: string, aiContext = false } else if (abs >= 1_000_000) { num = `${+(amount / 1_000_000).toFixed(2)} ΠΌΠ»Π½`; } else { - num = amount.toFixed(2); + num = amount.toFixed(precision); } return `${num} ${symbol}`; } diff --git a/src/services/currency/currency-rates.test.ts b/src/services/currency/currency-rates.test.ts new file mode 100644 index 00000000..89689885 --- /dev/null +++ b/src/services/currency/currency-rates.test.ts @@ -0,0 +1,86 @@ +/** Live/cache/offline exchange-rate behavior for newly supported currencies; no network calls. */ +import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; +import Big from 'big.js'; +import { createMockLogger } from '../../test-utils/mocks/logger'; + +const logMock = createMockLogger(); +mock.module('../../utils/logger.ts', () => ({ createLogger: () => logMock, logger: logMock })); +const { + convertAnyToEUR, + convertCurrency, + convertCurrencyBig, + getAllExchangeRates, + updateExchangeRates, +} = await import('./converter'); +const fallback = getAllExchangeRates(); +const fetchSpy = spyOn(globalThis, 'fetch'); + +beforeEach(() => { + fetchSpy.mockReset(); + logMock.warn.mockClear(); + logMock.error.mockClear(); +}); +afterAll(() => fetchSpy.mockRestore()); + +describe('new currency exchange rates', () => { + test('uses API quotes consistently for manual, bank, and exact calculator conversions', async () => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + result: 'success', + base_code: 'EUR', + rates: { EUR: 1, GEL: 3, OMR: 0.4, THB: 40, MYR: 5, KHR: 4500, TRY: 50 }, + }), + ), + ); + await updateExchangeRates(); + expect(convertCurrency(3, 'GEL', 'EUR')).toBe(1); + expect(convertCurrency(1, 'EUR', 'OMR')).toBe(0.4); + expect(convertCurrency(40, 'THB', 'EUR')).toBe(1); + expect(convertCurrency(5, 'MYR', 'EUR')).toBe(1); + expect(convertCurrency(4500, 'KHR', 'EUR')).toBe(1); + expect(convertCurrency(50, 'TRY', 'EUR')).toBe(1); + expect(convertCurrency(1, 'EUR', 'MYR')).toBe(5); + expect(convertCurrencyBig(new Big('5'), 'MYR', 'OMR').toString()).toBe('0.4'); + expect(convertAnyToEUR(0.4, 'OMR')).toBe(1); + expect(convertCurrencyBig(new Big('0.001'), 'OMR', 'EUR').toString()).toBe('0.0025'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(logMock.warn).not.toHaveBeenCalled(); + expect(logMock.error).not.toHaveBeenCalled(); + }); + + test('uses per-code offline rates for missing or invalid API quotes instead of EUR parity', async () => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + result: 'success', + base_code: 'EUR', + rates: { EUR: 1, GEL: -1, OMR: 0 }, + }), + ), + ); + await updateExchangeRates(); + for (const code of ['GEL', 'OMR', 'THB', 'MYR', 'KHR', 'TRY'] as const) { + expect(getAllExchangeRates()[code]).toBe(fallback[code]); + expect(getAllExchangeRates()[code]).not.toBe(1); + expect(convertAnyToEUR(100, code)).toBe(Math.round(10000 * fallback[code]) / 100); + expect(convertCurrencyBig(new Big('1'), code, 'EUR').toNumber()).toBeCloseTo( + fallback[code], + 12, + ); + } + expect(logMock.warn).not.toHaveBeenCalled(); + expect(logMock.error).not.toHaveBeenCalled(); + }); + + test('clears live caches and retains non-parity fallbacks during an API outage', async () => { + fetchSpy.mockRejectedValue(new Error('synthetic offline test')); + await updateExchangeRates(); + for (const code of ['GEL', 'OMR', 'THB', 'MYR', 'KHR', 'TRY'] as const) { + expect(getAllExchangeRates()[code]).toBe(fallback[code]); + expect(convertAnyToEUR(100, code)).toBe(Math.round(10000 * fallback[code]) / 100); + } + expect(logMock.warn).not.toHaveBeenCalled(); + expect(logMock.error).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/currency/currency-support.test.ts b/src/services/currency/currency-support.test.ts new file mode 100644 index 00000000..1dcc537c --- /dev/null +++ b/src/services/currency/currency-support.test.ts @@ -0,0 +1,229 @@ +/** Currency support contract across text expenses, budget input, arithmetic, and formatting. */ +import { describe, expect, test } from 'bun:test'; +import { type CurrencyCode, getCurrencySymbol, isSupportedCurrency } from '../../config/constants'; +import { parseBudgetInputAmount } from './budget-amount-parser'; +import { evaluateCurrencyExpression } from './calculator'; +import { convertCurrency, formatAmount, getAllExchangeRates } from './converter'; +import { parseExpenseMessage } from './parser'; + +const variants: [string, CurrencyCode][] = [ + ['GEL', 'GEL'], + ['gel', 'GEL'], + ['GeL', 'GEL'], + ['β‚Ύ', 'GEL'], + ['Π»Π°Ρ€ΠΈ', 'GEL'], + ['грузинских Π»Π°Ρ€ΠΈ', 'GEL'], + ['georgian lari', 'GEL'], + ['lari', 'GEL'], + ['laris', 'GEL'], + ['αƒšαƒαƒ αƒ˜', 'GEL'], + ['αƒ₯αƒαƒ αƒ—αƒ£αƒšαƒ˜ αƒšαƒαƒ αƒ˜', 'GEL'], + ['OMR', 'OMR'], + ['omr', 'OMR'], + ['OmR', 'OMR'], + ['RO', 'OMR'], + ['R.O.', 'OMR'], + ['Ρ€.ΠΎ.', 'OMR'], + ['оманский Ρ€ΠΈΠ°Π»', 'OMR'], + ['оманских Ρ€ΠΈΠ°Π»ΠΎΠ²', 'OMR'], + ['Ρ€ΠΈΠ°Π»Π° ΠΎΠΌΠ°Π½Π°', 'OMR'], + ['omani rial', 'OMR'], + ['omani rials', 'OMR'], + ['rial omani', 'OMR'], + ['omani riyals', 'OMR'], + ['Ψ±.ΨΉ.', 'OMR'], + ['Ψ±.ΨΉ.\u200f', 'OMR'], + ['Ψ±ΩŠΨ§Ω„ ΨΉΩ…Ψ§Ω†ΩŠ', 'OMR'], + ['Ψ§Ω„Ψ±ΩŠΨ§Ω„ Ψ§Ω„ΨΉΩ…Ψ§Ω†ΩŠ', 'OMR'], + ['Ψ±ΩŠΨ§Ω„Ψ§Ψͺ ΨΉΩ…Ψ§Ω†ΩŠΨ©', 'OMR'], + ['\u20c4', 'OMR'], + ['THB', 'THB'], + ['thb', 'THB'], + ['ThB', 'THB'], + ['ΰΈΏ', 'THB'], + ['Π±Π°Ρ‚', 'THB'], + ['Π±Π°Ρ‚Π°', 'THB'], + ['Π±Π°Ρ‚ΠΎΠ²', 'THB'], + ['Π±Π°Ρ‚Π°ΠΌΠΈ', 'THB'], + ['тайских Π±Π°Ρ‚ΠΎΠ²', 'THB'], + ['таиландский Π±Π°Ρ‚', 'THB'], + ['тайландских Π±Π°Ρ‚ΠΎΠ²', 'THB'], + ['baht', 'THB'], + ['bahts', 'THB'], + ['thai baht', 'THB'], + ['ΰΈšΰΈ²ΰΈ—', 'THB'], + ['ΰΈšΰΈ²ΰΈ—ΰΉ„ΰΈ—ΰΈ’', 'THB'], + ['MYR', 'MYR'], + ['myr', 'MYR'], + ['MyR', 'MYR'], + ['RM', 'MYR'], + ['Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚', 'MYR'], + ['Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚ΠΎΠ²', 'MYR'], + ['Ρ€ΠΈΠ½Π³ΠΈΡ‚', 'MYR'], + ['малайзийских Ρ€ΠΈΠ½Π³Π³ΠΈΡ‚ΠΎΠ²', 'MYR'], + ['ringgit', 'MYR'], + ['ringgits', 'MYR'], + ['malaysian ringgit', 'MYR'], + ['ringgit malaysia', 'MYR'], + ['KHR', 'KHR'], + ['khr', 'KHR'], + ['KhR', 'KHR'], + ['αŸ›', 'KHR'], + ['Ρ€ΠΈΠ΅Π»ΡŒ', 'KHR'], + ['Ρ€ΠΈΠ΅Π»Π΅ΠΉ', 'KHR'], + ['камбодТийских Ρ€ΠΈΠ΅Π»Π΅ΠΉ', 'KHR'], + ['riel', 'KHR'], + ['riels', 'KHR'], + ['cambodian riel', 'KHR'], + ['αžšαŸ€αž›', 'KHR'], + ['TRY', 'TRY'], + ['try', 'TRY'], + ['TrY', 'TRY'], + ['β‚Ί', 'TRY'], + ['TL', 'TRY'], + ['Π»ΠΈΡ€Π°', 'TRY'], + ['Π»ΠΈΡ€', 'TRY'], + ['Ρ‚ΡƒΡ€Π΅Ρ†ΠΊΠΈΡ… Π»ΠΈΡ€', 'TRY'], + ['turkish lira', 'TRY'], + ['tΓΌrk lirasΔ±', 'TRY'], + ['TΓΌrk LirasΔ±', 'TRY'], + ['TÜRK LΔ°RASI', 'TRY'], + ['turk lirasi', 'TRY'], +]; + +describe('travel currencies in every amount parser', () => { + test.each(variants)('accepts %s before and after an expense amount', (alias, code) => { + for (const input of [ + `12.34 ${alias} food lunch`, + `${alias} 12.34 food lunch`, + `12.34${alias} food lunch`, + `${alias}12.34 food lunch`, + ]) { + expect(parseExpenseMessage(input, 'EUR')).toMatchObject({ + amount: 12.34, + currency: code, + category: 'Food', + comment: 'Lunch', + }); + } + }); + + test.each(variants)('accepts %s in a lone budget amount', (alias, code) => { + expect(parseBudgetInputAmount(`12.34 ${alias}`)).toEqual({ amount: 12.34, currency: code }); + expect(parseBudgetInputAmount(`${alias}12.34`)).toEqual({ amount: 12.34, currency: code }); + }); + + test.each(variants)('accepts %s in currency arithmetic', (alias, code) => { + const result = evaluateCurrencyExpression(`1.25 ${alias} + ${alias} 2.50`, code); + expect(result?.value.toString()).toBe('3.75'); + expect(result?.hasCurrency).toBe(true); + }); + + test.each([ + 'GEL', + 'OMR', + 'THB', + 'MYR', + 'KHR', + 'TRY', + ] as const)('has metadata and positive fallback rates for %s', (code) => { + expect(isSupportedCurrency(code)).toBe(true); + expect(getAllExchangeRates()[code]).toBeGreaterThan(0); + expect(getCurrencySymbol(code)).toBe( + { GEL: 'β‚Ύ', OMR: 'OMR', THB: 'ΰΈΏ', MYR: 'RM', KHR: 'αŸ›', TRY: 'β‚Ί' }[code], + ); + }); + + test.each([ + 'gelatin', + 'ringgitfoo', + 'RMX', + 'Ρ€ΠΈΠ΅Π»Ρ‚ΠΎΡ€', + 'lirafoo', + 'tldr', + 'Π±Π°Ρ‚ΠΎΠ½', + 'bath', + 'bat', + 'rial', + 'riyal', + 'Ρ€ΠΈΠ°Π»', + 'риял', + 'Ψ±ΩŠΨ§Ω„', + 'SAR', + 'QAR', + 'IRR', + ])('does not mistake %s for a supported new currency', (word) => { + expect(parseExpenseMessage(`100 ${word} lunch`, 'EUR')?.currency).toBe('EUR'); + expect(parseBudgetInputAmount(`100 ${word}`)).toBeNull(); + expect(evaluateCurrencyExpression(`100 ${word}`, 'EUR')).toBeNull(); + }); + + test.each([ + ['1\u00a0234,56 RM', 'MYR'], + ['RM1 234.56', 'MYR'], + ['1 234,56αŸ›', 'KHR'], + ['αžšαŸ€αž›1\u00a0234.56', 'KHR'], + ['β‚Ί1 234,56', 'TRY'], + ['1\u00a0234.56 TL', 'TRY'], + ] as const)('accepts grouped amounts in %s', (input, currency) => { + expect(parseExpenseMessage(`${input} food lunch`, 'EUR')).toMatchObject({ + amount: 1234.56, + currency, + category: 'Food', + comment: 'Lunch', + }); + expect(parseBudgetInputAmount(input)).toEqual({ amount: 1234.56, currency }); + }); + + test('keeps lira, pound, riel and Omani rial distinct', () => { + for (const [alias, currency] of [ + ['β‚Ί', 'TRY'], + ['Β£', 'GBP'], + ['Ρ€ΠΈΠ΅Π»ΡŒ', 'KHR'], + ['оманский Ρ€ΠΈΠ°Π»', 'OMR'], + ] as const) { + expect(parseExpenseMessage(`10 ${alias} food`, 'EUR')?.currency).toBe(currency); + } + expect(parseBudgetInputAmount('10 β‚€')).toBeNull(); + expect(parseBudgetInputAmount('10 TRL')).toBeNull(); + expect(parseBudgetInputAmount('10 YTL')).toBeNull(); + }); + + test('does not recognize the Bitcoin sign as baht', () => { + expect(parseExpenseMessage('β‚Ώ100 food', 'EUR')).toBeNull(); + expect(parseBudgetInputAmount('β‚Ώ100')).toBeNull(); + }); +}); + +describe('Omani rial minor units', () => { + test.each([ + '0.001 OMR fee', + 'OMR0.001 fee', + '0,001 omr fee', + '0.001+0.001 OMR fee', + '1\u00a0234,567 OMR fee', + ])('preserves the third decimal in %s', (input) => { + const expected = input.includes('+') ? 0.002 : input.includes('234') ? 1234.567 : 0.001; + expect(parseExpenseMessage(input, 'EUR')).toMatchObject({ amount: expected, currency: 'OMR' }); + }); + test('preserves precision when OMR is the default', () => { + expect(parseExpenseMessage('0.001 fee', 'OMR')?.amount).toBe(0.001); + }); + test('preserves budget milli-rials', () => { + expect(parseBudgetInputAmount('1,234 OMR')).toEqual({ amount: 1.234, currency: 'OMR' }); + expect(parseBudgetInputAmount('OMR0.001')).toEqual({ amount: 0.001, currency: 'OMR' }); + }); + test('formats ordinary amounts and AI context to three places', () => { + expect(formatAmount(1.234, 'OMR')).toBe('1.234 OMR'); + expect(formatAmount(0.001, 'OMR', true)).toBe('0.001 OMR'); + expect(formatAmount(1.234, 'GEL')).toBe('1.23 β‚Ύ'); + expect(formatAmount(1.234, 'THB')).toBe('1.23 ΰΈΏ'); + expect(formatAmount(1.234, 'MYR')).toBe('1.23 RM'); + expect(formatAmount(1.234, 'KHR')).toBe('1.23 αŸ›'); + expect(formatAmount(1.234, 'TRY')).toBe('1.23 β‚Ί'); + }); + test('rounds conversions to the target currency precision', () => { + const rates = getAllExchangeRates(); + expect(convertCurrency(1, 'EUR', 'OMR')).toBe(Math.round(1000 / rates.OMR) / 1000); + }); +}); diff --git a/src/services/currency/parser.test.ts b/src/services/currency/parser.test.ts index cc94d2c6..c2a4fd12 100644 --- a/src/services/currency/parser.test.ts +++ b/src/services/currency/parser.test.ts @@ -146,13 +146,12 @@ describe('parseExpenseMessage', () => { expect(result).toBeNull(); }); - test('should parse amount with currency as category (100 USD parsed as default currency + category USD)', () => { + test('keeps a lone known currency out of the category and requires a real category', () => { const result = parseExpenseMessage('100 USD', 'EUR'); - expect(result).not.toBeNull(); - expect(result?.amount).toBe(100); - expect(result?.currency).toBe('EUR'); // Uses default currency - expect(result?.category).toBe('Usd'); - expect(result?.comment).toBe(''); // Single word = category only, no comment + expect(result).toMatchObject({ amount: 100, currency: 'USD', category: null, comment: '' }); + // A known currency token is never a category in the default currency. + // The message handler rejects incomplete expense entries before any pending/DB write. + expect(validateParsedExpense(result)).toBe(false); }); test('should return null for invalid amount', () => { diff --git a/src/services/currency/parser.ts b/src/services/currency/parser.ts index 7cb73f9a..33cf85bd 100644 --- a/src/services/currency/parser.ts +++ b/src/services/currency/parser.ts @@ -1,7 +1,8 @@ /** Expense message parser β€” extracts amount, currency, category, and comment from free-form text */ import Big from 'big.js'; import currency from 'currency.js'; -import { CURRENCY_ALIASES, type CurrencyCode } from '../../config/constants'; +import { type CurrencyCode, getCurrencyPrecision } from '../../config/constants'; +import { CURRENCY_TOKEN_PATTERN, normalizeCurrencyAlias, normalizeCurrencyText } from './tokens'; export interface ParsedExpense { amount: number; @@ -143,14 +144,6 @@ function evaluateMathExpression(expr: string): number | null { // ── End math expression evaluator ───────────────────────────────────── -/** - * Normalize currency code from alias - */ -function normalizeCurrency(currencyStr: string): CurrencyCode | null { - const normalized = currencyStr.toLowerCase().trim(); - return (CURRENCY_ALIASES[normalized] as CurrencyCode) || null; -} - /** * Parse expense message * @@ -172,7 +165,7 @@ export function parseExpenseMessage( text: string, defaultCurrency: CurrencyCode, ): ParsedExpense | null { - const trimmed = text.trim(); + const trimmed = normalizeCurrencyText(text).trim(); if (!trimmed) { return null; @@ -181,8 +174,7 @@ export function parseExpenseMessage( return ( parseSymbolBeforeAmount(trimmed) ?? parseCurrencyAfterAmount(trimmed) ?? - parseAmountOnly(trimmed, defaultCurrency) ?? - parseCyrillicLetterCurrency(trimmed) + parseAmountOnly(trimmed, defaultCurrency) ); } @@ -194,31 +186,33 @@ function buildParsedExpense( rest: string, ): ParsedExpense | null { if (!currency) return null; - const amount = parseAmount(amountStr); + const amount = parseAmount(amountStr, currency); if (amount === null) return null; const { category, comment } = parseRest(rest); return { amount, currency, category, comment, raw }; } -// Pattern 1: Currency symbol before amount ($190, €100, β‚½500, $10*3) -function parseSymbolBeforeAmount(trimmed: string): ParsedExpense | null { - const match = trimmed.match(/^([$€£₽Β₯])\s*([\d\s,.+*Γ—/]+)\s*(.+)?$/); - if (!match) return null; +const PREFIX_AMOUNT = new RegExp( + `^(${CURRENCY_TOKEN_PATTERN})\\s*([\\d\\s,.+*Γ—/]+)\\s*(.*)$`, + 'iu', +); +const SUFFIX_AMOUNT = new RegExp( + `^([\\d\\s,.+*Γ—/]+)\\s*(${CURRENCY_TOKEN_PATTERN})(?=\\s|$)\\s*(.*)$`, + 'iu', +); - const [, currencySymbol, amountStr, rest] = match; - if (!currencySymbol || !amountStr) return null; - return buildParsedExpense(trimmed, amountStr, normalizeCurrency(currencySymbol), rest || ''); +// Both symbols and complete currency names may precede or follow the amount. +function parseSymbolBeforeAmount(trimmed: string): ParsedExpense | null { + const match = PREFIX_AMOUNT.exec(trimmed); + if (!match?.[1] || !match[2]) return null; + return buildParsedExpense(trimmed, match[2], normalizeCurrencyAlias(match[1]), match[3] ?? ''); } -// Pattern 2: Amount with currency symbol/code after (190€, 100$, 1900RSD, 190 Π΅Π²Ρ€ΠΎ, 10*3$) function parseCurrencyAfterAmount(trimmed: string): ParsedExpense | null { - const match = trimmed.match(/^([\d\s,.+*Γ—/]+)\s*([Π°-яА-ЯёЁa-zA-Z$€£₽Β₯]+)\s+(.+)$/); - if (!match) return null; - - const [, amountStr, currencyStr, rest] = match; - if (!amountStr || !currencyStr || !rest) return null; - return buildParsedExpense(trimmed, amountStr, normalizeCurrency(currencyStr), rest); + const match = SUFFIX_AMOUNT.exec(trimmed); + if (!match?.[1] || !match[2]) return null; + return buildParsedExpense(trimmed, match[1], normalizeCurrencyAlias(match[2]), match[3] ?? ''); } // Pattern 3: Amount only (use default currency), must start with digit @@ -231,22 +225,13 @@ function parseAmountOnly(trimmed: string, defaultCurrency: CurrencyCode): Parsed return buildParsedExpense(trimmed, amountStr, defaultCurrency, rest); } -// Pattern 4: Currency symbol after, no space (190Π΅, 100Π΄, 10*3Π΄) -function parseCyrillicLetterCurrency(trimmed: string): ParsedExpense | null { - const match = trimmed.match(/^([\d\s,.+*Γ—/]+)([Π°-яА-ЯёЁ])\s+(.+)$/); - if (!match) return null; - - const [, amountStr, currencyLetter, rest] = match; - if (!amountStr || !currencyLetter || !rest) return null; - return buildParsedExpense(trimmed, amountStr, normalizeCurrency(currencyLetter), rest); -} - /** * Parse amount string to number * Handles: 190, 1900, 1 900, 1,900, 1.900, 1900.50 * Also handles math expressions: 10*3, 100/4, 10+5, 10*3+5 */ -function parseAmount(amountStr: string): number | null { +function parseAmount(amountStr: string, code: CurrencyCode): number | null { + const precision = getCurrencyPrecision(code); try { // Remove spaces let cleaned = amountStr.replace(/\s+/g, ''); @@ -255,16 +240,18 @@ function parseAmount(amountStr: string): number | null { if (/[+\-*Γ—/]/.test(cleaned)) { const result = evaluateMathExpression(cleaned); if (result === null || result <= 0) return null; - // Round to 2 decimal places (e.g. 100/3 = 33.333... β†’ 33.33) - return Math.round(result * 100) / 100; + const rounded = new Big(result).round(precision).toNumber(); + return rounded > 0 ? rounded : null; } // Handle European format (1.234,56 -> 1234.56) - if (cleaned.match(/\d+\.\d{3},\d{2}$/)) { + if (cleaned.match(/^\d{1,3}(?:\.\d{3})+,\d{1,3}$/)) { cleaned = cleaned.replace(/\./g, '').replace(',', '.'); } // Handle US format with comma thousands separator (1,234.56) - else if (cleaned.match(/\d+,\d{3}(\.\d+)?$/)) { + else if (precision === 3 && /^\d+,\d{1,3}$/.test(cleaned)) { + cleaned = cleaned.replace(',', '.'); + } else if (cleaned.match(/\d+,\d{3}(\.\d+)?$/)) { cleaned = cleaned.replace(/,/g, ''); } // Handle just comma as decimal separator (1234,56 -> 1234.56) @@ -272,7 +259,7 @@ function parseAmount(amountStr: string): number | null { cleaned = cleaned.replace(',', '.'); } - const parsed = currency(cleaned, { separator: '', decimal: '.' }); + const parsed = currency(cleaned, { separator: '', decimal: '.', precision }); if (parsed.value <= 0) { return null; diff --git a/src/services/currency/tokens.ts b/src/services/currency/tokens.ts new file mode 100644 index 00000000..f937034b --- /dev/null +++ b/src/services/currency/tokens.ts @@ -0,0 +1,29 @@ +/** Shared currency spellings for expense, budget, and calculator input. */ +import { CURRENCY_ALIASES, type CurrencyCode } from '../../config/constants'; + +/** Ignore direction marks commonly copied with Arabic currency abbreviations. */ +export function normalizeCurrencyText(text: string): string { + return text.normalize('NFC').replace(/[\u061c\u200e\u200f]/g, ''); +} + +export function normalizeCurrencyAlias(token: string): CurrencyCode | null { + // Turkish uppercase Δ° lowercases to i + combining dot; keep its currency alias stable. + const key = normalizeCurrencyText(token) + .toLowerCase() + .replace(/i\u0307/g, 'i') + .trim() + .replace(/\s+/g, ' '); + return (CURRENCY_ALIASES[key] as CurrencyCode | undefined) ?? null; +} + +/** Longest aliases win, so qualified names are consumed before their short forms. */ +export const CURRENCY_TOKEN_PATTERN = Object.keys(CURRENCY_ALIASES) + .sort((a, b) => b.length - a.length) + .map((alias) => { + const pattern = alias + .split(/\s+/) + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('\\s+'); + return CURRENCY_ALIASES[alias] === 'TRY' ? pattern.replace(/[iΔ±]/g, '[iΔ±Δ°]') : pattern; + }) + .join('|'); diff --git a/src/services/currency/totals.test.ts b/src/services/currency/totals.test.ts new file mode 100644 index 00000000..f17d479c --- /dev/null +++ b/src/services/currency/totals.test.ts @@ -0,0 +1,74 @@ +/** Native OMR totals and foreign subtotal conversion must retain one-baisa amounts. */ +import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import * as converter from './converter'; +import { expenseAmountInCurrency, sumExpensesInCurrency } from './totals'; + +const convertSpy = spyOn(converter, 'convertCurrency'); + +beforeEach(() => { + convertSpy.mockImplementation((amount) => amount * 2); +}); +afterEach(() => convertSpy.mockClear()); +afterAll(() => convertSpy.mockRestore()); + +describe('original-currency OMR totals', () => { + test('keeps native milli-rials even when stored EUR rounded to zero', () => { + const fee = { amount: 0.001, currency: 'OMR' as const, eur_amount: 0 }; + const expenses: Parameters[0] = [ + fee, + { amount: 1.234, currency: 'OMR', eur_amount: 2.86 }, + ]; + expect(sumExpensesInCurrency(expenses, 'OMR')).toBe(1.235); + expect(expenseAmountInCurrency(fee, 'OMR')).toBe(0.001); + expect(convertSpy).not.toHaveBeenCalled(); + }); + + test('converts the foreign EUR subtotal once before adding native OMR', () => { + const expenses: Parameters[0] = [ + { amount: 0.001, currency: 'OMR', eur_amount: 0 }, + { amount: 0.01, currency: 'EUR', eur_amount: 0.01 }, + { amount: 0.4, currency: 'THB', eur_amount: 0.01 }, + ]; + expect(sumExpensesInCurrency(expenses, 'OMR')).toBe(0.041); + expect(convertSpy).toHaveBeenCalledTimes(1); + expect(convertSpy).toHaveBeenCalledWith(0.02, 'EUR', 'OMR'); + }); + + test('retains stored-EUR normalization for other display currencies', () => { + expect( + sumExpensesInCurrency([{ amount: 1.234, currency: 'OMR', eur_amount: 2.86 }], 'GEL'), + ).toBe(5.72); + expect(convertSpy).toHaveBeenCalledWith(2.86, 'EUR', 'GEL'); + }); +}); + +describe('native travel-currency totals', () => { + test('keeps KHR units lost by EUR-cent normalization', () => { + const fee = { amount: 1, currency: 'KHR' as const, eur_amount: 0 }; + const expenses: Parameters[0] = [ + fee, + { amount: 1000, currency: 'KHR', eur_amount: 0.22 }, + ]; + expect(sumExpensesInCurrency(expenses, 'KHR')).toBe(1001); + expect(expenseAmountInCurrency(fee, 'KHR')).toBe(1); + expect(convertSpy).not.toHaveBeenCalled(); + }); + + test.each([ + 'GEL', + 'THB', + 'MYR', + 'KHR', + 'TRY', + ] as const)('keeps %s native fractions and converts only foreign subtotal', (currency) => { + const expenses: Parameters[0] = [ + { amount: 1.21, currency, eur_amount: 0 }, + { amount: 2.34, currency, eur_amount: 0.01 }, + { amount: 0.01, currency: 'EUR', eur_amount: 0.01 }, + { amount: 100, currency: 'USD', eur_amount: 0.01 }, + ]; + expect(sumExpensesInCurrency(expenses, currency)).toBe(3.59); + expect(convertSpy).toHaveBeenCalledTimes(1); + expect(convertSpy).toHaveBeenCalledWith(0.02, 'EUR', currency); + }); +}); diff --git a/src/services/currency/totals.ts b/src/services/currency/totals.ts new file mode 100644 index 00000000..f91fe6bd --- /dev/null +++ b/src/services/currency/totals.ts @@ -0,0 +1,44 @@ +/** Display totals preserve native amounts for new currencies and stored EUR for foreign expenses. */ +import Big from 'big.js'; +import { BASE_CURRENCY, type CurrencyCode, getCurrencyPrecision } from '../../config/constants'; +import type { Expense } from '../../database/types'; +import { convertCurrency } from './converter'; + +type ExpenseAmounts = Pick; + +/** New currencies preserve native amounts; older currencies retain their existing EUR-based reports. */ +export function usesOriginalCurrencyAmounts(currency: CurrencyCode): boolean { + return ['GEL', 'OMR', 'THB', 'MYR', 'KHR', 'TRY'].includes(currency); +} + +/** Individual display values for statistics such as minimum, maximum and median. */ +export function expenseAmountInCurrency( + expense: ExpenseAmounts, + displayCurrency: CurrencyCode, +): number { + if (usesOriginalCurrencyAmounts(displayCurrency) && expense.currency === displayCurrency) + return expense.amount; + return convertCurrency(expense.eur_amount, BASE_CURRENCY, displayCurrency); +} + +/** Convert only the foreign EUR subtotal; never reconstruct native travel-currency amounts from rounded EUR cents. */ +export function sumExpensesInCurrency( + expenses: readonly ExpenseAmounts[], + displayCurrency: CurrencyCode, +): number { + if (!usesOriginalCurrencyAmounts(displayCurrency)) { + const totalEur = expenses.reduce((total, expense) => total + expense.eur_amount, 0); + return convertCurrency(totalEur, BASE_CURRENCY, displayCurrency); + } + + let nativeAmount = new Big(0); + let foreignEur = new Big(0); + for (const expense of expenses) { + if (expense.currency === displayCurrency) nativeAmount = nativeAmount.plus(expense.amount); + else foreignEur = foreignEur.plus(expense.eur_amount); + } + const foreignAmount = foreignEur.eq(0) + ? 0 + : convertCurrency(foreignEur.toNumber(), BASE_CURRENCY, displayCurrency); + return nativeAmount.plus(foreignAmount).round(getCurrencyPrecision(displayCurrency)).toNumber(); +} diff --git a/src/services/google/sheets-delete.test.ts b/src/services/google/sheets-delete.test.ts index aa381d89..52cab18b 100644 --- a/src/services/google/sheets-delete.test.ts +++ b/src/services/google/sheets-delete.test.ts @@ -353,3 +353,28 @@ describe('deleteExpenseRowsByIndex', () => { ); }); }); + +describe('OMR row identity', () => { + test('keeps adjacent milli-rial rows distinct during deletion', async () => { + mockSpreadsheetsBatchUpdate.mockReset().mockResolvedValue({ data: {} }); + mockValuesGet.mockResolvedValueOnce({ + data: { + values: [ + ['Π”Π°Ρ‚Π°', 'ΠšΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΡ', 'ΠšΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΉ', 'OMR (OMR)', 'EUR (calc)'], + ['2026-11-10', 'Food', 'Tea', 1.234, 2.86], + ['2026-11-10', 'Food', 'Tea', 1.235000000000001, 2.87], + ], + }, + }); + const result = await findAndDeleteExpenseRow(TEST_CONN, TEST_SPREADSHEET, { + date: '2026-11-10', + category: 'Food', + comment: 'Tea', + amount: 1.235, + currency: 'OMR', + }); + expect(result.deletedRowIndex).toBe(3); + expect(logMock.warn).not.toHaveBeenCalled(); + expect(logMock.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/google/sheets-rows.ts b/src/services/google/sheets-rows.ts index 46536d86..a2796b5a 100644 --- a/src/services/google/sheets-rows.ts +++ b/src/services/google/sheets-rows.ts @@ -1,6 +1,6 @@ /** Raw Expenses-tab row access (migration helpers) and row deletion */ import { google } from 'googleapis'; -import { SPREADSHEET_CONFIG } from '../../config/constants'; +import { getCurrencyPrecision, SPREADSHEET_CONFIG } from '../../config/constants'; import { createLogger } from '../../utils/logger.ts'; import { authClient, @@ -266,7 +266,7 @@ function locateMatchColumns(headers: string[], currency: string): MatchColumns { }; } -/** True when a data row matches date, category, comment and amount (within half a cent). */ +/** True when a data row matches date, category, comment and amount (within half of that currency’s smallest unit). */ function rowMatchesCriteria( row: unknown[], cols: MatchColumns, @@ -279,13 +279,10 @@ function rowMatchesCriteria( const cellAmount = row[cols.currencyCol]; const numAmount = typeof cellAmount === 'number' ? cellAmount : Number.parseFloat(String(cellAmount ?? '')); - // Epsilon 0.005 = half of the smallest unit for 2-decimal currencies - // (cents/ΠΊΠΎΠΏΠ΅ΠΉΠΊΠΈ). Float round-trip through Sheets can drift by ~1e-14 - // for fractional values like $12.34; strict equality here would miss - // those rows and leave them in the sheet, re-importable via /sync. - // The gap between any two realistic neighboring sums (12.34 vs 12.35) - // is 0.01 > 0.005, so false positives are impossible. - return Number.isFinite(numAmount) && Math.abs(numAmount - criteria.amount) <= 0.005; + // Permit binary-float drift without ever matching a neighboring minor unit: + // 0.005 for cents, 0.0005 for OMR baisa (1.234 and 1.235 are distinct rows). + const tolerance = 0.5 / 10 ** getCurrencyPrecision(criteria.currency); + return Number.isFinite(numAmount) && Math.abs(numAmount - criteria.amount) <= tolerance; } async function findAndDeleteExpenseRowImpl( diff --git a/src/services/receipt/receipt-parser.test.ts b/src/services/receipt/receipt-parser.test.ts index fe8afb41..a2f1e413 100644 --- a/src/services/receipt/receipt-parser.test.ts +++ b/src/services/receipt/receipt-parser.test.ts @@ -467,3 +467,70 @@ describe('parseReceipt β€” sum verification', () => { expect(result.calculateSumRounds).toBe(0); }); }); + +describe('parseReceipt β€” OMR minor units', () => { + beforeEach(() => { + resetMocks(); + logMock.warn.mockClear(); + logMock.error.mockClear(); + }); + + it('preserves milli-rials through deterministic summation and emitted receipt totals', async () => { + mockRounds = [ + buildMockResult([calcSumCall('calc-omr', [1.234, 0.001])]), + buildMockResult([ + emitItemsCall( + 'emit-omr', + [makeItem({ price: 1.234, total: 1.234 }), makeItem({ price: 0.001, total: 0.001 })], + { currency: 'OMR', claimed_total: 1.235 }, + ), + ]), + ]; + const result = await parseReceipt('Muscat receipt: R.O. 1.234 + R.O. 0.001. Total OMR 1.235', [ + 'Π•Π΄Π°', + ]); + expect(result.currency).toBe('OMR'); + expect(result.computedSum).toBe(1.235); + expect(result.claimedTotal).toBe(1.235); + expect(result.items.map((item) => item.total)).toEqual([1.234, 0.001]); + expect(result.sumVerified).toBe(true); + const options = mockAiStreamRound.mock.calls[1]?.[0] as { + messages: { role: string; tool_call_id?: string; content?: string | null }[]; + }; + const calculation = options.messages.find((message) => message.tool_call_id === 'calc-omr'); + expect(JSON.parse(calculation?.content ?? '{}')).toMatchObject({ sum: 1.235, count: 2 }); + expect(logMock.warn).not.toHaveBeenCalled(); + expect(logMock.error).not.toHaveBeenCalled(); + }); +}); + +describe('parseReceipt β€” travel currencies', () => { + beforeEach(() => { + resetMocks(); + logMock.warn.mockClear(); + logMock.error.mockClear(); + }); + + it.each(['MYR', 'KHR', 'TRY'])('keeps %s and two-decimal line items', async (currency) => { + mockRounds = [ + buildMockResult([calcSumCall('calc-travel', [12.34, 0.01])]), + buildMockResult([ + emitItemsCall( + 'emit-travel', + [makeItem({ price: 12.34, total: 12.34 }), makeItem({ price: 0.01, total: 0.01 })], + { currency, claimed_total: 12.35 }, + ), + ]), + ]; + const result = await parseReceipt(`Synthetic receipt ${currency} 12.34 + 0.01 total 12.35`, [ + 'Π•Π΄Π°', + ]); + expect(result.currency).toBe(currency); + expect(result.computedSum).toBe(12.35); + expect(result.claimedTotal).toBe(12.35); + expect(result.items.map((item) => item.total)).toEqual([12.34, 0.01]); + expect(result.sumVerified).toBe(true); + expect(logMock.warn).not.toHaveBeenCalled(); + expect(logMock.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/receipt/receipt-parser.ts b/src/services/receipt/receipt-parser.ts index a939df44..104ac9db 100644 --- a/src/services/receipt/receipt-parser.ts +++ b/src/services/receipt/receipt-parser.ts @@ -10,8 +10,13 @@ * Runs on SMART_CHAIN because OCR correctness is critical β€” speed is not a concern. */ +import Big from 'big.js'; import type OpenAI from 'openai'; -import type { CurrencyCode } from '../../config/constants'; +import { + type CurrencyCode, + getCurrencyPrecision, + SUPPORTED_CURRENCIES, +} from '../../config/constants'; import { createLogger } from '../../utils/logger.ts'; import { aiStreamRound, @@ -19,6 +24,7 @@ import { type StreamToolCall, stripThinkingTags, } from '../ai/streaming'; +import { normalizeCurrencyAlias } from '../currency/tokens'; import { type AIExtractionResult, type AIReceiptItem, @@ -126,7 +132,7 @@ const TOOLS: OpenAI.ChatCompletionTool[] = [ }, currency: { type: 'string', - description: 'Currency code detected on receipt (RSD, EUR, USD, etc.)', + description: `Currency code detected on receipt (${SUPPORTED_CURRENCIES.join(', ')}, etc.). Omani rial is OMR, Georgian lari is GEL, Thai baht is THB.`, }, claimed_total: { type: 'number', @@ -156,10 +162,10 @@ function executeCalculateSum(args: CalculateSumArgs): { count: number; label: string; } { - const nums = args.numbers.filter((n): n is number => typeof n === 'number' && !Number.isNaN(n)); - const sum = nums.reduce((a, b) => a + b, 0); + const nums = args.numbers.filter((n): n is number => typeof n === 'number' && Number.isFinite(n)); + const sum = nums.reduce((a, b) => a.plus(b), new Big(0)); return { - sum: Math.round(sum * 100) / 100, + sum: sum.toNumber(), count: nums.length, label: args.label ?? 'sum', }; @@ -186,7 +192,8 @@ RULES: - NEVER call emit_items before calculate_sum. This is mandatory. - Translate item names to Russian (name_ru). Keep original in name_original. - Extract quantity, unit price (price), and line total (total) for each item. -- Detect the currency (RSD, EUR, USD, RUB, BYN, etc.). +- Detect the currency (${SUPPORTED_CURRENCIES.join(', ')}, etc.). Georgian lari (β‚Ύ, αƒšαƒαƒ αƒ˜) is GEL; Omani rial (RO, R.O., Ψ±.ΨΉ., Ψ±ΩŠΨ§Ω„ ΨΉΩ…Ψ§Ω†ΩŠ) is OMR; Thai baht (ΰΈΏ, ΰΈšΰΈ²ΰΈ—) is THB. Emit the uppercase ISO code. +- Preserve all printed decimal digits: OMR has three fractional digits (1,000 baisa per rial). Never round OMR prices or totals to two digits. GEL and THB use two fractional digits. - Extract the PRINTED RECEIPT DATE (look for "ПЀР Π²Ρ€Π΅ΠΌΠ΅", "Datum", "Date", "врСмя", or any printed date on the receipt). Convert it to YYYY-MM-DD format. Pass it as the "date" field in emit_items. This is the actual purchase date β€” critical for matching receipts with bank transactions. Omit the field only if no date is printed at all. - If the receipt has no line items (only fiscal header/footer, QR markers, no products) β†’ emit an empty items array. ${ @@ -324,7 +331,7 @@ function recordEmittedItems( ): void { state.emitCalled = true; state.emittedItems = args.items ?? []; - state.emittedCurrency = args.currency; + state.emittedCurrency = normalizeCurrencyAlias(args.currency ?? '') ?? args.currency; state.claimedTotal = args.claimed_total; // Validate date format: strict YYYY-MM-DD, drop otherwise if (args.date && ISO_DATE_RE.test(args.date)) { @@ -442,7 +449,10 @@ function isSumVerified(state: ParseState, actualSum: number): boolean { function buildParseResult(state: ParseState): ParseResult { const { emittedItems, emittedCurrency, emittedDate, claimedTotal, sumCallCount } = state; - const actualSum = Math.round(emittedItems.reduce((a, it) => a + (it.total ?? 0), 0) * 100) / 100; + const actualSum = emittedItems + .reduce((sum, item) => sum.plus(item.total ?? 0), new Big(0)) + .round(getCurrencyPrecision(emittedCurrency ?? 'EUR')) + .toNumber(); const sumVerified = isSumVerified(state, actualSum); if (sumCallCount === 0 && emittedItems.length > 0) { diff --git a/src/services/settings/group-settings-registry.test.ts b/src/services/settings/group-settings-registry.test.ts index 0b0ccd29..df821849 100644 --- a/src/services/settings/group-settings-registry.test.ts +++ b/src/services/settings/group-settings-registry.test.ts @@ -83,6 +83,24 @@ describe('registry enforcement', () => { describe('default_currency setting', () => { const def = GROUP_SETTINGS.default_currency; + test.each([ + 'GEL', + 'OMR', + 'THB', + 'MYR', + 'KHR', + 'TRY', + ])('enables %s as the group default', async (code) => { + expect(def.parse(code.toLowerCase(), fakeGroup())).toEqual({ ok: true, value: code }); + const result = await applyGroupSetting(def, fakeGroup(), code.toLowerCase()); + expect(result.ok).toBe(true); + expect(updateMock).toHaveBeenCalledWith(-100, { + default_currency: code, + enabled_currencies: ['USD', 'EUR', code], + }); + expect(def.aiValueHint).toContain(code); + }); + test('parse uppercases and accepts a supported code', () => { expect(def.parse('egp', fakeGroup())).toEqual({ ok: true, value: 'EGP' }); }); @@ -146,10 +164,10 @@ describe('enabled_currencies setting', () => { if (!result.ok) expect(result.error).toContain('AB'); }); - test('parse accepts custom ISO codes for onboarding parity (e.g. GEL)', () => { - const result = def.parse('usd, gel', fakeGroup({ default_currency: 'USD' })); - // GEL is a valid ISO code but outside the built-in set β€” onboarding allows it, so does this. - expect(result).toEqual({ ok: true, value: ['USD', 'GEL'] as CurrencyCode[] }); + test('parse accepts custom ISO codes for onboarding parity (e.g. KZT)', () => { + const result = def.parse('usd, kzt', fakeGroup({ default_currency: 'USD' })); + // KZT is a valid ISO code but outside the built-in set β€” onboarding allows it, so does this. + expect(result).toEqual({ ok: true, value: ['USD', 'KZT'] as CurrencyCode[] }); }); test('parse rejects an empty list', () => { diff --git a/src/services/settings/group-settings-registry.ts b/src/services/settings/group-settings-registry.ts index b75ae22d..ea86e631 100644 --- a/src/services/settings/group-settings-registry.ts +++ b/src/services/settings/group-settings-registry.ts @@ -256,8 +256,7 @@ const defaultCurrencySetting: CurrencySettingDef = { kind: 'currency', emoji: 'πŸ’±', labelRu: 'Π’Π°Π»ΡŽΡ‚Π° ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ', - aiValueHint: - 'one of the built-in supported ISO codes (USD, EUR, RUB, RSD, GBP, BYN, CHF, JPY, CNY, INR, LKR, AED, EGP), e.g. "EGP"', + aiValueHint: `one of the built-in supported ISO codes (${SUPPORTED_CURRENCIES.join(', ')}), e.g. "OMR"`, formatValue: (group) => group.default_currency, parse: (raw) => parseCurrency(raw), apply(group, value) { diff --git a/src/web/miniapp-api.dashboard.test.ts b/src/web/miniapp-api.dashboard.test.ts index 7e474610..95fac6c7 100644 --- a/src/web/miniapp-api.dashboard.test.ts +++ b/src/web/miniapp-api.dashboard.test.ts @@ -1,5 +1,7 @@ // Tests for Mini App API: analytics, dashboard widgets and dashboard SSE events +import { Database, type SQLQueryBindings } from 'bun:sqlite'; + import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import { handleMiniAppRequest } from './miniapp-api.ts'; import { @@ -43,14 +45,88 @@ describe('GET /api/analytics', () => { expect(res.status).toBe(401); }); + test.each([ + { + currency: 'OMR' as const, + original: 'OMR', + small: 0.001, + large: 1.234, + eur: 2.46, + total: 1.235, + }, + { + currency: 'EUR' as const, + original: 'OMR', + small: 0.001, + large: 1.234, + eur: 2.46, + total: 2.46, + }, + { + currency: 'KHR' as const, + original: 'KHR', + small: 1, + large: 1000.25, + eur: 0.22, + total: 1001.25, + }, + ])('preserves $currency accounting in the real aggregation query', async ({ + currency, + original, + small, + large, + eur, + total, + }) => { + const db = new Database(':memory:'); + try { + db.exec( + 'CREATE TABLE expenses (group_id INTEGER, date TEXT, category TEXT, amount REAL, currency TEXT, eur_amount REAL)', + ); + const insert = db.query('INSERT INTO expenses VALUES (?, ?, ?, ?, ?, ?)'); + insert.run(7, '2025-03-01', 'Food', small, original, 0); + insert.run(7, '2025-03-02', 'Food', large, original, eur); + insert.run(8, '2025-03-01', 'Other group', 10, 'OMR', 20); + insert.run(7, '2025-02-01', 'Other month', 10, 'OMR', 20); + mockDbQueryAll.mockImplementation((sql, ...params) => + db.query(sql).all(...(params as SQLQueryBindings[])), + ); + mockFindByTelegramId.mockImplementation(() => MOCK_USER); + mockGroupsFindByTelegramGroupId.mockReturnValue(stubGroup({ id: 7 })); + mockGroupMembersIsMember.mockReturnValue(true); + mockGroupsFindById.mockImplementation(() => + stubGroup({ id: 7, telegram_group_id: GROUP_ID, default_currency: currency }), + ); + + const req = makeRequest( + `/api/analytics?groupId=${GROUP_ID}&period=2025-03`, + 'GET', + buildInitData(42), + ); + const res = await handleMiniAppRequest(req, CORS_ORIGIN); + if (!res) throw new Error('expected Response, got null'); + expect(res.status).toBe(200); + const body = (await res.json()) as { + expenses: number; + balance: number; + byCategory: Record; + }; + expect(body.expenses).toBe(total); + expect(body.balance).toBe(-total); + expect(body.byCategory).toEqual({ Food: total }); + } finally { + db.close(); + } + }); + test('success β†’ 200 with correct shape', async () => { mockFindByTelegramId.mockImplementation(() => MOCK_USER); mockGroupsFindByTelegramGroupId.mockReturnValue(stubGroup({ id: 7 })); mockGroupMembersIsMember.mockReturnValue(true); mockGroupsFindById.mockImplementation(() => stubGroup({ telegram_group_id: GROUP_ID })); mockDbQueryAll.mockImplementation(() => [ - { category: 'Food', total: 100 }, - { category: 'Transport', total: 50 }, + { category: 'Food', currency: 'EUR', amount: 100, eur_amount: 100 }, + { category: 'Transport', currency: 'EUR', amount: 50, eur_amount: 50 }, ]); const initData = buildInitData(42); diff --git a/src/web/miniapp-api.ts b/src/web/miniapp-api.ts index 888d23f3..d7ea1b6f 100644 --- a/src/web/miniapp-api.ts +++ b/src/web/miniapp-api.ts @@ -5,7 +5,7 @@ import { type CurrencyCode, isValidCurrencyCode } from '../config/constants.ts'; import { env } from '../config/env.ts'; import { database } from '../database/index.ts'; import { sendDocumentDirect } from '../services/bank/telegram-sender.ts'; -import { convertCurrency } from '../services/currency/converter.ts'; +import { sumExpensesInCurrency } from '../services/currency/totals.ts'; import { getExpenseRecorder } from '../services/expense-recorder.ts'; import { extractTextFromImageBuffer } from '../services/receipt/ocr-extractor.ts'; import { fetchReceiptData } from '../services/receipt/receipt-fetcher.ts'; @@ -630,11 +630,9 @@ async function handleReceiptConfirm({ req, corsOrigin, corsHeaders }: RouteArgs) /** Row returned by the per-category aggregation query */ interface CategoryRow { category: string; - total: number; -} - -function roundCents(value: number): number { - return Math.round(value * 100) / 100; + currency: CurrencyCode; + amount: number; + eur_amount: number; } async function handleAnalytics({ req, url, corsOrigin, corsHeaders }: RouteArgs) { @@ -656,25 +654,29 @@ async function handleAnalytics({ req, url, corsOrigin, corsHeaders }: RouteArgs) } const rows = database.queryAll( - `SELECT category, SUM(eur_amount) as total + `SELECT category, currency, SUM(amount) AS amount, SUM(eur_amount) AS eur_amount FROM expenses WHERE group_id = ? AND date LIKE ? - GROUP BY category`, + GROUP BY category, currency`, ctx.internalGroupId, `${period}-%`, ); const defaultCurrency = group.default_currency as CurrencyCode; - let totalEur = 0; - const byCategory: Record = {}; - + const grouped = new Map(); for (const row of rows) { - totalEur += row.total; - byCategory[row.category] = roundCents(convertCurrency(row.total, 'EUR', defaultCurrency)); - } - - const totalInDefault = roundCents(convertCurrency(totalEur, 'EUR', defaultCurrency)); + const items = grouped.get(row.category) ?? []; + items.push(row); + grouped.set(row.category, items); + } + const byCategory = Object.fromEntries( + [...grouped].map(([category, items]) => [ + category, + sumExpensesInCurrency(items, defaultCurrency), + ]), + ); + const totalInDefault = sumExpensesInCurrency(rows, defaultCurrency); return jsonResponse( { From 3b3e6c7b5a3caf3b8cb88169b7246c7b4fd7b0ac Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:33:53 +0100 Subject: [PATCH 2/2] test(currency): fix SSR text parsing and dependency scope Replace the test-only HTML-stripping regular expression flagged by CodeQL with Bun's HTML parser while preserving all exact currency assertions. Analyze the Mini App and its SSR tests against its own package manifest in Knip, preserving the backend entries and existing analysis settings. Refs #250 --- knip.jsonc | 53 +++++++++++++++---------- miniapp/test/currency-precision.test.ts | 20 ++++++++-- 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/knip.jsonc b/knip.jsonc index fdc46ef2..62d9d9fc 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -1,27 +1,36 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - // Entry points knip cannot infer on its own. index.ts is auto-detected from - // package.json "module". - // - bank-sync.ts second PM2 process (see ecosystem.config.cjs) - // - ecosystem.config.cjs PM2 process definitions (read by pm2, never imported) - // - scripts/** standalone tooling run via `bun scripts/.ts` - // - src/**/*.test.ts test files (roots; make their imports count as usage) - // - the two *module barrels* below are the intended public import surface for - // their modules; consumers currently import the submodules directly, so the - // barrels are never imported, but they are a deliberate API boundary, not - // dead code β€” declaring them entries stops knip flagging the boundary. - "entry": [ - "bank-sync.ts", - "ecosystem.config.cjs", - "scripts/**/*.ts", - "src/**/*.test.ts", - "src/services/dev-pipeline/index.ts", - "src/services/analytics/ta/index.ts" - ], - // ZenPlugins is a third-party git submodule β€” excluded here exactly like in - // biome.jsonc ("!src/services/bank/ZenPlugins"). Not checked out in CI, and - // the types the bot needs are mirrored locally in zenmoney-types.ts. - "project": ["src/**/*.ts", "!src/services/bank/ZenPlugins/**", "libs/**/*.ts", "*.ts"], + // Resolve each package against its own manifest, including Mini App SSR tests. + "workspaces": { + ".": { + // Entry points knip cannot infer on its own. index.ts is auto-detected from + // package.json "module". + // - bank-sync.ts second PM2 process (see ecosystem.config.cjs) + // - ecosystem.config.cjs PM2 process definitions (read by pm2, never imported) + // - scripts/** standalone tooling run via `bun scripts/.ts` + // - src/**/*.test.ts test files (roots; make their imports count as usage) + // - the two *module barrels* below are the intended public import surface for + // their modules; consumers currently import the submodules directly, so the + // barrels are never imported, but they are a deliberate API boundary, not + // dead code β€” declaring them entries stops knip flagging the boundary. + "entry": [ + "bank-sync.ts", + "ecosystem.config.cjs", + "scripts/**/*.ts", + "src/**/*.test.ts", + "src/services/dev-pipeline/index.ts", + "src/services/analytics/ta/index.ts" + ], + // ZenPlugins is a third-party git submodule β€” excluded here exactly like in + // biome.jsonc ("!src/services/bank/ZenPlugins"). Not checked out in CI, and + // the types the bot needs are mirrored locally in zenmoney-types.ts. + "project": ["src/**/*.ts", "!src/services/bank/ZenPlugins/**", "libs/**/*.ts", "*.ts"] + }, + "miniapp": { + "entry": ["test/**/*.test.ts"], + "project": ["src/**/*.{ts,tsx}", "test/**/*.ts", "*.ts"] + } + }, "ignore": [ // Deliberately-complete local mirror of ZenMoney submodule types (see the // file header + CLAUDE.md "types maintained locally"). Some members are not diff --git a/miniapp/test/currency-precision.test.ts b/miniapp/test/currency-precision.test.ts index fa9b5fca..b82d32a8 100644 --- a/miniapp/test/currency-precision.test.ts +++ b/miniapp/test/currency-precision.test.ts @@ -5,6 +5,18 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { StatCard } from '../src/widgets/StatCard'; import { Ticker } from '../src/widgets/Ticker'; +function renderedText(markup: string): string { + let text = ''; + new HTMLRewriter() + .onDocument({ + text(chunk) { + text += chunk.text; + }, + }) + .transform(markup); + return text; +} + const widgets: Array<[string, ComponentType<{ value: number; currency?: string }>]> = [ ['StatCard', StatCard], ['Ticker', Ticker], @@ -17,7 +29,7 @@ for (const [name, Widget] of widgets) { { value: 1.234, text: '1,234 OMR' }, ])('renders $value OMR without losing baisa', ({ value, text }) => { const markup = renderToStaticMarkup(createElement(Widget, { value, currency: 'OMR' })); - expect(markup.replace(/<[^>]*>/g, '')).toBe(text); + expect(renderedText(markup)).toBe(text); }); test.each( @@ -27,12 +39,12 @@ for (const [name, Widget] of widgets) { ]), )('renders $value $currency without rounding away hundredths', ({ currency, value, text }) => { const markup = renderToStaticMarkup(createElement(Widget, { value, currency })); - expect(markup.replace(/<[^>]*>/g, '')).toBe(text); + expect(renderedText(markup)).toBe(text); }); test('retains the compact display for other currencies', () => { const markup = renderToStaticMarkup(createElement(Widget, { value: 1.234, currency: 'EUR' })); - expect(markup.replace(/<[^>]*>/g, '')).toBe('1 EUR'); + expect(renderedText(markup)).toBe('1 EUR'); }); }); } @@ -42,5 +54,5 @@ test.each([ { value: 1_200_000_000, text: '1.2 ΠΌΠ»Ρ€Π΄ OMR' }, ])('StatCard retains large-value abbreviations for $value', ({ value, text }) => { const markup = renderToStaticMarkup(createElement(StatCard, { value, currency: 'OMR' })); - expect(markup.replace(/<[^>]*>/g, '')).toBe(text); + expect(renderedText(markup)).toBe(text); });