dodil/erp-brick-tax
DODIL ERP brick: tax
tests/test_stories.py
"""The person stories of the tax module (module.yaml stories TAX-01 .. TAX-05, TAX-14), one test each, named for it.
Business 'today' is 2026-11-20 (conftest)."""
from conftest import (
ADMIN,
ADMIN2,
BILLER,
OWNER,
SYSTEM,
api,
compute,
customer,
gates_of,
line,
load_uk,
must,
on_day,
registered,
setup,
uk_rules,
)
# ── TAX-01 registration ──────────────────────────────────────────────────────────────────────
def test_tax_01_registration_starts_after_today_and_needs_its_number():
load_uk()
must(api("POST", "/registration/record-not-registered", ADMIN, {"effective_from": "2020-01-01"})) # the first row: any date
status, body = api("POST", "/registration/register", ADMIN, {"vat_number": "", "effective_from": "2027-01-01"})
assert status == 409 and gates_of(body)[0]["gate"] == "vat_number"
status, body = api("POST", "/registration/register", ADMIN, {"vat_number": "GB123", "effective_from": "2027-01-01"})
assert status == 409 and gates_of(body)[0]["gate"] == "vat_number"
for early in ("2026-11-20", "2026-11-01"): # today or earlier: documents already used "not registered"
status, body = api("POST", "/registration/register", ADMIN, {"vat_number": "GB123456789", "effective_from": early})
g = gates_of(body)[0]
assert status == 409 and g["gate"] == "starts_after_today" and g["met"] is False
assert "a registration starts tomorrow at the earliest" in g["reason"] and g["fix"] == "Tax settings > Registration"
got = must(api("POST", "/registration/register", ADMIN, {"vat_number": "GB 123 456 789", "effective_from": "2027-01-01"}))
assert got["vat_number"] == "GB123456789" and got["change_state"] == "approved"
reg = must(api("GET", "/registration", BILLER))
assert reg["status_today"] == "not_registered" and len(reg["registrations"]) == 2
def test_tax_01_the_first_row_may_carry_the_historic_hmrc_date_and_only_the_first():
load_uk()
got = registered(since="2011-01-04")
assert got["effective_from"] == "2011-01-04" and got["status"] == "registered"
status, body = api("POST", "/registration/record-registered", ADMIN, {"vat_number": "GB123456789", "effective_from": "2015-01-01"})
assert status == 409 and "first registration row is already recorded" in body["detail"]
assert must(api("GET", "/registration", ADMIN))["status_today"] == "registered"
def test_tax_01_deregistering_always_waits_for_a_second_person():
load_uk()
registered()
must(api("POST", "/registration/deregister", ADMIN, {"effective_from": "2027-06-30"}))
assert must(api("GET", "/registration", ADMIN))["registrations"][-1]["change_state"] == "pending"
status, body = api("POST", "/registration/approve", ADMIN, {"effective_from": "2027-06-30"})
assert status == 409 and gates_of(body)[0]["gate"] == "second_person"
assert must(api("POST", "/registration/approve", ADMIN2, {"effective_from": "2027-06-30"}))["change_state"] == "approved"
def test_tax_01_a_registration_change_waits_for_a_second_person_when_the_owner_asks():
load_uk()
must(api("POST", "/registration/record-not-registered", ADMIN, {"effective_from": "2020-01-01"}))
must(api("POST", "/tax-controls/tax_changes_need_second_person", OWNER, {"value": True}))
got = must(api("POST", "/registration/register", ADMIN, {"vat_number": "GB123456789", "effective_from": "2027-01-01"}))
assert got["change_state"] == "pending"
setup_rules_for_document()
assert must(compute([line(1, "100.00")], tax_point="2027-01-02"))["registered"] is False # a pending row takes no effect
must(api("POST", "/registration/approve", OWNER, {"effective_from": "2027-01-01"}))
assert must(compute([line(1, "100.00")], tax_point="2027-01-02"))["registered"] is True
def setup_rules_for_document():
uk_rules()
customer()
# ── TAX-02 the UK pack ───────────────────────────────────────────────────────────────────────
def test_tax_02_the_uk_pack_is_there_on_day_one_and_never_deletes_a_code():
load_uk(activate=False)
codes = {c["tax_code"]: c for c in must(api("GET", "/codes", ADMIN))["codes"]}
assert set(codes) == {"S", "R", "Z", "E", "OS", "RC", "NV"}
assert (codes["S"]["rate_pct"], codes["S"]["treatment"]) == ("20", "standard")
assert (codes["R"]["rate_pct"], codes["Z"]["rate_pct"], codes["E"]["treatment"]) == ("5", "0", "exempt")
assert (codes["RC"]["rate_pct"], codes["RC"]["charged_pct"]) == ("20", "0") # VAT due 20%, charged 0%
assert "Reverse charge" in codes["RC"]["document_wording"] and codes["NV"]["treatment"] == "not_registered"
assert all(c["lifecycle"] == "draft" for c in codes.values())
assert [c["tax_code"] for c in must(api("GET", "/codes", BILLER))["codes"]] == [] # a biller sees active codes only
assert must(api("POST", "/codes/load-pack", SYSTEM, {}))["codes_added"] == [] # repeated: nothing
assert api("DELETE", "/codes/S", ADMIN)[0] in (404, 405)
def test_tax_02_the_pack_loads_on_a_new_installation_only():
must(api("POST", "/codes", ADMIN, {"tax_code": "X", "treatment": "zero", "rate_pct": "0", "valid_from": "2020-01-01"}))
status, body = api("POST", "/codes/load-pack", SYSTEM, {})
assert status == 409 and gates_of(body)[0]["gate"] == "new_installation"
def test_tax_02_add_rename_and_retire_a_code_and_a_code_in_use_is_refused():
load_uk()
status, body = api("POST", "/codes", ADMIN, {"tax_code": "S", "treatment": "standard"})
assert status == 409 and "already in use" in body["detail"]
must(api("POST", "/codes", ADMIN, {"tax_code": "ZE", "treatment": "zero", "rate_pct": "0", "valid_from": "2021-01-01",
"description": "Zero rated exports"}))
assert must(api("POST", "/codes/ZE/activate-code", ADMIN, {}))["lifecycle"] == "active"
got = must(api("POST", "/codes/ZE/describe-code", ADMIN, {"description": "Exports, proof held"}))
assert got["description"] == "Exports, proof held"
assert must(api("POST", "/codes/ZE/retire-code", ADMIN, {}))["lifecycle"] == "retired"
assert api("POST", "/codes/ZE/schedule-rate", ADMIN, {"rate_pct": "0", "valid_from": "2027-01-01"})[0] == 409
def test_tax_02_activation_answers_its_gates():
must(api("POST", "/codes", ADMIN, {"tax_code": "RCX", "treatment": "reverse_charge"}))
status, body = api("POST", "/codes/RCX/activate-code", ADMIN, {})
assert status == 409 and {g["gate"] for g in gates_of(body)} == {"first_rate", "wording"}
must(api("POST", "/codes/RCX/schedule-rate", ADMIN, {"rate_pct": "20", "valid_from": "2021-03-01"})) # the first rate: any date
must(api("POST", "/codes/RCX/describe-code", ADMIN, {"document_wording": "Reverse charge"}))
assert must(api("POST", "/codes/RCX/activate-code", ADMIN, {}))["lifecycle"] == "active"
def test_tax_02_a_used_codes_meaning_is_fixed_but_its_wording_may_change():
setup()
must(compute([line(1, "10.00")], document_ref="INV-1"))
must(api("POST", "/documents/INV-1/freeze", SYSTEM, {"contents": {**__import__("conftest").FULL}}))
status, body = api("POST", "/codes/S/describe-code", ADMIN, {"treatment": "reduced"})
assert status == 409 and "fixed" in body["detail"]
assert api("POST", "/codes/S/describe-code", ADMIN, {"document_wording": "Standard rated"})[0] == 200
# ── TAX-03 a scheduled rate ──────────────────────────────────────────────────────────────────
def test_tax_03_a_rate_change_happens_at_midnight_and_issued_documents_are_unchanged():
setup()
must(api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "21", "valid_from": "2027-04-01"}))
before = must(compute([line(1, "100.00")], tax_point="2027-03-31"))
after = must(compute([line(1, "100.00")], tax_point="2027-04-01"))
assert before["lines"][0]["rate_pct"] == "20" and before["totals"]["vat"] == "20.00"
assert after["lines"][0]["rate_pct"] == "21" and after["totals"]["vat"] == "21.00"
def test_tax_03_a_second_rate_for_the_date_or_one_from_today_is_refused():
setup()
must(api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "21", "valid_from": "2027-04-01"}))
status, body = api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "22", "valid_from": "2027-04-01"})
assert status == 409 and "already has a rate from 2027-04-01" in body["detail"]
for early in ("2026-11-20", "2026-11-19"):
status, body = api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "22", "valid_from": early})
assert status == 409 and gates_of(body)[0]["gate"] == "starts_after_today"
def test_tax_03_a_rate_not_yet_in_effect_may_be_withdrawn_and_one_in_effect_may_not():
setup()
must(api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "21", "valid_from": "2027-04-01"}))
got = must(api("POST", "/codes/S/withdraw-rate", ADMIN, {"valid_from": "2027-04-01"}))
assert [r["valid_from"] for r in got["rates"]] == ["2011-01-04"] # a withdrawn row never takes effect
must(api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "21", "valid_from": "2027-04-01"})) # the date is free again
status, body = api("POST", "/codes/S/withdraw-rate", ADMIN, {"valid_from": "2011-01-04"})
assert status == 409 and "already in effect" in body["detail"]
with on_day("2027-04-02T09:00:00+00:00"): # after the date, a correction is a new row
assert api("POST", "/codes/S/withdraw-rate", ADMIN, {"valid_from": "2027-04-01"})[0] == 409
must(api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "20"})) # from tomorrow, never an edit
assert must(api("GET", "/codes/S", ADMIN))["rates"][-1]["valid_from"] == "2027-04-03"
def test_tax_03_where_the_owner_asks_a_rate_change_waits_for_a_second_person():
setup()
must(api("POST", "/tax-controls/tax_changes_need_second_person", OWNER, {"value": True}))
got = must(api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "21", "valid_from": "2027-04-01"}))
assert got["rates"][-1]["change_state"] == "pending"
assert must(compute([line(1, "100.00")], tax_point="2027-04-02"))["lines"][0]["rate_pct"] == "20" # waiting: not in effect
status, body = api("POST", "/codes/S/approve-rate", ADMIN, {"valid_from": "2027-04-01"}) # not its author
assert status == 409 and gates_of(body)[0]["gate"] == "second_person"
assert api("POST", "/codes/S/approve-rate", BILLER, {"valid_from": "2027-04-01"})[0] == 403 # needs tax:code:approve
must(api("POST", "/codes/S/approve-rate", ADMIN2, {"valid_from": "2027-04-01"}))
assert must(compute([line(1, "100.00")], tax_point="2027-04-02"))["lines"][0]["rate_pct"] == "21"
# ── TAX-04 rules ─────────────────────────────────────────────────────────────────────────────
def test_tax_04_rules_pick_the_code_and_name_themselves():
setup()
customer(8001, "overseas_business", vat="IE1234567X")
uk = must(compute([line(1, "100.00")]))["lines"][0]
assert uk["tax_code"] == "S" and uk["rate_pct"] == "20" and uk["source"] == "rule"
assert uk["rule"] == "(standard, uk_business, any) from 2026-11-20"
ov = must(api("POST", "/documents/compute", SYSTEM, {"lines": [line(1, "100.00", supply="services")], "tax_point": "2026-11-20", "bp_id": 8001}))
assert ov["lines"][0]["tax_code"] == "OS" and ov["totals"]["vat"] == "0.00"
assert ov["lines"][0]["rule"] == "(standard, overseas_business, services) from 2026-11-20"
def test_tax_04_a_pair_no_rule_covers_is_refused_never_zero_rated():
setup()
customer(8002, "uk_consumer")
out = must(api("POST", "/documents/compute", SYSTEM, {"lines": [line(1, "100.00")], "tax_point": "2026-11-20", "bp_id": 8002,
"document_ref": "Q-1"}))
ln = out["lines"][0]
assert ln["tax_code"] is None and ln["refusal"] == "no tax rule for (standard, uk_consumer)" and "vat" not in ln
assert out["complete"] is False and out["totals"] is None
assert out["gates"][0]["gate"] == "rule_covers_pair" and out["gates"][0]["met"] is False
def test_tax_04_a_new_pair_may_start_today_a_changed_pair_starts_after_today():
setup()
must(api("POST", "/rules", ADMIN, {"category_code": "standard", "treatment_code": "uk_consumer", "tax_code": "S", "valid_from": "2026-11-20"}))
status, body = api("POST", "/rules", ADMIN, {"category_code": "standard", "treatment_code": "uk_consumer", "tax_code": "R",
"valid_from": "2026-11-20"})
assert status == 409 and gates_of(body)[0]["gate"] == "starts_after_today"
status, body = api("POST", "/rules", ADMIN, {"category_code": "standard", "treatment_code": "uk_business", "tax_code": "R", "valid_from": "2026-11-01"})
assert status == 409 and gates_of(body)[0]["gate"] == "starts_after_today"
status, body = api("POST", "/rules", ADMIN, {"category_code": "standard", "treatment_code": "brand_new", "tax_code": "S"})
assert status == 422 and "does not exist" in body["detail"]
got = must(api("POST", "/rules", ADMIN, {"category_code": "standard", "treatment_code": "uk_business", "tax_code": "R"}))
assert got["valid_from"] == "2026-11-21" # tomorrow by default
again = must(api("POST", "/rules", ADMIN, {"category_code": "standard", "treatment_code": "uk_business", "tax_code": "R"}))
assert again["valid_from"] == "2026-11-21" # the same rule again changes nothing
assert must(compute([line(1, "100.00")], tax_point="2026-11-21"))["lines"][0]["tax_code"] == "R"
assert must(compute([line(1, "100.00")], tax_point="2026-11-20"))["lines"][0]["tax_code"] == "S"
def test_tax_04_a_code_a_rule_still_chooses_cannot_be_retired_until_the_rule_ends():
setup()
status, body = api("POST", "/codes/Z/retire-code", ADMIN, {})
assert status == 409 and gates_of(body)[0]["gate"] == "no_rule_points_here"
must(api("POST", "/rules/end", ADMIN, {"category_code": "zero", "treatment_code": "any"}))
status, body = api("POST", "/codes/Z/retire-code", ADMIN, {})
assert status == 409 # the ending starts tomorrow: still pointing today
with on_day("2026-11-22T09:00:00+00:00"):
assert must(api("POST", "/codes/Z/retire-code", ADMIN, {}))["lifecycle"] == "retired"
out = must(compute([line(1, "10.00", category="zero")], tax_point="2026-11-22"))
assert out["lines"][0]["refusal"] == "no tax rule for (zero, uk_business)" # ended: refused, not defaulted
def test_tax_04_priority_then_the_more_specific_rule_wins():
setup()
must(api("POST", "/rules", ADMIN, {"category_code": "any", "treatment_code": "uk_business", "tax_code": "E", "priority": 5}))
# a rule over a pair that is already covered is a change: it starts tomorrow (audit1 finding 1), never today
assert must(compute([line(1, "10.00")]))["lines"][0]["tax_code"] == "S"
assert must(compute([line(1, "10.00")], tax_point="2026-11-21"))["lines"][0]["tax_code"] == "E" # the higher priority beat the specific rule
must(api("POST", "/rules", ADMIN, {"category_code": "any", "treatment_code": "uk_business", "tax_code": "E", "priority": 0, "supply": "goods"}))
assert must(compute([line(1, "10.00", supply="goods")], tax_point="2026-11-21"))["lines"][0]["tax_code"] == "E"
# ── TAX-05 a customer's treatment ────────────────────────────────────────────────────────────
def test_tax_05_the_first_treatment_holds_from_the_start_and_a_change_from_tomorrow():
setup()
first = customer(7400, "uk_business")
assert first["valid_from"] is None
again = customer(7400, "uk_business")
assert again == first
changed = must(api("POST", "/parties/7400/set-party-tax", BILLER, {"treatment_code": "uk_consumer"}))
assert changed["valid_from"] == "2026-11-21"
status, body = api("POST", "/parties/7400/set-party-tax", BILLER, {"treatment_code": "uk_business", "valid_from": "2026-11-20"})
assert status == 409 and gates_of(body)[0]["gate"] == "starts_after_today"
for point, expect in (("2026-11-20", "uk_business"), ("2026-11-21", "uk_consumer")):
out = must(api("POST", "/documents/compute", SYSTEM, {"lines": [line(1, "10.00")], "tax_point": point, "bp_id": 7400}))
assert out["treatment_code"] == expect
def test_tax_05_a_treatment_that_needs_a_vat_number_refuses_to_save_without_one():
setup()
status, body = api("POST", "/parties/7500/set-party-tax", BILLER, {"treatment_code": "uk_cis_contractor"})
assert status == 409 and gates_of(body)[0]["gate"] == "vat_number"
assert api("GET", "/parties/7500", BILLER)[0] == 404 # nothing was written
got = must(api("POST", "/parties/7500/set-party-tax", BILLER, {"treatment_code": "uk_cis_contractor", "vat_number": "GB987654321"}))
assert got["vat_number"] == "GB987654321" and got["check_result"] is None # the HMRC check is M2
def test_tax_05_a_document_for_a_customer_without_a_treatment_is_refused_with_its_gate():
setup()
status, body = api("POST", "/documents/compute", SYSTEM, {"lines": [line(1, "10.00")], "tax_point": "2026-11-20", "bp_id": 9999})
assert status == 409 and gates_of(body)[0] == {"gate": "customer_treatment", "met": False,
"reason": "the customer has no tax treatment", "fix": "Customer tax"}
got = must(api("POST", "/documents/compute", SYSTEM, {"lines": [line(1, "10.00")], "tax_point": "2026-11-20", "treatment_code": "uk_business"}))
assert got["lines"][0]["tax_code"] == "S" # no business-partner: chosen on the document
# ── TAX-14 the owner's controls ──────────────────────────────────────────────────────────────
def test_tax_14_the_owner_sets_the_controls_and_the_tax_admin_is_refused_on_them():
setup()
for key, value in (("tax_changes_need_second_person", True), ("business_timezone", "Europe/London"), ("override_approval", "self_with_reason")):
status, body = api("POST", f"/settings/{key}", ADMIN, {"value": value})
assert status == 403 and "tax:settings:controls" in body["detail"], key
assert api("POST", f"/tax-controls/{key}", ADMIN, {"value": value})[0] == 403 # the admin lacks the permission itself
assert api("POST", "/tax-controls/tax_changes_need_second_person", OWNER, {"value": True})[0] == 200
assert api("POST", "/settings/rounding_method", OWNER, {"value": "per_line_nearest"})[0] == 403 # the owner keeps no settings
status, body = api("POST", "/tax-controls/business_timezone", OWNER, {"value": "Mars/Olympus"})
assert status == 422
def test_tax_14_today_is_the_date_in_the_business_time_zone():
setup()
must(api("POST", "/tax-controls/business_timezone", OWNER, {"value": "Pacific/Auckland"})) # the first row: from the start
from datetime import datetime, timezone
from erp_platform import work
work.set_clock(at=datetime(2026, 11, 20, 20, 0, tzinfo=timezone.utc), freeze=True) # 09:00 on the 21st in Auckland
status, body = api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "21", "valid_from": "2026-11-21"})
assert status == 409 and gates_of(body)[0]["gate"] == "starts_after_today" # 2026-11-21 IS today there
assert api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "21", "valid_from": "2026-11-22"})[0] == 200
def test_sc_tax_06_no_back_dating():
from datetime import datetime, timezone
from erp_platform import work
work.set_clock(at=datetime(2026, 11, 2, 9, 0, tzinfo=timezone.utc), freeze=True)
setup() # customer 7311 is uk_business from the start
attempts = (("POST", "/codes/S/schedule-rate", {"rate_pct": "21", "valid_from": "2026-11-02"}),
("POST", "/rules", {"category_code": "standard", "treatment_code": "uk_business", "tax_code": "R", "valid_from": "2026-11-01"}),
("POST", "/parties/7311/set-party-tax", {"treatment_code": "uk_consumer", "valid_from": "2026-11-02"}))
for method, path, body in attempts:
who = BILLER if "parties" in path else ADMIN
status, got = api(method, path, who, body)
assert status == 409 and gates_of(got)[0] == {**gates_of(got)[0], "gate": "starts_after_today", "met": False}, path
must(api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "21", "valid_from": "2026-11-03"}))
must(api("POST", "/rules", ADMIN, {"category_code": "standard", "treatment_code": "uk_business", "tax_code": "R", "valid_from": "2026-11-03"}))
must(api("POST", "/parties/7311/set-party-tax", BILLER, {"treatment_code": "uk_consumer", "valid_from": "2026-11-03"}))
out = must(compute([line(1, "100.00")], tax_point="2026-11-02")) # the invoice of today still charges 20% as uk_business
assert (out["treatment_code"], out["lines"][0]["rate_pct"], out["lines"][0]["tax_code"]) == ("uk_business", "20", "S")
def test_tax_first_rate_takes_effect_today_and_a_correction_starts_tomorrow():
must(api("POST", "/codes", ADMIN, {"tax_code": "FIRST1", "treatment": "standard"}))
first = must(api("POST", "/codes/FIRST1/schedule-rate", ADMIN, {"rate_pct": "20"})) # no date: the first row starts today
assert [r["valid_from"] for r in first["rates"]] == ["2026-11-20"]
fix = must(api("POST", "/codes/FIRST1/schedule-rate", ADMIN, {"rate_pct": "21"})) # a correction: tomorrow
assert [r["valid_from"] for r in fix["rates"]] == ["2026-11-20", "2026-11-21"]
assert api("POST", "/codes/FIRST1/schedule-rate", ADMIN, {"rate_pct": "22", "valid_from": "2026-11-20"})[0] == 409