dodil/erp-brick-tax

DODIL ERP brick: tax

tests/test_documents.py

"""The tax on a document (TAX-06 .. TAX-12, TAX-20, TAX-22 and scenarios SC-TAX-02 .. 05, 07): determine, override, AI
suggestion, content check, freeze."""
from decimal import Decimal
from types import SimpleNamespace

from conftest import (
    ADMIN,
    BILLER,
    FULL,
    OWNER,
    SYSTEM,
    TODAY,
    api,
    compute,
    customer,
    gates_of,
    line,
    load_uk,
    must,
    person,
    setup,
    uk_rules,
)

from brick_tax.infrastructure import ai_steps


def freeze(ref, contents=None, who=SYSTEM):
    return api("POST", f"/documents/{ref}/freeze", who, {"contents": contents or FULL})


# ── TAX-10 determination ─────────────────────────────────────────────────────────────────────

def test_tax_10_lines_in_s_z_and_e_get_a_per_rate_summary_and_a_total():
    setup()
    out = must(compute([line(1, "200.00"), line(2, "50.00", category="zero"), line(3, "20.00", category="exempt")]))
    by = {s["tax_code"]: s for s in out["summary"]}
    assert set(by) == {"S", "Z", "E"} and by["S"]["vat"] == "40.00" and by["Z"]["vat"] == "0.00" and by["E"]["vat"] == "0.00"
    t = out["totals"]
    assert (t["net"], t["vat"], t["gross"], t["net_zero_rated"], t["net_exempt"]) == ("270.00", "40.00", "310.00", "50.00", "20.00")
    assert out["complete"] is True and t["method"] == "per_line_nearest"


def test_tax_10_computing_twice_gives_the_same_answer_and_a_kept_document_survives_a_recompute():
    setup()
    lines = [line(1, "10.03"), line(2, "10.03"), line(3, "10.03")]
    assert must(compute(lines)) == must(compute(lines))                                            # deterministic, nothing kept
    k1, k2 = must(compute(lines, document_ref="Q-7")), must(compute(lines, document_ref="Q-7"))
    assert {**k2, "version": 0} == {**k1, "version": 0} and k2["version"] == k1["version"] + 1
    assert must(api("GET", "/documents/Q-7", BILLER))["totals"] == k2["totals"]


def test_sc_tax_05_mixed_rates_on_one_invoice():
    setup()
    out = must(compute([line(1, "100.00"), line(2, "100.00"), line(3, "40.00", category="zero"), line(4, "60.00", category="exempt")]))
    assert [(s["tax_code"], s["net"], s["vat"]) for s in out["summary"]] == [("E", "60.00", "0.00"), ("S", "200.00", "40.00"), ("Z", "40.00", "0.00")]
    assert (out["totals"]["net_zero_rated"], out["totals"]["net_exempt"]) == ("40.00", "60.00")    # para 16.5: separate totals


def test_tax_20_the_selling_brick_gets_everything_it_stores():
    setup()
    out = must(compute([line(1, "10.00", description="Hosting", quantity="2", unit_price="5.00")]))
    ln = out["lines"][0]
    assert {"tax_code", "rate_pct", "net", "vat", "gross", "wording", "reporting_box", "rule"} <= set(ln)
    assert out["totals"]["method"] == "per_line_nearest" and out["summary"] and out["gates"]


def test_the_rate_charged_is_the_rate_valid_on_the_tax_point():
    setup()
    must(api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "25", "valid_from": "2030-01-01"}))
    assert [must(compute([line(1, "100.00")], tax_point=p))["totals"]["vat"] for p in ("2026-11-20", "2029-12-31", "2030-01-01", "2040-01-01")] \
        == ["20.00", "20.00", "25.00", "25.00"]


# ── TAX-11 freezing; SC-TAX-02 ───────────────────────────────────────────────────────────────

def test_sc_tax_02_a_rate_change_between_two_invoices_changes_neither_after_issue():
    setup()
    must(api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "21", "valid_from": "2027-04-01"}))
    must(compute([line(1, "100.00")], tax_point="2027-03-31", document_ref="INV-A"))
    must(compute([line(1, "100.00")], tax_point="2027-04-01", document_ref="INV-B"))
    for ref in ("INV-A", "INV-B"):
        must(freeze(ref))
    must(api("POST", "/codes/S/schedule-rate", ADMIN, {"rate_pct": "22", "valid_from": "2027-05-01"}))
    a, b = must(api("GET", "/documents/INV-A", BILLER)), must(api("GET", "/documents/INV-B", BILLER))
    assert (a["totals"]["vat"], b["totals"]["vat"]) == ("20.00", "21.00") and a["status"] == b["status"] == "frozen"


