dodil/erp-brick-tax
DODIL ERP brick: tax
tests/test_vat_return.py
"""HMRC and the VAT return (v4, stories TAX-26 .. TAX-33 and scenarios SC-TAX-09, SC-TAX-10)."""
import json
from datetime import date
from decimal import Decimal
import pytest
from conftest import (
ADMIN,
AFTER_26AD,
BILLER,
BROWSER,
OWNER,
SERVICE,
SYSTEM,
TODAY,
api,
connect_hmrc,
customer,
filing_ready,
frozen_purchase,
frozen_sale,
gates_of,
hmrc_ob,
line,
load_uk,
must,
on_day,
person,
prepare,
purchase_setup,
registered,
sync,
)
from sqlalchemy import text as sql
AR = person("ar@svc.example", ["tax:tax_point:read"])
PAYMENTS = person("payments@svc.example", ["tax:vat_return:read"])
def submit(period="26AD", who=OWNER, **kw):
return api("POST", f"/vat-returns/{period}/submit", who, {"declaration": True, "client": BROWSER, **kw})
def lock(period="26AD", who=ADMIN):
return api("POST", f"/vat-returns/{period}/lock", who, {})
def the_quarter():
"""The card's quarter: sales S 206,000.00 (VAT 41,200.00) and RC 12,000.00; bills PS 49,250.00 (VAT 9,850.00 reclaimable) and
PE 700.00 (VAT 140.00 a cost); a late bill dated 2026-09-28 (1,000.00, VAT 200.00); a sale of June (another period)."""
filing_ready()
customer(7312, "uk_cis_contractor", "GB555555555")
frozen_sale("INV-1", [line(1, "206000.00")])
frozen_sale("INV-2", [line(1, "12000.00", category="construction_service")], bp_id=7312)
frozen_sale("INV-JUNE", [line(1, "5000.00")], tax_point="2026-06-15")
frozen_purchase("PB-1", [line(1, "49250.00")])
frozen_purchase("PB-2", [line(1, "700.00", category="entertainment")])
frozen_purchase("PB-LATE", [line(1, "1000.00")], tax_point="2026-09-28")
# ── TAX-26 the connection ─────────────────────────────────────────────────────────────────────
def test_tax_26_without_a_connection_every_return_transition_answers_the_gate():
purchase_setup()
got = must(api("GET", "/hmrc/connection", ADMIN))
assert got["connected"] is False and got["gate"] == {"gate": "hmrc_connected", "met": False,
"reason": "this business is not connected to HMRC", "fix": "Tax settings > HMRC connection (the owner)"}
status, body = api("POST", "/obligations/sync", SYSTEM, {})
assert status == 409 and gates_of(body)[0]["gate"] == "hmrc_connected" and gates_of(body)[0]["fix"] == got["gate"]["fix"]
def test_tax_26_the_owner_connects_and_the_tokens_live_only_in_the_secret_store(hmrc_world, engine):
purchase_setup()
url = must(api("GET", "/hmrc/connection/authorize-url", OWNER, {"redirect_uri": "https://app.example/cb"}))
assert url["url"].startswith("https://hmrc.test/sandbox/oauth/authorize")
out = connect_hmrc()
assert (out["vrn"], out["scopes"], out["environment"], out["authorised_by"], out["connected"]) == (
"123456789", ["read:vat", "write:vat"], "sandbox", "owner@shop.example", True)
assert str(out["expires_at"])[:10] == "2028-05-20" # HMRC's grant lasts 18 months
_, secrets = hmrc_world
assert secrets.store["hmrc/123456789"]["access_token"] == "AT-secret"
with engine.connect() as c: # never in a row
dump = json.dumps([list(map(str, r)) for r in c.execute(sql("select * from tax_hmrc_connection"))])
assert "AT-secret" not in dump and "RT-secret" not in dump and "AT-secret" not in json.dumps(out, default=str)
assert api("POST", "/hmrc/connection/connect", ADMIN, {"code": "x", "redirect_uri": "https://app.example/cb"})[0] == 403
def test_tax_26_thirty_days_before_expiry_the_owner_is_told_and_after_it_the_gate_returns():
purchase_setup()
connect_hmrc()
assert must(api("GET", "/hmrc/connection", ADMIN))["reconnect_due"] is False
with on_day("2028-04-25T09:00:00+00:00"):
got = must(api("GET", "/hmrc/connection", ADMIN))
assert got["reconnect_due"] is True and "reconnects" in got["notice"]
with on_day("2028-06-01T09:00:00+00:00"):
assert must(api("GET", "/hmrc/connection", ADMIN))["connected"] is False
status, body = api("POST", "/obligations/sync", SYSTEM, {})
assert status == 409 and gates_of(body)[0]["gate"] == "hmrc_connected"
def test_tax_26_the_environment_is_the_owners_control_and_decides_the_base_url(hmrc_world):
purchase_setup()
assert api("POST", "/tax-controls/hmrc_environment", ADMIN, {"value": "production"})[0] == 403
must(api("POST", "/tax-controls/hmrc_environment", OWNER, {"value": "production"}))
assert connect_hmrc()["environment"] == "production"
sync()
assert {entry[1] for entry in hmrc_world[0].log if entry[0] == "obligations"} == {"production"}
def test_tax_26_a_connection_needs_a_recorded_registration():
load_uk()
status, body = api("POST", "/hmrc/connection/connect", OWNER, {"code": "x", "redirect_uri": "https://app.example/cb"})
assert status == 409 and gates_of(body)[0]["gate"] == "registration_recorded"
registered()
assert must(api("POST", "/hmrc/connection/connect", OWNER, {"code": "x", "redirect_uri": "https://app.example/cb"}))["connected"]
# ── TAX-27 obligations ────────────────────────────────────────────────────────────────────────
def test_tax_27_one_row_per_period_with_hmrcs_dates_and_a_second_run_changes_nothing(hmrc_world):
purchase_setup()
connect_hmrc()
first = sync()
assert first["synced"] == 2 and [o["period_key"] for o in first["obligations"]] == ["26AC", "26AD"]
ad = next(o for o in first["obligations"] if o["period_key"] == "26AD")
assert (str(ad["period_start"]), str(ad["period_end"]), str(ad["due_date"]), ad["status"]) == ("2026-10-01", "2026-12-31", "2027-02-07", "O")
again = sync()
assert again["synced"] == 0 and again["obligations"] == first["obligations"]
for entry in (e for e in hmrc_world[0].log if e[0] == "obligations"): # HMRC answers at most 366 days at a time
assert (date.fromisoformat(entry[4]) - date.fromisoformat(entry[3])).days <= 366
def test_tax_27_a_period_hmrc_shows_filed_is_marked_filed_with_its_received_date(hmrc_world):
purchase_setup()
connect_hmrc()
hmrc_world[0].obs[0] = hmrc_ob("26AC", "2026-07-01", "2026-09-30", "2026-11-07", "F", "2026-10-30")
got = sync()["obligations"][0]
assert got["status"] == "F" and str(got["received_on"]) == "2026-10-30"
assert api("POST", "/vat-returns/26AC/prepare", ADMIN, {})[0] == 409 # filed elsewhere: nothing to prepare
def test_tax_27_an_hmrc_error_is_recorded_with_its_code_and_cleared_by_the_next_good_run(hmrc_world):
purchase_setup()
connect_hmrc()
hmrc_world[0].obligation_error = "SERVER_ERROR"
out = sync()
assert out["error"]["code"] == "SERVER_ERROR" and out["synced"] == 0
assert must(api("GET", "/hmrc/connection", ADMIN))["last_sync_error"] == "SERVER_ERROR"
hmrc_world[0].obligation_error = None
assert sync()["error"] is None and must(api("GET", "/hmrc/connection", ADMIN))["last_sync_error"] is None
def test_tax_27_only_the_system_syncs_obligations():
purchase_setup()
connect_hmrc()
for who in (ADMIN, OWNER, BILLER):
assert api("POST", "/obligations/sync", who, {})[0] == 403
# ── TAX-28 preparing the return ───────────────────────────────────────────────────────────────
def test_tax_28_the_boxes_are_sums_of_entries_and_each_document_enters_one_return():
the_quarter()
out = must(prepare())
b = out["boxes"]
assert (b["1"], b["2"], b["3"], b["4"], b["5"]) == ("41200.00", "0.00", "41200.00", "10050.00", "31150.00")
assert (b["6"], b["7"], b["8"], b["9"]) == ("218000.00", "50950.00", "0.00", "0.00") # whole pounds; the June sale is not here
assert out["lifecycle"] == "draft" and out["entries"]["already_in_a_return"] == 0
# every box drills to its entries, and equals their sum
for box in (1, 4, 6, 7):
d = must(api("GET", f"/vat-returns/26AD/boxes/{box}", ADMIN))
assert sum(Decimal(str(e["box_amount"])) for e in d["entries"]) == Decimal(str(d["value"])), box
refs = {e["document_ref"] for e in must(api("GET", "/vat-returns/26AD/boxes/4", ADMIN))["entries"]}
assert refs == {"PB-1", "PB-LATE"} # the late bill enters this return; the entertainment bill claims nothing
# preparing again replaces only this draft's entries
again = must(prepare())
assert again["boxes"] == b and again["entries"]["already_in_a_return"] == 0
# the earlier quarter, prepared after, finds the late bill already counted
other = must(prepare("26AC"))
assert other["boxes"]["4"] == "0.00" and other["boxes"]["7"] == "0.00"
assert must(api("GET", "/vat-returns/26AD/boxes/4", ADMIN))["value"] == "10050.00"
def test_tax_28_boxes_6_to_9_drop_the_pence():
filing_ready()
frozen_sale("INV-P", [line(1, "250.99")])
b = must(prepare())["boxes"]
assert b["6"] == "250.00" and b["1"] == "50.20" and b["5"] == "50.20"
def test_tax_28_a_document_cannot_be_claimed_twice_even_by_a_racing_preparation(engine):
from brick_tax.infrastructure.tables import Repository
the_quarter()
must(prepare())
row = {"document_ref": "INV-1", "tax_code": "S", "entry_kind": "document", "vrn": "123456789", "period_key": "26AC", "direction": "sale",
"tax_point": date(2026, 11, 20), "net_amount": Decimal(1), "vat_amount": Decimal(0), "box1_amount": Decimal(0),
"box4_amount": Decimal(0), "box_value": "6", "source": "document"}
with engine.begin() as conn:
assert Repository(conn).entries.claim(row) is False # the primary key refuses the second claim
assert Repository(conn).entries.where(document_ref="INV-1", tax_code="S")[0]["period_key"] == "26AD"
def test_tax_28_refresh_replaces_this_drafts_entries_and_a_submitted_return_never_changes():
the_quarter()
must(prepare())
frozen_sale("INV-3", [line(1, "1000.00")])
out = must(api("POST", "/vat-returns/26AD/refresh", ADMIN, {}))
assert out["boxes"]["1"] == "41400.00" and out["boxes"]["6"] == "219000.00"
with on_day(AFTER_26AD):
must(lock())
status, body = api("POST", "/vat-returns/26AD/refresh", ADMIN, {})
assert status == 409 and "only a draft changes" in body["detail"]
# ── TAX-29 adjustments ────────────────────────────────────────────────────────────────────────
def big_quarter(hmrc_world):
filing_ready()
hmrc_world[0].obs.insert(0, hmrc_ob("26AB", "2026-04-01", "2026-06-30", "2026-08-07", "F", "2026-08-01"))
sync()
frozen_sale("INV-BIG", [line(1, "400000.00")])
assert must(prepare())["boxes"]["6"] == "400000.00"
def test_tax_29_a_small_error_is_corrected_on_the_return_and_a_large_one_is_a_vat652_notice(hmrc_world):
big_quarter(hmrc_world)
out = must(api("POST", "/vat-returns/26AD/record-adjustment", ADMIN,
{"box": 1, "amount": "3200.00", "reason": "output VAT under-declared", "corrects_period_key": "26AB"}))
assert out["boxes"]["1"] == "83200.00" and out["adjustment_no"] == 1
d = must(api("GET", "/vat-returns/26AD/boxes/1", ADMIN))
assert d["adjustments"][0]["amount"] == "3200.00" and d["adjustments"][0]["corrects_period_key"] == "26AB"
status, body = api("POST", "/vat-returns/26AD/record-adjustment", ADMIN,
{"box": 1, "amount": "12000.00", "reason": "found in review", "corrects_period_key": "26AB"})
assert status == 409 and gates_of(body) == [{"gate": "within_error_threshold", "met": False,
"reason": "net errors of 12,000.00 exceed what may be corrected on a return (Notice 700/45)",
"fix": "notify HMRC on form VAT652 and record the notice"}]
notice = must(api("POST", "/vat-returns/26AD/record-error-notice", ADMIN,
{"reference": "VAT652-0001", "sent_on": TODAY, "periods": "26AB", "amount": "12000.00"}))
assert notice["notice_no"] == 1 and must(prepare())["boxes"]["1"] == "83200.00" # the notice is not on the return
def test_tax_29_the_thresholds_are_dated_statutory_rows_and_the_four_year_cap_holds(hmrc_world):
big_quarter(hmrc_world)
status, body = api("POST", "/vat-returns/26AD/record-adjustment", ADMIN,
{"box": 4, "amount": "100.00", "reason": "old", "corrects_period_key": "OLD", "corrects_period_end": "2022-01-31"})
assert status == 409 and gates_of(body)[0]["gate"] == "within_four_years"
status, body = api("POST", "/vat-returns/26AD/record-adjustment", ADMIN,
{"box": 4, "amount": "100.00", "reason": "x", "corrects_period_key": "NOPE"})
assert gates_of(body)[0]["gate"] == "corrected_period_known"
# 20,000 is above 10,000 and within 50,000, but above 1% of box 6: refused; 4,000 is exactly 1% of 400,000: accepted
assert api("POST", "/vat-returns/26AD/record-adjustment", ADMIN, {"box": 1, "amount": "20000.00", "reason": "x", "corrects_period_key": "26AB"})[0] == 409
assert api("POST", "/vat-returns/26AD/record-adjustment", ADMIN, {"box": 4, "amount": "4000.00", "reason": "x", "corrects_period_key": "26AB"})[0] == 200
for who in (BILLER, OWNER):
assert api("POST", "/vat-returns/26AD/record-adjustment", who, {"box": 1, "amount": "1.00", "reason": "x", "corrects_period_key": "26AB"})[0] == 403
# ── TAX-30 / TAX-31 submitting ────────────────────────────────────────────────────────────────
def locked_quarter():
the_quarter()
must(prepare())
with on_day(AFTER_26AD):
must(lock())
def test_tax_30_a_second_person_declares_and_submits_and_hmrcs_receipt_makes_it_immutable(hmrc_world):
locked_quarter()
with on_day(AFTER_26AD):
status, body = submit(who=ADMIN) # the person who locked it
assert status == 409 and gates_of(body)[0]["gate"] == "second_person"
out = must(submit())
assert out["lifecycle"] == "submitted" and out["submitted_by"] == "owner@shop.example"
assert out["receipt"] == {"processingDate": "2027-01-12T10:00:00.000Z", "formBundleNumber": "256660290587",
"paymentIndicator": "DD", "chargeRefNumber": "aaaaaaaaaaaaaaaa"}
vrn, body, headers = hmrc_world[0].submitted[0]
assert vrn == "123456789" and body == {
"periodKey": "26AD", "vatDueSales": Decimal("41200.00"), "vatDueAcquisitions": Decimal("0.00"), "totalVatDue": Decimal("41200.00"),
"vatReclaimedCurrPeriod": Decimal("10050.00"), "netVatDue": Decimal("31150.00"), "totalValueSalesExVAT": 218000,
"totalValuePurchasesExVAT": 50950, "totalValueGoodsSuppliedExVAT": 0, "totalAcquisitionsExVAT": 0, "finalised": True}
assert api("POST", "/vat-returns/26AD/reopen", ADMIN, {"reason": "oops"})[0] == 409 # a submitted return never changes
assert api("POST", "/vat-returns/26AD/refresh", ADMIN, {})[0] == 409
assert submit()[0] == 409 # never filed twice
periods = {p["period_key"]: p for p in must(api("GET", "/vat-returns", ADMIN))["periods"]}
assert periods["26AD"]["return_status"] == "submitted" and periods["26AD"]["status"] == "F"
def test_tax_30_the_declaration_is_ticked_a_service_client_cannot_and_the_same_person_under_another_login_is_refused():
locked_quarter()
with on_day(AFTER_26AD):
status, body = api("POST", "/vat-returns/26AD/submit", OWNER, {"declaration": False, "client": BROWSER})
assert status == 409 and gates_of(body)[0]["gate"] == "declaration_ticked"
assert submit(who=SERVICE)[0] == 403 # a declaration is a person's
alias = person("alias@shop.example", ["tax:vat_return:submit"])
alias["X-Dodil-User"] = json.dumps({"sub": "tax_admin@shop.example", "email": "alias@shop.example", "app_roles": []})
status, body = submit(who=alias) # same sub as the locker: the same person
assert status == 409 and gates_of(body)[0]["gate"] == "second_person"
def test_tax_30_a_firm_of_one_switches_the_second_person_off_and_a_return_must_be_locked_first():
the_quarter()
must(prepare())
status, body = submit(who=OWNER)
assert status == 409 and gates_of(body)[0]["gate"] == "return_locked"
must(api("POST", "/tax-controls/vat_return_needs_second_person", OWNER, {"value": False}))
with on_day(AFTER_26AD):
must(lock())
assert must(submit(who=ADMIN))["lifecycle"] == "submitted"
def test_tax_30_a_timeout_leaves_it_submitting_and_hmrc_is_asked_before_anything_is_sent_again(hmrc_world):
locked_quarter()
h = hmrc_world[0]
with on_day(AFTER_26AD):
h.timeout_next = True
status, body = submit()
assert status == 504 and "left submitting" in body["detail"]
assert must(api("GET", "/vat-returns/period/26AD", ADMIN))["lifecycle"] == "submitting"
assert must(submit())["lifecycle"] == "submitted" and len(h.submitted) == 1 # HMRC had nothing: now sent once
def test_tax_30_a_timeout_after_hmrc_filed_it_is_settled_by_a_read_never_filed_twice(hmrc_world):
locked_quarter()
h = hmrc_world[0]
with on_day(AFTER_26AD):
h.timeout_next, h.timeout_files = True, True
assert submit()[0] == 504
out = must(submit())
assert out["lifecycle"] == "submitted" and out["settled_by_read"] is True
assert [e for e in h.log if e[0] == "submit"] == [("submit", "26AD")] # one POST in all
def test_tax_30_an_obligation_hmrc_already_shows_filed_is_refused_and_recorded_never_retried(hmrc_world):
locked_quarter()
h = hmrc_world[0]
with on_day(AFTER_26AD):
h.obs[1] = hmrc_ob("26AD", "2026-10-01", "2026-12-31", "2027-02-07", "F", "2027-01-10")
status, body = submit()
assert status == 409 and "DUPLICATE_SUBMISSION" in body["detail"] and gates_of(body)[0]["gate"] == "obligation_open"
assert not [e for e in h.log if e[0] == "submit"]
h.obs[1] = hmrc_ob("26AD", "2026-10-01", "2026-12-31", "2027-02-07")
h.error_next = "DUPLICATE_SUBMISSION" # HMRC itself says so on the POST
status, body = submit()
assert status == 409 and "not retried" in body["detail"]
assert must(api("GET", "/vat-returns/period/26AD", ADMIN))["hmrc_error"] == "DUPLICATE_SUBMISSION"
def test_tax_31_the_sixteen_fraud_headers_go_with_the_submission_and_are_kept_for_audit(hmrc_world):
locked_quarter()
with on_day(AFTER_26AD):
out = must(submit())
sent = hmrc_world[0].submitted[0][2]
assert sorted(sent) == sorted([
"Gov-Client-Connection-Method", "Gov-Client-Browser-JS-User-Agent", "Gov-Client-Device-ID", "Gov-Client-Multi-Factor",
"Gov-Client-Public-IP", "Gov-Client-Public-IP-Timestamp", "Gov-Client-Public-Port", "Gov-Client-Screens", "Gov-Client-Timezone",
"Gov-Client-User-IDs", "Gov-Client-Window-Size", "Gov-Vendor-Forwarded", "Gov-Vendor-License-IDs", "Gov-Vendor-Product-Name",
"Gov-Vendor-Public-IP", "Gov-Vendor-Version"])
assert sent["Gov-Client-Connection-Method"] == "WEB_APP_VIA_SERVER" and sent["Gov-Client-User-IDs"] == "dodil=owner%40shop.example"
assert sent["Gov-Vendor-Forwarded"] == "by=198.51.100.9&for=203.0.113.7"
assert out["fraud_headers"] == sent
@pytest.mark.parametrize("missing", ["Gov-Client-Device-ID", "Gov-Client-Screens", "Gov-Client-Public-IP"])
def test_tax_31_a_header_the_session_cannot_supply_refuses_the_submission(missing):
locked_quarter()
with on_day(AFTER_26AD):
client = {k: v for k, v in BROWSER.items() if k != missing}
status, body = api("POST", "/vat-returns/26AD/submit", OWNER, {"declaration": True, "client": client})
assert status == 409
assert gates_of(body) == [{"gate": "fraud_headers_complete", "met": False, "reason": f"HMRC requires {missing}",
"fix": "submit from the web screen, not a script"}]
status, body = api("POST", "/vat-returns/26AD/submit", OWNER, {"declaration": True}) # a script: no browser at all
assert status == 409 and gates_of(body)[0]["gate"] == "fraud_headers_complete"
assert must(api("GET", "/vat-returns/period/26AD", ADMIN))["lifecycle"] == "locked"
# ── lock ────────────────────────────────────────────────────────────────────────────────────────
def test_a_return_locks_only_after_its_period_and_a_locked_one_can_be_reopened_with_a_reason():
the_quarter()
must(prepare())
status, body = lock()
assert status == 409 and gates_of(body)[0]["gate"] == "period_ended" and "2026-12-31" in body["detail"]
with on_day(AFTER_26AD):
assert must(lock())["lifecycle"] == "locked" and must(lock())["lifecycle"] == "locked"
assert api("POST", "/vat-returns/26AD/reopen", ADMIN, {"reason": ""})[0] in (409, 422)
assert must(api("POST", "/vat-returns/26AD/reopen", ADMIN, {"reason": "a bill was missed"}))["lifecycle"] == "draft"
# ── TAX-32 the settle journal and the liability view ──────────────────────────────────────────
def test_tax_32_on_submission_the_settle_journal_posts_once_each_codes_vat_against_vat_control(neighbours):
locked_quarter()
_, ledger = neighbours
with on_day(AFTER_26AD):
out = must(submit())
posted = ledger.posted[("vat_return:123456789:26AD", "settle")]["body"]
assert posted["journal_date"] == "2027-01-12" and posted["currency"] == "GBP" and posted["lines"] == [
{"purpose": "tax", "tax_code": "PS", "credit": "10050.00"}, {"purpose": "tax", "tax_code": "S", "debit": "41200.00"},
{"purpose": "vat_control", "credit": "31150.00"}]
assert out["settle_journal_id"] == ledger.posted[("vat_return:123456789:26AD", "settle")]["journal_id"]
assert must(api("POST", "/vat-returns/26AD/settle", ADMIN, {}))["settle_journal_id"] == out["settle_journal_id"]
assert ledger.calls == [("vat_return:123456789:26AD", "settle")] # posted once
def test_tax_32_if_the_ledger_is_down_the_return_is_still_submitted_and_settle_can_be_called_again(monkeypatch, neighbours):
from brick_tax.infrastructure import seams
locked_quarter()
_, ledger = neighbours
monkeypatch.setattr(seams, "ledger_post", type("Down", (), {"post_journal": lambda self, body: None})())
with on_day(AFTER_26AD):
out = must(submit())
assert out["lifecycle"] == "submitted" and out["settle_journal_id"] is None
assert api("POST", "/vat-returns/26AD/settle", ADMIN, {})[0] == 409
monkeypatch.setattr(seams, "ledger_post", ledger)
assert must(api("POST", "/vat-returns/26AD/settle", ADMIN, {}))["settle_journal_id"]
def test_tax_32_the_liability_view_shows_hmrcs_figures_the_due_date_and_any_difference(hmrc_world):
locked_quarter()
with on_day(AFTER_26AD):
must(submit())
h = hmrc_world[0]
h.liabs = [{"taxPeriod": {"from": "2026-10-01", "to": "2026-12-31"}, "type": "VAT Return Debit Charge",
"originalAmount": 31150.00, "outstandingAmount": 31150.00, "due": "2027-02-07"}]
assert must(api("POST", "/vat-returns/26AD/refresh-liability", SYSTEM, {}))["liabilities"] == 1
due = must(api("GET", "/vat-returns/liabilities", PAYMENTS))
assert due == {"liabilities": [{"period_key": "26AD", "charge_type": "VAT Return Debit Charge", "outstanding_amount": "31150.00",
"due_date": "2027-02-07"}], "cis_due": []}
view = must(api("GET", "/vat-returns/26AD/liability", ADMIN))
assert (view["return_net_vat"], view["outstanding"], view["due_date"], view["differs"]) == ("31150.00", "31150.00", "2027-02-07", False)
# HMRC has taken the direct debit; HMRC holds a different figure than our box 5: shown, never hidden
h.liabs[0] |= {"originalAmount": 31000.00, "outstandingAmount": 0}
h.pays = [{"amount": 31000.00, "received": "2027-02-08"}]
must(api("POST", "/vat-returns/26AD/refresh-liability", SYSTEM, {}))
view = must(api("GET", "/vat-returns/26AD/liability", ADMIN))
assert (view["outstanding"], view["differs"], view["difference"]) == ("0.00", True, "150.00")
assert view["hmrc_liabilities"][0]["paid_on"] == "2027-02-08" and must(api("GET", "/vat-returns/liabilities", PAYMENTS))["liabilities"] == []
# ── TAX-33 which return holds a tax point ─────────────────────────────────────────────────────
def status_of(on, who=AR):
return api("GET", "/vat-returns/tax-point-status", who, {"date": on})
def test_tax_33_ar_learns_which_return_holds_a_tax_point_and_whether_it_is_still_open():
filing_ready()
must(prepare("26AC"))
with on_day(AFTER_26AD):
must(lock("26AC"))
must(submit("26AC"))
must(prepare())
got = must(status_of("2026-09-15"))
assert (got["period_key"], got["status"], got["filed"]) == ("26AC", "submitted", True)
assert got["gate"] == {"gate": "tax_point_open", "met": False, "reason": "2026-09-15 is in VAT return 26AC, submitted 2027-01-12",
"fix": "credit it instead, or issue with today's tax point"}
got = must(status_of("2026-11-20"))
assert (got["period_key"], got["status"], got["filed"], got["gate"]["met"]) == ("26AD", "draft", False, True)
with on_day(AFTER_26AD):
must(lock())
got = must(status_of("2026-11-20"))
assert (got["status"], got["filed"], got["gate"]["met"], got["gate"]["reason"]) == ("locked", False, False, "return 26AD is locked for submission")
got = must(status_of("2025-01-01"))
assert (got["period_key"], got["status"], got["gate"]["met"]) == (None, "none", True)
def test_tax_33_a_period_hmrc_shows_filed_elsewhere_counts_as_filed_and_only_ar_s_grant_reads_it(hmrc_world):
purchase_setup()
connect_hmrc()
hmrc_world[0].obs[0] = hmrc_ob("26AC", "2026-07-01", "2026-09-30", "2026-11-07", "F", "2026-10-30")
sync()
got = must(status_of("2026-08-01"))
assert got["filed"] is True and got["gate"]["met"] is False and "filed" in got["gate"]["reason"]
for who in (ADMIN, OWNER, BILLER):
assert status_of("2026-08-01", who)[0] == 403
assert status_of("not-a-date")[0] == 422
def test_a_new_period_open_in_hmrc_but_not_yet_prepared_accepts_a_tax_point():
filing_ready()
got = must(status_of("2026-11-20"))
assert (got["period_key"], got["status"], got["filed"], got["gate"]["met"]) == ("26AD", "open", False, True)