"""Money, and the currencies it refuses to mix — the one money type every brick shares.
A VALUE OBJECT: immutable, compared by value. An amount without a currency cannot be built, and two
currencies cannot be added: a silent wrong total becomes an error at the line that wrote it.
(Design from a prior CRM money module, where amounts and currencies had been re-paired by hand in four
places and a sheet summed SEK and EUR side by side.)
* Decimal, never float, carried to the currency's MINOR UNIT (ISO 4217): two places for EUR, SEK and
most others, none for JPY and KRW, three for BHD, KWD, JOD, OMR, TND, LYD and IQD. Until 0.2.0 every
currency was carried to two places, which invents yen cents and drops a Kuwaiti fils: a KWD salary of
1234.567 became 1234.57. A code not in MINOR_UNITS is carried to two places — the ISO default, and
what every currency here was before — rather than refused, so a currency we have not listed still
works; add it to the table when its minor unit is not 2.
* Rounded HALF_UP — an invoice has to match what a person gets with a calculator, not banker's rounding.
* It leaves the system as a STRING: JSON has only doubles. Both brick doors encode Decimal as its
exact string (erp_platform.bricks.encode), so `amount` can be returned as it is.
RATES are not money. A rate is an amount PER something (an hour, a day, a month) and is carried to four
places, because a day rate of 412.3077 (a 53 600 annual salary over 130 days) rounded to 412.31 pays the
wrong amount on every day of a long assignment. `Rate` computes EXACT amounts (`ExactAmount`, a
rational, never rounded) and a payable line is rounded ONCE, at the end (`round_once`): rounding each day
and then summing gives a total no calculator agrees with (see tests/test_money.py).
COLUMNS (the shared vocabulary, modules/shared.yaml):
rate NUMERIC(19,4) with its basis (VARCHAR) and currency (CHAR(3)) beside it
payable money NUMERIC(18,2) a table that may hold a three-place currency (BHD, KWD, ...) uses
NUMERIC(18,3): every currency's amount stores exactly in it
an ExactAmount is never stored — round it to Money first.
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import (ROUND_CEILING, ROUND_DOWN, ROUND_FLOOR, ROUND_HALF_EVEN, ROUND_HALF_UP, ROUND_UP,
Decimal)
from fractions import Fraction
from typing import Iterable, Optional
CENTS = Decimal("0.01")
PCT = Decimal("0.01")
# ISO 4217 currencies whose minor unit is NOT two places (ISO 4217 list one, 2026). Everything else is 2.
# Funds codes (CLF, UYW, ...) are included because a payroll in Chile does meet CLF.
MINOR_UNITS = {
**dict.fromkeys(("BIF", "CLP", "DJF", "GNF", "ISK", "JPY", "KMF", "KRW", "PYG", "RWF", "UGX",
"UYI", "VND", "VUV", "XAF", "XOF", "XPF"), 0),
**dict.fromkeys(("BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"), 3),
**dict.fromkeys(("CLF", "UYW"), 4),
}
DEFAULT_MINOR_UNIT = 2
# The rounding modes a policy may name. HALF_UP is the default everywhere; the others exist because
# a contract or a payroll rule can say otherwise ("round down in the employee's disfavour" is not legal
# everywhere, which is exactly why it is the customer's row and not our default).
ROUNDING = {"HALF_UP": ROUND_HALF_UP, "HALF_EVEN": ROUND_HALF_EVEN, "DOWN": ROUND_DOWN, "UP": ROUND_UP,
"CEILING": ROUND_CEILING, "FLOOR": ROUND_FLOOR}
class CurrencyMismatch(Exception):
"""Two currencies met in one sum. A domain rule broken, not a bad request."""
class InvalidMoney(ValueError):
"""An amount or currency that is not money. Its own type so a caller can answer "your input"
(422) for THIS, without also blaming the caller for every other ValueError — a parse failure in
a driver is the system's fault and must surface as one."""
def minor_unit(currency: str) -> int:
"""How many decimal places this currency's amounts carry (ISO 4217; 2 for a code not listed)."""
return MINOR_UNITS.get(_currency(currency), DEFAULT_MINOR_UNIT)
def quantum(currency: str) -> Decimal:
"""The smallest amount of this currency: 0.01 EUR, 1 JPY, 0.001 KWD."""
return Decimal(1).scaleb(-minor_unit(currency))
def _currency(code) -> str:
if not isinstance(code, str) or len(code) != 3 or not code.isalpha():
raise InvalidMoney(f"currency must be a three-letter code, got {code!r}")
return code.upper()
def _mode(rounding_mode) -> str:
"""A rounding mode by its name (HALF_UP) or as the decimal module's constant (ROUND_HALF_UP)."""
name = str(rounding_mode).upper().removeprefix("ROUND_")
if name not in ROUNDING:
raise InvalidMoney(f"rounding mode must be one of {', '.join(ROUNDING)}, got {rounding_mode!r}")
return name
def _no_float(value, what: str):
"""Refuse a float before it is turned into anything. Decimal(str(0.1)) happens to look right;
Decimal(0.1) is 0.1000000000000000055511151231257827, and which one a caller gets depends on code
far from here. An amount arrives as a Decimal, an int or a string — or not at all."""
if isinstance(value, float):
raise InvalidMoney(f"{what} is a float ({value!r}); pass a Decimal or a string — a float "
"cannot hold most decimal amounts exactly")
if isinstance(value, bool):
raise InvalidMoney(f"{what} is a boolean ({value!r}), not a number")
return value
def pct(value) -> Decimal:
"""A percentage to two places, HALF_UP — the precision a discount policy is written in."""
return Decimal(str(value)).quantize(PCT, rounding=ROUND_HALF_UP)
@dataclass(frozen=True, slots=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self) -> None:
object.__setattr__(self, "currency", _currency(self.currency))
try:
amount = Decimal(str(self.amount))
except ArithmeticError:
raise InvalidMoney(f"{self.amount!r} is not an amount") from None
if not amount.is_finite():
raise InvalidMoney(f"{self.amount!r} is not an amount")
object.__setattr__(self, "amount", amount.quantize(quantum(self.currency), rounding=ROUND_HALF_UP))
@classmethod
def of(cls, amount, currency: str) -> "Money":
return cls(amount if amount is not None else Decimal("0"), currency) # validated in __post_init__
@classmethod
def zero(cls, currency: str) -> "Money":
return cls(Decimal("0"), currency)
def _same(self, other: "Money") -> None:
if self.currency != other.currency:
raise CurrencyMismatch(
f"cannot combine {self.currency} and {other.currency} — the sum would be a "
f"number that is true in neither")
def __add__(self, other: "Money") -> "Money":
self._same(other)
return Money(self.amount + other.amount, self.currency)
def __sub__(self, other: "Money") -> "Money":
self._same(other)
return Money(self.amount - other.amount, self.currency)
def __mul__(self, n) -> "Money":
"""Money times a COUNT, never money times money."""
if isinstance(n, Money):
raise TypeError("money times money is not a thing the business has a word for")
return Money(self.amount * Decimal(str(n)), self.currency)
__rmul__ = __mul__
def __lt__(self, other: "Money") -> bool:
self._same(other)
return self.amount < other.amount
def __le__(self, other: "Money") -> bool:
self._same(other)
return self.amount <= other.amount
def __bool__(self) -> bool:
return self.amount != 0
def percent_of(self, whole: "Money") -> Decimal:
"""This as a percentage of `whole`, to two places. 0 when the whole is 0 — a line with no
list price has no discount, which is a fact and not an error."""
self._same(whole)
if whole.amount == 0:
return Decimal("0.00")
return pct(self.amount / whole.amount * 100)
def split(self, parts: int) -> list["Money"]:
"""Divide into `parts` that SUM BACK TO THE WHOLE. The remainder goes on the first part:
recognising revenue earlier is the conservative direction, and it makes the odd one out
predictable."""
if parts < 1:
raise ValueError("cannot split into fewer than one part")
each = Money(self.amount / parts, self.currency)
first = Money(self.amount - each.amount * (parts - 1), self.currency)
return [first] + [each] * (parts - 1)
def __str__(self) -> str:
return f"{self.amount} {self.currency}"
def totals_by_currency(items: Iterable[Money]) -> dict[str, Money]:
"""One total PER currency — the shape every total takes. Never a single sum across them."""
out: dict[str, Money] = {}
for m in items:
out[m.currency] = out[m.currency] + m if m.currency in out else m
return out
# ── rates, and exact amounts that are rounded once ───────────────────────────────────────────
BASES = ("hour", "day", "month", "year", "fixed")
RATE_PLACES = Decimal("0.0001") # NUMERIC(19,4)
_RATE_LIMIT = Decimal(10) ** 15 # 19 digits, 4 of them after the point
class InvalidRate(InvalidMoney):
"""A rate that cannot be stored or used as asked (wrong basis, too many places, a float)."""
def _round_fraction(value: Fraction, places: int, mode: str) -> Decimal:
"""Round an exact rational to `places` decimals, with the decimal module's semantics per mode.
Written out rather than delegated to Decimal.quantize because the value is a Fraction: 10/60 has no
finite decimal form, so converting first would round once in the conversion and again here — the
double rounding this whole section exists to prevent.
"""
scaled = value * (10 ** places)
floor, rem = divmod(scaled.numerator, scaled.denominator) # rem in [0, denominator)
if rem == 0:
n = floor
else:
half = 2 * rem - scaled.denominator # >0 above half, 0 exactly half
away = floor + 1 if value > 0 else floor # away from zero
toward = floor if value > 0 else floor + 1 # toward zero
n = {"FLOOR": floor, "CEILING": floor + 1, "DOWN": toward, "UP": away,
"HALF_UP": floor + 1 if half > 0 else floor if half < 0 else away,
"HALF_EVEN": floor + 1 if half > 0 else floor if half < 0 else
(floor if floor % 2 == 0 else floor + 1)}[mode]
return Decimal(n).scaleb(-places)
@dataclass(frozen=True, slots=True)
class ExactAmount:
"""An amount of money that has NOT been rounded: what a rate times a quantity really comes to.
Its own type rather than a bare Decimal, for two reasons. A Decimal division is already a rounding
(10.01 / 60 is cut at 28 digits), and three of those summed and rounded DOWN give 0.49 where the
exact answer is 0.50 — so the value is a Fraction, exact by construction, and stays exact through any
sum. And it keeps its currency, so an unrounded yen amount cannot be added to an unrounded euro one,
and nothing can store it by accident: there is no column for it — `to_money` is the one way out.
"""
value: Fraction
currency: str
def __post_init__(self) -> None:
if not isinstance(self.value, Fraction):
raise InvalidMoney("an ExactAmount holds a Fraction; build it from a Rate")
object.__setattr__(self, "currency", _currency(self.currency))
@classmethod
def zero(cls, currency: str) -> "ExactAmount":
return cls(Fraction(0), currency)
@classmethod
def of(cls, amount, currency: str) -> "ExactAmount":
"""An amount already exact (a Decimal, an int or a string) — a fixed fee, an adjustment."""
return cls(_fraction(amount, "amount"), currency)
def _same(self, other: "ExactAmount") -> None:
if not isinstance(other, ExactAmount):
raise TypeError("an exact amount adds to an exact amount; round it with to_money() first")
if self.currency != other.currency:
raise CurrencyMismatch(f"cannot combine {self.currency} and {other.currency}")
def __add__(self, other: "ExactAmount") -> "ExactAmount":
self._same(other)
return ExactAmount(self.value + other.value, self.currency)
def __sub__(self, other: "ExactAmount") -> "ExactAmount":
self._same(other)
return ExactAmount(self.value - other.value, self.currency)
def __neg__(self) -> "ExactAmount":
return ExactAmount(-self.value, self.currency)
def to_money(self, rounding_mode: str = "HALF_UP") -> Money:
"""Round ONCE to the currency's minor unit. The only place an exact amount becomes money."""
return Money(_round_fraction(self.value, minor_unit(self.currency), _mode(rounding_mode)),
self.currency)
def __str__(self) -> str:
return f"{self.value} {self.currency} (exact, unrounded)"
def round_once(amounts: Iterable[ExactAmount], currency: Optional[str] = None,
rounding_mode: str = "HALF_UP") -> Money:
"""Sum exact amounts, then round the TOTAL once — one payable line from many days or hours.
Rounding each day and adding the results is the defect this prevents: three one-minute slots at an
hourly 10.01 are 0.17 + 0.17 + 0.17 = 0.51 that way, and 0.5005 → 0.50 this way. `currency` is
needed only for an empty list (a line with nothing on it is zero OF something).
"""
total: Optional[ExactAmount] = ExactAmount.zero(currency) if currency else None
for a in amounts:
total = a if total is None else total + a
if total is None:
raise InvalidMoney("round_once of no amounts needs the currency")
return total.to_money(rounding_mode)
def _fraction(value, what: str) -> Fraction:
_no_float(value, what)
try:
d = Decimal(value) if isinstance(value, (int, Decimal)) else Decimal(str(value).strip())
except ArithmeticError:
raise InvalidRate(f"{what} {value!r} is not a number") from None
if not d.is_finite():
raise InvalidRate(f"{what} {value!r} is not a number")
return Fraction(d)
@dataclass(frozen=True, slots=True)
class Rate:
"""An amount PER a basis — hour, day, month, year, or fixed (a flat fee per occurrence).
Carried to four places (NUMERIC(19,4)); more places are REFUSED rather than rounded, because rounding
a rate silently is a price change on every unit it is applied to — the caller rounds, and says how.
The basis is checked against BASES here; which bases a customer offers (a day rate for contractors,
a monthly salary for staff) is the customer's config row in the consuming brick.
Every computation returns an ExactAmount: unrounded until a payable line is rounded once.
"""
amount: Decimal
basis: str
currency: str
def __post_init__(self) -> None:
object.__setattr__(self, "currency", _currency(self.currency))
if self.basis not in BASES:
raise InvalidRate(f"basis must be one of {', '.join(BASES)}, got {self.basis!r}")
_no_float(self.amount, "a rate")
try:
d = Decimal(self.amount) if isinstance(self.amount, (int, Decimal)) else Decimal(str(self.amount))
except ArithmeticError:
raise InvalidRate(f"{self.amount!r} is not a rate") from None
if not d.is_finite():
raise InvalidRate(f"{self.amount!r} is not a rate")
if d != d.quantize(RATE_PLACES, rounding=ROUND_DOWN):
raise InvalidRate(f"a rate carries at most four decimal places, got {d}; round it "
"explicitly first — rounding a rate here would change every amount it pays")
if abs(d) >= _RATE_LIMIT:
raise InvalidRate(f"{d} does not fit NUMERIC(19,4)")
object.__setattr__(self, "amount", d.quantize(RATE_PLACES))
@classmethod
def of(cls, amount, basis: str, currency: str) -> "Rate":
return cls(amount, basis, currency)
def _exact(self, quantity: Fraction) -> ExactAmount:
return ExactAmount(Fraction(self.amount) * quantity, self.currency)
def _basis(self, expected: str, doing: str) -> None:
if self.basis != expected:
raise InvalidRate(f"a {self.basis} rate is not paid {doing}; use times() with a quantity in "
f"{self.basis}s, or convert the rate by the customer's own rule")
def for_minutes(self, minutes: int) -> ExactAmount:
"""An hourly rate for a number of minutes: rate × minutes / 60, exactly."""
self._basis("hour", "by the minute")
if isinstance(minutes, bool) or not isinstance(minutes, int):
raise InvalidRate(f"minutes is a whole number, got {minutes!r}")
return self._exact(Fraction(minutes, 60))
def for_days(self, days) -> ExactAmount:
"""A day rate for a number of days — half days included (Decimal("0.5"))."""
self._basis("day", "by the day")
return self._exact(_fraction(days, "days"))
def times(self, quantity) -> ExactAmount:
"""The rate times a quantity in its own basis: 1.5 months, 3 occurrences of a fixed fee."""
return self._exact(_fraction(quantity, "quantity"))
def __str__(self) -> str:
return f"{self.amount} {self.currency}/{self.basis}"