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