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)