spb/air Public MIT
AIR — The Language of Accounting.
Python 100%
1# =============================================================================2# Projet : AIR — Accounting Intermediate Representation3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : money.py6# Description : Exact decimal Money type — floats are forbidden for amounts.7# =============================================================================8"""Money: exact decimal amounts with an ISO 4217 currency code.910Rules (non-negotiable, see CLAUDE.md §5):11- Amounts are NEVER floats. Construction from float raises TypeError.12- Arithmetic across currencies raises CurrencyMismatchError.13- Rounding is explicit: a RoundingMode is always named by the caller14 (policies decide the mode per jurisdiction; nothing is implicit).15"""16from __future__ import annotations1718import enum19from dataclasses import dataclass20from decimal import ROUND_HALF_EVEN, ROUND_HALF_UP, Decimal2122# ISO 4217 minor-unit exponents for the currencies AIR handles today.23# Extend as needed; unknown currencies default to 2 decimal places.24CURRENCY_EXPONENTS: dict[str, int] = {25 "CAD": 2, "USD": 2, "EUR": 2, "GBP": 2, "AUD": 2, "CHF": 2,26 "JPY": 0, "KRW": 0,27}282930class RoundingMode(enum.Enum):31 """Named rounding modes; the mode applied is always a policy decision."""3233 HALF_UP = "half_up" # 0.5 rounds away from zero (common on tax lines)34 HALF_EVEN = "half_even" # banker's rounding3536 @property37 def decimal_mode(self) -> str:38 return ROUND_HALF_UP if self is RoundingMode.HALF_UP else ROUND_HALF_EVEN394041class CurrencyMismatchError(ValueError):42 """Raised when arithmetic mixes two different currencies."""434445def _to_decimal(value: Decimal | int | str) -> Decimal:46 if isinstance(value, float):47 raise TypeError(48 "Money amounts must not be floats. Pass a Decimal, int, or string "49 "(e.g. Decimal('19.99') or '19.99')."50 )51 if isinstance(value, Decimal):52 return value53 if isinstance(value, (int, str)):54 return Decimal(value)55 raise TypeError(f"Unsupported amount type: {type(value).__name__}")565758@dataclass(frozen=True, slots=True)59class Money:60 """An exact amount in a single currency. Immutable."""6162 amount: Decimal63 currency: str6465 def __post_init__(self) -> None:66 object.__setattr__(self, "amount", _to_decimal(self.amount))67 if not (isinstance(self.currency, str) and len(self.currency) == 368 and self.currency.isalpha() and self.currency.isupper()):69 raise ValueError(f"Invalid ISO 4217 currency code: {self.currency!r}")7071 # --- arithmetic (same-currency only) -------------------------------------72 def _check(self, other: "Money") -> None:73 if self.currency != other.currency:74 raise CurrencyMismatchError(75 f"Cannot combine {self.currency} with {other.currency}; "76 "convert explicitly through the FX pass first."77 )7879 def __add__(self, other: "Money") -> "Money":80 self._check(other)81 return Money(self.amount + other.amount, self.currency)8283 def __sub__(self, other: "Money") -> "Money":84 self._check(other)85 return Money(self.amount - other.amount, self.currency)8687 def __neg__(self) -> "Money":88 return Money(-self.amount, self.currency)8990 def multiply(self, factor: Decimal | int | str) -> "Money":91 """Unrounded multiplication (e.g. qty × unit price, rate × base).9293 The result keeps full precision; call .quantized() when a policy94 says the amount becomes a posted figure.95 """96 return Money(self.amount * _to_decimal(factor), self.currency)9798 # --- rounding -------------------------------------------------------------99 def exponent(self) -> int:100 return CURRENCY_EXPONENTS.get(self.currency, 2)101102 def quantized(self, mode: RoundingMode) -> "Money":103 """Round to the currency's minor unit using an explicit, named mode."""104 quantum = Decimal(1).scaleb(-self.exponent())105 return Money(self.amount.quantize(quantum, rounding=mode.decimal_mode),106 self.currency)107108 # --- predicates / display ---------------------------------------------------109 def is_zero(self) -> bool:110 return self.amount == 0111112 def is_negative(self) -> bool:113 return self.amount < 0114115 def __str__(self) -> str:116 return f"{self.amount} {self.currency}"117118119def money(amount: Decimal | int | str, currency: str) -> Money:120 """Convenience constructor: money('19.99', 'CAD')."""121 return Money(_to_decimal(amount), currency)122