dodil/erp-brick-ar

DODIL ERP brick: accounts receivable

tests/test_v4b.py

"""Spec v4, slice B: AR-42 .. AR-47 (and the slice-A leftovers). Given/When/Then from the signed stories; one test per rule a story states.
Business 'today' is 2026-11-20."""
import json
import xml.etree.ElementTree as ET
from decimal import Decimal

from conftest import (
    APPROVER,
    BILLER,
    ROLES,
    SALES,
    SYSTEM,
    account_map,
    api,
    approved_credit,
    billing,
    control,
    hand_draft,
    issue,
    issued,
    line,
    must,
    on_day,
    person,
    tool,
    tools,
)
from sqlalchemy.exc import IntegrityError

from brick_ar.application import use_cases as uc

D = Decimal
GLN = "0088:5060012349998"


def setting(key, value):
    return must(api("POST", "/settings", APPROVER, {"key": key, "value": value}))


def peppol_world(world, provider="peppol", **profile):
    """Customer 7311 on Peppol, our own details set, the access point composed."""
    for k, v in (("supplier_name", "Dodil Ltd"), ("supplier_address", "1 Mill Rd, London, GB"), ("supplier_country", "GB"),
                 ("einvoice_sender_id", "0088:5060000000001"), ("einvoice_provider", provider)):
        setting(k, v)
    return billing(einvoice_channel="peppol", peppol_participant_id=GLN, bill_to_address="2 High St, Leeds, GB", **profile)


def posted(world, doc_id):
    must(api("POST", f"/documents/{doc_id}/post", SYSTEM))


def journals_of(world, doc_id):
    return [j for j in world.gl.journals.values() if j["source_document"] == doc_id]


# ── AR-42: a recurring invoice with no order ──────────────────────────────────────────────────────

def desk(**kw):
    body = {"bp_id": 7311, "currency": "GBP", "description": "Desk rental", "unit_price": "450.00", "starts_on": "2026-12-01", "periods": 12,
            "tax_category": "standard", **kw}
    return api("POST", "/schedules/manual", BILLER, body)


def test_ar_42_a_manual_schedule_is_keyed_man_uuid_and_runs_on_the_same_engine(world):
    billing()
    s = must(desk())
    assert s["schedule_id"].startswith("man:") and s["schedule_id"].endswith("|1")
    assert s["source_kind"] == "manual" and s["status"] == "active" and s["periods"] == 12 and s["next_billing_date"].startswith("2026-12-01")
    with on_day("2026-12-01"):
        raised = must(api("POST", "/schedules/raise-due", SYSTEM, {}))
    assert raised["raised"] == [f"rec:{s['schedule_id']}:1"]
    d = must(api("GET", f"/invoices/rec:{s['schedule_id']}:1", BILLER))
    assert d["lifecycle"] == "draft" and d["lines"][0]["service_from"] == "2026-12-01" and d["lines"][0]["service_to"] == "2026-12-31"


def test_ar_42_the_draft_issues_itself_only_when_the_owner_names_manual(world):
    billing()
    must(control("auto_issue_sources", "manual"))
    s = must(desk())
    with on_day("2026-12-01"):
        must(api("POST", "/schedules/raise-due", SYSTEM, {}))
    assert must(api("GET", f"/invoices/rec:{s['schedule_id']}:1", BILLER))["lifecycle"] == "issued"


def test_ar_42_it_starts_today_or_later_never_earlier(world):
    billing()
    status, body = desk(starts_on="2026-11-19")
    assert status == 409 and "starts_on" in body["detail"]
    must(desk(starts_on="2026-11-20"))                                  # today is fine


def test_ar_42_finished_after_period_12_and_stopped_with_a_reason_dated_today(world):
    billing()
    s = must(desk(periods=2))
    with on_day("2027-03-01"):
        assert len(must(api("POST", "/schedules/raise-due", SYSTEM, {}))["raised"]) == 2
        assert must(api("POST", "/schedules/finish", SYSTEM, {}))["finished"] == [s["schedule_id"]]
        assert must(api("POST", "/schedules/raise-due", SYSTEM, {}))["raised"] == []
    t = must(desk())
    stopped = must(api("POST", "/schedules/stop", SYSTEM, {"source_id": t["source_id"], "reason": "customer left"}))
    assert stopped["stopped_on"] == "2026-11-20"
    got = must(api("GET", f"/schedules/{t['schedule_id']}", BILLER))
    assert got["status"] == "stopped" and got["stop_reason"] == "customer left"


