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