diff --git a/domain/currency.py b/domain/currency.py index 1393302..1c87abd 100644 --- a/domain/currency.py +++ b/domain/currency.py @@ -13,6 +13,32 @@ CURRENCY_SYMBOLS = { "UAH": "₴", } +# ISO 4217 minor units per currency (https://en.wikipedia.org/wiki/ISO_4217). +# Keys are uppercase 3-letter codes, values are the number of decimal digits used +# when DISPLAYING the currency. Internal arithmetic keeps full Decimal precision +# (do not modify — see T8 decision `internal_precision`). +# +# - 0 decimals: JPY (Japanese yen) +# - 2 decimals: most currencies (RUB, USD, EUR, GBP, CNY, KZT, UAH, ...) +# - 3 decimals: BHD (Bahraini dinar), KWD (Kuwaiti dinar), OMR (Omani rial) +# +# Unknown currencies fall back to `DEFAULT_CURRENCY_DECIMALS` (2). +CURRENCY_DECIMALS: dict[str, int] = { + "JPY": 0, + "RUB": 2, + "USD": 2, + "EUR": 2, + "GBP": 2, + "CNY": 2, + "KZT": 2, + "UAH": 2, + "BHD": 3, + "KWD": 3, + "OMR": 3, +} + +DEFAULT_CURRENCY_DECIMALS = 2 + class ExchangeRate(BaseModel): from_currency: str = "USD" @@ -78,6 +104,31 @@ class CurrencyConverter: def get_symbol(self, currency: str) -> str: return CURRENCY_SYMBOLS.get(currency, currency) + @staticmethod + def format_money( + amount: Decimal | float | int | str, + currency: str, + ) -> str: + """Format `amount` for display using ISO 4217 decimal digits for `currency`. + + Rounding: ROUND_HALF_UP (0.5 -> 1) before display — internal arithmetic in + Decimal is unchanged. Known symbols are rendered as a tight prefix + (e.g. "$1,234.57"); unknown codes are rendered as a spaced prefix + ("BHD 1.234") so the layout is consistent regardless of symbol availability. + Negative values render with the sign before the symbol ("-₽1,200.00"). + """ + cur = currency.upper() + decimals = CURRENCY_DECIMALS.get(cur, DEFAULT_CURRENCY_DECIMALS) + a = Decimal(str(amount)) if not isinstance(amount, Decimal) else amount + quantizer = Decimal("1") if decimals == 0 else Decimal(1).scaleb(-decimals) + quantized = a.quantize(quantizer, rounding=ROUND_HALF_UP) + sign = "-" if quantized < 0 else "" + abs_quantized = -quantized if quantized < 0 else quantized + num_str = f"{abs_quantized:,.{decimals}f}" + if cur in CURRENCY_SYMBOLS: + return f"{sign}{CURRENCY_SYMBOLS[cur]}{num_str}" + return f"{sign}{cur} {num_str}" + @classmethod def with_defaults(cls) -> "CurrencyConverter": return cls(DEFAULT_RATES) diff --git a/infrastructure/cli/commands/config_cmd.py b/infrastructure/cli/commands/config_cmd.py index 8876878..1d84779 100644 --- a/infrastructure/cli/commands/config_cmd.py +++ b/infrastructure/cli/commands/config_cmd.py @@ -6,6 +6,7 @@ from rich.table import Table from domain import ( Account, Asset, + CurrencyConverter, ExchangeRate, Liability, RecurringCashflow, @@ -132,7 +133,8 @@ def account_list() -> None: table.add_column(t("table.accounts_config.col.currency")) table.add_column(t("table.accounts_config.col.balance"), justify="right") for a in model.accounts: - table.add_row(str(a.id)[:8], a.name, a.currency, f"{a.balance:,.2f}") + balance_str = CurrencyConverter.format_money(a.balance, a.currency) + table.add_row(str(a.id)[:8], a.name, a.currency, balance_str) console.print(table) @@ -182,8 +184,9 @@ def transaction_add( model.transactions.append(txn) save_model(model) kind = t("cmd.config.transaction_add.kind.income") if amount >= 0 else t("cmd.config.transaction_add.kind.expense") + amount_str = CurrencyConverter.format_money(abs(amount), _account_currency(model, account_id)) ok = t("global.ok") - msg = t("cmd.config.transaction_add.ok", kind=kind, amount=abs(amount), account=account) + msg = t("cmd.config.transaction_add.ok", kind=kind, amount_str=amount_str, account=account) console.print(f"[green]{ok}[/green] {msg}") @@ -203,9 +206,12 @@ def transaction_list() -> None: table.add_column(t("table.transactions.col.description")) for tx in model.transactions: account_name = _account_name(model, tx.account) + amount_str = CurrencyConverter.format_money( + tx.amount, _account_currency(model, tx.account) + ) table.add_row( str(tx.id)[:8], tx.date, account_name, tx.category, - f"{tx.amount:,.2f}", tx.description, + amount_str, tx.description, ) console.print(table) @@ -245,8 +251,9 @@ def recurring_add( ) model.recurring.append(rc) save_model(model) + amount_str = CurrencyConverter.format_money(abs(amount), model.base_currency) ok = t("global.ok") - msg = t("cmd.config.recurring_add.ok", amount=abs(amount), frequency=frequency) + msg = t("cmd.config.recurring_add.ok", amount_str=amount_str, frequency=frequency) console.print(f"[green]{ok}[/green] {msg}") @@ -265,7 +272,8 @@ def recurring_list() -> None: table.add_column(t("table.recurring.col.amount"), justify="right") table.add_column(t("table.recurring.col.category")) for r in model.recurring: - row = (str(r.id)[:8], r.start_date, r.end_date, r.frequency, f"{r.amount:,.2f}", r.category) + amount_str = CurrencyConverter.format_money(r.amount, model.base_currency) + row = (str(r.id)[:8], r.start_date, r.end_date, r.frequency, amount_str, r.category) table.add_row(*row) console.print(table) @@ -316,7 +324,8 @@ def asset_list() -> None: table.add_column(t("table.assets.col.value"), justify="right") table.add_column(t("table.assets.col.growth"), justify="right") for a in model.assets: - table.add_row(str(a.id)[:8], a.name, f"{a.value:,.2f}", f"{a.growth_rate:.1f}%") + value_str = CurrencyConverter.format_money(a.value, model.base_currency) + table.add_row(str(a.id)[:8], a.name, value_str, f"{a.growth_rate:.1f}%") console.print(table) @@ -371,8 +380,9 @@ def liability_list() -> None: table.add_column(t("table.liabilities.col.rate"), justify="right") table.add_column(t("table.liabilities.col.payment"), justify="right") for li in model.liabilities: - b, i, p = f"{li.balance:,.2f}", f"{li.interest:.1f}%", f"{li.payment:,.2f}" - row = (str(li.id)[:8], li.name, b, i, p) + balance_str = CurrencyConverter.format_money(li.balance, model.base_currency) + payment_str = CurrencyConverter.format_money(li.payment, model.base_currency) + row = (str(li.id)[:8], li.name, balance_str, f"{li.interest:.1f}%", payment_str) table.add_row(*row) console.print(table) @@ -411,3 +421,10 @@ def _account_name(model, account_id: str) -> str: if str(a.id) == account_id: return a.name return account_id[:8] + + +def _account_currency(model, account_id: str) -> str: + for a in model.accounts: + if str(a.id) == account_id: + return a.currency + return model.base_currency diff --git a/infrastructure/cli/commands/info.py b/infrastructure/cli/commands/info.py index 38bd083..3722524 100644 --- a/infrastructure/cli/commands/info.py +++ b/infrastructure/cli/commands/info.py @@ -31,7 +31,7 @@ def info() -> None: tbl.add_column(t("table.accounts.col.currency")) tbl.add_column(t("table.accounts.col.balance"), justify="right") for a in model.accounts: - tbl.add_row(a.name, a.currency, f"{a.balance:,.2f}") + tbl.add_row(a.name, a.currency, CurrencyConverter.format_money(a.balance, a.currency)) console.print(tbl) if model.transactions: @@ -41,7 +41,8 @@ def info() -> None: tbl.add_column(t("table.transactions.col.amount"), justify="right") tbl.add_column(t("table.transactions.col.description")) for tx in model.transactions[:10]: - tbl.add_row(tx.date, tx.category, f"{tx.amount:,.2f}", tx.description) + amount_str = CurrencyConverter.format_money(tx.amount, model.base_currency) + tbl.add_row(tx.date, tx.category, amount_str, tx.description) if len(model.transactions) > 10: console.print(tbl) console.print(t("cmd.info.more", count=len(model.transactions) - 10)) @@ -54,7 +55,8 @@ def info() -> None: tbl.add_column(t("table.recurring.col.amount"), justify="right") tbl.add_column(t("table.recurring.col.category")) for r in model.recurring: - tbl.add_row(r.frequency, f"{r.amount:,.2f}", r.category) + amount_str = CurrencyConverter.format_money(r.amount, model.base_currency) + tbl.add_row(r.frequency, amount_str, r.category) console.print(tbl) if model.assets: @@ -63,7 +65,8 @@ def info() -> None: tbl.add_column(t("table.assets.col.value"), justify="right") tbl.add_column(t("table.assets.col.growth"), justify="right") for a in model.assets: - tbl.add_row(a.name, f"{a.value:,.2f}", f"{a.growth_rate:.1f}%") + value_str = CurrencyConverter.format_money(a.value, model.base_currency) + tbl.add_row(a.name, value_str, f"{a.growth_rate:.1f}%") console.print(tbl) if model.liabilities: @@ -73,5 +76,7 @@ def info() -> None: tbl.add_column(t("table.liabilities.col.rate"), justify="right") tbl.add_column(t("table.liabilities.col.payment"), justify="right") for li in model.liabilities: - tbl.add_row(li.name, f"{li.balance:,.2f}", f"{li.interest:.1f}%", f"{li.payment:,.2f}") + balance_str = CurrencyConverter.format_money(li.balance, model.base_currency) + payment_str = CurrencyConverter.format_money(li.payment, model.base_currency) + tbl.add_row(li.name, balance_str, f"{li.interest:.1f}%", payment_str) console.print(tbl) diff --git a/infrastructure/cli/i18n.py b/infrastructure/cli/i18n.py index 9d7c753..144c8ce 100644 --- a/infrastructure/cli/i18n.py +++ b/infrastructure/cli/i18n.py @@ -190,7 +190,7 @@ _r("cmd.config.transaction_add.opt.amount", "Сумма (доход/расход _r("cmd.config.transaction_add.opt.category", "Категория") _r("cmd.config.transaction_add.opt.description", "Описание") _r("cmd.config.transaction_add.opt.date", "Дата (YYYY-MM-DD)") -_r("cmd.config.transaction_add.ok", "Транзакция '{kind}' на {amount:.2f} (счёт: {account})") +_r("cmd.config.transaction_add.ok", "Транзакция '{kind}' на {amount_str} (счёт: {account})") _r("cmd.config.transaction_add.kind.income", "доход") _r("cmd.config.transaction_add.kind.expense", "расход") _r("cmd.config.transaction_add.err", "Счёт '{account}' не найден") @@ -209,7 +209,7 @@ _r("cmd.config.recurring_add.opt.category", "Категория") _r("cmd.config.recurring_add.opt.frequency", "Периодичность") _r("cmd.config.recurring_add.opt.start", "Дата начала (YYYY-MM-DD)") _r("cmd.config.recurring_add.opt.end", "Дата окончания (YYYY-MM-DD)") -_r("cmd.config.recurring_add.ok", "Регулярный платёж на {amount:.2f} ({frequency})") +_r("cmd.config.recurring_add.ok", "Регулярный платёж на {amount_str} ({frequency})") _r("cmd.config.recurring_list.help", "Список регулярных платежей") _r("cmd.config.recurring_list.empty", "Регулярные платежи не добавлены") diff --git a/infrastructure/cli/services.py b/infrastructure/cli/services.py index 6c61dac..0fb65b6 100644 --- a/infrastructure/cli/services.py +++ b/infrastructure/cli/services.py @@ -54,7 +54,11 @@ def convert_value( return converter.convert(amount, from_curr, to_curr) -def fmt_amount(amount: Decimal | float, currency: str, symbol: str) -> str: - if isinstance(amount, Decimal): - return f"{symbol}{amount:,.2f}" - return f"{symbol}{amount:,.2f}" +def fmt_amount(amount: Decimal | float, currency: str, symbol: str = "") -> str: + """Format money via the canonical display rule. + + `symbol` is retained as a positional parameter for backward compatibility with + existing callers (compare.py / forecast.py / scenario.py / whatif.py); the + symbol/layout decision is centralised in `CurrencyConverter.format_money`. + """ + return CurrencyConverter.format_money(amount, currency) diff --git a/tests/test_currency.py b/tests/test_currency.py index 76bc7cf..1a75178 100644 --- a/tests/test_currency.py +++ b/tests/test_currency.py @@ -1,3 +1,5 @@ +from decimal import Decimal + import pytest from domain import CurrencyConverter, CurrencyError, ExchangeRate @@ -70,3 +72,41 @@ class TestCurrencyConverter: assert converter.convert(10, "USD", "RUB") == 800.0 assert converter.convert(10, "EUR", "RUB") == 900.0 assert converter.convert(900, "RUB", "EUR") == 10.0 + + +class TestFormatMoney: + """ISO 4217-aware display formatting. Rounding mode: ROUND_HALF_UP.""" + + def test_jpy_zero_decimals(self): + c = CurrencyConverter() + assert c.format_money(Decimal("1234.567"), "JPY") == "¥1,235" + + def test_usd_two_decimals(self, sample_converter): + assert sample_converter.format_money(Decimal("1234.567"), "USD") == "$1,234.57" + + def test_bhd_three_decimals(self): + # BHD has 3 minor units and is NOT in CURRENCY_SYMBOLS — code prefix with space. + c = CurrencyConverter() + assert c.format_money(Decimal("1.234"), "BHD") == "BHD 1.234" + + def test_chf_unknown_code_two_decimals(self): + # Unknown code falls back to DEFAULT_CURRENCY_DECIMALS=2, layout is code prefix. + c = CurrencyConverter() + assert c.format_money(Decimal("1.234"), "CHF") == "CHF 1.23" + + def test_negative_amount(self, sample_converter): + # Sign must be rendered before the symbol, not embedded in the number. + assert sample_converter.format_money(Decimal("-1200.00"), "RUB") == "-₽1,200.00" + + def test_zero_amount(self, sample_converter): + assert sample_converter.format_money(Decimal("0"), "JPY") == "¥0" + assert sample_converter.format_money(Decimal("0"), "USD") == "$0.00" + + def test_accepts_float_and_int(self, sample_converter): + # Defensive: callers from CLI pass float — format_money must coerce. + assert sample_converter.format_money(1500.5, "USD") == "$1,500.50" + assert sample_converter.format_money(1500, "JPY") == "¥1,500" + + def test_case_insensitive_currency_code(self, sample_converter): + # Defensive: lower-case codes are accepted; storage stays upper-case. + assert sample_converter.format_money(Decimal("100"), "usd") == "$100.00"