def test_ar_42_the_billing_profile_is_a_gate_and_the_tool_exists(world):
    status, body = desk()
    assert status == 409 and "billing_profile" in body["detail"]
    billing()
    kind, got = tool(BILLER, "create_recurring_invoice", {"bp_id": 7311, "currency": "GBP", "description": "Desk", "unit_price": "450.00",
                                                           "starts_on": "2026-12-01", "periods": 12})
    assert kind == "ok" and got["source_kind"] == "manual"
    assert api("POST", "/schedules/manual", SALES, {"bp_id": 7311, "currency": "GBP", "description": "x", "unit_price": "1", "starts_on": "2026-12-01", "periods": 1})[0] == 403


# ── AR-46: deferred revenue ───────────────────────────────────────────────────────────────────────

def annual(world):
    """Issued 2026-10-01 for 2026-10-01 .. 2027-09-30 at 12,000.00 net, posted to the ledger."""
    with on_day("2026-10-01"):
        account_map()
        billing()
        d = hand_draft([line("Annual support", "1", "12000.00", service_from="2026-10-01", service_to="2027-09-30")])
        inv = issue(d["invoice_id"], world=world)
        posted(world, inv["invoice_id"])
    return inv


def release(period, day, who=SYSTEM):
    with on_day(day):
        return api("POST", "/revenue/release", who, {"period_id": period})


def test_ar_46_issue_posts_the_service_period_to_deferred_revenue(world):
    inv = annual(world)
    (j,) = journals_of(world, inv["invoice_id"])
    assert {"purpose": "deferred_revenue", "credit": "12000.00", "memo": j["lines"][1]["memo"]} == j["lines"][1]
    assert not any(ln.get("account_id") == "4000" for ln in j["lines"])            # none went straight to revenue


def test_ar_46_october_releases_31_over_365_once_and_twice_adds_nothing(world):
    inv = annual(world)
    first = must(release("2026-10", "2026-11-01"))
    assert [r["amount"] for r in first["released"]] == ["1019.18"] and first["posted"] == 1
    again = must(release("2026-10", "2026-11-01"))
    assert again["released"] == [] and again["already_released"] == 1 and again["posted"] == 0
    (j,) = journals_of(world, f"rel:{inv['invoice_id']}:1:2026-10")
    assert j["event"] == "approve" and j["journal_date"] == "2026-10-31"
    assert j["lines"][0]["purpose"] == "deferred_revenue" and j["lines"][0]["debit"] == "1019.18"
    assert j["lines"][1]["purpose"] == "revenue" and j["lines"][1]["credit"] == "1019.18"


def test_ar_46_the_last_period_takes_the_remainder_so_the_line_releases_exactly_its_net(world):
    annual(world)
    got = must(release("2027-09", "2027-10-01"))                                    # a catch-up run releases every month through September
    assert len(got["released"]) == 12 and sum(D(r["amount"]) for r in got["released"]) == D("12000.00")
    assert uc.repository  # (the monthly amounts are the days over 365: Feb 28/365 etc.)
    assert [r["amount"] for r in got["released"]][:2] == ["1019.18", "986.30"]


def test_ar_46_a_credit_note_takes_back_what_is_not_yet_released_first(world):
    inv = annual(world)
    must(release("2026-10", "2026-11-01"))
    with on_day("2026-11-20"):
        c = approved_credit(world, inv["invoice_id"], lines=[{"description": "Support credit", "quantity": "1", "unit_price": "3000.00"}])
    posted(world, c["credit_id"])
    (j,) = journals_of(world, c["credit_id"])
    assert {"purpose": "deferred_revenue", "debit": "3000.00", "memo": j["lines"][1]["memo"]} == j["lines"][1]
    got = must(release("2027-09", "2027-10-01"))
    assert sum(D(r["amount"]) for r in got["released"]) == D("12000.00") - D("3000.00") - D("1019.18")


