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]