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)}"