dodil/erp-brick-tax
DODIL ERP brick: tax
tests/test_rounding.py
"""VAT arithmetic under the three HMRC methods (VAT Notice 700 para 17.5, 17.5.1): TAX-07, TAX-08, SC-TAX-01 and the
invariants every method keeps."""
import random
from decimal import Decimal as D
import pytest
from conftest import ADMIN, api, compute, gates_of, line, must, setup
from brick_tax.domain import rounding
from brick_tax.domain.rounding import Item
METHODS = ("per_invoice_round_down", "per_line_nearest", "per_line_down_0_1p")
def items(*amounts, pct="20", code="S"):
return [Item(i, code, D(a), D(pct), D(pct)) for i, a in enumerate(amounts, 1)]
def test_tax_07_the_three_methods_on_three_lines_of_10_03():
got = {m: rounding.compute(items("10.03", "10.03", "10.03"), m, False) for m in METHODS}
assert got["per_invoice_round_down"].vat == D("6.01") and got["per_invoice_round_down"].gross == D("36.10")
assert got["per_line_nearest"].vat == D("6.03") and got["per_line_nearest"].gross == D("36.12")
assert got["per_line_down_0_1p"].vat == D("6.01") and got["per_line_down_0_1p"].gross == D("36.10")
assert [r.vat for r in got["per_line_nearest"].lines.values()] == [D("2.01")] * 3 # 17.5.1: 2.01 x 3
assert [r.vat for r in got["per_line_down_0_1p"].lines.values()] == [D("2.006")] * 3 # 17.5.1: 2.006 x 3 = 6.018
for c in got.values():
assert c.net == D("30.09") and c.net + c.vat == c.gross
def test_tax_08_a_consumer_price_that_includes_vat_is_split():
c = rounding.compute(items("12.00"), "per_line_nearest", True)
assert (c.net, c.vat, c.gross) == (D("10.00"), D("2.00"), D("12.00")) # the gross stays the price shown
def test_reverse_charge_charges_nothing_and_records_the_vat_due():
c = rounding.compute([Item(1, "RC", D("1000.00"), D(20), D(0))], "per_line_nearest", False)
assert (c.vat, c.vat_due, c.gross, c.net) == (D(0), D("200.00"), D("1000.00"), D("1000.00"))
@pytest.mark.parametrize("method", METHODS)
@pytest.mark.parametrize("inclusive", [False, True])
def test_net_plus_vat_is_gross_and_the_summary_sums_to_the_total_under_every_method(method, inclusive):
rnd = random.Random(7)
for _ in range(200):
its = [Item(i, rnd.choice(["S", "R", "Z"]), D(rnd.randint(1, 99999)) / 100, *rnd.choice(
[(D(20), D(20)), (D(5), D(5)), (D(0), D(0))])) for i in range(1, rnd.randint(1, 7))]
# one rate per code, as a tax point guarantees
by = {}
its = [Item(i.line_no, i.code, i.amount, *by.setdefault(i.code, (i.rate_pct, i.charged_pct))) for i in its]
c = rounding.compute(its, method, inclusive)
assert c.net + c.vat == c.gross
assert sum(r.vat for r in c.summary) == c.vat and sum(r.net for r in c.summary) == c.net
assert all(r.net + r.vat == r.gross for r in c.summary)
assert c.vat == c.vat.quantize(D("0.01"))
assert rounding.compute(its, method, inclusive) == c # deterministic: the same bytes
def test_sc_tax_01_each_method_by_the_tax_point_and_the_retail_flag_refuses_the_round_down_ones():
setup()
must(api("POST", "/settings/rounding_method", ADMIN, {"value": "per_invoice_round_down"})) # the key's first row: from the start
must(api("POST", "/settings/rounding_method", ADMIN, {"value": "per_line_nearest", "valid_from": "2026-12-01"}))
must(api("POST", "/settings/rounding_method", ADMIN, {"value": "per_line_down_0_1p", "valid_from": "2027-01-01"}))
for point, method, vat, gross in (("2026-11-20", "per_invoice_round_down", "6.01", "36.10"),
("2026-12-01", "per_line_nearest", "6.03", "36.12"),
("2027-01-01", "per_line_down_0_1p", "6.01", "36.10")):
t = must(compute([line(n, "10.03") for n in (1, 2, 3)], tax_point=point))["totals"]
assert (t["vat"], t["gross"], t["method"]) == (vat, gross, method)
status, body = api("POST", "/settings/retailer", ADMIN, {"value": True, "valid_from": "2027-02-01"})
assert status == 409 and gates_of(body)[0]["gate"] == "retailer_round_down" # the method then is a round-down one
must(api("POST", "/settings/rounding_method", ADMIN, {"value": "per_line_nearest", "valid_from": "2027-02-01"}))
must(api("POST", "/settings/retailer", ADMIN, {"value": True, "valid_from": "2027-02-01"}))
status, body = api("POST", "/settings/rounding_method", ADMIN, {"value": "per_invoice_round_down", "valid_from": "2027-03-01"})
assert status == 409 and gates_of(body)[0]["gate"] == "retailer_round_down" and "17.6" in body["detail"]
status, body = api("POST", "/settings/retailer", ADMIN, {"value": True, "valid_from": "2026-11-20"})
assert status == 409 and gates_of(body)[0]["gate"] == "starts_after_today"