def test_ar_46_lines_without_a_service_period_post_straight_to_revenue(world):
    account_map()
    i = issued(world)
    posted(world, i["invoice_id"])
    (j,) = journals_of(world, i["invoice_id"])
    assert any(ln.get("account_id") == "4000" for ln in j["lines"]) and not any("purpose" in ln for ln in j["lines"])


def test_ar_46_a_period_that_has_not_ended_is_refused_and_a_person_may_not_release(world):
    annual(world)
    assert release("2026-11", "2026-11-20")[0] == 409
    assert release("2026-10", "2026-11-01", BILLER)[0] == 403
    assert release("2026-1x", "2026-11-01")[0] == 422


def test_ar_46_an_invoice_with_released_revenue_cannot_be_voided(world):
    inv = annual(world)
    must(release("2026-10", "2026-11-01"))
    status, body = api("POST", f"/invoices/{inv['invoice_id']}/void", APPROVER, {"reason": "mistake"})
    assert status == 409 and "no_released_revenue" in body["detail"]


def test_ar_46_the_switch_off_posts_straight_to_revenue(world):
    account_map()
    billing()
    must(control("defer_service_revenue", False, valid_from="2026-12-01"))
    with on_day("2026-12-02"):
        d = hand_draft([line("Annual support", "1", "1200.00", service_from="2026-12-02", service_to="2027-12-01")])
        inv = issue(d["invoice_id"], world=world)
        posted(world, inv["invoice_id"])
    assert not any("purpose" in ln for j in journals_of(world, inv["invoice_id"]) for ln in j["lines"])


# ── AR-47: cost centre on revenue ────────────────────────────────────────────────────────────────

def test_ar_47_revenue_lines_carry_their_cost_centre_and_receivable_and_vat_go_unsplit(world):
    account_map()
    billing()
    d = hand_draft([line("Consulting", "1", "6000.00", cost_centre="CC-CONS"), line("Training", "1", "4000.00", cost_centre="CC-TRAIN")])
    inv = issue(d["invoice_id"], world=world)
    posted(world, inv["invoice_id"])
    (j,) = journals_of(world, inv["invoice_id"])
    by = {(ln["account_id"], ln.get("cost_centre")): ln for ln in j["lines"]}
    assert by[("1100", None)]["debit"] == "12000.00" and by[("2200", None)]["credit"] == "2000.00"
    assert by[("4000", "CC-CONS")]["credit"] == "6000.00" and by[("4000", "CC-TRAIN")]["credit"] == "4000.00"


def test_ar_47_a_line_with_none_takes_the_customers_default_else_the_gate_where_the_ledger_requires_one(world):
    account_map()
    billing(default_cost_centre="CC-MAIN")
    must(control("cost_centre_required", True))
    d = hand_draft([line("Consulting", "1", "100.00")])
    inv = issue(d["invoice_id"], world=world)
    assert inv["lines"][0]["cost_centre"] == "CC-MAIN"
    billing(8000)
    d2 = hand_draft([line("Consulting", "1", "100.00")], bp=8000)
    status, body = api("POST", f"/invoices/{d2['invoice_id']}/issue", BILLER, {})
    assert status == 409 and "cost_centre" in body["detail"]
    assert next(g for g in must(api("GET", f"/invoices/{d2['invoice_id']}/readiness", BILLER))["gates"] if g["gate"] == "cost_centre")["met"] is False


# ── AR-43: the Peppol e-invoice ──────────────────────────────────────────────────────────────────

