"""Money value object. All money in Ledgerly is stored and computed as *integer cents*. ``Money`` is an immutable, total-ordered value object. Domain services enforce positivity where it is required (transactions, budgets); reports may rely on negative values for nets and remainders. """ from __future__ import annotations from dataclasses import dataclass from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Union __all__ = ["InvalidMoney", "Money"] class InvalidMoney(ValueError): """Raised when text cannot be parsed as a monetary amount (R3).""" @dataclass(frozen=True) class Money: """An immutable amount of money expressed in integer cents (signed).""" cents: int def __post_init__(self) -> None: if isinstance(self.cents, bool) or not isinstance(self.cents, int): raise TypeError(f"cents must be an int, got {type(self.cents).__name__}") # -- construction ------------------------------------------------------ @classmethod def zero(cls) -> Money: return cls(cents=0) @classmethod def parse(cls, text: Union[str, int, float, Decimal]) -> Money: """Parse decimal text/number into Money (R3). Accepts at most 2 decimal places; any further precision is rounded half-up to cents. Raises :class:`InvalidMoney` for unparseable input. """ try: value = Decimal(str(text).strip()) except (InvalidOperation, ValueError) as exc: raise InvalidMoney(f"not a valid amount: {text!r}") from exc if not value.is_finite(): raise InvalidMoney(f"not a valid amount: {text!r}") cents = int(value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) * 100) return cls(cents=cents) # -- predicates -------------------------------------------------------- @property def is_zero(self) -> bool: return self.cents == 0 @property def is_positive(self) -> bool: return self.cents > 0 # -- arithmetic -------------------------------------------------------- def __add__(self, other: Money) -> Money: if not isinstance(other, Money): return NotImplemented return Money(cents=self.cents + other.cents) def __sub__(self, other: Money) -> Money: if not isinstance(other, Money): return NotImplemented return Money(cents=self.cents - other.cents) # -- ordering ---------------------------------------------------------- def __eq__(self, other: object) -> bool: return isinstance(other, Money) and self.cents == other.cents def __lt__(self, other: Money) -> bool: return self.cents < other.cents def __le__(self, other: Money) -> bool: return self.cents <= other.cents def __hash__(self) -> int: return hash(("Money", self.cents)) # -- presentation ------------------------------------------------------ def __str__(self) -> str: sign = "-" if self.cents < 0 else "" return f"{sign}${abs(self.cents) / 100:,.2f}" def __format__(self, spec: str) -> str: """Support alignment specs such as ``f"{money:<12}"``.""" return str.__format__(str(self), spec) def __repr__(self) -> str: return f"Money({self.cents})"