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