File size: 5,892 Bytes
c7bece7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
"""Deterministic validation math (spec §8 Definition of Done, item c).

These assert the arithmetic itself, not that a function was called. Every
expectation below is hand-computed.
"""

from __future__ import annotations

from datetime import date
from decimal import Decimal

import pytest

from app.models.schemas import InvoiceExtraction, parse_date, parse_money
from app.pipeline.validate import (
    RULE_DATE_SANITY,
    RULE_DUE_AFTER_ISSUE,
    RULE_LINE_ITEM_MATH,
    RULE_LINE_ITEMS_SUM,
    RULE_TOTAL_POSITIVE,
    RULE_TOTALS_RECONCILE,
    RULE_VAT_RATE,
    RULE_VENDOR_PRESENT,
    validate_extraction,
)

TODAY = date(2026, 7, 29)

CLEAN = {
    "vendor": "Gulf Metals LLC",
    "invoice_number": "INV-1001",
    "issue_date": "2026-07-01",
    "due_date": "2026-07-31",
    "currency": "AED",
    "payment_terms": "Net 30",
    "line_items": [
        {"description": "Steel", "qty": "10", "unit_price": "100.00", "amount": "1000.00"},
        {"description": "Bolts", "qty": "5", "unit_price": "20.00", "amount": "100.00"},
    ],
    "subtotal": "1100.00",
    "tax": "55.00",
    "total": "1155.00",
}


def _validate(**overrides: object):
    payload = {**CLEAN, **overrides}
    return validate_extraction(InvoiceExtraction.model_validate(payload), today=TODAY)


def _failed_rules(report) -> set[str]:
    return {check.rule for check in report.checks if not check.passed}


def test_clean_invoice_passes_every_rule() -> None:
    report = _validate()
    assert report.passed
    assert _failed_rules(report) == set()


def test_subtotal_plus_tax_must_equal_total() -> None:
    # 1100.00 + 55.00 = 1155.00, so a printed 1200.00 is a 45.00 discrepancy.
    report = _validate(total="1200.00")
    assert not report.passed
    assert RULE_TOTALS_RECONCILE in _failed_rules(report)
    assert "45.00" in report.failure_summary()


def test_two_cent_rounding_is_tolerated() -> None:
    """Display rounding must not flood the review queue."""
    assert _validate(total="1155.02").passed
    assert not _validate(total="1155.05").passed


def test_line_items_must_sum_to_subtotal() -> None:
    report = _validate(subtotal="1500.00", tax="75.00", total="1575.00")
    assert RULE_LINE_ITEMS_SUM in _failed_rules(report)


def test_line_item_must_multiply_out() -> None:
    report = _validate(
        line_items=[
            {"description": "Steel", "qty": "10", "unit_price": "100.00", "amount": "999.00"},
            {"description": "Bolts", "qty": "5", "unit_price": "20.00", "amount": "100.00"},
        ],
        subtotal="1099.00",
        tax="54.95",
        total="1153.95",
    )
    assert RULE_LINE_ITEM_MATH in _failed_rules(report)


def test_uae_vat_enforced_only_for_aed() -> None:
    """5% is the UAE rate; enforcing it on a USD invoice would flag every foreign
    supplier, so the rule is currency-scoped."""
    aed = _validate(tax="200.00", total="1300.00")
    assert RULE_VAT_RATE in _failed_rules(aed)

    usd = _validate(currency="USD", tax="88.00", total="1188.00")
    assert usd.passed


def test_zero_rated_supply_is_legitimate() -> None:
    assert _validate(tax="0", total="1100.00").passed


def test_future_issue_date_is_rejected() -> None:
    report = _validate(issue_date="2027-01-01", due_date="2027-01-31")
    assert RULE_DATE_SANITY in _failed_rules(report)


def test_due_date_cannot_precede_issue_date() -> None:
    report = _validate(due_date="2026-06-01")
    assert RULE_DUE_AFTER_ISSUE in _failed_rules(report)


def test_missing_required_fields_block_auto_commit() -> None:
    report = _validate(vendor=None)
    assert not report.passed
    assert RULE_VENDOR_PRESENT in _failed_rules(report)


def test_negative_total_is_rejected() -> None:
    report = _validate(subtotal="-1100.00", tax="-55.00", total="-1155.00")
    assert RULE_TOTAL_POSITIVE in _failed_rules(report)


def test_failure_summary_names_the_rule_and_the_numbers() -> None:
    """The summary is fed back to the model as repair feedback, so it has to be
    specific enough to act on."""
    summary = _validate(total="1200.00").failure_summary()
    assert RULE_TOTALS_RECONCILE in summary
    assert "1155.00" in summary and "1200.00" in summary


@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        ("AED 1,234.56", Decimal("1234.56")),
        ("1.234,56", Decimal("1234.56")),
        ("(500.00)", Decimal("-500.00")),
        ("$99", Decimal("99")),
        ("1,23", Decimal("1.23")),
        ("12,345,678.90", Decimal("12345678.90")),
        ("  42  ", Decimal("42")),
        ("", None),
        ("abc", None),
        (None, None),
        (True, None),  # bool is an int subclass but never a money value
    ],
)
def test_money_parsing(raw: object, expected: Decimal | None) -> None:
    assert parse_money(raw) == expected


@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        ("2026-03-14", date(2026, 3, 14)),
        ("14/03/2026", date(2026, 3, 14)),
        ("14 Mar 2026", date(2026, 3, 14)),
        ("March 14, 2026", date(2026, 3, 14)),
        ("not a date", None),
        ("", None),
        (None, None),
    ],
)
def test_date_parsing(raw: object, expected: date | None) -> None:
    assert parse_date(raw) == expected


def test_schema_forbids_invented_fields() -> None:
    """`extra="forbid"` is what turns "the model made something up" into a
    repairable schema error instead of silent data."""
    with pytest.raises(ValueError, match=r"extra_forbidden|Extra inputs"):
        InvoiceExtraction.model_validate({**CLEAN, "approved_by": "the model"})


def test_nulls_are_allowed_everywhere() -> None:
    """A field that is genuinely absent must have a representation, or the model
    is cornered into guessing."""
    extraction = InvoiceExtraction.model_validate({})
    assert extraction.vendor is None
    assert extraction.total is None
    assert extraction.line_items == []