Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 33 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
53 changes: 31 additions & 22 deletions knip.jsonc
Original file line number Diff line number Diff line change
@@ -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/<name>.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/<name>.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
Expand Down
3 changes: 2 additions & 1 deletion miniapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 4 additions & 4 deletions miniapp/src/widgets/StatCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div style={{ padding: '12px 16px', minWidth: 120 }}>
{label && <div style={{ fontSize: 11, opacity: 0.5, marginBottom: 4 }}>{label}</div>}
<div style={{ fontSize: 28, fontWeight: 600, letterSpacing: -1 }}>
{formatNum(value)}
{formatNum(value, currency)}
{currency ? ` ${currency}` : ''}
</div>
{delta != null && (
Expand All @@ -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 });
}
2 changes: 1 addition & 1 deletion miniapp/src/widgets/Ticker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function Ticker({ value, label, currency, series }: Props) {
<div>
{label && <div style={{ fontSize: 10, opacity: 0.5 }}>{label}</div>}
<div style={{ fontSize: 18, fontWeight: 600 }}>
{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}` : ''}
</div>
</div>
Expand Down
58 changes: 58 additions & 0 deletions miniapp/test/currency-precision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/** Server-rendered widgets preserve native travel amounts and retain legacy compact display. */
import { describe, expect, test } from 'bun:test';
import { type ComponentType, createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { StatCard } from '../src/widgets/StatCard';
import { Ticker } from '../src/widgets/Ticker';

function renderedText(markup: string): string {
let text = '';
new HTMLRewriter()
.onDocument({
text(chunk) {
text += chunk.text;
},
})
.transform(markup);
return text;
}

const widgets: Array<[string, ComponentType<{ value: number; currency?: string }>]> = [
['StatCard', StatCard],
['Ticker', Ticker],
];

for (const [name, Widget] of widgets) {
describe(name, () => {
test.each([
{ value: 0.001, text: '0,001 OMR' },
{ value: 1.234, text: '1,234 OMR' },
])('renders $value OMR without losing baisa', ({ value, text }) => {
const markup = renderToStaticMarkup(createElement(Widget, { value, currency: 'OMR' }));
expect(renderedText(markup)).toBe(text);
});

test.each(
['GEL', 'THB', 'MYR', 'KHR', 'TRY'].flatMap((currency) => [
{ currency, value: 0.01, text: `0,01 ${currency}` },
{ currency, value: 12.5, text: `12,5 ${currency}` },
]),
)('renders $value $currency without rounding away hundredths', ({ currency, value, text }) => {
const markup = renderToStaticMarkup(createElement(Widget, { value, currency }));
expect(renderedText(markup)).toBe(text);
});

test('retains the compact display for other currencies', () => {
const markup = renderToStaticMarkup(createElement(Widget, { value: 1.234, currency: 'EUR' }));
expect(renderedText(markup)).toBe('1 EUR');
});
});
}

test.each([
{ value: 1_200_000, text: '1.2 млн OMR' },
{ value: 1_200_000_000, text: '1.2 млрд OMR' },
])('StatCard retains large-value abbreviations for $value', ({ value, text }) => {
const markup = renderToStaticMarkup(createElement(StatCard, { value, currency: 'OMR' }));
expect(renderedText(markup)).toBe(text);
});
17 changes: 17 additions & 0 deletions src/bot/commands/budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
82 changes: 9 additions & 73 deletions src/bot/commands/budget.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -28,81 +30,15 @@ const logger = createLogger('budget');
const NO_BUDGET_HINT =
'Используй:\n• /budget set &lt;Категория&gt; &lt;Сумма&gt;\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);
}

/**
Expand Down
Loading
Loading