def test_tax_11_recomputing_an_issued_document_is_refused_and_freezing_twice_is_a_noop():
    setup()
    first = must(compute([line(1, "100.00")], document_ref="INV-1"))
    frozen = must(freeze("INV-1"))
    assert frozen["status"] == "frozen" and frozen["frozen_by"] == "system@shop.example"
    assert must(freeze("INV-1")) == frozen                                                         # a no-op: same bytes, same version
    status, body = compute([line(1, "999.00")], document_ref="INV-1")
    assert status == 409 and "issued: credit it instead" in body["detail"]
    assert must(api("GET", "/documents/INV-1", BILLER))["totals"] == first["totals"]


# ── TAX-06 overrides ─────────────────────────────────────────────────────────────────────────

def kept(amount="100.00"):
    setup()
    return must(compute([line(1, amount)], document_ref="INV-O"))


def test_tax_06_an_override_that_lowers_tax_waits_for_a_second_person_before_the_document_can_issue():
    kept()
    out = must(api("POST", "/documents/INV-O/lines/1/override-line", BILLER,
                   {"tax_code": "Z", "reason": "export of goods, proof of export held"}))
    ln = out["lines"][0]
    assert ln["tax_code"] == "Z" and ln["source"] == "override" and out["totals"]["vat"] == "0.00"
    assert ln["override"] == {**ln["override"], "reason": "export of goods, proof of export held", "by": "biller@shop.example",
                              "lowers_tax": True, "state": "waiting"}
    status, body = freeze("INV-O")
    assert status == 409 and "override_approved" in {g["gate"] for g in gates_of(body)}
    assert api("POST", "/documents/INV-O/lines/1/approve-override", BILLER, {})[0] == 403         # a biller cannot approve
    status, body = api("POST", "/documents/INV-O/lines/1/approve-override", ADMIN, {})
    assert status == 200 and body["lines"][0]["override"]["approved_by"] == "tax_admin@shop.example"
    assert freeze("INV-O")[0] == 200


def test_tax_06_nobody_approves_their_own_override_by_default_and_a_firm_of_one_may_with_a_reason():
    kept()
    solo = person("solo@shop.example", ["tax:document_tax:override", "tax:document_tax:approve_override", "tax:code:read"])
    must(api("POST", "/documents/INV-O/lines/1/override-line", solo, {"tax_code": "Z", "reason": "export, proof held"}))
    status, body = api("POST", "/documents/INV-O/lines/1/approve-override", solo, {"reason": "I am the only one"})
    assert status == 409 and gates_of(body)[0]["gate"] == "override_approved"
    must(api("POST", "/tax-controls/override_approval", OWNER, {"value": "self_with_reason"}))
    status, body = api("POST", "/documents/INV-O/lines/1/approve-override", solo, {})
    assert status == 409 and "needs a reason" in body["detail"]
    got = must(api("POST", "/documents/INV-O/lines/1/approve-override", solo, {"reason": "firm of one, export proof held"}))
    assert got["lines"][0]["override"]["approval_reason"] == "firm of one, export proof held"


def test_tax_06_an_override_that_does_not_lower_tax_needs_no_approval_and_always_a_reason():
    setup()
    must(compute([line(1, "100.00", category="reduced")], document_ref="INV-R"))                   # R 5%
    assert api("POST", "/documents/INV-R/lines/1/override-line", BILLER, {"tax_code": "S", "reason": " "})[0] == 409
    out = must(api("POST", "/documents/INV-R/lines/1/override-line", BILLER, {"tax_code": "S", "reason": "supplied hot"}))
    assert out["lines"][0]["override"]["state"] == "approved" and out["totals"]["vat"] == "20.00"
    assert api("POST", "/documents/INV-R/lines/1/override-line", BILLER, {"tax_code": "XX", "reason": "x"})[0] == 409


def test_tax_06_a_recompute_keeps_the_persons_decision():
    kept()
    must(api("POST", "/documents/INV-O/lines/1/override-line", BILLER, {"tax_code": "Z", "reason": "export"}))
    again = must(compute([line(1, "250.00")], document_ref="INV-O"))
    assert again["lines"][0]["tax_code"] == "Z" and again["lines"][0]["override"]["state"] == "waiting" and again["totals"]["net"] == "250.00"


