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)
|
||||
|
||||
Reference in New Issue
Block a user