dodil/erp-brick-gl
DODIL ERP brick: general ledger
src/brick_gl/domain/money.py
"""Money and rates: bounds, rounding once, and splitting an amount exactly (largest remainder, to the penny)."""
from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal
from typing import Sequence
from brick_gl.domain.errors import RuleBroken
CENT = Decimal("0.01")
ZERO = Decimal("0.00")
MONEY_LIMIT = Decimal("1e16") # NUMERIC(18,2): 16 digits before the point
RATE_LIMIT = Decimal("1e13") # NUMERIC(19,6): 13 digits before the point
RATE_PLACES = Decimal("0.000001")
KEY_FIGURE_LIMIT = Decimal("1e15") # NUMERIC(19,4)
def q2(x) -> Decimal:
"""An exact 2-place Decimal, rounded half up (never a float)."""
return Decimal(str(x)).quantize(CENT, rounding=ROUND_HALF_UP)
def money(x, what: str = "an amount", *, allow_negative: bool = False) -> Decimal:
"""A bounded NUMERIC(18,2) amount, or a 422 in words: finite, at most 2 places, below 1e16, not negative."""
if isinstance(x, bool) or isinstance(x, float):
raise RuleBroken(f"{what} is an exact number: give it as a string such as \"100.00\"", status=422, gate="bounds")
try:
d = x if isinstance(x, Decimal) else Decimal(str(x).strip())
except Exception:
raise RuleBroken(f"{x!r} is not {what}", status=422, gate="bounds") from None
if not d.is_finite():
raise RuleBroken(f"{what} is a finite number", status=422, gate="bounds")
if d.as_tuple().exponent < -2:
raise RuleBroken(f"{what} has at most two decimal places ({d})", status=422, gate="bounds")
if abs(d) >= MONEY_LIMIT:
raise RuleBroken(f"{what} is below 10,000,000,000,000,000 ({d})", status=422, gate="bounds")
if d < 0 and not allow_negative:
raise RuleBroken(f"{what} is zero or more ({d}); a debit and a credit are never negative", status=422, gate="bounds")
return d.quantize(CENT)
def rate(x, what: str = "a rate") -> Decimal:
"""An exchange rate NUMERIC(19,6): positive, finite, at most six places."""
if isinstance(x, bool) or isinstance(x, float):
raise RuleBroken(f"{what} is an exact number: give it as a string such as \"0.790000\"", status=422, gate="bounds")
try:
d = x if isinstance(x, Decimal) else Decimal(str(x).strip())
except Exception:
raise RuleBroken(f"{x!r} is not {what}", status=422, gate="bounds") from None
if not d.is_finite() or d <= 0:
raise RuleBroken(f"{what} is a positive number ({x})", status=422, gate="bounds")
if d.as_tuple().exponent < -6:
raise RuleBroken(f"{what} has at most six decimal places ({d})", status=422, gate="bounds")
if d >= RATE_LIMIT:
raise RuleBroken(f"{what} is below 10,000,000,000,000 ({d})", status=422, gate="bounds")
return d
def convert(amount: Decimal, fx: Decimal) -> Decimal:
"""Restate an amount at a rate (units of the target currency for one of this), rounded ONCE to the cent."""
return (amount * fx).quantize(CENT, rounding=ROUND_HALF_UP)
def largest_remainder(total: Decimal, weights: Sequence[Decimal]) -> list:
"""Share `total` (to the cent) in proportion to `weights`, the shares summing to it EXACTLY: each gets its floor to the
cent and the leftover cents go to the largest fractional remainders (ties: the earlier weight)."""
total = q2(total)
n = len(weights)
if n == 0:
return []
sign = -1 if total < 0 else 1
total = abs(total)
ws = [abs(Decimal(w)) for w in weights]
s = sum(ws, Decimal(0))
if s == 0:
# nothing to go by: everything to the first receiver
out = [ZERO] * n
out[0] = total * sign
return out
exact = [total * w / s for w in ws]
floors = [e.quantize(CENT, rounding=ROUND_FLOOR) for e in exact]
left = int(((total - sum(floors, Decimal(0))) / CENT).to_integral_value())
order = sorted(range(n), key=lambda i: (-(exact[i] - floors[i]), i))
for i in order[:left]:
floors[i] += CENT
return [f * sign for f in floors]