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