102 lines
3.2 KiB
Python
102 lines
3.2 KiB
Python
|
|
"""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})"
|