dodil/erp-brick-ar
DODIL ERP brick: accounts receivable
tests/test_v4a.py
"""Spec v4, slice A: AR-14 and AR-15 (changed), AR-35 .. AR-41 (new). Given/When/Then from the signed stories; one test per rule a
story states. Business 'today' is 2026-11-20."""
from decimal import Decimal
import pytest
from conftest import (
APPROVER,
BILLER,
SALES,
SYSTEM,
api,
billing,
control,
hand_draft,
issue,
issued,
line,
must,
on_day,
raise_so,
tool,
)
from sqlalchemy.exc import IntegrityError
from brick_ar.application import use_cases as uc
D = Decimal
def request_mode():
must(control("deposit_mode", "request"))
def new_request(**kw):
body = {"bp_id": 7311, "currency": "GBP", "net_amount": "5000.00", "sales_order_id": "SO-9", "request_no": 1, **kw}
return must(api("POST", "/payment-requests", BILLER, body))
def settle(request_id, payment_id="P1", amount="6000.00", received_on="2026-11-19"):
return api("POST", f"/payment-requests/{request_id}/settle", SYSTEM,
{"payment_id": payment_id, "received_on": received_on, "amount": amount, "currency": "GBP"})
def deposit(world, net="5000.00", so="SO-9"):
billing()
d = must(raise_so(f"so:{so}:1", [line("Deposit 50%", "1", net)], kind="deposit"))
return issue(d["invoice_id"], world=world)
def final(world, so, amount, request_no=2):
return must(raise_so(f"so:{so}:{request_no}", [line("Project", "1", "10000.00")], kind="final",
deposit_deductions=[{"invoice_id": f"so:{so}:1", "amount": amount}]))
# ── AR-14: void and the tax point, as tax answers it (TAX-33) ─────────────────────────────────────
def test_ar_14_a_tax_point_in_a_filed_vat_return_does_not_issue(world):
billing()
d = hand_draft()
world.tax.filed.add("2026-11-20")
s, b = api("POST", f"/invoices/{d['invoice_id']}/issue", BILLER, {})
assert s == 409 and "tax_point_open" in b["detail"] and "filed VAT return" in b["detail"]
def test_ar_14_a_tax_that_does_not_answer_means_nobody_can_vouch_for_the_period(world):
billing()
d = hand_draft()
world.tax.tax_point_down = True
s, b = api("POST", f"/invoices/{d['invoice_id']}/issue", BILLER, {})
assert s == 409 and "tax_point_open" in b["detail"] and "did not say" in b["detail"]
def test_ar_14_void_follows_the_owners_policy_and_the_tax_answer(world):
inv = issued(world)
world.tax.filed.add(inv["tax_point_date"] or inv["issue_date"])
s, b = api("POST", f"/invoices/{inv['invoice_id']}/void", APPROVER, {"reason": "oops"})
assert s == 409 and "void_policy" in b["detail"] and "VAT return is filed" in b["detail"] and "credit it instead" in b["detail"]
world.tax.filed.clear()
world.tax.tax_point_down = True
s, b = api("POST", f"/invoices/{inv['invoice_id']}/void", APPROVER, {"reason": "oops"})
assert s == 409 and "void_policy" in b["detail"]
world.tax.tax_point_down = False
assert must(api("POST", f"/invoices/{inv['invoice_id']}/void", APPROVER, {"reason": "raised in error"}))["lifecycle"] == "void"
# ── AR-15: a deposit is deducted whole or in part, pinned, and never beyond what remains ──────────
def test_ar_15_a_part_of_a_deposit_is_deducted_pinned_to_the_deposits_code_and_rate(world):
dep = deposit(world)
fin = final(world, "SO-9", "3000.00")
ded = [ln for ln in fin["lines"] if ln["deducts_invoice_id"]]
assert [(ln["net_amount"], ln["tax_code"], ln["pinned_from"]) for ln in ded] == [("-3000.00", "S", dep["invoice_id"])]
assert fin["subtotal"] == "7000.00" and fin["tax_total"] == "1400.00"
pinned = [x for c in world.tax.calls for x in c["lines"] if x.get("pinned_from")]
assert pinned and pinned[-1]["tax_code"] == "S" and pinned[-1]["pinned_from"] == dep["invoice_id"]
issued_fin = issue(fin["invoice_id"], world=world)
assert issued_fin["lifecycle"] == "issued"
def test_ar_15_two_finals_cannot_both_take_the_same_remainder(world):
"""SC-AR-09: 3,000.00 then 2,500.00 of a 5,000.00 deposit: the second is refused deposit_remaining, 2,000.00 remains."""
deposit(world)
first, second = final(world, "SO-9", "3000.00", 2), final(world, "SO-9", "2500.00", 3)
issue(first["invoice_id"], world=world)
s, b = api("POST", f"/invoices/{second['invoice_id']}/issue", BILLER, {})
assert s == 409 and "deposit_remaining" in b["detail"] and "2000.00 remains" in b["detail"]
r = must(api("GET", f"/invoices/{second['invoice_id']}/readiness", BILLER))
assert [g["gate"] for g in r["gates"] if not g["met"]] == ["deposit_remaining"]
third = final(world, "SO-9", "2000.00", 4)
assert issue(third["invoice_id"], world=world)["lifecycle"] == "issued"
def test_ar_15_a_claim_row_per_deduction_makes_a_race_for_one_remainder_collide(world):
"""Two finals that both saw no claim insert the same slot (deposit, 1): the second fails 23505 and is re-run, never swallowed."""
dep = deposit(world)
fin = final(world, "SO-9", "3000.00")
issue(fin["invoice_id"], world=world)
with uc.repository() as repo:
with pytest.raises(IntegrityError):
repo.deposits.claim(dep["invoice_id"], "so:SO-9:3", D("2500.00"), uc.work.now(), count=0)
def test_ar_15_what_remains_of_a_deposit_is_derived_and_a_credit_against_it_lowers_it(world):
dep = deposit(world)
issue(final(world, "SO-9", "1000.00")["invoice_id"], world=world)
from conftest import approved_credit
approved_credit(world, dep["invoice_id"], lines=[line("Part refund", "1", "500.00")])
with uc.repository() as repo:
assert uc.sv.deposit_remaining(repo, repo.invoices.get(dep["invoice_id"])[0]) == D("3500.00")
# ── AR-35: a payment request is not a VAT invoice ─────────────────────────────────────────────────
def test_ar_35_a_deposit_request_has_its_own_number_and_posts_nothing(world):
request_mode()
billing()
r = new_request()
assert r["request_id"] == "dpr:SO-9:1" and (r["net_amount"], r["tax_amount_display"], r["total"]) == ("5000.00", "1000.00", "6000.00")
assert new_request()["request_id"] == r["request_id"] and len(must(api("GET", "/payment-requests", BILLER))["requests"]) == 1
r = must(api("POST", f"/payment-requests/{r['request_id']}/issue", BILLER, {}))
assert r["request_no"] == "DPR-2026-0001" and r["title"] == "Payment request - this is not a VAT invoice" and r["lifecycle"] == "issued"
assert world.gl.journals == {} and must(api("GET", "/invoices", BILLER))["invoices"] == []
assert r["owed"] == "0" or D(str(r["owed"])) == 0
def test_ar_35_a_pro_forma_is_the_same_document_with_its_own_title(world):
billing()
r = must(api("POST", "/payment-requests", BILLER, {"bp_id": 7311, "currency": "GBP", "net_amount": "800.00", "kind": "pro_forma"}))
assert r["title"] == "Pro forma invoice - this is not a VAT invoice" and r["request_id"].startswith("dpr:")
s, b = api("POST", "/payment-requests", BILLER, {"bp_id": 7311, "currency": "GBP", "net_amount": "800.00"})
assert s == 409 and "deposit_mode" in b["detail"] # a down payment asks deposit_mode request
def test_ar_35_cancel_is_allowed_while_nothing_is_received(world):
request_mode()
billing()
a = new_request()
must(api("POST", f"/payment-requests/{a['request_id']}/issue", BILLER, {}))
assert must(api("POST", f"/payment-requests/{a['request_id']}/cancel", BILLER, {"reason": "order changed"}))["lifecycle"] == "cancelled"
b = new_request(request_no=2)
must(api("POST", f"/payment-requests/{b['request_id']}/issue", BILLER, {}))
must(settle(b["request_id"], amount="1000.00"))
s, body = api("POST", f"/payment-requests/{b['request_id']}/cancel", BILLER, {"reason": "oops"})
assert s == 409 and "was received" in body["detail"]
# ── AR-36: the receipt turns the request into a VAT invoice ───────────────────────────────────────
def test_ar_36_payment_settles_the_request_into_a_deposit_invoice_dated_by_the_receipt(world):
from conftest import account_map
account_map()
request_mode()
billing()
r = new_request()
must(api("POST", f"/payment-requests/{r['request_id']}/issue", BILLER, {}))
out = must(settle(r["request_id"]))
assert (out["deposit_invoice_id"], out["invoice_no"], out["amount"], out["remaining"], out["status"]) == \
("dep:dpr:SO-9:1:1", "INV-2026-0001", "6000.00", "0.00", "settled")
inv = must(api("GET", f"/invoices/{out['deposit_invoice_id']}", BILLER))
assert (inv["kind"], inv["lifecycle"], inv["subtotal"], inv["tax_total"], inv["total"]) == ("deposit", "issued", "5000.00", "1000.00", "6000.00")
assert inv["tax_point_date"] == "2026-11-19" and inv["issue_date"] == "2026-11-20" # a document-dated fact; issued today
assert out["deposit_invoice_id"] in world.tax.frozen and len(world.gl.journals) == 1 # frozen by tax, posted
assert must(api("GET", f"/payment-requests/{r['request_id']}", BILLER))["lifecycle"] == "settled"
def test_ar_36_the_same_payment_settles_once_and_a_part_payment_leaves_the_request_open(world):
request_mode()
billing()
r = new_request()
must(api("POST", f"/payment-requests/{r['request_id']}/issue", BILLER, {}))
first = must(settle(r["request_id"], "P1", "3600.00"))
assert (first["status"], first["remaining"]) == ("issued", "2400.00")
assert must(api("GET", f"/invoices/{first['deposit_invoice_id']}", BILLER))["subtotal"] == "3000.00"
core = ("request_id", "deposit_invoice_id", "invoice_no", "amount", "remaining", "status")
assert {k: must(settle(r["request_id"], "P1", "3600.00"))[k] for k in core} == {k: first[k] for k in core} and len(must(api("GET", "/invoices", BILLER))["invoices"]) == 1
last = must(settle(r["request_id"], "P2", "2400.00"))
assert last["deposit_invoice_id"].endswith(":2") and last["status"] == "settled"
nets = [must(api("GET", f"/invoices/{i}", BILLER))["subtotal"] for i in (first["deposit_invoice_id"], last["deposit_invoice_id"])]
assert sum(D(n) for n in nets) == D("5000.00") # the deposit invoices sum to what was received
def test_ar_36_more_than_what_remains_is_refused_within_request(world):
request_mode()
billing()
r = new_request()
must(api("POST", f"/payment-requests/{r['request_id']}/issue", BILLER, {}))
s, b = settle(r["request_id"], amount="6000.01")
assert s == 422 and "within_request" in b["detail"]
assert api("POST", f"/payment-requests/{r['request_id']}/settle", BILLER, {"payment_id": "P", "received_on": "2026-11-19",
"amount": "1.00", "currency": "GBP"})[0] == 403
# ── AR-37 / AR-38: 2/10 net 30 ────────────────────────────────────────────────────────────────────
def two_ten(world, lines=None):
must(api("POST", "/terms", APPROVER, {"terms_id": "2/10", "days": 30, "early_discount_pct": "2", "early_discount_days": 10}))
with on_day("2026-11-02"):
billing(terms_id="2/10")
return issue(hand_draft(lines or [line("Work", "1", "1000.00")])["invoice_id"], world=world)
def test_ar_37_the_invoice_shows_the_discount_its_end_and_the_discounted_amount(world):
inv = two_ten(world)
assert (D(inv["settlement_discount_pct"]), inv["settlement_discount_until"], inv["settlement_discount_amount"]) == (D(2), "2026-11-12", "1176.00")
assert inv["total"] == "1200.00" and inv["due_date"] == "2026-12-02" and "settlement_discount_statement" not in inv
must(control("settlement_discount_vat", "invoice_wording"))
again = must(api("GET", f"/invoices/{inv['invoice_id']}", BILLER))
assert "No credit note will be issued" in again["settlement_discount_statement"] and "VAT actually paid" in again["settlement_discount_statement"]
def test_ar_37_the_terms_row_is_frozen_once_an_issued_invoice_used_it(world):
two_ten(world)
s, b = api("POST", "/terms", APPROVER, {"terms_id": "2/10", "days": 30, "early_discount_pct": "3", "early_discount_days": 10})
assert s == 409 and "were used" in b["detail"]
def discount(inv, amount="24.00", received_on="2026-11-10", payment_id="P1"):
return api("POST", f"/invoices/{inv['invoice_id']}/settlement-discount", SYSTEM,
{"payment_id": payment_id, "received_on": received_on, "discount_amount": amount})
def test_ar_38_the_discount_is_a_credit_note_approved_by_the_terms_and_allocated(world):
inv = two_ten(world)
with on_day("2026-11-10"):
out = must(discount(inv))
again = must(discount(inv))
assert (out["net"], out["tax"], out["total"], out["approved_by"], out["credit_no"]) == ("20.00", "4.00", "24.00", "terms", "CN-2026-0001")
assert again["credit_id"] == out["credit_id"] and len(world.payments.allocations) == 1 # the same payment answers the same credit note
assert world.payments.allocations[0][:3] == ("credit_note", out["credit_id"], inv["invoice_id"])
cn = must(api("GET", f"/credit-notes/{out['credit_id']}", BILLER))
assert cn["source_kind"] == "settlement_discount" and cn["payment_id"] == "P1" and cn["credit_date"] == "2026-11-10"
assert [(ln["tax_code"], ln["pinned_from"]) for ln in cn["lines"]] == [("S", inv["invoice_id"])]
def test_ar_38_a_mixed_invoice_is_credited_pro_rata_per_code_at_its_frozen_rates(world):
inv = two_ten(world, [line("Work", "1", "1000.00"), line("Books", "1", "500.00", tax_category="zero")])
assert inv["total"] == "1700.00"
with on_day("2026-11-10"):
out = must(discount(inv, "34.00"))
assert (out["net"], out["tax"], out["total"]) == ("30.00", "4.00", "34.00")
cn = must(api("GET", f"/credit-notes/{out['credit_id']}", BILLER))
assert {ln["tax_code"]: ln["net_amount"] for ln in cn["lines"]} == {"S": "20.00", "Z": "10.00"}
def test_ar_38_outside_the_window_or_off_the_terms_percentage_it_is_refused(world):
inv = two_ten(world)
with on_day("2026-11-13"):
s, b = discount(inv, received_on="2026-11-13")
assert s == 422 and "within_discount_window" in b["detail"]
with on_day("2026-11-10"):
s, b = discount(inv, "24.01")
assert s == 422 and "discount_matches_terms" in b["detail"]
assert discount(inv, "24.00", received_on="2026-11-12")[0] == 200 # the last day still counts
assert len(world.payments.allocations) == 1
def test_ar_38_under_invoice_wording_no_credit_note_is_raised(world):
inv = two_ten(world)
must(control("settlement_discount_vat", "invoice_wording"))
s, b = discount(inv)
assert s == 409 and "no credit note is raised" in b["detail"] and "settlement_discount" in b["detail"]
assert must(api("GET", "/credit-notes", BILLER))["credit_notes"] == []
# ── AR-39: statutory interest ─────────────────────────────────────────────────────────────────────
def late_invoice(world, treatment="uk_business"):
must(api("POST", "/interest-rates", APPROVER, {"valid_from": "2026-06-30", "reference_rate_pct": "4.25"}))
world.tax.treatment[7311] = treatment
with on_day("2026-05-31"):
billing()
inv = issue(hand_draft([line("Programme", "1", "12000.00", tax_category="zero")])["invoice_id"], world=world)
assert inv["due_date"] == "2026-06-30" and inv["total"] == "12000.00"
world.payments.receive(inv["invoice_id"], "12000.00", ref="R1", received_on="2026-09-28")
return inv
def test_ar_39_interest_is_simple_daily_at_the_half_years_statutory_rate_plus_the_fixed_sum(world):
inv = late_invoice(world)
case = must(api("GET", f"/invoices/{inv['invoice_id']}/interest-suggestion", BILLER))
assert (case["suggested"], case["days"], D(str(case["rate_pct"])), case["interest"], case["compensation"]) == (True, 90, D("12.25"), "362.47", "100.00")
draft = must(api("POST", f"/invoices/{inv['invoice_id']}/interest-draft", BILLER, {}))
assert draft["kind"] == "interest" and draft["lifecycle"] == "draft" and draft["total"] == "462.47"
assert [(ln["tax_code"], ln["net_amount"]) for ln in draft["lines"]] == [("OS", "362.47"), ("OS", "100.00")]
first = draft["lines"][0]
assert first["interest_on_invoice_id"] == inv["invoice_id"] and first["interest_days"] == 90 and "90 days" in first["description"]
assert must(api("POST", f"/invoices/{inv['invoice_id']}/interest-draft", BILLER, {}))["invoice_id"] == draft["invoice_id"] # once
assert issue(draft["invoice_id"], world=world)["lifecycle"] == "issued" # reviewed and issued like any other
def test_ar_39_a_consumer_is_never_charged_and_mode_off_suggests_nothing(world):
inv = late_invoice(world, treatment="uk_consumer")
case = must(api("GET", f"/invoices/{inv['invoice_id']}/interest-suggestion", BILLER))
assert case["suggested"] is False and "business_customer" in [g["gate"] for g in case["gates"] if not g["met"]]
s, b = api("POST", f"/invoices/{inv['invoice_id']}/interest-draft", BILLER, {})
assert s == 409 and "business_customer" in b["detail"]
world.tax.treatment[7311] = "uk_business"
must(control("late_interest_mode", "off"))
assert "interest_mode_on" in [g["gate"] for g in must(api("GET", f"/invoices/{inv['invoice_id']}/interest-suggestion", BILLER))["gates"] if not g["met"]]
def test_ar_39_the_rate_is_the_row_in_force_on_the_due_dates_half_year():
from datetime import date
from brick_ar.domain import finance
assert finance.reference_date(date(2026, 7, 1)) == date(2026, 6, 30) and finance.reference_date(date(2027, 1, 5)) == date(2026, 12, 31)
assert finance.interest(D("12000"), D("12.25"), 90) == D("362.47")
assert [finance.compensation(D(x)) for x in ("999.99", "1000", "9999.99", "10000")] == [D("40.00"), D("70.00"), D("70.00"), D("100.00")]
assert finance.interest(D("12000"), D("12.25"), 0) == 0
def test_ar_39_only_the_approver_sets_a_reference_rate_and_only_on_a_half_year_start(world):
assert api("POST", "/interest-rates", SALES, {"valid_from": "2026-06-30", "reference_rate_pct": "4.25"})[0] == 403
s, b = api("POST", "/interest-rates", APPROVER, {"valid_from": "2026-07-01", "reference_rate_pct": "4.25"})
assert s == 409 and "30 June or 31 December" in b["detail"]
# ── AR-40 / AR-41: the intercompany invoice ───────────────────────────────────────────────────────
def recharge(world, company="UK01", partner="UK02"):
must(control("default_company_code", company))
billing(intercompany_company_code=partner)
return issue(hand_draft([line("Management recharge", "1", "8000.00")])["invoice_id"], world=world)
def test_ar_40_the_invoice_carries_both_companies_and_posts_to_the_intercompany_purposes(world):
inv = recharge(world)
assert (inv["company_code"], inv["trading_partner"], inv["invoice_no"]) == ("UK01", "UK02", "INV-2026-0001")
res = must(api("POST", f"/documents/{inv['invoice_id']}/post", SYSTEM, {}))["results"][0]
lines = world.gl.journals[res["journal_id"]]["lines"]
assert world.gl.journals[res["journal_id"]]["company_code"] == "UK01"
assert [(x["purpose"], x["partner_company"]) for x in lines] == [("ic_receivable", "UK02"), ("ic_revenue", "UK02"), ("tax", "UK02")]
assert lines[0]["debit"] == "9600.00" and lines[1]["credit"] == "8000.00" and lines[2]["credit"] == "1600.00"
def test_ar_40_each_company_numbers_from_its_own_series(world):
first = recharge(world)
billing(7312)
with_uk02 = must(api("POST", "/invoices", BILLER, {"bp_id": 7312, "currency": "GBP", "company_code": "UK02", "lines": [line()]}))
assert issue(with_uk02["invoice_id"], world=world)["invoice_no"] == first["invoice_no"] == "INV-2026-0001"
second = issue(hand_draft([line("Again", "1", "10.00")])["invoice_id"], world=world)
assert second["invoice_no"] == "INV-2026-0002"
def test_ar_40_the_open_items_are_the_receivable_side_of_the_intercompany_match(world):
inv = recharge(world)
out = must(api("GET", "/intercompany/open", BILLER, {"company_code": "UK01", "trading_partner": "UK02"}))
assert out["documents"] == [{"invoice_id": inv["invoice_id"], "number": "INV-2026-0001", "currency": "GBP", "total": "9600.00",
"outstanding": "9600.00"}]
assert must(api("GET", "/intercompany/open", BILLER, {"company_code": "UK01", "trading_partner": "UK09"}))["documents"] == []
def test_ar_41_the_partner_company_gets_one_bill_with_the_same_totals(world):
inv = recharge(world)
assert inv["handed_to_partner"] is True or list(world.pur.bills) == [f"ic:{inv['invoice_id']}"]
for _ in range(2):
out = must(api("POST", f"/invoices/{inv['invoice_id']}/hand-over", SYSTEM, {}))
assert out["handed"] is True and out["bill_id"] == f"ic:{inv['invoice_id']}"
bill = world.pur.bills[f"ic:{inv['invoice_id']}"]
assert list(world.pur.bills) == [f"ic:{inv['invoice_id']}"]
assert (bill["company_code"], bill["supplier_company_code"], bill["supplier_invoice_no"]) == ("UK02", "UK01", "INV-2026-0001")
assert bill["totals"] == {"net": "8000.00", "vat": "1600.00", "gross": "9600.00"}
assert [(x["net_amount"], x["tax_code"], x["tax_amount"]) for x in bill["lines"]] == [("8000.00", "S", "1600.00")]
def test_ar_41_a_partner_company_elsewhere_is_sent_like_any_other_invoice(world):
inv = recharge(world, partner="UK09")
assert world.pur.bills == {}
out = must(api("POST", f"/invoices/{inv['invoice_id']}/hand-over", SYSTEM, {}))
assert out["handed"] is False and "not in this installation" in out["reason"]
def test_ar_41_hand_over_and_settlement_are_machine_only_and_agents_see_the_tools():
assert api("POST", "/invoices/x/hand-over", BILLER, {})[0] == 403
assert api("POST", "/invoices/x/settlement-discount", APPROVER, {"payment_id": "p", "received_on": "2026-11-20", "discount_amount": "1.00"})[0] == 403
status, _ = tool(SYSTEM, "settle_payment_request", {"request_id": "nope", "payment_id": "p", "received_on": "2026-11-20", "amount": "1.00",
"currency": "GBP"})
assert status == "refused"
def test_ar_37_invoice_totals_returns_the_frozen_rate_and_the_discount_window_payments_reads(world):
inv = two_ten(world)
got = must(api("GET", "/invoice-totals", BILLER, {"invoice_ids": inv["invoice_id"]}))["invoices"][inv["invoice_id"]]
assert (D(got["settlement_discount_pct"]), str(got["settlement_discount_until"])) == (D(2), "2026-11-12")
assert "fx_rate" in got