T8: ISO 4217 per-currency decimals + format_money()

- Add CURRENCY_DECIMALS dict (JPY=0, BHD/KWD/OMR=3, rest=2) and DEFAULT_CURRENCY_DECIMALS=2
- Add CurrencyConverter.format_money() staticmethod with ROUND_HALF_UP; symbol-prefix for known, code-prefix for unknown
- Replace 14 :,.2f literals across services.py / info.py / config_cmd.py with format_money() calls
- Refactor 2 i18n templates (transaction_add.ok, recurring_add.ok): {amount:.2f} -> {amount_str}; callers pass pre-formatted string
- New _account_currency() helper for transaction currency lookup
- Extend test_currency.py: 8 new tests (JPY/BHD/USD/CHF/negative/zero/float-int/lowercase)
- 75/75 tests pass; 0 :,.2f literals remain in infrastructure/cli/
This commit is contained in:
2026-10-09 19:24:47 +03:00
parent 9175d5c76b
commit d58547e8f8
6 changed files with 136 additions and 19 deletions
+51
View File
@@ -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)
+25 -8
View File
@@ -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
+10 -5
View File
@@ -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)
+2 -2
View File
@@ -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", "Регулярные платежи не добавлены")
+8 -4
View File
@@ -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)
+40
View File
@@ -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"