Ledgerly/ledgerly/money.py

102 lines
3.2 KiB
Python
Raw Normal View History

2026-10-27 16:04:50 +00:00
"""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})"