# ── TAX-09 the AI suggests, a person decides ─────────────────────────────────────────────────

class FakeAI:
    """A provider that answers: it never applies anything."""
    def __init__(self, code="OS", reason="services to an overseas business are outside the scope (VAT Notice 741A)"):
        self.code, self.reason = code, reason

    def __call__(self, step, key, input, mode):
        if mode == "off":
            return None
        return SimpleNamespace(status="done", message="", output={"suggestion": {"tax_code": self.code}, "rationale": self.reason,
                                                                  "confidence": 82, "model": "kimi-k2.6"})


def unknown_line():
    setup()
    return must(compute([line(1, "100.00", category=None, description="on-site training, Dublin office")], document_ref="INV-AI"))


def test_tax_09_with_no_provider_the_line_stays_refused_until_a_person_chooses():
    out = unknown_line()
    assert out["lines"][0]["refusal"] == "no tax rule for (no category, uk_business)" and out["complete"] is False
    res = must(api("POST", "/documents/INV-AI/lines/1/ai-suggest-code", SYSTEM, {}))
    assert res["suggested"] is False and res["ai"]["kind"] in ("manual", "off") and res["document"]["suggestions"] == []
    got = must(api("POST", "/documents/INV-AI/lines/1/override-line", BILLER, {"tax_code": "S", "reason": "UK training, standard rated"}))
    assert got["lines"][0]["tax_code"] == "S" and got["complete"] is True


def test_sc_tax_07_an_ai_suggestion_is_a_persons_decision(monkeypatch):
    unknown_line()
    monkeypatch.setattr(ai_steps, "propose", FakeAI())
    res = must(api("POST", "/documents/INV-AI/lines/1/ai-suggest-code", SYSTEM, {}))
    assert res["suggested"] is True and res["document"]["lines"][0]["tax_code"] is None          # nothing applied
    s = res["document"]["suggestions"][0]
    assert (s["tax_code"], s["confidence"], s["model"], s["state"]) == ("OS", 82, "kimi-k2.6", "open")
    status, body = freeze("INV-AI")
    assert status == 409 and {"no_open_suggestion", "rule_covers_pair"} <= {g["gate"] for g in gates_of(body)}
    got = must(api("POST", "/documents/INV-AI/lines/1/accept-suggestion", BILLER, {"decision": "accept"}))
    ln = got["lines"][0]
    assert ln["tax_code"] == "OS" and ln["source"] == "suggestion" and ln["override"]["by"] == "biller@shop.example"
    assert "services to an overseas business" in ln["override"]["reason"]
    assert got["suggestions"][0]["state"] == "accepted" and got["suggestions"][0]["decided_by"] == "biller@shop.example"
    assert "no_open_suggestion" not in {g["gate"] for g in gates_of(freeze("INV-AI")[1])}


def test_tax_09_rejecting_leaves_the_line_refused_and_an_accepted_lower_code_waits_for_approval(monkeypatch):
    unknown_line()
    monkeypatch.setattr(ai_steps, "propose", FakeAI())
    must(api("POST", "/documents/INV-AI/lines/1/ai-suggest-code", SYSTEM, {}))
    got = must(api("POST", "/documents/INV-AI/lines/1/accept-suggestion", BILLER, {"decision": "reject", "note": "it is a UK supply"}))
    assert got["lines"][0]["tax_code"] is None and got["lines"][0]["refusal"] and got["suggestions"][0]["state"] == "rejected"
    must(api("POST", "/documents/INV-AI/lines/1/ai-suggest-code", SYSTEM, {}))                     # a new suggestion is allowed after a rejection
    got = must(api("POST", "/documents/INV-AI/lines/1/accept-suggestion", BILLER, {"decision": "accept"}))
    assert got["lines"][0]["override"]["lowers_tax"] is True and got["lines"][0]["override"]["state"] == "waiting"   # below the standard code
    assert "override_approved" in {g["gate"] for g in gates_of(freeze("INV-AI")[1])}


def test_tax_09_the_suggestion_step_can_be_off_and_a_made_up_code_is_never_recorded(monkeypatch):
    unknown_line()
    monkeypatch.setattr(ai_steps, "propose", FakeAI(code="NOPE"))
    res = must(api("POST", "/documents/INV-AI/lines/1/ai-suggest-code", SYSTEM, {}))
    assert res["suggested"] is False and "not an active code" in res["ai"]["message"]
    must(api("POST", "/settings/ai.suggest_code.mode", ADMIN, {"value": "off"}))
    status, body = api("POST", "/documents/INV-AI/lines/1/ai-suggest-code", SYSTEM, {})
    assert status == 409 and "switched off" in body["detail"]


