dodil/erp-brick-tax

DODIL ERP brick: tax

tests/test_bad_debt.py

"""Bad debt relief and its repayment (v4, stories TAX-34, TAX-35, scenario SC-TAX-11)."""
from conftest import (
    ADMIN,
    BILLER,
    OWNER,
    SYSTEM,
    api,
    filing_ready,
    frozen_sale,
    gates_of,
    hmrc_ob,
    line,
    must,
    on_day,
    prepare,
)
from test_vat_return import lock, submit

INV = "INV-2026-0107"


def writeoff(invoice, due="2026-04-09", supplied="2026-03-10", wo="WO-1", total="1200.00"):
    return {"writeoff_id": wo, "invoice_id": invoice, "bp_id": 7311, "amount": "720.00", "currency": "GBP", "approved_at": "2026-10-30T10:00:00+00:00",
            "invoice_issue_date": supplied, "tax_point_date": supplied, "due_date": due, "total": total}


def scenario(ar_payments, hmrc_world):
    """INV-2026-0107 (net 1,000.00, VAT 200.00) supplied 2026-03-10 due 2026-04-09, its VAT in submitted return 26AA; 480.00
    received so 720.00 is unpaid; ar wrote it off on 2026-10-30."""
    ar, payments = ar_payments
    filing_ready()
    h = hmrc_world[0]
    h.obs[:0] = [hmrc_ob("26AA", "2026-01-01", "2026-03-31", "2026-05-07")]
    h.obs += [hmrc_ob("27AA", "2027-01-01", "2027-03-31", "2027-05-07"), hmrc_ob("27AB", "2027-04-01", "2027-06-30", "2027-08-07")]
    must(api("POST", "/obligations/sync", SYSTEM, {"from_date": "2025-12-01", "to_date": "2027-12-31"}))
    frozen_sale(INV, [line(1, "1000.00")], tax_point="2026-03-10")
    frozen_sale("INV-NOWO", [line(1, "500.00")], tax_point="2026-03-11")
    must(prepare("26AA"))
    must(lock("26AA"))
    must(submit("26AA"))
    ar.rows.append(writeoff(INV))
    payments.outstanding[INV] = "720.00"


def claim(invoice=INV, period="26AD", who=ADMIN):
    return api("POST", f"/bad-debt/{invoice}/claim", who, {"period_key": period})


def test_tax_34_an_eligible_invoice_is_listed_once_and_claimed_as_the_vat_fraction_of_what_is_unpaid(ar_and_payments, hmrc_world):
    scenario(ar_and_payments, hmrc_world)
    assert must(api("POST", "/bad-debt/suggest", SYSTEM, {}))["eligible"] == [INV]
    assert must(api("POST", "/bad-debt/suggest", SYSTEM, {}))["eligible"] == []            # one eligible row per invoice
    listed = must(api("GET", "/bad-debt", ADMIN))["claims"][0]
    assert (listed["lifecycle"], str(listed["eligible_from"])) == ("eligible", "2026-10-09") and all(g["met"] for g in listed["gates"])
    must(prepare())
    out = must(claim())
    assert out["relief_amount"] == "120.00" and out["unpaid_at_claim"] == "720.00" and out["return"]["boxes"]["4"] == "120.00"
    drill = must(api("GET", "/vat-returns/26AD/boxes/4", ADMIN))["entries"]
    assert [(e["document_ref"], e["entry_kind"], e["box_amount"]) for e in drill] == [(INV, "bad_debt_claim", "120.00")]
    status, body = claim()
    assert status == 409 and gates_of(body)[0]["gate"] == "not_claimed"                    # one claim per invoice
    assert must(prepare())["boxes"]["4"] == "120.00"                                      # preparing again keeps the claim


