dodil/erp-brick-tax
DODIL ERP brick: tax
src/brick_tax/domain/rounding.py
"""VAT arithmetic under the three rounding methods HMRC permits (VAT Notice 700 para 17.5, 17.5.1). Pure: no storage.
per_line_nearest each line's VAT to the nearest penny (half up); the invoice's VAT is their sum (17.5.1)
per_line_down_0_1p each line's VAT rounded DOWN to 0.1p; the invoice total rounded DOWN to the penny (17.5.1)
per_invoice_round_down VAT worked out unrounded; the invoice total rounded DOWN to the penny (17.5)
Under every method net + VAT = gross, and the per-rate summary sums to the VAT total: where the total is rounded after
adding, the pennies are handed to the rates with the largest remainders (ties by code), so no row is left to disagree.
A line's own VAT under the two round-down methods is informational (to 0.1p, or to four places); the summary and the
total are what the invoice shows.
"""
from dataclasses import dataclass
from decimal import ROUND_DOWN, ROUND_HALF_EVEN, ROUND_HALF_UP, Decimal
from typing import Iterable
from brick_tax.domain.values import Rounding
P2, P3, P4 = Decimal("0.01"), Decimal("0.001"), Decimal("0.0001")
HUNDRED = Decimal(100)
@dataclass(frozen=True)
class Item:
"""One resolved line: what it is worth as entered (net, or gross when prices include VAT) and its code's rates."""
line_no: int
code: str
amount: Decimal
rate_pct: Decimal # what is due
charged_pct: Decimal # what the supplier charges (0 under reverse charge)
@dataclass(frozen=True)
class LineResult:
line_no: int
net: Decimal
vat: Decimal # charged
vat_due: Decimal # due (equals vat unless reverse charge)
gross: Decimal
@dataclass(frozen=True)
class SummaryRow:
code: str
rate_pct: Decimal
charged_pct: Decimal
net: Decimal
vat: Decimal
vat_due: Decimal
gross: Decimal
@dataclass(frozen=True)
class Computed:
lines: dict
summary: list
net: Decimal
vat: Decimal
vat_due: Decimal
gross: Decimal
method: str
def exact_vat(amount: Decimal, pct: Decimal, inclusive: bool) -> Decimal:
# erp-lint: allow L5 amount < 1e15 (inputs.Money) and pct <= 100 (inputs.Pct); use_cases._bounds refuses a document past the ledger limit first
return amount * pct / (HUNDRED + pct) if inclusive else amount * pct / HUNDRED
def _floor2(v: Decimal) -> Decimal:
return v.quantize(P2, ROUND_DOWN)
def _side(items: list, pct_of, method: str, inclusive: bool) -> tuple:
"""(line_vat by line_no, vat by code, total) for one side (charged or due)."""
exact = {i.line_no: exact_vat(i.amount, pct_of(i), inclusive) for i in items}
if method == Rounding.PER_LINE_NEAREST.value:
line = {n: v.quantize(P2, ROUND_HALF_UP) for n, v in exact.items()}
by_code: dict = {}
for i in items:
by_code[i.code] = by_code.get(i.code, Decimal(0)) + line[i.line_no]
return line, by_code, sum(by_code.values(), Decimal(0))
if method == Rounding.PER_LINE_DOWN_0_1P.value:
basis = {n: v.quantize(P3, ROUND_DOWN) for n, v in exact.items()}
line = basis
elif method == Rounding.PER_INVOICE_ROUND_DOWN.value:
basis = exact
line = {n: v.quantize(P4, ROUND_HALF_EVEN) for n, v in exact.items()}
else:
raise ValueError(f"unknown rounding method {method!r}")
sums: dict = {}
for i in items:
sums[i.code] = sums.get(i.code, Decimal(0)) + basis[i.line_no]
total = _floor2(sum(sums.values(), Decimal(0)))
floors = {c: _floor2(s) for c, s in sums.items()}
left = int(((total - sum(floors.values(), Decimal(0))) / P2).to_integral_value())
step = P2 if left >= 0 else -P2
order = sorted(sums, key=lambda c: (-abs(sums[c] - floors[c]), c))
for c in order[:abs(left)]:
floors[c] += step
return line, floors, total
def compute(items: Iterable[Item], method: str, inclusive: bool) -> Computed:
"""Net, VAT charged, VAT due and gross per line, per code and in all."""
items = sorted(items, key=lambda i: i.line_no)
line_vat, code_vat, total_vat = _side(items, lambda i: i.charged_pct, method, inclusive)
line_due, code_due, total_due = _side(items, lambda i: i.rate_pct, method, inclusive)
lines, nets = {}, {}
for i in items:
v = line_vat[i.line_no]
net = i.amount - v if inclusive else i.amount
lines[i.line_no] = LineResult(i.line_no, net, v, line_due[i.line_no], net + v)
nets[i.code] = nets.get(i.code, Decimal(0)) + i.amount
first = {}
for i in items:
first.setdefault(i.code, i)
summary = []
for code in sorted(nets):
vat = code_vat[code]
net = nets[code] - vat if inclusive else nets[code]
i = first[code]
summary.append(SummaryRow(code, i.rate_pct, i.charged_pct, net, vat, code_due[code], net + vat))
net_total = sum((r.net for r in summary), Decimal(0))
return Computed(lines, summary, net_total, total_vat, total_due, net_total + total_vat, method)