# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : money.py # Description : Exact decimal Money type — floats are forbidden for amounts. # ============================================================================= """Money: exact decimal amounts with an ISO 4217 currency code. Rules (non-negotiable, see CLAUDE.md §5): - Amounts are NEVER floats. Construction from float raises TypeError. - Arithmetic across currencies raises CurrencyMismatchError. - Rounding is explicit: a RoundingMode is always named by the caller (policies decide the mode per jurisdiction; nothing is implicit). """ from __future__ import annotations import enum from dataclasses import dataclass from decimal import ROUND_HALF_EVEN, ROUND_HALF_UP, Decimal # ISO 4217 minor-unit exponents for the currencies AIR handles today. # Extend as needed; unknown currencies default to 2 decimal places. CURRENCY_EXPONENTS: dict[str, int] = { "CAD": 2, "USD": 2, "EUR": 2, "GBP": 2, "AUD": 2, "CHF": 2, "JPY": 0, "KRW": 0, } class RoundingMode(enum.Enum): """Named rounding modes; the mode applied is always a policy decision.""" HALF_UP = "half_up" # 0.5 rounds away from zero (common on tax lines) HALF_EVEN = "half_even" # banker's rounding @property def decimal_mode(self) -> str: return ROUND_HALF_UP if self is RoundingMode.HALF_UP else ROUND_HALF_EVEN class CurrencyMismatchError(ValueError): """Raised when arithmetic mixes two different currencies.""" def _to_decimal(value: Decimal | int | str) -> Decimal: if isinstance(value, float): raise TypeError( "Money amounts must not be floats. Pass a Decimal, int, or string " "(e.g. Decimal('19.99') or '19.99')." ) if isinstance(value, Decimal): return value if isinstance(value, (int, str)): return Decimal(value) raise TypeError(f"Unsupported amount type: {type(value).__name__}") @dataclass(frozen=True, slots=True) class Money: """An exact amount in a single currency. Immutable.""" amount: Decimal currency: str def __post_init__(self) -> None: object.__setattr__(self, "amount", _to_decimal(self.amount)) if not (isinstance(self.currency, str) and len(self.currency) == 3 and self.currency.isalpha() and self.currency.isupper()): raise ValueError(f"Invalid ISO 4217 currency code: {self.currency!r}") # --- arithmetic (same-currency only) ------------------------------------- def _check(self, other: "Money") -> None: if self.currency != other.currency: raise CurrencyMismatchError( f"Cannot combine {self.currency} with {other.currency}; " "convert explicitly through the FX pass first." ) def __add__(self, other: "Money") -> "Money": self._check(other) return Money(self.amount + other.amount, self.currency) def __sub__(self, other: "Money") -> "Money": self._check(other) return Money(self.amount - other.amount, self.currency) def __neg__(self) -> "Money": return Money(-self.amount, self.currency) def multiply(self, factor: Decimal | int | str) -> "Money": """Unrounded multiplication (e.g. qty × unit price, rate × base). The result keeps full precision; call .quantized() when a policy says the amount becomes a posted figure. """ return Money(self.amount * _to_decimal(factor), self.currency) # --- rounding ------------------------------------------------------------- def exponent(self) -> int: return CURRENCY_EXPONENTS.get(self.currency, 2) def quantized(self, mode: RoundingMode) -> "Money": """Round to the currency's minor unit using an explicit, named mode.""" quantum = Decimal(1).scaleb(-self.exponent()) return Money(self.amount.quantize(quantum, rounding=mode.decimal_mode), self.currency) # --- predicates / display --------------------------------------------------- def is_zero(self) -> bool: return self.amount == 0 def is_negative(self) -> bool: return self.amount < 0 def __str__(self) -> str: return f"{self.amount} {self.currency}" def money(amount: Decimal | int | str, currency: str) -> Money: """Convenience constructor: money('19.99', 'CAD').""" return Money(_to_decimal(amount), currency)