# ── TAX-12 the invoice-content check ─────────────────────────────────────────────────────────

def test_tax_12_a_missing_customer_address_is_one_gate_with_the_guide_and_the_fix(neighbours):
    setup()
    neighbours[0].known[7311] = {"legal_name": "Acme Builders Ltd", "address": ""}
    must(compute([line(1, "100.00")], document_ref="INV-C"))
    status, body = freeze("INV-C", {**FULL, "customer_address": ""})
    gates = gates_of(body)
    assert status == 409 and [g["gate"] for g in gates if g["gate"] != "contents_complete"] == ["customer_address"]
    assert gates[0] == {"gate": "customer_address", "met": False,
                        "reason": "a VAT invoice shows the customer's address (VAT Notice 700 para 16.3.1)",
                        "fix": "the customer's address on the business partner"}
    neighbours[0].known[7311]["address"] = "1 High Street, London"                                 # the partner record now has it
    assert freeze("INV-C", {k: v for k, v in FULL.items() if k not in ("customer_address", "customer_name")})[0] == 200


def test_tax_12_every_absent_item_is_its_own_gate_and_check_contents_changes_nothing():
    setup()
    must(compute([line(1, "100.00", description="", quantity=None, unit_price=None)], document_ref="INV-D"))
    res = must(api("POST", "/documents/INV-D/check-contents", SYSTEM, {"contents": {}}))
    unmet = {g["gate"] for g in res["gates"] if not g["met"]}
    assert res["ready"] is False and {"invoice_number", "issue_date", "supplier_name", "supplier_address", "line_description",
                                      "line_quantity", "line_unit_price", "contents_complete"} <= unmet
    assert "customer_name" not in unmet                                                            # the partner record supplies the name
    assert res["title"] == "VAT invoice" and must(api("GET", "/documents/INV-D", BILLER))["status"] == "computed"


def test_sc_tax_04_not_registered_yet():
    load_uk()
    must(api("POST", "/registration/record-not-registered", ADMIN, {"effective_from": "2020-01-01"}))
    must(api("POST", "/registration/register", ADMIN, {"vat_number": "GB123456789", "effective_from": "2027-01-01"}))
    uk_rules()
    customer()
    must(compute([line(1, "100.00")], tax_point="2026-12-31", document_ref="INV-N1"))
    must(compute([line(1, "100.00")], tax_point="2027-01-01", document_ref="INV-N2"))
    n1, n2 = must(api("GET", "/documents/INV-N1", BILLER)), must(api("GET", "/documents/INV-N2", BILLER))
    assert (n1["registered"], n1["title"], n1["totals"]["vat"], n1["lines"][0]["tax_code"], n1["vat_number"]) == (False, "Invoice", "0.00", "NV", None)
    assert (n2["registered"], n2["title"], n2["totals"]["vat"], n2["vat_number"]) == (True, "VAT invoice", "20.00", "GB123456789")
    status, body = freeze("INV-N1", {"title": "VAT invoice", "supplier_vat_number": "GB123456789"})   # the opposite check
    assert status == 409 and {"no_vat_number", "not_titled_vat_invoice"} <= {g["gate"] for g in gates_of(body)}
    assert freeze("INV-N1", {"title": "Invoice"})[0] == 200
    assert freeze("INV-N2", FULL)[0] == 200


def test_a_vat_number_other_than_the_registered_one_is_a_gate():
    setup()
    must(compute([line(1, "100.00")], document_ref="INV-V"))
    status, body = freeze("INV-V", {**FULL, "supplier_vat_number": "GB999999999"})
    assert status == 409 and "supplier_vat_number" in {g["gate"] for g in gates_of(body)}


def test_nothing_recorded_about_registration_refuses_the_document_with_its_gate():
    load_uk()
    uk_rules()
    customer()
    status, body = compute([line(1, "100.00")])
    assert status == 409 and gates_of(body)[0]["gate"] == "registration_known" and gates_of(body)[0]["fix"] == "Tax settings > Registration"


# ── SC-TAX-03 reverse charge ─────────────────────────────────────────────────────────────────