def test_tax_34_an_invoice_inside_its_six_months_is_refused_with_its_date_and_every_gate_is_named(ar_and_payments, hmrc_world):
    scenario(ar_and_payments, hmrc_world)
    ar, payments = ar_and_payments
    frozen_sale("INV-2026-0200", [line(1, "1000.00")], tax_point="2026-08-01")
    ar.rows.append(writeoff("INV-2026-0200", due="2026-08-31", supplied="2026-08-01", wo="WO-2"))
    payments.outstanding["INV-2026-0200"] = "1200.00"
    must(prepare())
    status, body = claim("INV-2026-0200")
    gates = {g["gate"]: g for g in gates_of(body)}
    assert status == 409 and "2027-02-28" in body["detail"] and set(gates) == {"six_months_passed", "vat_accounted"}
    assert all({"gate", "met", "reason", "fix"} <= set(g) for g in gates.values())
    assert [c["invoice_id"] for c in must(api("GET", "/bad-debt", ADMIN))["claims"]] == [INV]   # listed by the prepare; the new one not until it qualifies


def test_tax_34_an_invoice_not_written_off_in_receivables_is_refused(ar_and_payments, hmrc_world):
    scenario(ar_and_payments, hmrc_world)
    must(prepare())
    status, body = claim("INV-NOWO")
    assert status == 409 and [g["gate"] for g in gates_of(body)] == ["written_off"] and "not written off" in body["detail"]
    assert claim("INV-UNKNOWN")[0] == 409


def test_tax_34_the_time_limit_is_four_years_and_six_months(ar_and_payments, hmrc_world):
    scenario(ar_and_payments, hmrc_world)
    must(prepare())
    with on_day("2031-01-01T09:00:00+00:00"):
        status, body = claim()
        assert status == 409 and "within_time_limit" in {g["gate"] for g in gates_of(body)}


def test_tax_34_a_dismissed_invoice_is_not_claimed_and_only_the_tax_admin_claims(ar_and_payments, hmrc_world):
    scenario(ar_and_payments, hmrc_world)
    must(api("POST", "/bad-debt/suggest", SYSTEM, {}))
    must(prepare())
    for who in (BILLER, OWNER):
        assert claim(who=who)[0] == 403
    assert api("POST", f"/bad-debt/{INV}/dismiss", ADMIN, {"reason": ""})[0] in (409, 422)
    assert must(api("POST", f"/bad-debt/{INV}/dismiss", ADMIN, {"reason": "customer paid in cash"}))["lifecycle"] == "dismissed"
    status, body = claim()
    assert status == 409 and "dismissed" in body["detail"]


def test_tax_35_each_later_receipt_repays_its_vat_fraction_once_and_an_undo_reverses_it(ar_and_payments, hmrc_world):
    scenario(ar_and_payments, hmrc_world)
    _, payments = ar_and_payments
    must(prepare())
    must(claim())
    alloc = {"source_kind": "receipt", "source_id": "R-77", "invoice_id": INV, "seq": 1, "amount": "360.00", "currency": "GBP",
             "allocated_on": "2027-02-20", "received_on": "2027-02-20", "reverses_seq": None}
    before = {**alloc, "source_id": "R-70", "amount": "100.00", "allocated_on": "2026-11-10"}          # before the claim: already in the unpaid figure
    payments.allocs += [alloc, before]
    with on_day("2027-02-21T09:00:00+00:00"):
        out = must(prepare("27AA"))
        assert out["boxes"]["1"] == "60.00" and out["entries"]["repayments"] == 1
        entry = must(api("GET", "/vat-returns/27AA/boxes/1", ADMIN))["entries"][0]
        assert (entry["document_ref"], entry["tax_code"], entry["entry_kind"], entry["box_amount"]) == (INV, "receipt:R-77:1", "bad_debt_repayment", "60.00")
        assert INV in entry["detail"]
        assert must(prepare("27AA"))["boxes"]["1"] == "60.00"                                         # the same allocation, once
    with on_day("2027-04-05T09:00:00+00:00"):
        must(lock("27AA"))
        must(submit("27AA"))
        assert must(prepare("27AB"))["boxes"]["1"] == "0.00"                                          # already repaid on 27AA
        payments.allocs.append({**alloc, "seq": 3, "reverses_seq": 1, "allocated_on": "2027-04-20"})      # PAY-06: the allocation is undone
    with on_day("2027-05-01T09:00:00+00:00"):
        assert must(prepare("27AB"))["boxes"]["1"] == "-60.00"
        assert must(api("POST", "/vat-returns/27AB/refresh", ADMIN, {}))["boxes"]["1"] == "-60.00"