dodil/erp-brick-ar

DODIL ERP brick: accounts receivable

tests/test_audit_musts.py

"""Audit of ar v4 (b121ec0..b4f8956): each test states a MUST and FAILS on the current code for the stated reason.
Business 'today' is 2026-11-20 unless a test moves the day."""
import json
from decimal import Decimal

from conftest import APPROVER, BILLER, OWNER, SYSTEM, api, approved_credit, billing, issue, line, must, on_day
from test_v4a import deposit, final, recharge
from test_v4b import aged, annual, approve, propose, release, sent_invoice

from brick_ar.application import use_cases as uc
from brick_ar.infrastructure import tables

D = Decimal


def _remaining(dep_id):
    with uc.repository() as repo:
        return uc.sv.deposit_remaining(repo, repo.invoices.get(dep_id)[0])


# ── M1: a credit note on a deposit invoice ignores what finals already deducted ────────────────────

def test_m1_a_deposit_fully_deducted_cannot_also_be_credited(world):
    """5,000.00 deposit, a final deducts all of it, then a 5,000.00 credit on the deposit is approved: the deposit is used twice
    (refunded AND deducted) and what remains is -5,000.00. approve_credit checks only invoice.total - credited, never the claims."""
    dep = deposit(world)
    issue(final(world, "SO-9", "5000.00")["invoice_id"], world=world)
    assert _remaining(dep["invoice_id"]) == D("0.00")
    d = must(api("POST", "/credit-notes", BILLER, {"invoice_id": dep["invoice_id"], "reason": "refund", "lines": [line("Refund", "1", "5000.00")]}))
    world.payments.credit_faces[d["credit_id"]] = Decimal(d["total"])
    s, _b = api("POST", f"/credit-notes/{d['credit_id']}/approve", APPROVER, {})
    assert s == 409, "a credit on a deposit must stay within what remains of the deposit (deposit_remaining)"
    assert _remaining(dep["invoice_id"]) >= 0


# ── M2: a deposit invoice is voided after a final deducted it ──────────────────────────────────────

def test_m2_a_deposit_that_a_final_deducted_cannot_be_voided(world):
    dep = deposit(world)
    issue(final(world, "SO-9", "3000.00")["invoice_id"], world=world)
    s, _b = api("POST", f"/invoices/{dep['invoice_id']}/void", APPROVER, {"reason": "raised in error"})
    assert s == 409, "void_gates has no gate on deposit claims: the final now deducts 3,000.00 from a void deposit"


# ── M3: voiding the final never frees its deposit claim ────────────────────────────────────────────

def test_m3_voiding_a_final_gives_its_deduction_back_to_the_deposit(world):
    """DepositRepository.claimed sums every claim whatever the final's lifecycle (its own docstring says 'on ISSUED finals')."""
    dep = deposit(world)
    fin = issue(final(world, "SO-9", "3000.00")["invoice_id"], world=world)
    must(api("POST", f"/invoices/{fin['invoice_id']}/void", APPROVER, {"reason": "raised in error"}))
    assert _remaining(dep["invoice_id"]) == D("5000.00")


# ── M4: release and credit take-back are two bounded claims on one line that never collide ─────────

def test_m4_a_credit_racing_the_last_release_cannot_take_back_revenue_already_released(world, monkeypatch):
    """The credit's snapshot does not see the final month's release (committed meanwhile): the claims are keyed (line, period) and
    (line, cn:<id>), so nothing collides and released + taken back exceeds the line's net."""
    inv = annual(world)
    must(release("2027-09", "2027-10-01"))                                     # all 12,000.00 released
    real = tables.ReleaseRepository.rows
    stale = {"left": 1}

    def snapshot_rows(self, invoice_id):
        if stale["left"]:
            stale["left"] -= 1
            return []
        return real(self, invoice_id)
    monkeypatch.setattr(tables.ReleaseRepository, "rows", snapshot_rows)
    with on_day("2027-10-01"):
        approved_credit(world, inv["invoice_id"], lines=[{"description": "Support credit", "quantity": "1", "unit_price": "3000.00"}])
    monkeypatch.setattr(tables.ReleaseRepository, "rows", real)
    with uc.repository() as repo:
        total = sum((r["amount"] for r in repo.releases.rows(inv["invoice_id"])), D(0))
    assert total <= D("12000.00"), f"released + taken back = {total} on a 12,000.00 line"


