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/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/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..b82d32a8 --- /dev/null +++ b/miniapp/test/currency-precision.test.ts @@ -0,0 +1,58 @@ +/** 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'; + +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], +]; + +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(renderedText(markup)).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(renderedText(markup)).toBe(text); + }); + + test('retains the compact display for other currencies', () => { + const markup = renderToStaticMarkup(createElement(Widget, { value: 1.234, currency: 'EUR' })); + expect(renderedText(markup)).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(renderedText(markup)).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( {