dodil/erp-brick-payments
DODIL ERP brick: payments
tests/test_rules.py
"""One test per story a person agreed to, named for it (module.yaml stories PAY-01 ... PAY-15, PAY-30 ... PAY-44), written
from the story's Given / When / Then. Through the real app on SQLite, with invoicing, the partner register and the ledger
standing in (tests/fakes.py)."""
import json
from decimal import Decimal
from conftest import (
APPROVER,
APPROVER2,
CASHIER,
SYSTEM,
allocate,
api,
bank,
must,
on_day,
receipt,
refused,
set_control,
settle,
three_invoices,
)
def camt(entries, account="GB00BANK"):
"""A minimal ISO 20022 camt.053 statement: entries are (ref, 'CRDT'|'DBIT', amount, ccy, booked_on, remittance)."""
body = "".join(
f"<Ntry><NtryRef>{r}</NtryRef><Amt Ccy='{c}'>{a}</Amt><CdtDbtInd>{d}</CdtDbtInd><BookgDt><Dt>{day}</Dt></BookgDt>"
f"<NtryDtls><TxDtls><RltdPties><Dbtr><Nm>Acme Ltd</Nm></Dbtr></RltdPties><RmtInf><Ustrd>{u}</Ustrd></RmtInf></TxDtls></NtryDtls></Ntry>"
for r, d, a, c, day, u in entries)
return ("<?xml version='1.0'?><Document xmlns='urn:iso:std:iso:20022:tech:xsd:camt.053.001.02'><BkToCstmrStmt><Stmt>"
f"<Acct><Id><IBAN>{account}</IBAN></Id></Acct>{body}</Stmt></BkToCstmrStmt></Document>")
# ── PAY-01: record a receipt ─────────────────────────────────────────────────────────────────────
def test_PAY_01_a_receipt_is_unallocated_cash_on_the_customer(nb):
three_invoices(nb)
r = receipt()
assert r["status"] == "received" and r["unallocated"] == "10000.00" and r["allocation_state"] == "unallocated"
items = must(api("GET", "/customers/7311/open-items", CASHIER))
assert [c["amount"] for c in items["credits"]] == ["-10000.00"] # shows as unallocated cash
assert items["closing_balance"] == {"GBP": "500.00"} # 10,500 owed less 10,000 cash on the customer
def test_PAY_01_the_same_bank_reference_is_refused_as_a_duplicate(nb):
first = receipt()
refused(api("POST", "/receipts", CASHIER, {"bp_id": 7311, "amount": "10000.00", "currency": "GBP", "received_on": "2026-11-20",
"bank_reference": "INV-2026-0041 0042"}), 409,
f"Bank reference INV-2026-0041 0042 is already on receipt {first['payment_id']}")
def test_PAY_01_money_not_yet_received_is_not_a_receipt(nb):
body = refused(api("POST", "/receipts", CASHIER, {"bp_id": 7311, "amount": "10.00", "currency": "GBP", "received_on": "2026-11-21"}),
409, "Received on 2026-11-21, which is after today (2026-11-20, Europe/London)")
assert "Record it on the day the bank shows it" in json.dumps(body)
assert must(api("GET", "/receipts", CASHIER))["receipts"] == []
def test_PAY_01_today_is_counted_in_the_business_timezone_not_utc(nb):
set_control("business_timezone", "Pacific/Auckland") # a dated control: it starts on the NEXT business date (audit 1)
with on_day("2026-11-21T00:30:00+00:00"): # 2026-11-21 in London, 13:30 in Auckland
assert receipt(received_on="2026-11-21", reference="R1")["received_on"] == "2026-11-21"
refused(api("POST", "/receipts", CASHIER, {"bp_id": 7311, "amount": "1.00", "currency": "GBP", "received_on": "2026-11-22"}),
409, "after today (2026-11-21, Pacific/Auckland)")
def test_PAY_01_an_unknown_customer_is_refused_and_a_receipt_is_never_edited(nb):
refused(api("POST", "/receipts", CASHIER, {"bp_id": 9_000_001, "amount": "5.00", "currency": "GBP", "received_on": "2026-11-20"}),
422, "no business partner 9000001")
r = receipt(reference="WRONG")
assert api("POST", f"/receipts/{r['payment_id']}", CASHIER, {"amount": "1.00"})[0] in (404, 405) # no edit door exists
# ── PAY-02: the bank statement ───────────────────────────────────────────────────────────────────
def test_PAY_02_a_statement_imports_credits_once_and_skips_what_it_knows(nb):
bank()
first = camt([(f"E{n}", "CRDT", "100.00", "GBP", "2026-11-1%d" % n, "pay") for n in range(1, 4)])
got = must(api("POST", "/receipts/import", CASHIER, {"format": "camt053", "content": first, "bank_account_id": "BANK1"}))
assert got["created"] == 3 and got["skipped_duplicate"] == 0
second = camt([(f"E{n}", "CRDT", "100.00", "GBP", "2026-11-1%d" % n, "pay") for n in range(1, 6)] +
[("D1", "DBIT", "50.00", "GBP", "2026-11-19", "fee")])
got = must(api("POST", "/receipts/import", CASHIER, {"format": "camt053", "content": second, "bank_account_id": "BANK1"}))
assert (got["created"], got["skipped_duplicate"], got["debits_kept"]) == (2, 3, 1)
ids = [r["payment_id"] for r in must(api("GET", "/receipts", CASHIER))["receipts"]]
assert sorted(ids) == [f"bank:BANK1:E{n}" for n in range(1, 6)] # keyed by the bank's entry reference
one = must(api("GET", "/receipts/bank:BANK1:E1", CASHIER))
assert one["received_on"] == "2026-11-11" and one["origin"] == "statement:camt053" # dated by the bank's booking date
def test_PAY_02_the_system_ingests_the_same_file_through_its_own_grant_and_the_two_doors_agree(nb):
bank()
f = camt([("S1", "CRDT", "5.00", "GBP", "2026-11-18", "x")])
body = {"format": "camt053", "content": f, "bank_account_id": "BANK1"}
assert api("POST", "/receipts/ingest-statement", CASHIER, body)[0] == 403 # a person's import is the other route
assert must(api("POST", "/receipts/ingest-statement", SYSTEM, body))["created"] == 1
assert must(api("POST", "/receipts/import", CASHIER, body))["skipped_duplicate"] == 1 # the same rows, from either door
assert api("POST", "/receipts/import", SYSTEM, body)[0] == 403
def test_PAY_02_lines_before_the_cutover_are_skipped_as_already_migrated(nb):
bank()
set_control("statement_import_from", "2026-11-15")
f = camt([("A", "CRDT", "10.00", "GBP", "2026-11-14", "x"), ("B", "CRDT", "20.00", "GBP", "2026-11-15", "y")])
got = must(api("POST", "/receipts/import", CASHIER, {"format": "camt053", "content": f, "bank_account_id": "BANK1"}))
assert (got["created"], got["skipped_before_cutover"]) == (1, 1)
def test_PAY_02_a_csv_imports_the_same_way_and_a_credit_naming_nobody_lands_unassigned(nb):
bank()
csv = "booking_date,amount,currency,entry_ref,remittance,bp_id\n2026-11-18,250.00,GBP,C1,thanks,7311\n2026-11-18,-5.00,GBP,C2,fee,\n2026-11-19,80.00,GBP,C3,who?,\n"
got = must(api("POST", "/receipts/import", CASHIER, {"format": "csv", "content": csv, "bank_account_id": "BANK1"}))
assert (got["created"], got["debits_kept"], got["unassigned"]) == (2, 1, 1)
assert must(api("GET", "/receipts/bank:BANK1:C3", CASHIER))["bp_id"] is None
assert must(api("GET", "/receipts/bank:BANK1:C1", CASHIER))["bp_id"] == "7311"
def test_PAY_02_a_statement_with_a_doctype_or_no_bank_account_is_refused(nb):
bank()
refused(api("POST", "/receipts/import", CASHIER, {"format": "camt053", "content": "<!DOCTYPE x><a/>", "bank_account_id": "BANK1"}), 409, "DOCTYPE")
refused(api("POST", "/receipts/import", CASHIER, {"format": "csv", "content": "a,b\n1,2", "bank_account_id": "NOPE"}), 422, "no bank account NOPE")
def test_PAY_02_unassigned_money_is_cleared_by_naming_the_customer_once(nb):
bank()
csv = "booking_date,amount,currency,entry_ref\n2026-11-19,80.00,GBP,C3\n"
must(api("POST", "/receipts/import", CASHIER, {"format": "csv", "content": csv, "bank_account_id": "BANK1"}))
got = must(api("POST", "/receipts/bank:BANK1:C3/assign-customer", CASHIER, {"bp_id": 7311}))
assert got["bp_id"] == "7311"
refused(api("POST", "/receipts/bank:BANK1:C3/assign-customer", CASHIER, {"bp_id": 7312}), 409, "already on customer 7311")
# ── PAY-03: one receipt, several invoices ────────────────────────────────────────────────────────
def test_PAY_03_one_receipt_settles_several_invoices(nb):
three_invoices(nb)
r = receipt()
got = must(allocate(r["payment_id"], [("INV-2026-0041", "4000.00"), ("INV-2026-0042", "3500.00"), ("INV-2026-0043", "2500.00")]))
assert got["source_remainder"] == "0.00"
assert {a["allocated_on"] for a in got["allocations"]} == {"2026-11-20"} # dated today, never earlier
s = settle("INV-2026-0041", "INV-2026-0042", "INV-2026-0043")
assert [(k, v["outstanding"], v["state"]) for k, v in s.items()] == [
("INV-2026-0041", "0.00", "paid"), ("INV-2026-0042", "0.00", "paid"), ("INV-2026-0043", "500.00", "part_paid")]
assert must(api("GET", f"/receipts/{r['payment_id']}", CASHIER))["unallocated"] == "0.00"
def test_PAY_03_over_allocating_is_refused_naming_both_figures(nb):
three_invoices(nb)
r = receipt(amount="1000.00", reference="x")
body = refused(allocate(r["payment_id"], [("INV-2026-0041", "1500.00")]), 409,
"Allocating 1,500.00; the receipt has 1,000.00 left and the invoice 4,000.00 outstanding")
assert "Allocate at most the smaller figure" in json.dumps(body)
full = receipt(amount="9000.00", reference="y")
refused(allocate(full["payment_id"], [("INV-2026-0042", "3600.00")]), 409, "the invoice 3,500.00 outstanding")
assert settle("INV-2026-0041")["INV-2026-0041"]["outstanding"] == "4000.00" # nothing was written
def test_PAY_03_an_invoice_of_another_customer_and_a_returned_payment_are_refused(nb):
three_invoices(nb)
nb.invoice("OTHER-1", 7312, "100.00")
r = receipt(amount="500.00", reference="z")
refused(allocate(r["payment_id"], [("OTHER-1", "100.00")]), 409, "belongs to customer 7312")
refused(allocate(r["payment_id"], [("NOPE", "1.00")]), 404, "no settleable invoice NOPE")
refused(allocate(r["payment_id"], [("INV-2026-0041", "1.00"), ("INV-2026-0041", "2.00")]), 409, "appears twice")
# ── PAY-04: tolerance ────────────────────────────────────────────────────────────────────────────
def test_PAY_04_a_difference_within_tolerance_is_proposed_as_a_write_off(nb):
nb.invoice("INV-1", 7311, "1000.00")
set_control("match_tolerance", "2.00")
r = receipt(amount="998.50", reference="p")
got = must(allocate(r["payment_id"], [("INV-1", "998.50")]))
assert got["invoices"][0]["outstanding"] == "1.50"
assert got["write_off_proposals"] == [{"invoice_id": "INV-1", "amount": "1.50", "proposed": True, "writeoff_id": "wo:1"}]
assert nb.writeoffs[0]["invoice_id"] == "INV-1" and nb.writeoffs[0]["amount"] == "1.50"
assert settle("INV-1")["INV-1"]["outstanding"] == "1.50" # allocated 998.50; the 1.50 is invoicing's decision
def test_PAY_04_above_the_tolerance_the_difference_stays_outstanding(nb):
nb.invoice("INV-1", 7311, "1000.00")
set_control("match_tolerance", "2.00")
r = receipt(amount="990.00", reference="p")
got = must(allocate(r["payment_id"], [("INV-1", "990.00")]))
assert got["write_off_proposals"] == [] and nb.writeoffs == []
refused(api("POST", f"/receipts/{r['payment_id']}/settle-difference", CASHIER, {"invoice_id": "INV-1"}), 409,
"The difference 10.00 is above the tolerance 2.00")
def test_PAY_04_with_the_tolerance_off_nothing_is_proposed(nb):
nb.invoice("INV-1", 7311, "1000.00")
r = receipt(amount="999.00", reference="p")
assert must(allocate(r["payment_id"], [("INV-1", "999.00")]))["write_off_proposals"] == []
refused(api("POST", f"/receipts/{r['payment_id']}/settle-difference", CASHIER, {"invoice_id": "INV-1"}), 409, "above the tolerance 0.00")
# ── PAY-05: credit stays on the customer ─────────────────────────────────────────────────────────
def test_PAY_05_unallocated_money_pays_the_next_invoice(nb):
r = receipt(amount="500.00", reference="adv")
nb.invoice("INV-NEW", 7311, "800.00")
must(allocate(r["payment_id"], [("INV-NEW", "500.00")]))
assert settle("INV-NEW")["INV-NEW"]["outstanding"] == "300.00"
def test_PAY_05_a_credit_is_never_applied_across_currencies(nb):
nb.invoice("INV-GBP", 7311, "800.00")
r = receipt(amount="500.00", currency="EUR", reference="eur")
refused(allocate(r["payment_id"], [("INV-GBP", "500.00")]), 409, "The receipt is EUR, the invoice GBP")
def test_PAY_05_a_credit_note_is_applied_like_a_receipt(nb):
nb.invoice("INV-1", 7311, "800.00")
nb.credit_note("CN-1", 7311, "200.00")
got = must(api("POST", "/credit-notes/CN-1/allocate", CASHIER, {"lines": [{"invoice_id": "INV-1", "amount": "150.00"}]}))
assert got["source_remainder"] == "50.00"
assert settle("INV-1")["INV-1"]["outstanding"] == "650.00"
refused(api("POST", "/credit-notes/CN-1/allocate", CASHIER, {"lines": [{"invoice_id": "INV-1", "amount": "60.00"}]}), 409, "has 50.00 left")
# ── PAY-06: undo an allocation ───────────────────────────────────────────────────────────────────
def test_PAY_06_an_undo_is_a_reversing_row_and_history_is_kept(nb):
nb.invoice("INV-1", 7311, "1000.00")
r = receipt(amount="1000.00", reference="u")
with on_day("2026-11-18T09:00:00+00:00"):
must(allocate(r["payment_id"], [("INV-1", "1000.00")]))
assert settle("INV-1")["INV-1"]["outstanding"] == "0.00"
got = must(api("POST", f"/receipts/{r['payment_id']}/unallocate", CASHIER, {"invoice_id": "INV-1", "seq": 1, "reason": "wrong invoice"}))
rev = got["reversal"]
assert (rev["amount"], rev["allocated_on"], rev["reverses_seq"], rev["seq"], rev["reason"]) == ("-1000.00", "2026-11-20", 1, 2, "wrong invoice")
assert settle("INV-1")["INV-1"]["outstanding"] == "1000.00" # rises back from today...
assert settle("INV-1", as_at="2026-11-19")["INV-1"]["outstanding"] == "0.00" # ...and the past still reads as it did
one = must(api("GET", f"/receipts/{r['payment_id']}", CASHIER))
assert len(one["allocations"]) == 2 and one["unallocated"] == "1000.00" # both rows kept
refused(api("POST", f"/receipts/{r['payment_id']}/unallocate", CASHIER, {"invoice_id": "INV-1", "seq": 1, "reason": "again"}), 409, "already reversed")
refused(api("POST", f"/receipts/{r['payment_id']}/unallocate", CASHIER, {"invoice_id": "INV-1", "seq": 2, "reason": "x"}), 409, "itself a reversal")
def test_PAY_06_the_same_pair_can_be_allocated_again_with_the_next_seq(nb):
nb.invoice("INV-1", 7311, "1000.00")
r = receipt(amount="1000.00", reference="u")
must(allocate(r["payment_id"], [("INV-1", "400.00")]))
must(allocate(r["payment_id"], [("INV-1", "600.00")]))
seqs = [(a["seq"], a["amount"]) for a in must(api("GET", f"/receipts/{r['payment_id']}", CASHIER))["allocations"]]
assert seqs == [(1, "400.00"), (2, "600.00")]
# ── PAY-07: a returned payment ───────────────────────────────────────────────────────────────────
def test_PAY_07_a_returned_payment_reverses_its_allocations_and_posts(nb):
bank()
nb.invoice("INV-1", 7311, "700.00")
nb.invoice("INV-2", 7311, "500.00")
r = receipt(amount="1200.00", reference="dd", bank_account_id="BANK1", received_on="2026-11-17")
assert nb.journals[f"receipt:{r['payment_id']}"]["lines"][0] == {"account_id": "1200", "debit": "1200.00"}
must(allocate(r["payment_id"], [("INV-1", "700.00"), ("INV-2", "500.00")]))
got = must(api("POST", f"/receipts/{r['payment_id']}/reverse", CASHIER, {"bank_return_ref": "RTN-1", "returned_on": "2026-11-19"}))
assert got["status"] == "reversed" and got["returned_on"] == "2026-11-19" and len(got["allocation_reversals"]) == 2
s = settle("INV-1", "INV-2")
assert (s["INV-1"]["outstanding"], s["INV-2"]["outstanding"]) == ("700.00", "500.00") # owed again
one = must(api("GET", f"/receipts/{r['payment_id']}", CASHIER))
assert sum(Decimal(a["amount"]) for a in one["allocations"]) == 0 and one["unallocated"] == "0.00" # net to zero
assert nb.journals[f"receipt_reversal:{r['payment_id']}"]["lines"][0] == {"purpose": "receivable", "bp_id": "7311", "debit": "1200.00"}
again = must(api("POST", f"/receipts/{r['payment_id']}/reverse", CASHIER, {"bank_return_ref": "RTN-1"}))
assert again["unchanged"] is True # a repeat changes nothing
refused(allocate(r["payment_id"], [("INV-1", "1.00")]), 409, "A returned payment settles nothing")
def test_PAY_07_the_return_date_is_not_after_today_and_not_before_the_receipt(nb):
r = receipt(amount="100.00", reference="dd", received_on="2026-11-17")
refused(api("POST", f"/receipts/{r['payment_id']}/reverse", CASHIER, {"bank_return_ref": "R", "returned_on": "2026-11-21"}), 409,
"The return date 2026-11-21 is after today or before the receipt (2026-11-17)")
refused(api("POST", f"/receipts/{r['payment_id']}/reverse", CASHIER, {"bank_return_ref": "R", "returned_on": "2026-11-16"}), 409, "before the receipt")
assert api("POST", f"/receipts/{r['payment_id']}/reverse", CASHIER, {"returned_on": "2026-11-18"})[0] == 422 # the bank's reference is required
# ── PAY-08: a refund ─────────────────────────────────────────────────────────────────────────────
def test_PAY_08_an_overpayment_is_refunded_with_a_second_persons_approval(nb):
bank()
nb.invoice("INV-1", 7311, "1000.00")
r = receipt(amount="1300.00", reference="over", bank_account_id="BANK1")
must(allocate(r["payment_id"], [("INV-1", "1000.00")]))
ask = must(api("POST", "/refunds", CASHIER, {"bp_id": 7311, "amount": "300.00", "currency": "GBP", "reason": "overpaid"}))
rid = ask["refund_id"]
refused(api("POST", "/refunds", CASHIER, {"bp_id": 7311, "amount": "300.01", "currency": "GBP"}), 409, "unallocated credit in GBP is 0.00") # already held
refused(api("POST", f"/refunds/{rid}/approve", BOSS_ALSO_REQUESTER(), {}), 409, "You requested this refund")
refused(api("POST", f"/refunds/{rid}/pay", CASHIER, {"bank_ref": "BNK-9"}), 409, "is requested")
assert must(api("POST", f"/refunds/{rid}/approve", APPROVER, {}))["status"] == "approved"
paid = must(api("POST", f"/refunds/{rid}/pay", CASHIER, {"bank_ref": "BNK-9", "bank_account_id": "BANK1"}))
assert paid["status"] == "paid" and paid["allocations"][0]["source_kind"] == "refund" and paid["allocations"][0]["amount"] == "300.00"
assert must(api("GET", f"/receipts/{r['payment_id']}", CASHIER))["unallocated"] == "0.00"
assert must(api("GET", "/customers/7311/open-items", CASHIER))["credits"] == [] # the customer's credit is 0.00
assert nb.journals[f"refund:{rid}"]["lines"][1] == {"account_id": "1200", "credit": "300.00"}
refused(api("POST", "/refunds", CASHIER, {"bp_id": 7311, "amount": "1.00", "currency": "GBP"}), 409, "unallocated credit in GBP is 0.00")
def BOSS_ALSO_REQUESTER():
"""Someone holding BOTH permissions who requested the refund: nobody approves their own."""
from conftest import person
return person("cashier@shop.example", ["pay:refund:approve", "pay:refund:request"])
def test_PAY_08_a_refund_needs_an_approver_pays_only_once_and_a_rejected_one_leaves_the_credit(nb):
r = receipt(amount="300.00", reference="c")
ask = must(api("POST", "/refunds", CASHIER, {"bp_id": 7311, "amount": "100.00", "currency": "GBP"}))
assert api("POST", f"/refunds/{ask['refund_id']}/approve", CASHIER, {})[0] == 403 # a cashier cannot approve
got = must(api("POST", f"/refunds/{ask['refund_id']}/reject", APPROVER2, {"reason": "not ours"}))
assert got["status"] == "rejected"
assert must(api("GET", f"/receipts/{r['payment_id']}", CASHIER))["unallocated"] == "300.00"
refused(api("POST", "/refunds", CASHIER, {"bp_id": 7311, "amount": "400.00", "currency": "GBP"}), 409, "is 300.00")
ok = must(api("POST", "/refunds", CASHIER, {"bp_id": 7311, "amount": "300.00", "currency": "GBP"}))
must(api("POST", f"/refunds/{ok['refund_id']}/approve", APPROVER, {}))
refused(api("POST", f"/refunds/{ok['refund_id']}/pay", CASHIER, {"bank_ref": "B", "paid_on": "2026-11-21"}), 409, "after today or before the approval")
must(api("POST", f"/refunds/{ok['refund_id']}/pay", CASHIER, {"bank_ref": "B"}))
again = must(api("POST", f"/refunds/{ok['refund_id']}/pay", CASHIER, {"bank_ref": "B"})) # a repeat changes nothing
assert again["status"] == "paid" and len(must(api("GET", "/refunds", CASHIER))["refunds"]) == 2
def test_PAY_08_a_refund_is_drawn_from_credit_notes_after_receipts_and_never_across_currencies(nb):
nb.credit_note("CN-1", 7311, "100.00")
receipt(amount="50.00", reference="r", currency="EUR")
ask = must(api("POST", "/refunds", CASHIER, {"bp_id": 7311, "amount": "100.00", "currency": "GBP"})) # the credit note only
refused(api("POST", "/refunds", CASHIER, {"bp_id": 7311, "amount": "0.01", "currency": "GBP"}), 409, "is 0.00")
must(api("POST", f"/refunds/{ask['refund_id']}/approve", APPROVER, {}))
paid = must(api("POST", f"/refunds/{ask['refund_id']}/pay", CASHIER, {"bank_ref": "B"}))
assert paid["allocations"][0]["invoice_id"] == "credit_note:CN-1"
# ── PAY-30: settlement is derived ────────────────────────────────────────────────────────────────
def test_PAY_30_outstanding_is_the_total_less_every_kind_of_allocation(nb):
nb.invoice("INV-1", 7311, "1000.00", due_date="2026-10-01")
nb.credit_note("CN-1", 7311, "100.00")
r = receipt(amount="400.00", reference="s")
must(allocate(r["payment_id"], [("INV-1", "400.00")]))
must(api("POST", "/credit-notes/CN-1/allocate", CASHIER, {"lines": [{"invoice_id": "INV-1", "amount": "100.00"}]}))
one = settle("INV-1")["INV-1"]
assert (one["outstanding"], one["state"]) == ("500.00", "part_paid")
assert one["bucket"] == "31-60" and one["age_days"] == 50 # aged from the due date into its bucket
def test_PAY_30_a_write_off_clears_an_invoice_as_written_off(nb):
nb.invoice("INV-1", 7311, "100.00")
must(api("POST", "/allocations/from-source", SYSTEM, {"source_kind": "writeoff", "source_id": "WO-1", "invoice_id": "INV-1", "amount": "100.00"}))
assert settle("INV-1")["INV-1"]["state"] == "written_off"
def test_PAY_30_age_can_run_from_the_invoice_date(nb):
nb.invoice("INV-1", 7311, "100.00", issued_on="2026-09-01", due_date="2026-10-31")
must(api("POST", "/settings/age_from", APPROVER, {"value": "invoice_date"}))
one = settle("INV-1")["INV-1"]
assert (one["age_days"], one["bucket"]) == (80, "61-90")
# ── PAY-41: invoicing allocates credit notes and write-offs ──────────────────────────────────────
def test_PAY_41_an_allocation_from_invoicing_is_idempotent(nb):
nb.invoice("INV-1", 7311, "500.00")
nb.credit_note("CN-1", 7311, "100.00")
body = {"source_kind": "credit_note", "source_id": "CN-1", "invoice_id": "INV-1", "amount": "100.00", "seq": 1}
assert must(api("POST", "/allocations/from-source", SYSTEM, body))["created"] is True
assert must(api("POST", "/allocations/from-source", SYSTEM, body))["created"] is False
assert settle("INV-1")["INV-1"]["outstanding"] == "400.00"
refused(api("POST", "/allocations/from-source", SYSTEM, {**body, "seq": 2}), 409, "has 0.00 left") # the note is spent
assert api("POST", "/allocations/from-source", CASHIER, body)[0] == 403 # a person is not invoicing
# ── PAY-43: a provider's confirmation is a receipt ───────────────────────────────────────────────
def test_PAY_43_a_provider_payment_is_a_receipt_allocated_to_its_invoice(nb):
nb.invoice("INV-1", 7311, "250.00")
body = {"provider": "stripe", "payment_ref": "pi_123", "bp_id": 7311, "amount": "250.00", "currency": "GBP",
"received_on": "2026-11-20", "invoice_id": "INV-1"}
got = must(api("POST", "/receipts/ingest", SYSTEM, body))
assert got["payment_id"] == "provider:stripe:pi_123" and got["unallocated"] == "0.00" and got["duplicate"] is False
again = must(api("POST", "/receipts/ingest", SYSTEM, body))
assert again["duplicate"] is True and len(must(api("GET", "/receipts", CASHIER))["receipts"]) == 1
assert settle("INV-1")["INV-1"]["outstanding"] == "0.00"