Files
nifodea/domain/currency.py
T
oqyude d58547e8f8 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/
2026-10-09 19:24:47 +03:00

135 lines
4.3 KiB
Python

from decimal import Decimal, ROUND_HALF_UP
from pydantic import BaseModel, Field, field_validator
CURRENCY_SYMBOLS = {
"RUB": "₽",
"USD": "$",
"EUR": "€",
"GBP": "£",
"CNY": "¥",
"JPY": "¥",
"KZT": "₸",
"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"
to_currency: str = "RUB"
rate: Decimal = Field(default=Decimal("80"))
@field_validator("rate")
@classmethod
def _rate_positive(cls, v: Decimal) -> Decimal:
if v <= 0:
raise ValueError("rate must be positive")
return v
DEFAULT_RATES: list[ExchangeRate] = [
ExchangeRate(from_currency="USD", to_currency="RUB", rate=Decimal("80")),
]
class CurrencyError(Exception):
pass
class CurrencyConverter:
def __init__(self, rates: list[ExchangeRate] | None = None):
self._rates: dict[tuple[str, str], Decimal] = {}
if rates:
for r in rates:
self.set_rate(r.from_currency, r.to_currency, r.rate)
def set_rate(
self,
from_currency: str,
to_currency: str,
rate: Decimal | float | int | str,
) -> None:
d = Decimal(str(rate)) if not isinstance(rate, Decimal) else rate
if d <= 0:
raise CurrencyError(f"Rate must be positive: {d}")
self._rates[(from_currency, to_currency)] = d
self._rates[(to_currency, from_currency)] = Decimal("1") / d
def get_rate(self, from_currency: str, to_currency: str) -> Decimal:
if from_currency == to_currency:
return Decimal("1")
try:
return self._rates[(from_currency, to_currency)]
except KeyError:
raise CurrencyError(f"No exchange rate: {from_currency} → {to_currency}")
def convert(
self,
amount: Decimal | float | int | str,
from_currency: str,
to_currency: str,
) -> Decimal:
a = Decimal(str(amount)) if not isinstance(amount, Decimal) else amount
if from_currency == to_currency:
return a
rate = self.get_rate(from_currency, to_currency)
return (a * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
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)