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)