SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
4.5 KB · 122 lines python
Raw Blame History
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