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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", "Регулярные платежи не добавлены")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user