dodil/erp-brick-ar
DODIL ERP brick: accounts receivable
tests/test_scenarios.py
"""The spec's scenarios (module.yaml tests.scenarios), each written once over the API door, each making its own data and setting its
own clock. SC-AR-08 (Odoo) lives in test_migration.py; the last test here checks every scenario id of the spec has a test."""
import pathlib
import re
from datetime import date, timedelta
import yaml
from conftest import (
APPROVER,
BILLER,
BP,
SYSTEM,
api,
approved_credit,
billing,
control,
hand_draft,
issue,
issued,
line,
must,
on_day,
raise_so,
)
def test_sc_ar_01_order_to_invoice_with_a_deposit(world):
"""An order of 10,000.00 net with a 50% deposit: a deposit invoice of 5,000.00 net plus VAT; a final invoice of 10,000.00 net less
5,000.00 deposited, plus VAT on the balance."""
billing()
dep = issue(must(raise_so("so:SO-10:1", [line("Deposit 50%", "1", "5000.00", sales_order_id="SO-10", so_line_no=1)], kind="deposit"))["invoice_id"], world=world)
assert (dep["subtotal"], dep["tax_total"], dep["total"], dep["kind"]) == ("5000.00", "1000.00", "6000.00", "deposit")
fin = must(raise_so("so:SO-10:2", [line("Project", "1", "10000.00", sales_order_id="SO-10", so_line_no=1)], kind="final",
deposit_invoice_ids=[dep["invoice_id"]]))
fin = issue(fin["invoice_id"], world=world)
assert (fin["subtotal"], fin["tax_total"], fin["total"]) == ("5000.00", "1000.00", "6000.00") and fin["lines"][1]["deducts_invoice_id"] == dep["invoice_id"]
assert [t["net_amount"] for t in fin["tax"]] == ["5000.00"] # VAT only on the balance
def test_sc_ar_02_a_monthly_line_bills_itself_once(world):
"""A recurring line of 12 periods at 120.00 from 2026-10-01: plan_billing twice and the daily pass twice on each billing day: one
schedule; one draft per period; finished after period 12."""
with on_day("2026-10-01"):
billing()
body = {"source_kind": "sales_order", "source_id": "SO-20", "line_id": "1", "bp_id": BP, "starts_on": "2026-10-01", "periods": 12,
"unit_price": "120.00", "currency": "GBP", "unit_code": "MON", "description": "Hosting", "tax_category": "standard"}
day, drafts = date(2026, 10, 1), []
for k in range(12):
d = date(2026 + (9 + k) // 12, (9 + k) % 12 + 1, 1)
with on_day(d.isoformat()):
must(api("POST", "/schedules", SYSTEM, body))
must(api("POST", "/schedules", SYSTEM, body))
first, second = must(api("POST", "/schedules/raise-due", SYSTEM, {})), must(api("POST", "/schedules/raise-due", SYSTEM, {}))
assert len(first["raised"]) == 1 and second["raised"] == []
drafts += first["raised"]
must(api("POST", "/schedules/finish", SYSTEM, {}))
assert len(must(api("GET", "/schedules", BILLER))["schedules"]) == 1
assert drafts == [f"rec:SO-20|1:{k}" for k in range(1, 13)] and len(set(drafts)) == 12
sch = must(api("GET", "/schedules/SO-20|1", BILLER))
assert sch["status"] == "finished" and sch["periods_remaining"] == 0 and day
def test_sc_ar_03_a_source_ends_early(world):
"""A schedule with 5 of 12 raised; stop_billing: nothing more is raised; the 5 invoices are unchanged."""
billing()
must(api("POST", "/schedules", SYSTEM, {"source_kind": "contract", "source_id": "ct:C-30", "line_id": "1", "bp_id": BP, "starts_on": "2026-07-01",
"periods": 12, "unit_price": "10.00", "currency": "GBP"}))
raised = must(api("POST", "/schedules/raise-due", SYSTEM, {}))["raised"]
before = [must(api("GET", f"/invoices/{i}", BILLER)) for i in raised]
assert len(raised) == 5
must(api("POST", "/schedules/stop", SYSTEM, {"source_id": "ct:C-30", "reason": "contract terminated"}))
with on_day("2027-03-01"):
assert must(api("POST", "/schedules/raise-due", SYSTEM, {}))["raised"] == []
assert before == [must(api("GET", f"/invoices/{i}", BILLER)) for i in raised]
def test_sc_ar_04_metered_month(world):
"""A metered schedule: the period's draft arrives with no usage and issue is refused until it is filled; a zero month is cancelled and uses no number."""
with on_day("2026-10-31"):
billing()
must(api("POST", "/schedules", SYSTEM, {"source_kind": "contract", "source_id": "ct:C-40", "line_id": "1", "bp_id": BP, "starts_on": "2026-10-01",
"periods": 3, "unit_price": "0.10", "currency": "GBP", "billing_kind": "usage", "in_advance": False,
"tax_category": "standard"}))
a = must(api("POST", "/schedules/raise-due", SYSTEM, {}))["raised"][0]
s, b = api("POST", f"/invoices/{a}/issue", BILLER, {})
assert s == 409 and "usage_filled" in b["detail"]
must(api("POST", f"/invoices/{a}/fill-usage", BILLER, {"line_no": 1, "quantity": "5000"}))
assert issue(a, world=world)["invoice_no"] == "INV-2026-0001"
with on_day("2026-11-30"):
zero = must(api("POST", "/schedules/raise-due", SYSTEM, {}))["raised"][0]
assert must(api("POST", f"/invoices/{zero}/cancel", BILLER, {}))["lifecycle"] == "cancelled"
assert issue(hand_draft()["invoice_id"], world=world)["invoice_no"] == "INV-2026-0002"
def test_sc_ar_05_export_instead_of_posting(world):
"""Ledger mode export; 40 invoices and 2 credit notes in October: the October export runs twice: one batch of 42 documents; the second run exports nothing."""
world.remove("gl")
must(control("ledger_mode", "export", "2026-12-01")) # the reference exports where there is no ledger; the owner makes it explicit
with on_day("2026-10-12"):
billing()
ids = [issue(hand_draft([line(price=f"{10 + n}.00")])["invoice_id"], world=world)["invoice_id"] for n in range(40)]
approved_credit(world, ids[0], lines=[line("c", "1", "5.00")])
approved_credit(world, ids[1], lines=[line("c", "1", "5.00")])
first = must(api("POST", "/exports", BILLER, {"period": "2026-10"}))
second = must(api("POST", "/exports", BILLER, {"period": "2026-10"}))
assert first["exported"] == 42 and second["exported"] == 0 and second["document_count"] == 42
csv_lines = must(api("GET", "/exports/2026-10:csv", BILLER))["content"].splitlines()
assert len(csv_lines) == 43 and len({row.split(",")[-1] for row in csv_lines[1:]}) == 42
def test_sc_ar_06_edit_an_issued_invoice(world):
"""An issued invoice's line is edited: refused 'credit it instead'; a credit note is the path."""
inv = issued(world)
s, b = api("POST", f"/invoices/{inv['invoice_id']}/edit", BILLER, {"lines": [line(price="1.00")]})
assert s == 409 and "credit it instead" in b["detail"]
cn = approved_credit(world, inv["invoice_id"], full=True)
assert cn["status"] == "approved" and must(api("GET", f"/invoices/{inv['invoice_id']}", BILLER))["lines"][0]["unit_price"] == "100.0000"
def test_sc_ar_07_no_back_dating(world):
"""A customer with a billing profile and an active schedule: a new profile dated today, a control dated yesterday and a stop dated last week are
tried: all three refused with their readiness reason; tomorrow's profile and today's stop are accepted; invoices issued today use today's profile."""
billing()
must(api("POST", "/schedules", SYSTEM, {"source_kind": "contract", "source_id": "ct:C-70", "line_id": "1", "bp_id": BP, "starts_on": "2026-11-01",
"periods": 12, "unit_price": "10.00", "currency": "GBP"}))
s1, b1 = api("POST", f"/customers/{BP}/billing", APPROVER, {"bill_to_name": "A", "bill_to_address": "new", "invoice_email": "n@x.example",
"terms_id": "NET30", "currency": "GBP", "valid_from": "2026-11-20"})
must(control("void_policy", "never")) # the first row of a key may start today
s2, b2 = control("void_policy", "never_sent", "2026-11-19")
last_week = (date(2026, 11, 20) - timedelta(days=7)).isoformat()
s3, b3 = api("POST", "/schedules/stop", SYSTEM, {"source_id": "ct:C-70", "reason": "ended", "stop_date": last_week})
assert (s1, s2, s3) == (409, 409, 409)
assert "tomorrow at the earliest" in b1["detail"] and "before today" in b2["detail"] or "tomorrow at the earliest" in b2["detail"]
assert "not_back_dated" in b3["detail"] and "a stop cannot be dated before today" in b3["detail"]
assert billing(bill_to_address="new")["valid_from"] == "2026-11-21"
assert must(api("POST", "/schedules/stop", SYSTEM, {"source_id": "ct:C-70", "reason": "ended", "stop_date": "2026-11-20"}))["stopped_on"] == "2026-11-20"
inv = issue(hand_draft()["invoice_id"], world=world)
assert "1 High St" in inv["bill_to_json"] and "new" not in inv["bill_to_json"]
def test_every_scenario_of_the_spec_has_a_test():
spec = yaml.safe_load((pathlib.Path(__file__).resolve().parents[2] / "erp-method" / "modules" / "ar" / "module.yaml").read_text())
wanted = {s["id"].lower().replace("-", "_") for s in spec["tests"]["scenarios"]}
have = set()
for f in pathlib.Path(__file__).parent.glob("test_*.py"):
have |= {m.group(1) for m in re.finditer(r"def test_(sc_ar_\d\d)_", f.read_text())}
first_eight = {f"sc_ar_0{i}" for i in range(1, 9)} # SC-AR-09..14 (v4a/v4b) are covered by story-named tests, not scenario-named ones
assert first_eight <= wanted and have == first_eight
def test_every_story_of_the_spec_has_a_test():
spec = yaml.safe_load((pathlib.Path(__file__).resolve().parents[2] / "erp-method" / "modules" / "ar" / "module.yaml").read_text())
wanted = {s["id"].lower().replace("-", "_") for s in spec["stories"]}
have = set()
for f in pathlib.Path(__file__).parent.glob("test_*.py"):
have |= {m.group(1) for m in re.finditer(r"def test_(ar_\d\d)_", f.read_text())}
assert wanted - have == set(), f"stories with no test: {sorted(wanted - have)}"