def test_ar_43_a_valid_invoice_goes_as_ubl_bis3_with_the_message_id_recorded(world):
    peppol_world(world)
    i = issued(world)
    got = must(api("POST", f"/invoices/{i['invoice_id']}/send", BILLER, {}))
    (sent,) = world.peppol.sent
    root = ET.fromstring(sent["xml"])
    ns = {"cbc": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
          "cac": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"}
    assert root.find("cbc:CustomizationID", ns).text == "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0"
    assert root.tag.endswith("}Invoice") and root.find("cbc:ID", ns).text == i["invoice_no"]
    assert root.find("cac:InvoiceLine/cbc:InvoicedQuantity", ns).get("unitCode") == "MON"
    assert root.find("cac:AccountingCustomerParty/cac:Party/cbc:EndpointID", ns).get("schemeID") == "0088"
    assert root.find("cac:TaxTotal/cac:TaxSubtotal/cac:TaxCategory/cbc:ID", ns).text == "S"
    assert sent["receiver_id"] == GLN and sent["document_type"] == "invoice"
    assert got["send"]["message_id"] == "MSG-1" and got["send"]["format"] == "ubl" and "Invoice" in got["send"]["record"]
    assert got["sends"][0]["channel"] == "peppol" and got["einvoice_message_id"] == "MSG-1"


def test_ar_43_a_failed_rule_refuses_the_issue_before_a_number_is_claimed_then_it_issues_and_delivers(world):
    peppol_world(world)                                                           # SC-AR-12
    d = hand_draft([{"description": "Hosting", "quantity": "2", "unit_price": "100.00", "tax_category": "standard"}])
    status, body = api("POST", f"/invoices/{d['invoice_id']}/issue", BILLER, {})
    assert status == 409 and "einvoice_valid" in body["detail"] and "BR-23" in body["detail"] and "line 1 unit code" in body["detail"]
    assert must(api("GET", f"/invoices/{d['invoice_id']}", BILLER))["invoice_no"] == ""            # nothing claimed
    must(api("POST", f"/invoices/{d['invoice_id']}/edit", BILLER, {"lines": [line("Hosting", "2", "100.00")]}))
    inv = issue(d["invoice_id"], world=world)
    assert inv["invoice_no"].endswith("0001")                                                 # the first number: no gap was burned
    must(api("POST", f"/invoices/{d['invoice_id']}/send", BILLER, {}))
    must(api("POST", "/einvoices/MSG-1/status", SYSTEM, {"status": "delivered"}))
    must(api("POST", "/einvoices/MSG-1/status", SYSTEM, {"status": "AP"}))
    got = must(api("GET", f"/invoices/{d['invoice_id']}", BILLER))
    assert [s["status"] for s in got["einvoice"]["statuses"]] == ["delivered", "AP"]


def test_ar_43_the_send_itself_refuses_a_document_that_fails_a_rule_and_sends_nothing(world):
    peppol_world(world)
    i = issued(world)
    setting("supplier_name", "")                                                  # the seller name goes missing from tomorrow
    with on_day("2026-11-21"):
        status, body = api("POST", f"/invoices/{i['invoice_id']}/send", BILLER, {})
    assert status == 409 and "BR-06" in body["detail"] and world.peppol.sent == []


def test_ar_43_no_provider_composed_is_a_named_gate_and_a_down_provider_is_a_502(world):
    peppol_world(world, provider="")
    i = issued(world)
    status, body = api("POST", f"/invoices/{i['invoice_id']}/send", BILLER, {})
    assert status == 409 and "einvoice_provider" in body["detail"]
    setting("einvoice_provider", "peppol")
    world.peppol.down = True
    with on_day("2026-11-21"):
        assert api("POST", f"/invoices/{i['invoice_id']}/send", BILLER, {})[0] == 502
    return
    world.peppol.down = True
    assert api("POST", f"/invoices/{i['invoice_id']}/send", BILLER, {})[0] == 502


def test_ar_43_a_credit_note_is_a_ubl_creditnote_the_same_way(world):
    peppol_world(world)
    i = issued(world)
    c = approved_credit(world, i["invoice_id"], full=True)
    must(api("POST", f"/credit-notes/{c['credit_id']}/send", BILLER, {}))
    root = ET.fromstring(world.peppol.sent[-1]["xml"])
    assert root.tag.endswith("}CreditNote") and world.peppol.sent[-1]["document_type"] == "credit_note"
    assert must(api("POST", f"/credit-notes/{c['credit_id']}/send", BILLER, {}))["already"] is True


def test_ar_43_a_peppol_customer_needs_a_participant_id_and_an_email_customer_is_unchanged(world):
    status, body = api("POST", "/customers/7311/billing", APPROVER, {"bill_to_name": "A", "bill_to_address": "x", "invoice_email": "a@b.c",
                                                                       "terms_id": "NET30", "currency": "GBP", "einvoice_channel": "peppol"})
    assert status == 409 and "participant id" in body["detail"]
    i = issued(world)
    got = must(api("POST", f"/invoices/{i['invoice_id']}/send", BILLER, {}))
    assert got["send"]["address"] == "ap@acme.example" and world.peppol.sent == []


# ── AR-44: the access point reports ──────────────────────────────────────────────────────────────

def sent_invoice(world):
    peppol_world(world)
    i = issued(world)
    must(api("POST", f"/invoices/{i['invoice_id']}/send", BILLER, {}))
    return i


def test_ar_44_delivered_then_a_rejection_with_its_reason_shows_on_the_invoice_which_is_unchanged(world):
    i = sent_invoice(world)
    must(api("POST", "/einvoices/MSG-1/status", SYSTEM, {"status": "delivered", "at": "2026-11-20T08:00:00Z"}))
    got = must(api("POST", "/einvoices/MSG-1/status", SYSTEM, {"status": "RE", "reason": "PO number missing", "at": "2026-11-20T09:00:00+00:00"}))
    assert [s["status"] for s in got["statuses"]] == ["delivered", "RE"] and got["rejected"] == "PO number missing"
    inv = must(api("GET", f"/invoices/{i['invoice_id']}", BILLER))
    assert inv["lifecycle"] == "issued" and inv["total"] == i["total"] and inv["invoice_no"] == i["invoice_no"]
    assert inv["einvoice"]["rejection"] == "PO number missing"
    assert json.loads(inv["sends"][0]["statuses_json"])[1] == {"status": "RE", "at": "2026-11-20T09:00:00", "reason": "PO number missing"}


def test_ar_44_a_duplicate_notification_adds_nothing(world):
    sent_invoice(world)
    a = must(api("POST", "/einvoices/MSG-1/status", SYSTEM, {"status": "AB"}))
    b = must(api("POST", "/einvoices/MSG-1/status", SYSTEM, {"status": "AB"}))
    assert a["statuses"] == b["statuses"] and len(b["statuses"]) == 1


def test_ar_44_an_unknown_message_a_bad_status_and_a_person_are_refused(world):
    sent_invoice(world)
    assert api("POST", "/einvoices/NOPE/status", SYSTEM, {"status": "AB"})[0] == 404
    assert api("POST", "/einvoices/MSG-1/status", SYSTEM, {"status": "WAT"})[0] == 422
    assert api("POST", "/einvoices/MSG-1/status", BILLER, {"status": "AB"})[0] == 403


def test_ar_44_a_lost_status_claim_is_a_409_never_swallowed(world, monkeypatch):
    sent_invoice(world)
    real = uc.Repository if hasattr(uc, "Repository") else None
    from brick_ar.infrastructure import tables

    def boom(self, *a, **k):
        raise IntegrityError("INSERT INTO ar_einvoice_status", {}, Exception("duplicate key value violates unique constraint (23505)"))
    monkeypatch.setattr(tables.EinvoiceRepository, "add_status", boom)
    assert api("POST", "/einvoices/MSG-1/status", SYSTEM, {"status": "AB"})[0] == 409
    del real


# ── AR-45: the doubtful-debt provision ───────────────────────────────────────────────────────────

def aged(world):
    """Three issued invoices across the buckets: 1,200.00 gross (net 1,000.00) at 31-60, 2,400.00 (net 2,000.00) at 61-90, and 600.00
    (net 500.00) in dispute at 90+. Returns their ids."""
    account_map()
    ids = []
    for bp, price, bucket, disputed in ((7311, "1000.00", "31-60", False), (7312, "2000.00", "61-90", False), (7313, "500.00", "90+", True)):
        billing(bp)
        inv = issue(hand_draft([line("Work", "1", price)], bp=bp)["invoice_id"], world=world)
        world.payments.aged[bucket].append({"invoice_id": inv["invoice_id"], "bp_id": bp, "outstanding": inv["total"], "days_past_due": 40,
                                            "disputed": disputed})
        ids.append(inv["invoice_id"])
    for bucket, rate in (("0-30", "0"), ("31-60", "2"), ("61-90", "10"), ("90+", "50"), ("specific", "100")):
        must(api("POST", "/provision-rates", APPROVER, {"bucket": bucket, "rate_pct": rate}))
    return ids


def propose(period="2026-11", day="2026-12-01", who=BILLER):
    with on_day(day):
        return api("POST", "/provisions", who, {"period_id": period})


def approve(run_id, who=APPROVER, day="2026-12-01"):
    with on_day(day):
        return api("POST", f"/provisions/{run_id}/approve", who, {})


def test_ar_45_the_run_lists_each_bucket_and_each_disputed_invoice_net_of_vat(world):
    ids = aged(world)
    run = must(propose())
    assert run["run_id"] == "prov::2026-11".replace("::", ":default:") and run["lifecycle"] == "proposed" and run["as_at"] == "2026-11-30"
    by = {(ln["kind"], ln["bucket"]): ln for ln in run["lines"]}
    assert by[("bucket", "31-60")]["outstanding_net"] == "1000.00" and by[("bucket", "31-60")]["provision"] == "20.00"
    assert by[("bucket", "61-90")]["outstanding_net"] == "2000.00" and by[("bucket", "61-90")]["provision"] == "200.00"
    assert by[("bucket", "90+")]["outstanding_net"] == "0.00"                          # the disputed one is its own line
    (sp,) = [ln for ln in run["lines"] if ln["kind"] == "specific"]
    assert sp["invoice_id"] == ids[2] and sp["outstanding_net"] == "500.00" and sp["rate_pct"] == "100.0000" and sp["provision"] == "500.00"
    assert run["total"] == "720.00" and run["movement"] == "720.00" and run["previous_total"] == "0.00"


def test_ar_45_approval_by_another_person_posts_the_movement_once_dated_the_period_end(world):
    aged(world)
    run = must(propose())
    done = must(approve(run["run_id"]))
    assert done["lifecycle"] == "approved" and done["posting"]["posted"] is True
    again = must(approve(run["run_id"]))
    assert again["posting"]["posted"] is True
    (j,) = [x for x in world.gl.journals.values() if x["source_document"] == run["run_id"]]
    assert j["event"] == "approve" and j["journal_date"] == "2026-11-30" and j["currency"] == "GBP"
    assert j["lines"][0]["purpose"] == "doubtful_debt_expense" and j["lines"][0]["debit"] == "720.00"
    assert j["lines"][1]["purpose"] == "doubtful_debt_allowance" and j["lines"][1]["credit"] == "720.00"
    assert world.gl.calls.count(j["journal_id"]) == 1                              # the repeat never reached the ledger again


def test_ar_45_next_month_posts_only_the_movement_and_a_fall_reverses_the_sides(world):
    ids = aged(world)
    must(approve(must(propose())["run_id"]))
    world.payments.aged_out.add(ids[1])                                              # the 2,400 invoice is paid in December
    with on_day("2027-01-02"):
        run = must(api("POST", "/provisions", BILLER, {"period_id": "2026-12"}))
        assert run["previous_total"] == "720.00" and run["total"] == "520.00" and run["movement"] == "-200.00"
        must(api("POST", f"/provisions/{run['run_id']}/approve", APPROVER, {}))
    j = next(x for x in world.gl.journals.values() if x["source_document"] == run["run_id"])
    assert j["lines"][0] == {"purpose": "doubtful_debt_expense", "memo": j["lines"][0]["memo"], "credit": "200.00"}
    assert j["lines"][1]["debit"] == "200.00"


def test_ar_45_the_proposer_may_not_approve_by_email_or_by_sub_where_second_approver_is_on(world):
    aged(world)
    must(control("second_approver", True))
    both = ROLES["biller"] + ROLES["approver"]
    run = must(propose(who=person("pat@shop.example", both)))
    status, body = approve(run["run_id"], person("pat@shop.example", both))
    assert status == 409 and "proposer" in body["detail"]
    other_email = person("someone-else@shop.example", both)                           # same sub, another email
    other_email["X-Dodil-User"] = json.dumps({"sub": "pat@shop.example", "email": "someone-else@shop.example", "app_roles": []})
    assert approve(run["run_id"], other_email)[0] == 409
    assert must(approve(run["run_id"]))["lifecycle"] == "approved"                    # the approver persona is another person


def test_ar_45_the_month_must_have_ended_and_a_rejection_can_be_proposed_again(world):
    aged(world)
    assert propose(day="2026-11-30")[0] == 409
    assert propose(period="nope")[0] == 422
    run = must(propose())
    assert api("POST", f"/provisions/{run['run_id']}/reject", APPROVER, {"reason": ""})[0] in (409, 422)
    with on_day("2026-12-01"):
        assert must(api("POST", f"/provisions/{run['run_id']}/reject", APPROVER, {"reason": "rates are stale"}))["lifecycle"] == "rejected"
    assert must(propose())["lifecycle"] == "proposed"


def test_ar_45_a_closed_ledger_period_blocks_approval_and_a_refusing_ledger_leaves_it_to_repeat(world):
    aged(world)
    run = must(propose())
    world.gl.closed_before = "2026-11-30"
    status, body = approve(run["run_id"])
    assert status == 409 and "period_open" in body["detail"]
    world.gl.closed_before = None
    world.install_down = world.remove("gl")                                           # the ledger is away: approved, not posted
    got = must(approve(run["run_id"]))
    assert got["lifecycle"] == "approved" and got["posting"]["posted"] is False
    world.install("gl")
    assert must(approve(run["run_id"]))["posting"]["posted"] is True


def test_ar_45_rates_are_dated_rows_a_person_cannot_run_the_machine_and_the_tools_exist(world):
    must(api("POST", "/provision-rates", APPROVER, {"bucket": "31-60", "rate_pct": "2"}))
    assert api("POST", "/provision-rates", APPROVER, {"bucket": "31-60", "rate_pct": "3", "valid_from": "2026-11-20"})[0] == 409
    must(api("POST", "/provision-rates", APPROVER, {"bucket": "31-60", "rate_pct": "3", "valid_from": "2026-12-01"}))
    assert len(must(api("GET", "/provision-rates", BILLER))["rates"]) == 2
    assert api("POST", "/provisions", SALES, {"period_id": "2026-10"})[0] == 403
    names = tools(BILLER)
    assert {"create_recurring_invoice", "propose_doubtful_provision", "approve_doubtful_provision", "release_deferred_revenue",
            "record_einvoice_status", "send_credit_einvoice", "hand_over_credit_note"} <= set(names)


# ── slice A leftovers: AR-41 credits ─────────────────────────────────────────────────────────────

def ic_invoice(world):
    account_map()
    billing(intercompany_company_code="UK02")
    must(control("default_company_code", "UK01"))
    i = issued(world)
    return i


def test_ar_41_a_credit_note_against_an_intercompany_invoice_is_handed_over_once_with_negative_amounts(world):
    i = ic_invoice(world)
    c = approved_credit(world, i["invoice_id"], full=True)
    assert c["handed_to_partner"] is True
    bill = world.pur.bills[f"ic:{c['credit_id']}"]
    assert bill["company_code"] == "UK02" and bill["totals"]["gross"] == f"-{c['total']}" and bill["lines"][0]["net_amount"].startswith("-")
    again = must(api("POST", f"/credit-notes/{c['credit_id']}/hand-over", SYSTEM, {}))
    assert again["already"] is True and len(world.pur.bills) == 2                        # the invoice's bill and the credit's


def test_ar_41_intercompany_open_lists_the_credit_as_a_negative_document(world):
    i = ic_invoice(world)
    c = approved_credit(world, i["invoice_id"], full=True)
    got = must(api("GET", "/intercompany/open", SYSTEM, {"company_code": "UK01", "trading_partner": "UK02"}))
    docs = {d.get("credit_id") or d["invoice_id"]: d for d in got["documents"]}
    assert docs[c["credit_id"]]["total"] == f"-{c['total']}" and docs[i["invoice_id"]]["total"] == i["total"]