dodil/erp-brick-payroll

DODIL ERP brick: payroll

src/brick_payroll/domain/money.py

"""Money is an exact Decimal carried to two places (NUMERIC(18,2)); a rate to four (NUMERIC(19,4)). Never a float.

HMRC's rounding is part of the METHOD and lives in code (module.yaml in_code_not_rows), each rule named where it is used:
  - free pay to date: rounded UP to the penny          - taxable pay to date: rounded DOWN to the pound
  - cumulative rate bands to date: rounded UP to the pound   - tax: rounded DOWN to the penny
  - NICs (exact percentage method): to the nearest penny, an exact half penny rounded DOWN
  - everything else (a prorated salary, a day's pay times days, a pension contribution): half up, ONCE per line
Fractions are computed exactly (fractions.Fraction) and rounded once, so no binary or Decimal-context tail decides a penny.
"""
from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_DOWN, ROUND_HALF_UP, Decimal
from fractions import Fraction

CENT = Decimal("0.01")
POUND = Decimal("1")
RATE = Decimal("0.0001")
ZERO = Decimal("0.00")
MONEY_MAX = Decimal("10000000000000")          # below NUMERIC(18,2)'s 10^16 with room for a run's totals


def _dec(x) -> Decimal:
    """An exact Fraction (or int, or Decimal) as a Decimal with enough places to round once without error."""
    if isinstance(x, Fraction):
        return Decimal(x.numerator) / Decimal(x.denominator)
    return Decimal(x)


def _q(x, unit: Decimal, mode: str) -> Decimal:
    if isinstance(x, Fraction):
        # exact: scale, round the rational with integer arithmetic, scale back
        scale = int(1 / unit)
        if x < 0 and mode in (ROUND_HALF_UP, ROUND_HALF_DOWN):
            return -_q(-x, unit, mode)                   # half-way cases round away from / towards zero, symmetric
        n = x * scale
        if mode == ROUND_FLOOR:
            v = n.numerator // n.denominator
        elif mode == ROUND_CEILING:
            v = -((-n.numerator) // n.denominator)
        else:
            lo = n.numerator // n.denominator
            frac = n - lo
            half = Fraction(1, 2)
            if frac > half or (frac == half and mode == ROUND_HALF_UP):
                v = lo + 1
            else:
                v = lo
        return (Decimal(v) / Decimal(scale)).quantize(unit)
    return Decimal(x).quantize(unit, rounding=mode)


def money(x) -> Decimal:
    """Half up to the penny (the default for a line rounded once)."""
    return _q(Fraction(Decimal(str(x))) if not isinstance(x, Fraction) else x, CENT, ROUND_HALF_UP)


def rate4(x) -> Decimal:
    """A rate to four places, half up (a day's pay: 36,000.00 / 260 = 138.4615)."""
    return _q(x if isinstance(x, Fraction) else Fraction(Decimal(str(x))), RATE, ROUND_HALF_UP)


def up_penny(x: Fraction) -> Decimal:
    return _q(x, CENT, ROUND_CEILING)


def down_penny(x: Fraction) -> Decimal:
    return _q(x, CENT, ROUND_FLOOR)


def down_pound(x: Fraction) -> Decimal:
    return _q(x, POUND, ROUND_FLOOR)


def up_pound(x: Fraction) -> Decimal:
    return _q(x, POUND, ROUND_CEILING)


def ni_penny(x: Fraction) -> Decimal:
    """The exact percentage method: the nearest penny, an exact half penny rounded DOWN (HMRC CWG2)."""
    return _q(x, CENT, ROUND_HALF_DOWN)


def F(x) -> Fraction:
    """An exact rational from a Decimal, an int or a decimal string."""
    if isinstance(x, Fraction):
        return x
    return Fraction(Decimal(str(x)))


def pct(p) -> Fraction:
    """A percent row (8.0000) as an exact fraction (2/25)."""
    return F(p) / 100