# ── M5: void and release race: nothing collides ────────────────────────────────────────────────────

def test_m5_a_void_racing_a_release_collides(world, monkeypatch):
    """void reads releases.any_for in its snapshot; release never writes the invoice row, so a release committed meanwhile does not
    conflict with the void's save: the invoice is void with revenue released (and a release journal) behind it."""
    inv = annual(world)
    must(release("2026-10", "2026-11-01"))
    monkeypatch.setattr(tables.ReleaseRepository, "any_for", lambda self, iid: False)    # the void's snapshot
    s, _b = api("POST", f"/invoices/{inv['invoice_id']}/void", APPROVER, {"reason": "mistake"})
    assert s == 409, "a release committed beside the void must make the void collide"


# ── M6: provision runs approved out of order post the allowance twice ──────────────────────────────

def test_m6_an_earlier_periods_run_approved_after_a_later_one_does_not_post_the_allowance_again(world):
    aged(world)
    dec = must(propose("2026-12", "2027-01-02"))
    must(approve(dec["run_id"], day="2027-01-02"))                              # movement 720.00 from nothing
    s, nov = propose("2026-11", "2027-01-02")
    if s == 200:
        approve(nov["run_id"], day="2027-01-02")                                  # last_approved_before(2026-11) = none -> 720.00 again
    expense = sum((D(ln.get("debit", "0")) - D(ln.get("credit", "0")) for j in world.gl.journals.values()
                   if j["source_document"].startswith("prov:") for ln in j["lines"] if ln["purpose"] == "doubtful_debt_expense"), D(0))
    assert expense == D("720.00"), f"the ledger carries an allowance of {expense} for 720.00 of doubtful debt"


# ── M7: an e-invoice status moves the document backwards ───────────────────────────────────────────

def test_m7_a_rejection_after_acceptance_does_not_reject_an_accepted_invoice(world):
    """AP (accepted) and PD (paid) are final Invoice Responses; record_einvoice_status keys only (message, status), so a later RE is
    recorded and the invoice shows 'rejected' after the buyer accepted it."""
    i = sent_invoice(world)
    must(api("POST", "/einvoices/MSG-1/status", SYSTEM, {"status": "AP", "at": "2026-11-20T09:00:00Z"}))
    api("POST", "/einvoices/MSG-1/status", SYSTEM, {"status": "RE", "reason": "late", "at": "2026-11-20T10:00:00Z"})
    inv = must(api("GET", f"/invoices/{i['invoice_id']}", BILLER))
    assert inv["einvoice"]["rejection"] is None


# ── M8: the served process is the v3 one ───────────────────────────────────────────────────────────

def test_m8_the_served_bpmn_is_the_signed_v4_process():
    """process.yaml says version 3: no payment_request / provision_run workflow, and v4 transition keys (the frozen BPMN ids:
    approve_doubtful_provision, settle_payment_request, release_deferred_revenue, plan_manual_schedule) are absent."""
    s, body = api("GET", "/process/bpmn", OWNER)
    text = json.dumps(body)
    assert s == 200
    missing = [k for k in ("approve_doubtful_provision", "settle_payment_request", "release_deferred_revenue", "plan_manual_schedule",
                           "raise_settlement_discount", "record_einvoice_status") if k not in text]
    assert not missing, f"the signed v4 transitions are not in the served BPMN: {missing}"


# ── M9: a credit note is numbered outside its company's series ─────────────────────────────────────

def test_m9_a_credit_on_another_companys_invoice_numbers_from_that_companys_series(world):
    """approve_credit calls claim_number without company=inv.company_code (use_cases.py:782): every company's credit notes share
    one series, so UK02's first credit note is CN-...-0002 after UK01's."""
    uk01 = recharge(world)
    billing(7312)
    d = must(api("POST", "/invoices", BILLER, {"bp_id": 7312, "currency": "GBP", "company_code": "UK02", "lines": [line()]}))
    uk02 = issue(d["invoice_id"], world=world)
    c1 = approved_credit(world, uk01["invoice_id"], lines=[line("Credit", "1", "10.00")])
    c2 = approved_credit(world, uk02["invoice_id"], lines=[line("Credit", "1", "10.00")])
    assert c2["credit_no"] == c1["credit_no"], f"UK02's first credit note is {c2['credit_no']}, UK01's is {c1['credit_no']}"