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