dodil/erp-brick-expenses
DODIL ERP brick: expenses
tests/test_rebill.py
"""Slice B item 5: re-billing an approved cost to a customer through ar (EXP-21, the rebill extension): ONE draft invoice line per marked line, asked once
from a pending row after the commit, never issued here."""
import pytest
from conftest import FINANCE, MANAGER, SYSTEM, add, api, approve, must, new_report, person, refused, report, submit
from erp_platform import seam
from brick_expenses.application import use_cases
HOTEL = {"supplier_name": "Grand Hotel", "supplier_vat_number": "GB987654321", "invoice_number": "INV-1", "customer_name": "Dodil Ltd",
"supplier_address": {"lines": ["1 Deansgate"], "city": "Manchester", "country": "GB"}}
@pytest.fixture
def ar(nb):
seam.register_local("ar", nb.ar)
return nb
def claim(amount="86.40", **kw):
rid = new_report()["report_id"]
add(rid, amount=amount, **kw)
return rid
def rebill(rid, line=1, who=None, **kw):
return api("POST", f"/reports/{rid}/lines/{line}/rebill", who or FINANCE, {"bp_id": "50211", **kw})
def test_EXP_21_a_line_marked_before_posting_is_asked_of_ar_when_the_claim_posts_once_and_the_expense_is_not_posted_twice(ar):
rid = claim()
submit(rid)
got = must(rebill(rid))
assert got["lines"][0]["rebill"] == {"bp_id": "50211", "markup_pct": "0.0000", "tax_category": "standard"} and ar.invoices == {} # nothing is asked yet
assert must(approve(rid, MANAGER))["status"] == "posted"
(body,) = ar.invoices.values()
assert (body["source_kind"], body["source_id"], body["bp_id"], body["currency"], body["kind"]) == ("expense", f"exp:{rid}:1", "50211", "GBP", "regular")
assert body["lines"] == [{"description": "Northern Rail (2026-11-04)", "quantity": "1", "unit_code": "C62", "unit_price": "86.4000", "tax_category": "standard"}]
must(api("POST", "/outbox/drain", SYSTEM))
must(api("POST", f"/reports/{rid}/post", FINANCE))
assert ar.invoice_posts == 1 and len(ar.journals) == 1 # one ask, one journal: the cost is not posted twice
refused(rebill(rid), 409, "already asked of ar")
def test_EXP_21_a_line_marked_after_posting_is_asked_at_once_and_the_invoice_is_never_issued_here(ar):
rid = claim()
submit(rid)
must(approve(rid, MANAGER))
got = must(rebill(rid, markup_pct="10"))
assert ar.invoice_posts == 1 and next(iter(ar.invoices.values()))["lines"][0]["unit_price"] == "95.0400" # 86.40 plus 10%
box = [o for o in got["outbox"] if o["target"] == "ar"]
assert [(o["ref"], o["status"]) for o in box] == [(f"exp:{rid}:1", "sent")]
def test_EXP_21_the_cost_is_the_amount_less_the_vat_that_comes_back_to_the_company(ar):
seam.register_local("tax", ar.tax)
rid = new_report()["report_id"]
add(rid, category_code="ACCOMMODATION", merchant="Grand Hotel", amount="264.00", vat_amount="44.00", receipt=HOTEL)
submit(rid)
must(approve(rid, MANAGER))
must(rebill(rid, tax_category="standard"))
assert next(iter(ar.invoices.values()))["lines"][0]["unit_price"] == "220.0000" # 264.00 less 44.00 reclaimable VAT
def test_EXP_21_ar_refusing_the_customer_leaves_the_row_refused_and_finance_marks_the_line_again(ar):
ar.known_customers = {50212}
rid = claim()
submit(rid)
must(approve(rid, MANAGER))
got = must(rebill(rid))
(box,) = [o for o in got["outbox"] if o["target"] == "ar"]
assert box["status"] == "refused" and "customer" in box["last_error"]
with use_cases.repository() as repo:
assert repo.outbox.get("ar", f"exp:{rid}:1")["status"] == "refused" and "customer" in repo.outbox.get("ar", f"exp:{rid}:1")["last_error"]
again = rebill(rid, bp_id="50212")
assert again[0] == 200 and ar.invoice_posts == 2 and next(iter(ar.invoices.values()))["bp_id"] == "50212"
refused(rebill(rid, bp_id="50212"), 409, "already asked of ar")
def test_EXP_21_ar_down_leaves_it_pending_and_the_next_drain_asks_again(ar):
rid = claim()
submit(rid)
must(approve(rid, MANAGER))
ar.ar_status = 500
must(rebill(rid))
assert ar.invoices == {}
ar.ar_status = None
out = must(api("POST", "/outbox/drain", SYSTEM))
assert [s["status"] for s in out["sent"]] == ["raised"] and len(ar.invoices) == 1
def test_EXP_21_without_ar_there_is_nothing_to_mark_it_for(nb):
rid = claim()
submit(rid)
must(approve(rid, MANAGER))
body = refused(rebill(rid), 409, "ar is not part of this installation")
assert body["gate"] == "ar_installed"
def test_EXP_21_only_finance_marks_and_only_a_claim_in_flight_or_posted_has_lines_to_bill(ar):
rid = claim()
refused(rebill(rid), 409, "only a submitted, approved or posted claim") # a draft
submit(rid)
refused(rebill(rid, who=MANAGER), 403, "exp:report:post")
refused(rebill(rid, line=7), 404, "no line 7")
refused(rebill(rid, who=person("x@uk.example", [])), 403)
refused(api("POST", f"/reports/{rid}/lines/1/rebill", FINANCE, {"bp_id": "x"}), 422)
refused(api("POST", f"/reports/{rid}/lines/1/rebill", FINANCE, {"bp_id": "50211", "markup_pct": "101"}), 422)
refused(api("POST", f"/reports/{rid}/lines/1/rebill", FINANCE, {"bp_id": "50211", "markup_pct": "-1"}), 422)
rejected = claim()
submit(rejected)
must(api("POST", f"/reports/{rejected}/reject", MANAGER, {"reason": "no"}))
refused(rebill(rejected), 409, "is rejected")
assert report(rid)["lines"][0]["rebill"] is None
def test_EXP_21_a_line_that_is_all_reclaimable_vat_has_no_cost_to_bill(ar):
from decimal import Decimal
from brick_expenses.domain import rebill as rb
from brick_expenses.domain.errors import RuleBroken
with pytest.raises(RuleBroken, match="no cost to bill"):
rb.unit_price(Decimal("10.00"), Decimal("10.00"), Decimal(0))
assert rb.unit_price(Decimal("33.33"), Decimal(0), Decimal("12.5")) == Decimal("37.4963") # rounded once, half up, to four places