dodil/erp-brick-ar
DODIL ERP brick: accounts receivable
tests/test_stories.py
"""One test per story of module.yaml (version 3), named for it: the Given / When / Then of each, through the API door.
The Odoo stories (AR-33, AR-34) are in test_migration.py; the scenarios SC-AR-01..08 in test_scenarios.py."""
import json
from decimal import Decimal
from conftest import (
APPROVER,
BILLER,
BP,
COLLECTOR,
OWNER,
SALES,
SYSTEM,
account_map,
api,
approved_credit,
billing,
control,
hand_draft,
issue,
issued,
line,
must,
on_day,
raise_so,
)
def settle_view(inv_id):
return must(api("GET", f"/invoices/{inv_id}", COLLECTOR))["settlement"]
# ── person stories ───────────────────────────────────────────────────────────────────────────
def test_ar_01_a_draft_from_what_was_sold(world):
"""Given a confirmed order with invoiceable lines; When the order asks (SO-21); Then one draft keyed so:<order>:<n>, each line
carrying the order line it bills and its unit_code, tax computed by tax; asking again returns the same invoice."""
billing()
d = must(raise_so())
assert d["invoice_id"] == "so:SO-1:1" and d["lifecycle"] == "draft" and d["source_kind"] == "sales_order"
ln = d["lines"][0]
assert (ln["sales_order_id"], ln["so_line_no"], ln["unit_code"]) == ("SO-1", 1, "MON")
assert d["tax_total"] == "40.00" and d["total"] == "240.00" and ln["tax_code"] == "S" and d["tax_pending"] is False
assert world.tax.calls[-1]["tax_point"] == "2026-11-20"
again = must(raise_so())
assert again["version"] == d["version"] and len(must(api("GET", "/invoices", BILLER))["invoices"]) == 1
status, body = raise_so(lines=[line(sales_order_id="SO-1", so_line_no=9)]) # the order has no line 9
assert status == 200 # a repeat returns the existing invoice, whatever it carries
status, body = raise_so(source_id="so:SO-1:2", lines=[line(sales_order_id="SO-1", so_line_no=9)])
assert status == 409 and "no line 9" in body["detail"]
def test_ar_02_by_hand_totals_are_computed(world):
"""Given a customer and lines with a tax category; When saved; Then net, per-rate tax summary and total are computed."""
d = hand_draft([line(qty="3", price="33.33"), line("Setup", "1", "50.00", tax_category="zero"), line("Training", "1", "10.10", tax_category="reduced")])
assert d["lifecycle"] == "draft" and d["subtotal"] == "160.09"
assert {t["tax_code"]: t["tax_amount"] for t in d["tax"]} == {"S": "20.00", "Z": "0.00", "R": "0.51"}
assert Decimal(d["subtotal"]) + Decimal(d["tax_total"]) == Decimal(d["total"]) == Decimal("180.60")
assert sum(Decimal(t["tax_amount"]) for t in d["tax"]) == Decimal(d["tax_total"])
status, body = api("POST", "/invoices", BILLER, {"bp_id": BP, "currency": "GBP", "lines": [{"description": "x", "unit_price": "1.00"}]})
assert status == 409 and "needs a quantity" in body["detail"]
def test_ar_03_issue_fixes_number_dates_contents_and_tax(world):
"""Given a draft, a profile; When issued; Then the next number, issue date today, tax point, due date from terms, frozen tax and
bill-to copied; every later edit refused with 'credit it instead'."""
billing()
d = hand_draft()
out = issue(d["invoice_id"], world=world)
assert (out["invoice_no"], out["issue_date"], out["tax_point_date"], out["due_date"]) == ("INV-2026-0001", "2026-11-20", "2026-11-20", "2026-12-20")
assert json.loads(out["bill_to_json"])["bill_to_address"] == "1 High St, London" and out["terms_id"] == "NET30"
from fakes import RATES
RATES["S"] = Decimal("25")
try:
again = must(api("GET", f"/invoices/{d['invoice_id']}", BILLER))
assert again["tax_total"] == "40.00" and again["tax"][0]["rate_pct"] == "20.0000"
finally:
RATES["S"] = Decimal("20")
s, b = api("POST", f"/invoices/{d['invoice_id']}/edit", BILLER, {"customer_po_ref": "PO-1"})
assert s == 409 and "credit it instead" in b["detail"]
def test_ar_03_a_failed_gate_names_each_gate(world):
"""When a gate fails, issue is refused and the screen shows each gate as {gate, met, reason, fix}, tax's content check among them."""
d = hand_draft() # the customer has no billing profile
s, b = api("POST", f"/invoices/{d['invoice_id']}/issue", BILLER, {})
assert s == 409 and "billing_profile" in b["detail"] and "Customer billing" in b["detail"]
r = must(api("GET", f"/invoices/{d['invoice_id']}/readiness", BILLER))
unmet = {g["gate"]: g for g in r["gates"] if not g["met"]}
assert not r["ready"] and {"billing_profile", "customer_address"} <= set(unmet)
assert unmet["customer_address"]["fix"] == "the customer's address on the business partner"
assert set(unmet["billing_profile"]) == {"gate", "met", "reason", "fix"}
billing()
assert must(api("GET", f"/invoices/{d['invoice_id']}/readiness", BILLER))["ready"] is True
def test_ar_04_a_credit_note_corrects_an_issued_invoice(world):
"""Given an issued invoice; When credited and approved; Then its own number, date today, tax from the tax module, allocated against
the invoice through payments; the original stays readable and unchanged."""
inv = issued(world)
before = must(api("GET", f"/invoices/{inv['invoice_id']}", BILLER))
cn = approved_credit(world, inv["invoice_id"], full=True)
assert cn["credit_no"] == "CN-2026-0001" and cn["credit_date"] == "2026-11-20" and cn["status"] == "approved" and cn["total"] == "240.00"
assert cn["tax"][0]["tax_code"] == "S" and world.tax.calls[-1]["tax_point"] == (inv["tax_point_date"] or inv["issue_date"])
assert world.payments.allocations[-1][:3] == ("credit_note", cn["credit_id"], inv["invoice_id"])
after = must(api("GET", f"/invoices/{inv['invoice_id']}", BILLER))
assert after["settlement"]["outstanding"] == "0.00" and after["settlement"]["state"] == "paid"
assert {k: v for k, v in after.items() if k != "settlement"} == {k: v for k, v in before.items() if k != "settlement"}
def test_ar_04_a_credit_cannot_exceed_what_remains_creditable(world):
inv = issued(world)
approved_credit(world, inv["invoice_id"], lines=[line("Part", "1", "100.00")]) # 120.00 of 240.00
d = must(api("POST", "/credit-notes", BILLER, {"invoice_id": inv["invoice_id"], "reason": "again", "full": True}))
s, b = api("POST", f"/credit-notes/{d['credit_id']}/approve", APPROVER, {})
assert s == 409 and "within_invoice" in b["detail"] and "creditable" in b["detail"]
def test_ar_09_a_small_remainder_is_written_off(world):
"""Given 12.40 outstanding below the 25.00 threshold; When proposed with a reason and approved; Then allocated through payments
like a receipt, the balance clears, and the loss posts as bad debt."""
must(control("writeoff_threshold", 25))
inv = issued(world, lines=[line("Small", "1", "10.33")])
assert inv["total"] == "12.40"
w = must(api("POST", "/writeoffs", BILLER, {"invoice_id": inv["invoice_id"], "amount": "12.40", "reason": "customer closed"}))
out = must(api("POST", f"/writeoffs/{w['writeoff_id']}/approve", APPROVER, {}))
assert out["status"] == "approved" and out["writeoff_date"] == "2026-11-20"
assert world.payments.allocations[-1][:3] == ("writeoff", w["writeoff_id"], inv["invoice_id"])
assert settle_view(inv["invoice_id"])["outstanding"] == "0.00"
account_map()
res = must(api("POST", f"/documents/{w['writeoff_id']}/post", SYSTEM, {}))["results"][0]
j = world.gl.journals[res["journal_id"]]
assert {(x["account_id"], x.get("debit") or x.get("credit")) for x in j["lines"]} == {("6500", "12.40"), ("1100", "12.40")}
assert j["lines"][0].get("debit") == "12.40" # bad debt is debited
def test_ar_09_above_the_threshold_asks_for_a_confirmation(world):
must(control("writeoff_threshold", 25))
inv = issued(world) # 240.00 outstanding
w = must(api("POST", "/writeoffs", BILLER, {"invoice_id": inv["invoice_id"], "amount": "100.00", "reason": "disputed"}))
s, b = api("POST", f"/writeoffs/{w['writeoff_id']}/approve", APPROVER, {})
assert s == 409 and "threshold" in b["detail"] and "confirm" in b["detail"]
assert must(api("POST", f"/writeoffs/{w['writeoff_id']}/approve", APPROVER, {"confirm_above_threshold": True}))["status"] == "approved"
w2 = must(api("POST", "/writeoffs", BILLER, {"invoice_id": inv["invoice_id"], "amount": "500.00", "reason": "too much"}))
s, b = api("POST", f"/writeoffs/{w2['writeoff_id']}/approve", APPROVER, {"confirm_above_threshold": True})
assert s == 409 and "outstanding" in b["detail"]
w3 = must(api("POST", "/writeoffs", BILLER, {"invoice_id": inv["invoice_id"], "amount": "1.00", "reason": "no"}))
assert must(api("POST", f"/writeoffs/{w3['writeoff_id']}/reject", APPROVER, {"reason": "keep chasing"}))["status"] == "rejected"
def test_ar_12_sales_sees_whether_a_customer_is_overdue(world):
"""Given an invoice 40 days past due; When sales opens the customer; Then the overdue amount, oldest age and credit hold, never lines."""
with on_day("2026-09-11"):
inv = issued(world) # due 2026-10-11
world.payments.hold = True
out = must(api("GET", f"/customers/{BP}/status", SALES))
assert out["overdue_amount"] == "240.00" and out["oldest_days_overdue"] == 40 and out["on_credit_hold"] is True
assert "lines" not in json.dumps(out) and set(out) == {"bp_id", "overdue_amount", "overdue_by_currency", "oldest_days_overdue", "on_credit_hold", "hold_reason", "known", "note"}
assert api("GET", f"/invoices/{inv['invoice_id']}", SALES)[0] == 403
assert api("GET", "/invoices", SALES)[0] == 403
def test_ar_13_send_is_recorded_and_resending_is_a_second_send_not_a_second_invoice(world):
inv = issued(world)
out = must(api("POST", f"/invoices/{inv['invoice_id']}/send", BILLER, {}))
assert out["send"]["address"] == "ap@acme.example" and out["send"]["manual"] is True and len(out["sends"]) == 1
out = must(api("POST", f"/invoices/{inv['invoice_id']}/send", BILLER, {"address": "boss@acme.example"}))
assert len(out["sends"]) == 2 and len(must(api("GET", "/invoices", BILLER))["invoices"]) == 1
d = hand_draft()
assert api("POST", f"/invoices/{d['invoice_id']}/send", BILLER, {})[0] == 409
def test_ar_14_cancel_a_draft_leaves_no_number_and_void_is_policy_bound(world):
billing()
d = hand_draft()
must(api("POST", f"/invoices/{d['invoice_id']}/cancel", BILLER, {}))
inv = issue(hand_draft()["invoice_id"], world=world)
assert inv["invoice_no"] == "INV-2026-0001" # the cancelled draft took no number
v = must(api("POST", f"/invoices/{inv['invoice_id']}/void", APPROVER, {"reason": "raised in error"}))
assert v["lifecycle"] == "void" and v["invoice_no"] == "INV-2026-0001" and v["voided_on"] == "2026-11-20"
def test_ar_14_a_sent_invoice_is_credited_not_voided(world):
inv = issued(world)
must(api("POST", f"/invoices/{inv['invoice_id']}/send", BILLER, {}))
s, b = api("POST", f"/invoices/{inv['invoice_id']}/void", APPROVER, {"reason": "oops"})
assert s == 409 and "void_policy" in b["detail"] and "credit it instead" in b["detail"]
r = must(api("GET", f"/invoices/{inv['invoice_id']}/readiness", BILLER))
assert [g["gate"] for g in r["gates"] if not g["met"]] == ["void_policy"]
def test_ar_14_an_invoice_with_allocations_is_credited_not_voided(world):
inv = issued(world)
world.payments.receive(inv["invoice_id"], "100.00")
s, b = api("POST", f"/invoices/{inv['invoice_id']}/void", APPROVER, {"reason": "oops"})
assert s == 409 and "no_allocations" in b["detail"]
def test_ar_14_void_reverses_the_posting_on_todays_date(world):
account_map()
inv = issued(world)
must(api("POST", f"/documents/{inv['invoice_id']}/post", SYSTEM, {}))
with on_day("2026-11-25"):
must(api("POST", f"/invoices/{inv['invoice_id']}/void", APPROVER, {"reason": "raised in error"}))
res = must(api("POST", f"/documents/{inv['invoice_id']}/post", SYSTEM, {}))["results"]
assert [r["event"] for r in res] == ["issue", "void"] and res[0]["already"] is True
rev = world.gl.journals[res[1]["journal_id"]]
assert rev["journal_date"] == "2026-11-25" and rev["lines"][0] == {"account_id": "1100", "credit": "240.00", "memo": rev["memo"]}
def test_ar_15_a_deposit_is_deducted_from_the_final_invoice(world):
billing()
dep = must(raise_so("so:SO-9:1", [line("Deposit 50%", "1", "5000.00")], kind="deposit"))
dep = issue(dep["invoice_id"], world=world)
assert dep["kind"] == "deposit" and dep["tax_total"] == "1000.00"
fin = must(raise_so("so:SO-9:2", [line("Project", "1", "10000.00")], kind="final", deposit_invoice_ids=[dep["invoice_id"]]))
assert fin["subtotal"] == "5000.00" and fin["tax_total"] == "1000.00" and fin["total"] == "6000.00"
assert [ln["deducts_invoice_id"] for ln in fin["lines"]] == ["", dep["invoice_id"]] and fin["lines"][1]["net_amount"] == "-5000.00"
s, b = raise_so("so:SO-9:3", [line("x")], kind="deposit", deposit_invoice_ids=[dep["invoice_id"]])
assert s == 409 and "deposit invoice does not deduct" in b["detail"]
def test_ar_17_the_list_filters_pages_and_shows_outstanding(world):
a = issued(world)
billing(8000)
b = issue(hand_draft(bp=8000, lines=[line(price="10.00")])["invoice_id"], world=world)
hand_draft(bp=8000) # a draft
world.payments.receive(a["invoice_id"], "40.00")
page = must(api("GET", "/invoices", BILLER, {"lifecycle": "issued", "limit": 1, "offset": 0}))
assert page["total"] == 2 and len(page["invoices"]) == 1
rows = must(api("GET", "/invoices", BILLER, {"lifecycle": "issued"}))["invoices"]
assert {r["invoice_no"]: r["outstanding"] for r in rows} == {"INV-2026-0001": "200.00", "INV-2026-0002": "24.00"}
assert [r["invoice_id"] for r in must(api("GET", "/invoices", BILLER, {"bp_id": 8000, "lifecycle": "issued"}))["invoices"]] == [b["invoice_id"]]
assert len(must(api("GET", "/invoices", BILLER, {"source_kind": "manual"}))["invoices"]) == 3
assert must(api("GET", "/invoices", BILLER, {"currency": "EUR"}))["total"] == 0
assert must(api("GET", "/invoices", BILLER, {"issued_from": "2026-11-21"}))["total"] == 0
assert all(r["lifecycle"] != "draft" for r in must(api("GET", "/invoices", COLLECTOR))["invoices"]) # drafts are the biller's
def test_ar_19_a_metered_period_arrives_with_usage_left_for_the_biller(world):
with on_day("2026-10-31"):
billing()
must(api("POST", "/schedules", SYSTEM, {"source_kind": "contract", "source_id": "ct:C-1", "line_id": "L1", "bp_id": BP,
"starts_on": "2026-10-01", "periods": 12, "unit_price": "2.50", "currency": "GBP",
"billing_kind": "usage", "billing_plan": "monthly", "in_advance": False,
"description": "API calls", "unit_code": "C62", "tax_category": "standard"}))
out = must(api("POST", "/schedules/raise-due", SYSTEM, {}))
iid = out["raised"][0]
d = must(api("GET", f"/invoices/{iid}", BILLER))
assert d["lines"][0]["usage"] is True and d["lines"][0]["quantity"] is None and d["total"] == "0.00"
s, b = api("POST", f"/invoices/{iid}/issue", BILLER, {})
assert s == 409 and "usage_filled" in b["detail"]
d = must(api("POST", f"/invoices/{iid}/fill-usage", BILLER, {"line_no": 1, "quantity": "1000"}))
assert d["subtotal"] == "2500.00" and d["total"] == "3000.00"
assert issue(iid, world=world)["invoice_no"] == "INV-2026-0001"
with on_day("2026-11-30"): # the next month: a zero month is cancelled, no number
iid2 = must(api("POST", "/schedules/raise-due", SYSTEM, {}))["raised"][0]
must(api("POST", f"/invoices/{iid2}/cancel", BILLER, {}))
assert issue(hand_draft()["invoice_id"], world=world)["invoice_no"] == "INV-2026-0002"
def test_ar_23_system_drafts_wait_for_review_unless_the_setting_names_the_source(world):
billing()
d = must(raise_so())
assert d["lifecycle"] == "draft" # waits
fnd = must(api("POST", f"/invoices/{d['invoice_id']}/ai-check", SYSTEM, {}))
assert fnd["mode"] == "suggest"
r = must(api("POST", f"/invoices/{d['invoice_id']}/review", BILLER, {}))
assert r["reviewed_by"] == "biller@shop.example"
s, b = control("auto_issue_sources", "sales_order", "2026-11-20") # a control dated today is refused (a first row may start today)
assert s == 200 # ...but this IS the first row of the key
s, b = control("auto_issue_sources", "", "2026-11-20")
assert s == 409 and "tomorrow at the earliest" in b["detail"]
d2 = must(raise_so("so:SO-2:1"))
assert d2["lifecycle"] == "issued" and d2["invoice_no"].startswith("INV-2026-") # the setting names this source
d3 = must(api("POST", "/invoices/raise-for-acceptance", SYSTEM, {"item_id": "I-1", "bp_id": BP, "currency": "GBP", "lines": [line()]}))
assert d3["lifecycle"] == "draft" # acceptance is not named: it waits
def test_ar_24_the_monthly_export_has_every_document_once(world):
world.remove("gl") # no ledger installed: the reference exports monthly
with on_day("2026-10-10"):
billing()
a = issued()
b = issue(hand_draft()["invoice_id"])
approved_credit(world, a["invoice_id"], lines=[line("Credit", "1", "50.00")])
out = must(api("POST", "/exports", BILLER, {"period": "2026-10"}))
assert out["batch_id"] == "2026-10:csv" and out["exported"] == 3
csv_ = must(api("GET", "/exports/2026-10:csv", BILLER))["content"].splitlines()
assert csv_[0].startswith("kind,number,date") and len(csv_) == 4 and {r.split(",")[0] for r in csv_[1:]} == {"invoice", "credit_note"}
again = must(api("POST", "/exports", BILLER, {"period": "2026-10"}))
assert again["exported"] == 0 and again["document_count"] == 3
assert must(api("GET", f"/invoices/{b['invoice_id']}", BILLER))["export_batch_id"] == "2026-10:csv"
world.install("gl")
s, body = api("POST", "/exports", BILLER, {"period": "2026-10"})
assert s == 409 and "ledger_mode" in body["detail"] # in post mode nothing is exported (it would book revenue twice)
def test_ar_25_a_billing_profile_is_a_dated_row(world):
"""A first profile may start today; a new one starts tomorrow; one dated today or earlier is refused; at issue the profile in force
is copied onto the invoice, so a later change never alters an issued invoice."""
billing()
s, b = api("POST", f"/customers/{BP}/billing", APPROVER, {"bill_to_name": "Acme", "bill_to_address": "2 New St", "invoice_email": "n@acme.example",
"terms_id": "NET30", "currency": "GBP", "valid_from": "2026-11-20"})
assert s == 409 and "tomorrow at the earliest" in b["detail"]
s, b = api("POST", f"/customers/{BP}/billing", APPROVER, {"bill_to_name": "Acme", "bill_to_address": "2 New St", "invoice_email": "n@acme.example",
"terms_id": "NET30", "currency": "GBP", "valid_from": "2026-11-10"})
assert s == 409
new = billing(bill_to_address="2 New St") # default: from tomorrow
assert new["valid_from"] == "2026-11-21"
old_inv = issue(hand_draft()["invoice_id"], world=world)
assert json.loads(old_inv["bill_to_json"])["bill_to_address"] == "1 High St, London"
with on_day("2026-11-21"):
new_inv = issue(hand_draft()["invoice_id"], world=world)
assert json.loads(new_inv["bill_to_json"])["bill_to_address"] == "2 New St"
assert json.loads(must(api("GET", f"/invoices/{old_inv['invoice_id']}", BILLER))["bill_to_json"])["bill_to_address"] == "1 High St, London"
got = must(api("GET", f"/customers/{BP}/billing", BILLER))
assert got["in_force"]["bill_to_address"] == "1 High St, London" and got["later"][0]["bill_to_address"] == "2 New St"
def test_ar_31_the_owner_holds_the_controls_and_they_are_dated(world):
"""auto_issue_sources empty and void_policy never_sent; the owner sets auto_issue from tomorrow; the approver's change is refused;
a control dated today or earlier is refused."""
s, b = control("auto_issue_sources", "schedule", "2026-11-21")
assert s == 200 and b["valid_from"] == "2026-11-21"
assert must(api("GET", "/settings", OWNER))["settings"]["auto_issue_sources"]["value"] == "" # today it is still empty
assert api("POST", "/controls", APPROVER, {"key": "void_policy", "value": "never"})[0] == 403
s, b = api("POST", "/settings", APPROVER, {"key": "void_policy", "value": "never"})
assert s == 403 and "ar:settings:controls" in b["detail"]
s, b = control("void_policy", "never", "2026-11-20")
assert s == 200 # the first row of its key may start today
s, b = control("void_policy", "never_sent", "2026-11-19")
assert s == 409
s, b = control("void_policy", "never_sent", "2026-11-20")
assert s == 409 and "tomorrow at the earliest" in b["detail"]
with on_day("2026-11-21"):
assert must(api("GET", "/settings", OWNER))["settings"]["auto_issue_sources"]["value"] == "schedule"
assert "void_policy" not in must(api("GET", "/settings", COLLECTOR))["settings"] # controls are for those who hold them
def test_ar_32_a_finding_is_accepted_or_dismissed_and_recorded(world):
"""Given a draft with the check on and a finding; When I dismiss it with a reason; Then it is recorded and the draft is unchanged."""
for i in range(1, 5):
with on_day(f"2026-0{i}-15"):
issued(world, lines=[line(price="100.00")])
d = hand_draft([line(qty="1", price="1000.00")], customer_po_ref="")
before = must(api("GET", f"/invoices/{d['invoice_id']}", BILLER))
out = must(api("POST", f"/invoices/{d['invoice_id']}/ai-check", SYSTEM, {}))
kinds = {f["finding"] for f in out["findings"]}
assert "an unusual amount" in kinds and all(f["decision"] == "open" for f in out["findings"])
assert "median" in next(f for f in out["findings"] if f["finding"] == "an unusual amount")["evidence"]
assert len(must(api("POST", f"/invoices/{d['invoice_id']}/ai-check", SYSTEM, {}))["findings"]) == len(out["findings"]) # not duplicated
f = out["findings"][0]
s, b = api("POST", f"/invoices/{d['invoice_id']}/findings/ai_check_draft/{f['run_no']}/decide", BILLER, {"decision": "dismissed"})
assert s == 409 and "reason" in b["detail"]
done = must(api("POST", f"/invoices/{d['invoice_id']}/findings/ai_check_draft/{f['run_no']}/decide", BILLER,
{"decision": "dismissed", "reason": "annual renewal"}))
rec = next(x for x in done["findings"] if x["run_no"] == f["run_no"])
assert (rec["decision"], rec["reason"], rec["decided_by"], rec["model"]) == ("dismissed", "annual renewal", "biller@shop.example", "baseline-rules/1")
assert rec["decided_at"] is not None
assert {k: v for k, v in done.items() if k not in ("findings", "version")} == {k: v for k, v in before.items() if k not in ("findings", "version")}
assert issue(d["invoice_id"], world=world)["lifecycle"] == "issued" # issue never waits on a finding
# ── system stories ───────────────────────────────────────────────────────────────────────────
def test_ar_10_outstanding_is_read_from_payments_never_stored(world):
"""An invoice of 1,000.00 with a 400.00 receipt and a 100.00 credit note: 500.00 outstanding, part paid."""
inv = issued(world, lines=[line("Work", "1", "833.33")])
assert inv["total"] == "1000.00"
world.payments.receive(inv["invoice_id"], "400.00")
approved_credit(world, inv["invoice_id"], lines=[line("Goodwill", "1", "83.33")])
s = settle_view(inv["invoice_id"])
assert (s["outstanding"], s["state"]) == ("500.00", "part_paid")
from brick_ar.infrastructure import tables
assert not {"paid", "outstanding", "settlement"} & {c.name for t in tables.md.tables.values() for c in t.columns}
world.remove("payments")
assert settle_view(inv["invoice_id"])["known"] is False
def test_ar_18_a_schedule_period_is_raised_once(world):
"""A schedule of 12 monthly periods from 2026-10-01, in advance; the daily pass on 2026-11-01, twice: one draft for period 2."""
with on_day("2026-10-01"):
billing()
sch = must(api("POST", "/schedules", SYSTEM, {"source_kind": "contract", "source_id": "ct:C-1", "line_id": "L1", "bp_id": BP,
"starts_on": "2026-10-01", "periods": 12, "unit_price": "120.00", "currency": "GBP",
"unit_code": "MON", "description": "Hosting", "tax_category": "standard"}))
assert sch["schedule_id"] == "ct:C-1|L1"
assert must(api("POST", "/schedules/raise-due", SYSTEM, {}))["raised"] == ["rec:ct:C-1|L1:1"]
with on_day("2026-11-01"):
first = must(api("POST", "/schedules/raise-due", SYSTEM, {}))
second = must(api("POST", "/schedules/raise-due", SYSTEM, {}))
assert first["raised"] == ["rec:ct:C-1|L1:2"] and second["raised"] == [] and second["already_raised"] == 0
d = must(api("GET", "/invoices/rec:ct:C-1|L1:2", BILLER))
assert d["source_kind"] == "schedule" and d["lines"][0]["service_from"] == "2026-11-01" and d["lines"][0]["service_to"] == "2026-11-30"
got = must(api("GET", "/schedules/ct:C-1|L1", BILLER))
assert got["periods_raised"] == [1, 2] and got["periods_remaining"] == 10 and got["next_billing_date"] == "2026-12-01"
def test_ar_26_a_schedule_finishes_when_every_period_is_raised(world):
with on_day("2026-10-01"):
billing()
must(api("POST", "/schedules", SYSTEM, {"source_kind": "contract", "source_id": "ct:C-2", "line_id": "L1", "bp_id": BP,
"starts_on": "2026-10-01", "periods": 2, "unit_price": "10.00", "currency": "GBP"}))
must(api("POST", "/schedules/raise-due", SYSTEM, {}))
assert must(api("POST", "/schedules/finish", SYSTEM, {}))["finished"] == []
with on_day("2026-11-01"):
must(api("POST", "/schedules/raise-due", SYSTEM, {}))
assert must(api("POST", "/schedules/finish", SYSTEM, {}))["finished"] == ["ct:C-2|L1"]
assert must(api("POST", "/schedules/raise-due", SYSTEM, {}))["raised"] == []
assert must(api("POST", "/schedules/finish", SYSTEM, {}))["finished"] == []
assert must(api("GET", "/schedules/ct:C-2|L1", BILLER))["status"] == "finished"
# ── integration stories ──────────────────────────────────────────────────────────────────────
def test_ar_20_one_use_case_for_every_source_and_billed_vs_sold_is_one_group_by(world):
billing()
a = must(raise_so("so:SO-1:1", [line(qty="2", sales_order_id="SO-1", so_line_no=1), line("Setup", "1", "50.00", sales_order_id="SO-1", so_line_no=2)]))
assert must(raise_so("so:SO-1:1"))["version"] == a["version"]
issue(a["invoice_id"], world=world)
inv2 = must(raise_so("so:SO-1:2", [line(qty="3", sales_order_id="SO-1", so_line_no=1)]))
issue(inv2["invoice_id"], world=world)
must(raise_so("so:SO-1:3", [line(qty="9", sales_order_id="SO-1", so_line_no=1)])) # a draft is not billed
billed = must(api("GET", "/sources/sales_order/SO-1/billed", SYSTEM))["lines"]
assert [(b["so_line_no"], b["quantity_billed"], b["net_billed"]) for b in billed] == [(1, "5.0000", "500.00"), (2, "1.0000", "50.00")]
approved_credit(world, a["invoice_id"], lines=[line(qty="1", sales_order_id="SO-1", so_line_no=1)])
billed = must(api("GET", "/sources/sales_order/SO-1/billed", SYSTEM))["lines"]
assert billed[0]["quantity_billed"] == "4.0000"
s, b = api("POST", "/invoices/raise", SYSTEM, {"source_kind": "sales_order", "source_id": "wrong:1", "bp_id": BP, "currency": "GBP", "lines": [line()]})
assert s == 422
ok = must(api("POST", "/invoices/raise", SYSTEM, {"source_kind": "contract", "source_id": "ct:C-7", "bp_id": BP, "currency": "GBP", "lines": [line()]}))
assert ok["invoice_id"] == "ct:C-7" and ok["source_kind"] == "contract"
def test_ar_21_posting_is_a_balanced_journal_that_changes_the_ledger_once(world):
"""An issued invoice of 1,000.00 plus 200.00 VAT posts receivable 1,200.00 / revenue 1,000.00 / VAT 200.00; re-posting changes the
ledger once; a closed period is refused and named."""
account_map()
inv = issued(world, lines=[line("Work", "1", "1000.00")])
res = must(api("POST", f"/documents/{inv['invoice_id']}/post", SYSTEM, {}))["results"]
j = world.gl.journals[res[0]["journal_id"]]
assert {(x["account_id"], "debit" in x, x.get("debit") or x.get("credit")) for x in j["lines"]} == {
("1100", True, "1200.00"), ("4000", False, "1000.00"), ("2200", False, "200.00")}
must(api("POST", f"/documents/{inv['invoice_id']}/post", SYSTEM, {}))
assert len(world.gl.journals) == 1
with on_day("2026-12-05"):
inv2 = issue(hand_draft()["invoice_id"], world=world)
world.gl.closed_before = "2026-12-31"
out = must(api("POST", f"/documents/{inv2['invoice_id']}/post", SYSTEM, {}))["results"][0]
assert out["posted"] is False and "2026-12 is closed" in out["reason"]
s = api("POST", f"/documents/{inv['invoice_id']}/post", COLLECTOR, {})
assert s[0] == 403
def test_ar_21_an_unmapped_account_is_named(world):
inv = issued(world) # no account map yet
out = must(api("POST", f"/documents/{inv['invoice_id']}/post", SYSTEM, {}))["results"][0]
assert out["posted"] is False and out["gate"]["gate"] == "account_map" and "receivable" in out["gate"]["reason"]
r = must(api("GET", f"/invoices/{inv['invoice_id']}/readiness", BILLER))["transitions"]["post_to_ledger"]
assert r["ready"] is False and r["gates"][0]["fix"] == "Settings: account map"
assert world.gl.journals == {}
def test_ar_22_the_customer_is_the_shared_record(world):
"""An invoice is raised for a bp_id the register knows; one it does not know is refused; no second customer row is created."""
billing()
assert must(raise_so())["bp_id"] == str(BP)
s, b = api("POST", "/invoices/raise", SYSTEM, {"source_kind": "sales_order", "source_id": "so:SO-5:1", "bp_id": 9_000_001, "currency": "GBP", "lines": [line()]})
assert s == 422 and "no business partner 9000001" in b["detail"]
s, b = api("POST", "/invoices", BILLER, {"bp_id": 9_000_001, "currency": "GBP", "lines": [line()]})
assert s == 422
from brick_ar.infrastructure import tables
assert "ar_customers" not in tables.md.tables and "bp_key" not in {c.name for t in tables.md.tables.values() for c in t.columns}
def test_ar_27_a_repeating_line_becomes_one_schedule_and_missed_periods_bill_now(world):
"""plan_billing twice: one schedule; a start in the past bills the missed periods in the next daily pass, each draft waiting."""
billing()
body = {"source_kind": "sales_order", "source_id": "SO-4", "line_id": "2", "bp_id": BP, "starts_on": "2026-09-01", "periods": 12,
"unit_price": "120.00", "currency": "GBP", "unit_code": "MON", "description": "Hosting", "tax_category": "standard"}
a = must(api("POST", "/schedules", SYSTEM, body))
assert must(api("POST", "/schedules", SYSTEM, body))["version"] == a["version"] and len(must(api("GET", "/schedules", BILLER))["schedules"]) == 1
out = must(api("POST", "/schedules/raise-due", SYSTEM, {}))
assert out["raised"] == ["rec:SO-4|2:1", "rec:SO-4|2:2", "rec:SO-4|2:3"] # Sep, Oct, Nov
drafts = [must(api("GET", f"/invoices/{i}", BILLER)) for i in out["raised"]]
assert all(d["lifecycle"] == "draft" for d in drafts) and drafts[0]["lines"][0]["service_from"] == "2026-09-01"
assert drafts[0]["lines"][0]["sales_order_id"] == "SO-4" and drafts[0]["lines"][0]["so_line_no"] == 2
issued_ = issue(drafts[0]["invoice_id"], world=world)
assert issued_["issue_date"] == "2026-11-20" and issued_["lines"][0]["service_from"] == "2026-09-01" # issued today, covering its own period
s, b = api("POST", "/schedules", SYSTEM, {**body, "line_id": "3", "bp_id": 8888})
assert s == 409 and "customer_profile" in b["detail"]
def test_ar_28_billing_stops_after_the_period_in_progress(world):
"""An active schedule with 5 of 12 raised; stop_billing: period 6 is never raised; the 5 issued invoices are unchanged."""
billing()
must(api("POST", "/schedules", SYSTEM, {"source_kind": "contract", "source_id": "ct:C-3", "line_id": "L", "bp_id": BP, "starts_on": "2026-07-01",
"periods": 12, "unit_price": "10.00", "currency": "GBP"}))
assert len(must(api("POST", "/schedules/raise-due", SYSTEM, {}))["raised"]) == 5
first = issue("rec:ct:C-3|L:1", world=world)
s, b = api("POST", "/schedules/stop", SYSTEM, {"source_id": "ct:C-3", "reason": "contract terminated", "stop_date": "2026-11-13"})
assert s == 409 and "not_back_dated" in b["detail"] and "credit the periods already issued" in b["detail"]
out = must(api("POST", "/schedules/stop", SYSTEM, {"source_id": "ct:C-3", "reason": "contract terminated", "stop_date": "2026-11-20"}))
assert out["stopped"] == ["ct:C-3|L"] and out["stopped_on"] == "2026-11-20"
with on_day("2026-12-01"):
assert must(api("POST", "/schedules/raise-due", SYSTEM, {}))["raised"] == []
sch = must(api("GET", "/schedules/ct:C-3|L", BILLER))
assert sch["status"] == "stopped" and sch["stop_reason"] == "contract terminated" and sch["periods_raised"] == [1, 2, 3, 4, 5]
assert must(api("GET", f"/invoices/{first['invoice_id']}", BILLER))["invoice_no"] == first["invoice_no"]
assert must(api("POST", "/schedules/stop", SYSTEM, {"source_id": "ct:C-3", "reason": "again"}))["stopped"] == ["ct:C-3|L"]
def test_ar_29_an_accepted_item_is_billed_once(world):
billing()
body = {"item_id": "ITEM-1", "bp_id": BP, "currency": "GBP", "lines": [line("Phase 1 delivered", "1", "35000.00")], "accepted_on": "2026-11-18"}
a = must(api("POST", "/invoices/raise-for-acceptance", SYSTEM, body))
assert a["invoice_id"] == "acc:ITEM-1" and a["subtotal"] == "35000.00" and a["tax_point_date"] == "2026-11-18"
assert must(api("POST", "/invoices/raise-for-acceptance", SYSTEM, body))["version"] == a["version"]
assert issue(a["invoice_id"], world=world)["tax_point_date"] == "2026-11-18"
def test_ar_30_credit_notes_and_writeoffs_reduce_a_balance_only_through_payments(world):
inv = issued(world)
cn = approved_credit(world, inv["invoice_id"], lines=[line("Credit", "1", "100.00")])
assert cn["allocated"] == {"invoice_id": inv["invoice_id"], "amount": "120.00"}
assert [a[0] for a in world.payments.allocations] == ["credit_note"]
assert settle_view(inv["invoice_id"])["outstanding"] == "120.00"
s, b = api("POST", f"/credit-notes/{cn['credit_id']}/allocate", BILLER, {"invoice_id": inv["invoice_id"], "amount": "500.00"})
assert s == 409 and "only 0.00 of this credit note is unallocated" in b["detail"]
from brick_ar.infrastructure import tables
assert not [t for t in tables.md.tables if t.startswith("pay_") or "allocation" in t]
world.remove("payments")
w = must(api("POST", "/writeoffs", BILLER, {"invoice_id": inv["invoice_id"], "amount": "1.00", "reason": "x"}))
assert api("POST", f"/writeoffs/{w['writeoff_id']}/approve", APPROVER, {})[0] == 409