dodil/erp-brick-expenses

DODIL ERP brick: expenses

tests/test_rules.py

"""Rules without a database or a door: one test per sentence a finance manager would agree to (domain/report.py)."""
from datetime import date
from decimal import Decimal

import pytest

from brick_expenses.domain import report as rp
from brick_expenses.domain.errors import RuleBroken

ME = rp.Identity("emp@uk.example", "sub-emp")


def mgr(email, sub, pid=1):
    return {"person_id": pid, "email": email, "pool_user_sub": sub}


def test_the_same_person_is_the_same_under_either_login():
    assert ME.same_as(rp.Identity("EMP@uk.example ", "other-sub"))            # same email, another sub (case and spaces do not matter)
    assert ME.same_as(rp.Identity("other@uk.example", "sub-emp"))             # same sub, another email
    assert not ME.same_as(rp.Identity("other@uk.example", "other-sub"))
    assert not rp.Identity("", "").same_as(rp.Identity("", ""))               # nothing known is nobody, never "the same person"


def test_the_chain_is_the_manager_then_finance_above_the_threshold_only():
    m = {1: mgr("m@uk.example", "sub-m", 2280)}
    assert [(c.kind, c.person_id) for c in rp.derive_chain(ME, Decimal("500.00"), Decimal("500.00"), m.get)] == [("manager", 2280)]
    assert [c.kind for c in rp.derive_chain(ME, Decimal("500.01"), Decimal("500.00"), m.get)] == ["manager", "finance"]
    assert [c.level for c in rp.derive_chain(ME, Decimal("900"), Decimal("500.00"), m.get)] == [1, 2]


def test_a_level_whose_approver_is_the_claimant_goes_one_up_and_with_nobody_up_it_goes_to_finance():
    m = {1: mgr("emp@uk.example", "x", 3301), 2: mgr("boss@uk.example", "sub-boss", 1904)}
    chain = rp.derive_chain(ME, Decimal("10"), Decimal("500"), m.get)
    assert [(c.person_id, c.rerouted_from) for c in chain] == [(1904, "emp@uk.example")]
    both = {1: mgr("emp@uk.example", "x", 3301), 2: mgr("x@uk.example", "sub-emp", 3301)}      # level 2 is also them, by sub
    assert [c.kind for c in rp.derive_chain(ME, Decimal("10"), Decimal("500"), both.get)] == ["finance"]
    assert [c.kind for c in rp.derive_chain(ME, Decimal("10"), Decimal("500"), lambda n: None)] == ["finance"]


def test_every_move_goes_forward_and_the_reverse_of_each_is_refused():
    for key, (frm, to) in rp.MOVES.items():
        r = rp.Report("R", 1, "UK01", None, ME, "GBP", status=to)
        if to in frm:
            continue
        with pytest.raises(RuleBroken):
            r.move(key)                                                       # a claim already in the target state cannot take the arrow again
    for key, (frm, to) in rp.MOVES.items():
        for start in rp.STATES:
            r = rp.Report("R", 1, "UK01", None, ME, "GBP", status=start)
            if start in frm:
                r.move(key)
                assert r.status == to
            else:
                with pytest.raises(RuleBroken):
                    r.move(key)
    for terminal in rp.TERMINAL:                                              # nothing leaves a terminal state (a return starts revision n+1 instead)
        assert all(terminal not in frm for frm, _ in rp.MOVES.values())
    order = {s: i for i, s in enumerate(("draft", "submitted", "approved", "post_failed", "posted", "paid"))}
    for key, (frm, to) in rp.MOVES.items():
        if to in order:
            assert all(order[f] <= order[to] or (f, to) == ("post_failed", "posted") for f in frm), key


def test_money_is_exact_and_the_journal_balances_to_the_penny():
    r = rp.Report("R", 1, "UK01", 7, ME, "GBP", lines=[
        rp.Line(1, "A", date(2026, 11, 4), "x", Decimal("0.10")), rp.Line(2, "A", date(2026, 11, 4), "x", Decimal("0.20")),
        rp.Line(3, "B", date(2026, 11, 4), "x", Decimal("86.40"))])
    assert r.total() == Decimal("86.70") and r.reimbursable() == Decimal("86.70")             # 0.1 + 0.2 is 0.30, not 0.30000000000000004
    lines = rp.journal_lines(r, {"A": "travel", "B": "travel"}, "SALES-N")
    assert lines == [{"purpose": "travel", "cost_centre": "SALES-N", "debit": "86.70"}, {"purpose": "employee_payable", "bp_id": "7", "credit": "86.70"}]
    r.lines.append(rp.Line(4, "A", date(2026, 11, 4), "x", Decimal("5.00"), paid_with="card", card_txn_id="card:4417:TX-1"))
    lines = rp.journal_lines(r, {"A": "travel", "B": "travel"}, "")           # a card line is the card's debt, not the employee's: its own credit
    assert lines[-2:] == [{"purpose": "employee_payable", "bp_id": "7", "credit": "86.70"}, {"purpose": "card_liability", "memo": "card 4417", "credit": "5.00"}]
    assert sum(Decimal(x.get("debit", 0)) for x in lines) == sum(Decimal(x.get("credit", 0)) for x in lines) == Decimal("91.70")
    assert r.reimbursable() == Decimal("86.70")                                # the employee is owed only what they paid themselves


def test_the_leaver_window_is_last_day_plus_the_days():
    assert rp.claims_until(date(2026, 11, 6), 90) == date(2027, 2, 4)
    assert rp.may_claim(True, None, date(2030, 1, 1)) == (True, "")
    assert rp.may_claim(False, date(2027, 2, 4), date(2027, 2, 4)) == (True, "")
    assert rp.may_claim(False, date(2027, 2, 4), date(2027, 2, 5))[0] is False
    assert rp.may_claim(False, None, date(2026, 1, 1))[0] is False


def test_a_line_cannot_be_future_dated_or_have_more_vat_than_total():
    ok = rp.Line(1, "A", date(2026, 11, 4), "x", Decimal("10.00"), Decimal("1.00"))
    rp.check_line(ok, today=date(2026, 11, 7), currency="GBP", report_currency="GBP")
    for bad, words in ((rp.Line(1, "A", date(2026, 11, 8), "x", Decimal("1")), "future"), (rp.Line(1, "A", date(2026, 11, 4), "x", Decimal("1"), Decimal("2")), "VAT")):
        with pytest.raises(RuleBroken, match=words):
            rp.check_line(bad, today=date(2026, 11, 7), currency="GBP", report_currency="GBP")
    rp.check_line(ok, today=date(2026, 11, 7), currency="EUR", report_currency="GBP")        # another currency is converted, not refused (EXP-05)