def test_sc_tax_03_reverse_charge_for_a_construction_contractor():
    setup()
    assert api("POST", "/parties/7700/set-party-tax", BILLER, {"treatment_code": "uk_cis_contractor"})[0] == 409   # no VAT number: refused
    customer(7700, "uk_cis_contractor", vat="GB987654321")
    out = must(api("POST", "/documents/compute", SYSTEM, {"lines": [line(1, "1000.00", category="construction_service")],
                                                           "tax_point": TODAY, "bp_id": 7700, "document_ref": "INV-RC"}))
    ln, t = out["lines"][0], out["totals"]
    assert (ln["tax_code"], ln["charged_pct"], ln["rate_pct"], ln["vat"], ln["vat_due"]) == ("RC", "0", "20", "0.00", "200.00")
    assert (t["vat"], t["vat_due"], t["gross"]) == ("0.00", "200.00", "1000.00") and "Reverse charge" in ln["wording"]
    assert api("POST", "/documents/INV-RC/freeze", SYSTEM, {"contents": {**FULL, "customer_name": "C", "customer_address": "A"}})[0] == 200


# ── TAX-08 / TAX-22 ──────────────────────────────────────────────────────────────────────────

def test_tax_08_prices_that_include_vat_are_split_and_the_gross_stays_the_price():
    setup()
    out = must(compute([line(1, "12.00")], prices_include_tax=True))
    assert (out["lines"][0]["net"], out["lines"][0]["vat"], out["lines"][0]["gross"]) == ("10.00", "2.00", "12.00")
    must(api("POST", "/settings/prices_include_tax", ADMIN, {"value": True, "valid_from": "2026-11-21"}))
    assert must(compute([line(1, "12.00")], tax_point="2026-11-21"))["totals"]["net"] == "10.00"   # the setting is the default from its date
    assert must(compute([line(1, "12.00")], tax_point="2026-11-21", prices_include_tax=False))["totals"]["net"] == "12.00"


def test_tax_22_a_foreign_currency_vat_total_is_also_shown_in_sterling_and_frozen(neighbours):
    setup()
    out = must(compute([line(1, "1000.00")], currency="EUR", document_ref="INV-EUR"))
    assert out["totals"]["vat"] == "200.00" and out["totals"]["vat_total_gbp"] == "170.00"
    neighbours[1].rates["EUR"] = None                                                              # without the ledger the rate is entered
    out = must(compute([line(1, "1000.00")], currency="EUR", document_ref="INV-EUR2"))
    assert out["totals"]["vat_total_gbp"] is None
    status, body = freeze("INV-EUR2")
    assert status == 409 and "vat_total_gbp" in {g["gate"] for g in gates_of(body)}
    must(compute([line(1, "1000.00")], currency="EUR", document_ref="INV-EUR2", fx_rate="0.9"))
    assert must(freeze("INV-EUR2"))["totals"]["vat_total_gbp"] == "180.00"
    neighbours[1].rates["EUR"] = Decimal("2")
    assert must(api("GET", "/documents/INV-EUR2", BILLER))["totals"]["vat_total_gbp"] == "180.00"  # frozen with the document


def test_a_simplified_invoice_is_allowed_up_to_the_threshold_only():
    setup()
    simple = {"invoice_number": "S-1", "issue_date": TODAY, "supplier_name": "Dodil", "supplier_address": "Leeds", "simplified": True}
    must(compute([line(1, "100.00")], document_ref="INV-S1"))
    assert freeze("INV-S1", simple)[0] == 200                                                      # 120.00 gross: no customer or unit price needed
    must(compute([line(1, "300.00")], document_ref="INV-S2"))
    status, body = freeze("INV-S2", simple)
    assert status == 409 and "simplified_allowed" in {g["gate"] for g in gates_of(body)}


def test_a_cash_discount_must_show_its_rate():
    setup()
    must(compute([line(1, "100.00")], document_ref="INV-CD"))
    status, body = freeze("INV-CD", {**FULL, "cash_discount_applies": True})
    assert status == 409 and "cash_discount_rate" in {g["gate"] for g in gates_of(body)}
    assert freeze("INV-CD", {**FULL, "cash_discount_applies": True, "cash_discount_pct": "2.5"})[0] == 200


def test_a_document_must_have_a_line_and_distinct_line_numbers():
    setup()
    assert compute([])[0] == 409
    assert compute([line(1, "1.00"), line(1, "2.00")])[0] == 409