dodil/erp-brick-expenses
DODIL ERP brick: expenses
tests/test_fx_vat.py
"""Slice B item 3: foreign-currency lines (EXP-05: gl's rate, frozen on the line) and VAT reclaim through tax's answer and content check (EXP-11,
EXP-18). Each test is written from the story's Given/When/Then (module.yaml)."""
from decimal import Decimal
import pytest
from conftest import (
EMPLOYEE,
FINANCE,
MANAGER,
add,
api,
approve,
must,
new_report,
refused,
report,
seed_card,
submit,
)
from erp_platform import seam
from brick_expenses.application import use_cases
from brick_expenses.domain import vat
HOTEL_CONTENTS = {"supplier_name": "Manchester Grand Hotel Ltd", "supplier_vat_number": "GB987654321", "invoice_number": "INV-4471", "customer_name": "Dodil Ltd",
"supplier_address": {"lines": ["1 Deansgate"], "city": "Manchester", "postcode": "M1 1AA", "country": "GB"}}
@pytest.fixture
def tax(nb):
seam.register_local("tax", nb.tax)
return nb
def eur_line(rid, **kw):
return add(rid, category_code="ACCOMMODATION", merchant="Paris Hotel", amount="238.00", currency="EUR", expense_date="2026-11-03", **kw)
# ── EXP-05: foreign currency ──────────────────────────────────────────────────────────────────
def test_EXP_05_a_receipt_in_euros_is_converted_at_gls_rate_on_its_date_rounded_once_and_the_rate_kept(nb):
nb.fx_rates["EUR"] = [("2026-11-03", "0.870000")]
rid = new_report()["report_id"]
got = eur_line(rid)
line = got["lines"][0]
assert (line["receipt_amount"], line["receipt_currency"], line["fx_rate"], line["amount"]) == ("238.00", "EUR", "0.870000", "207.06")
assert got["total_amount"] == "207.06" and got["currency"] == "GBP" # amounts are never summed across currencies
assert nb.fx_calls == [("EUR", "2026-11-03", "spot", "GBP")]
def test_EXP_05_the_newest_rate_on_or_before_the_receipt_date_is_the_one_used(nb):
nb.fx_rates["EUR"] = [("2026-11-01", "0.860000"), ("2026-11-02", "0.870000"), ("2026-11-05", "0.900000")]
rid = new_report()["report_id"]
assert eur_line(rid)["lines"][0]["fx_rate"] == "0.870000"
def test_EXP_05_a_rate_missing_in_gl_blocks_the_line_naming_the_currency_and_the_date(nb):
nb.fx_rates["EUR"] = [("2026-11-04", "0.870000")]
rid = new_report()["report_id"]
body = refused(api("POST", f"/reports/{rid}/lines", EMPLOYEE, {"category_code": "OFFICE", "expense_date": "2026-11-03", "merchant": "Paris", "amount": "10.00",
"currency": "EUR"}), 409, "EUR to GBP rate on or before 2026-11-03")
assert body["gate"] == "fx_rate" and report(rid)["lines"] == []
seam.unregister_local("gl")
refused(api("POST", f"/reports/{rid}/lines", EMPLOYEE, {"category_code": "OFFICE", "expense_date": "2026-11-03", "merchant": "Paris", "amount": "10.00",
"currency": "EUR"}), 409, "gl is not part of this installation")
seam.register_local("gl", nb.gl)
nb.gl_status = 500
refused(api("POST", f"/reports/{rid}/lines", EMPLOYEE, {"category_code": "OFFICE", "expense_date": "2026-11-03", "merchant": "Paris", "amount": "10.00",
"currency": "EUR"}), 502, "gl failed")
def test_EXP_05_a_foreign_amount_that_rounds_to_nothing_or_overflows_is_a_422_never_a_500(nb):
nb.fx_rates["JPY"] = [("2026-01-01", "0.000001")]
nb.fx_rates["XXX"] = [("2026-01-01", "999999999")]
rid = new_report()["report_id"]
body = {"category_code": "OFFICE", "expense_date": "2026-11-03", "merchant": "x"}
refused(api("POST", f"/reports/{rid}/lines", EMPLOYEE, {**body, "amount": "1.00", "currency": "JPY"}), 422, "more than zero")
refused(api("POST", f"/reports/{rid}/lines", EMPLOYEE, {**body, "amount": "9999999999999", "currency": "XXX"}), 422, "below 10,000,000,000,000")
nb.fx_rates["BAD"] = [("2026-01-01", "0")]
refused(api("POST", f"/reports/{rid}/lines", EMPLOYEE, {**body, "amount": "1.00", "currency": "BAD"}), 409, "more than zero")
refused(api("POST", f"/reports/{rid}/lines", EMPLOYEE, {**body, "amount": "1.00", "currency": "EU"}), 422, "currency")
def test_EXP_05_the_vat_on_a_foreign_receipt_converts_with_the_same_rate(nb):
nb.fx_rates["EUR"] = [("2026-11-03", "0.870000")]
rid = new_report()["report_id"]
line = eur_line(rid, vat_amount="39.67")["lines"][0]
assert line["vat_amount"] == "34.51" # 39.67 x 0.87 = 34.5129
def test_EXP_05_the_card_dinner_takes_the_billed_amount_once_matched_and_keeps_the_receipt_amount(nb):
nb.fx_rates["EUR"] = [("2026-11-03", "0.870000")]
seed_card("4417", 3301, [("TX-PARIS", "2026-11-03", "Paris Bistro", Decimal("56.12"))])
with use_cases.repository() as repo:
t = repo.cards.txn("card:4417:TX-PARIS")
repo.cards.save_txn({**t, "original_amount": Decimal("64.00"), "original_currency": "EUR"}, use_cases._now())
rid = new_report()["report_id"]
dinner = add(rid, category_code="MEALS_STAFF", merchant="Paris Bistro", amount="64.00", currency="EUR", expense_date="2026-11-03", paid_with="card")
assert dinner["lines"][0]["amount"] == "55.68" # 64.00 x 0.87 until the card says otherwise
sug = must(api("GET", f"/reports/{rid}/card-suggestions", EMPLOYEE))["suggestions"]
assert [(s["card_txn_id"], s["line_no"]) for s in sug] == [("card:4417:TX-PARIS", 1)] # the receipt amount matches the transaction's original
got = must(api("POST", "/card-txns/card:4417:TX-PARIS/match", EMPLOYEE, {"report_id": rid, "line_no": 1}))["lines"][0]
assert (got["amount"], got["receipt_amount"], got["receipt_currency"], got["card_txn_id"]) == ("56.12", "64.00", "EUR", "card:4417:TX-PARIS")
again = must(api("POST", f"/reports/{rid}/lines/1", EMPLOYEE, {"vat_amount": "5.00"}))["lines"][0] # a later edit keeps the card's amount
assert again["amount"] == "56.12" and again["vat_amount"] == "4.35"
refused(api("POST", f"/reports/{rid}/lines/1", EMPLOYEE, {"amount": "1.00"}), 409, "billed amount")
def test_EXP_05_editing_a_foreign_lines_date_or_amount_converts_again(nb):
nb.fx_rates["EUR"] = [("2026-11-01", "0.860000"), ("2026-11-03", "0.870000")]
rid = new_report()["report_id"]
eur_line(rid)
got = must(api("POST", f"/reports/{rid}/lines/1", EMPLOYEE, {"amount": "100.00"}))["lines"][0]
assert (got["amount"], got["fx_rate"]) == ("87.00", "0.870000") # the rate stays unless the date moves
got = must(api("POST", f"/reports/{rid}/lines/1", EMPLOYEE, {"expense_date": "2026-11-02"}))["lines"][0]
assert (got["amount"], got["fx_rate"]) == ("86.00", "0.860000")
def test_EXP_20_a_draft_takes_gls_rate_as_it_is_now_but_a_submitted_claims_rate_is_frozen(nb):
nb.fx_rates["EUR"] = [("2026-11-03", "0.870000")]
rid = new_report()["report_id"]
eur_line(rid)
nb.fx_rates["EUR"] = [("2026-11-03", "0.880000")] # gl corrects the rate
got = must(api("POST", f"/reports/{rid}/recompute", EMPLOYEE))
assert [(c["field"], c["was"], c["now"]) for c in got["changes"]] == [("amount", "207.06", "209.44")] and got["lines"][0]["fx_rate"] == "0.880000"
submit(rid)
nb.fx_rates["EUR"] = [("2026-11-03", "0.900000")]
assert must(api("POST", f"/reports/{rid}/recompute", MANAGER))["changes"] == [] # frozen at submission
assert report(rid, MANAGER)["lines"][0]["fx_rate"] == "0.880000"
# ── EXP-11 / EXP-18: VAT through tax ────────────────────────────────────────────────────────────
def claim_551(nb):
"""The story claim: train 86.40 (zero-rated), the hotel 264.00 with VAT 44.00 and a VAT invoice, the client dinner 96.00 (entertainment), 105.00 of
trips: 551.40, all own money."""
rid = new_report()["report_id"]
add(rid, category_code="TRAVEL_RAIL", merchant="Northern Rail", amount="86.40")
add(rid, category_code="ACCOMMODATION", merchant="Manchester Grand Hotel", amount="264.00", vat_amount="44.00", expense_date="2026-11-05", receipt=HOTEL_CONTENTS)
add(rid, category_code="MEALS_CLIENT", merchant="The Ivy", amount="96.00", vat_amount="16.00", attendees=["A", "B"])
add(rid, category_code="TRAVEL_RAIL", merchant="Trains to Leeds", amount="105.00")
return rid
def test_EXP_11_the_hotel_vat_is_reclaimed_the_entertainment_vat_is_cost_and_the_journal_splits_them(tax):
rid = claim_551(tax)
got = submit(rid)
hotel, dinner, train = got["lines"][1], got["lines"][2], got["lines"][0]
assert hotel["tax"] == {"tax_code": "PS", "vat_reclaimable": "44.00", "vat_non_reclaimable": "0.00", "checked": True, "gates": []}
assert dinner["tax"]["tax_code"] == "PE" and (dinner["tax"]["vat_reclaimable"], dinner["tax"]["vat_non_reclaimable"]) == ("0.00", "16.00")
assert train["tax"]["tax_code"] == "PZ" and train["vat_amount"] == "0.00"
must(approve(rid, MANAGER))
assert must(approve(rid, FINANCE))["status"] == "posted"
(j,) = tax.journals.values()
assert j["lines"] == [{"purpose": "accommodation", "cost_centre": "SALES-N", "debit": "220.00"},
{"purpose": "entertainment", "cost_centre": "SALES-N", "debit": "96.00"},
{"purpose": "travel", "cost_centre": "SALES-N", "debit": "191.40"},
{"purpose": "tax", "tax_code": "PS", "cost_centre": "SALES-N", "debit": "44.00"},
{"purpose": "employee_payable", "bp_id": "73301", "credit": "551.40"}] # 551.40 each side
assert sum(Decimal(x.get("debit", 0)) for x in j["lines"]) == sum(Decimal(x.get("credit", 0)) for x in j["lines"]) == Decimal("551.40")
def test_EXP_11_a_receipt_that_fails_taxs_content_check_has_its_vat_made_non_reclaimable_and_the_gate_shown(tax):
rid = new_report()["report_id"]
no_vat_number = {k: v for k, v in HOTEL_CONTENTS.items() if k != "supplier_vat_number"}
add(rid, category_code="ACCOMMODATION", merchant="Grand", amount="300.00", vat_amount="50.00", expense_date="2026-11-05", receipt=no_vat_number)
got = submit(rid)
t = got["lines"][0]["tax"]
assert (t["vat_reclaimable"], t["vat_non_reclaimable"], t["checked"]) == ("0.00", "50.00", True)
assert t["gates"][0]["gate"] == "supplier_vat_number" and "VAT" in t["gates"][0]["reason"] and "ask the supplier" in t["gates"][0]["fix"]
must(approve(rid, MANAGER)) # 300.00 is under the threshold: the manager's is the last level
(j,) = tax.journals.values()
assert j["lines"][0] == {"purpose": "accommodation", "cost_centre": "SALES-N", "debit": "300.00"} and all(x["purpose"] != "tax" for x in j["lines"])
def test_EXP_11_a_receipt_with_no_particulars_cannot_be_shown_to_be_a_vat_invoice_so_nothing_is_reclaimed(tax):
rid = new_report()["report_id"]
add(rid, category_code="ACCOMMODATION", merchant="Grand", amount="264.00", vat_amount="44.00", expense_date="2026-11-05")
t = submit(rid)["lines"][0]["tax"]
assert (t["vat_reclaimable"], t["checked"], t["gates"][0]["gate"]) == ("0.00", False, "receipt_particulars")
assert tax.check_calls == [] # nothing to check: tax is not asked
def test_EXP_18_a_receipt_over_the_limit_needs_our_name_and_under_it_the_less_detailed_invoice_will_do(tax):
rid = new_report()["report_id"]
anonymous = {k: v for k, v in HOTEL_CONTENTS.items() if k != "customer_name"}
add(rid, category_code="ACCOMMODATION", merchant="Grand", amount="312.00", vat_amount="52.00", expense_date="2026-11-05", receipt=anonymous)
add(rid, category_code="OFFICE", merchant="Till", amount="9.60", vat_amount="1.60", expense_date="2026-11-05", receipt=anonymous)
got = submit(rid)
big, small = got["lines"][0]["tax"], got["lines"][1]["tax"]
assert big["vat_reclaimable"] == "0.00" and big["gates"][0]["gate"] == "customer_name"
assert small["vat_reclaimable"] == "1.60" and small["gates"] == []
def test_EXP_18_expenses_sends_tax_the_receipt_as_the_spec_says_and_never_applies_a_rate(tax):
nb = tax
nb.fx_rates["EUR"] = [("2026-11-03", "0.870000")]
rid = new_report()["report_id"]
add(rid, category_code="ACCOMMODATION", merchant="Paris Hotel", amount="238.00", currency="EUR", vat_amount="39.67", expense_date="2026-11-03",
receipt=HOTEL_CONTENTS, treatment_code="eu_business")
got = submit(rid)
(call,) = nb.tax_calls
assert call["document_ref"] == f"exp:{rid}:1" and call["direction"] == "purchase" and call["tax_point"] == "2026-11-03" and call["currency"] == "EUR"
assert call["treatment_code"] == "eu_business" and call["prices_include_tax"] is True and call["fx_rate"] == "0.870000" and "bp_id" not in call
assert call["lines"] == [{"line_no": 1, "description": "Paris Hotel", "category_code": "standard", "supply": "services", "amount": "238.00", "quantity": "1",
"unit_price": "238.00"}]
t = got["lines"][0]
assert (t["vat_amount"], t["tax"]["vat_reclaimable"]) == ("34.51", "34.51") # tax's 39.67 in euros, converted by the frozen rate
domestic = new_report()["report_id"]
add(domestic, category_code="OFFICE", merchant="Staples", amount="12.00", vat_amount="2.00")
submit(domestic)
assert "fx_rate" not in nb.tax_calls[-1] and nb.tax_calls[-1]["treatment_code"] == "uk_business" and nb.tax_calls[-1]["lines"][0]["supply"] == "goods"
def test_EXP_18_a_returned_claims_unchanged_lines_are_not_asked_about_again(tax):
rid = claim_551(tax)
submit(rid)
asked = len(tax.tax_calls)
assert asked == 4 and len(tax.check_calls) == 1 # four receipts, one with reclaimable VAT to check
must(approve(rid, MANAGER))
must(api("POST", f"/reports/{rid}/return", FINANCE, {"reason": "which client?"}))
must(api("POST", f"/reports/{rid}/lines/3", EMPLOYEE, {"description": "client dinner"}))
submit(rid)
assert len(tax.tax_calls) == asked and len(tax.check_calls) == 1
def test_EXP_18_a_change_to_a_receipt_asks_tax_again(tax):
rid = new_report()["report_id"]
add(rid, category_code="ACCOMMODATION", merchant="Grand", amount="264.00", vat_amount="44.00", receipt=HOTEL_CONTENTS)
must(api("POST", f"/reports/{rid}/lines/1", EMPLOYEE, {"amount": "120.00"}))
got = submit(rid)
assert got["lines"][0]["tax"]["vat_reclaimable"] == "20.00" and len(tax.tax_calls) == 1
def test_EXP_18_with_tax_down_the_claim_is_not_submitted_and_with_tax_not_installed_nothing_is_reclaimed(nb):
rid = new_report()["report_id"]
add(rid, category_code="ACCOMMODATION", merchant="Grand", amount="264.00", vat_amount="44.00", receipt=HOTEL_CONTENTS)
seam.register_local("tax", nb.tax)
nb.tax_status = 500
refused(api("POST", f"/reports/{rid}/submit", EMPLOYEE), 502, "tax")
assert report(rid)["status"] == "draft" # nothing half-submitted
nb.tax_status = None
seam.unregister_local("tax")
got = submit(rid)
assert got["lines"][0]["vat_amount"] == "44.00" and got["lines"][0]["tax"]["vat_reclaimable"] == "0.00" # the VAT is part of the cost
must(approve(rid, MANAGER))
(j,) = nb.journals.values()
assert j["lines"][0] == {"purpose": "accommodation", "cost_centre": "SALES-N", "debit": "264.00"}
def test_EXP_18_a_pair_tax_has_no_rule_for_makes_the_vat_non_reclaimable_with_taxs_own_reason(tax):
tax.tax_refusal = {"gate": "no_rule", "reason": "no purchase rule for (standard, eu_business)", "fix": "ask the tax administrator"}
rid = new_report()["report_id"]
add(rid, category_code="OFFICE", merchant="Shop", amount="12.00", vat_amount="2.00")
t = submit(rid)["lines"][0]
assert t["vat_amount"] == "2.00" and t["tax"]["vat_reclaimable"] == "0.00" and t["tax"]["gates"][0]["gate"] == "no_rule"
def test_a_line_tax_was_never_asked_about_is_asked_when_the_last_level_is_approved(nb):
rid = new_report()["report_id"]
add(rid, category_code="OFFICE", merchant="Shop", amount="12.00", vat_amount="2.00")
submit(rid) # tax was not installed at submission
seam.register_local("tax", nb.tax)
must(approve(rid, MANAGER))
assert len(nb.tax_calls) == 0 # the line already has its (no-tax) answer: not asked
rid2 = new_report()["report_id"]
add(rid2, category_code="OFFICE", merchant="Shop", amount="12.00", vat_amount="2.00")
submit(rid2)
assert len(nb.tax_calls) == 1 # asked at submission
with use_cases.repository() as repo: # a line with no answer at all (migrated, say)
r = repo.reports.get(rid2)
r.lines[0].tax = {}
repo.reports.save_lines(rid2, 1, r.lines, use_cases._now())
must(approve(rid2, MANAGER))
assert len(nb.tax_calls) == 2 and nb.journals # asked again for the line with no answer
def test_the_reclaimable_vat_is_never_more_than_the_vat_on_the_receipt_and_cost_plus_vat_is_the_amount():
ans = {"tax_code": "PS", "vat": "10.00", "vat_reclaimable": "50.00", "vat_non_reclaimable": "0"} # a tax answer that contradicts itself
got = vat.settle(ans, {"ready": True, "gates": []}, Decimal(1), Decimal(0), Decimal("60.00"))
assert got["reclaimable"] == Decimal("10.00") and got["reclaimable"] + got["non_reclaimable"] == got["vat"]
over = vat.settle({"tax_code": "PS", "vat": "99.00", "vat_reclaimable": "99.00"}, {"ready": True}, Decimal(1), Decimal(0), Decimal("60.00"))
assert over["vat"] == Decimal("60.00") and over["reclaimable"] <= Decimal("60.00") # never more than the receipt
assert vat.convert(Decimal("238.00"), Decimal("0.870000")) == Decimal("207.06") and vat.convert(Decimal("0.005"), Decimal(1)) == Decimal("0.01")