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
+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"