diff --git a/backend/app/collections/README.md b/backend/app/collections/README.md index 13c63cf..6ac0387 100644 --- a/backend/app/collections/README.md +++ b/backend/app/collections/README.md @@ -5,17 +5,22 @@ service built on `fastapi/full-stack-fastapi-template`. This file is the single source of truth for what the service does and how to run it; see `docs/` for internals (owned per file, see below). -Status: **Phase 1 (Domain core)**. This README will be filled in as each -phase lands; see `../../../docs/CHANGELOG.md` for what has shipped so far. +Status: **Phase 2 (Validation and exceptions) complete**. This README +will be filled in as each phase lands; see `../../../docs/CHANGELOG.md` +for what has shipped so far. ## What this does Computes an overdue collections position — invoice-level and by region/customer — from an ERP export (Customers, Invoices, Payments, -Region_Mapping sheets), as of a fixed report date. Phase 1 covers the pure -calculation core: loading the workbook into typed records and computing -outstanding/overdue/ageing/region figures. No API, database, or exception -reporting yet — see `docs/CHANGELOG.md` for what each phase adds. +Region_Mapping sheets), as of a fixed report date, and flags every data +quality or business-rule issue it finds along the way rather than +silently dropping the affected row. Phase 1 built the pure calculation +core (loading the workbook into typed records, computing +outstanding/overdue/ageing/region figures); Phase 2 added the 14-rule +exception catalogue (`docs/RULES.md`) that explains every exclusion. No +API or database persistence yet — see `docs/CHANGELOG.md` for what each +phase adds. ## How to run @@ -70,9 +75,30 @@ guard, and the agent-side build tooling (ponytail, caveman, anydoc)._ ## Validation performed -_Filled in Phase 2 and 10: rule coverage test, reconciliation test, -independent SQL recompute — see `docs/DEVELOPMENT.md` for how to run the -test suite._ +- **107 tests** (`uv run pytest app/collections/tests -v`): loader/resolver + tests, boundary tests on the calculators, a positive and a negative case + per exception rule against hand-built records, and the tests below. +- **Rule coverage test** (`tests/validate/test_coverage.py`): asserts every + one of the 14 rules fires at least once against `dataset_a_original.xlsx`, + and that the registry covers exactly E001-E014 — a rule that never + triggers is treated as a bug, not good news. +- **Reconciliation tests** (`tests/test_reconcile.py`): two identities + proving nothing silently vanishes. Every invoice's + `outstanding + valid_paid == invoice_amount + overpaid`, summed across + the full 36-invoice portfolio; and every payment rupee is either applied + to reduce some invoice's outstanding or explicitly unapplied, with every + unapplied payment traceable to an exception row. These are + internal-consistency checks — verifying the calculator's own arithmetic + never creates or destroys a rupee — not an independently-derived second + method; that's the SQL crosscheck deferred to Phase 3+, once persistence + exists to recompute from. Verifying the pandas-free result with a second + method there will be the most convincing possible answer to "what + validation did you perform." +- **Exact-ID assertions throughout**, not just "fires at least once": + every rule test and the reference-number regression test assert the + precise set of invoice/payment/customer IDs a rule or calculation + should produce, so a rule silently over- or under-firing is caught, not + just a rule going fully silent. ## Owning-doc map diff --git a/backend/app/collections/docs/DEVELOPMENT.md b/backend/app/collections/docs/DEVELOPMENT.md index 742f2f6..9261b0d 100644 --- a/backend/app/collections/docs/DEVELOPMENT.md +++ b/backend/app/collections/docs/DEVELOPMENT.md @@ -3,7 +3,7 @@ Owns: local setup, commands, git workflow, commit format, testing, release process. Does not own: business logic (see `README.md`). -Status: **Phase 0 skeleton.** +Status: **Phase 2 complete.** ## Local setup @@ -17,7 +17,8 @@ changes that flow yet. ``` cd backend -uv run pytest app/collections/tests -v # Phase 1 test suite +uv run pytest app/collections/tests -v # full test suite (107 tests) +uv run pytest app/collections/tests/validate -v # exception rule tests only uv run python -m app.collections.scripts.reference_summary # reference numbers against dataset A ``` @@ -31,9 +32,14 @@ the repo root for the full standing instructions this build follows. Phase 1 covers boundary tests (due date exactly on the report date; a payment landing exactly on the report date) and a reference-number regression test against `fixtures/dataset_a_original.xlsx` -(`tests/test_reference_numbers.py`). Rule-unit/coverage/gate/guard/ -failure/reconcile layers are added from Phase 2 on — see MASTER_PLAN.md -section 10 for the full test layer breakdown. +(`tests/test_reference_numbers.py`). Phase 2 added one test file per +exception rule under `tests/validate/` (positive case against dataset A +plus hand-built negative/boundary cases), a rule-coverage test +(`tests/validate/test_coverage.py`), and two reconciliation identities +(`tests/test_reconcile.py`). Gate/guard/failure layers are added from +Phase 3 on; the independently-derived SQL crosscheck needs persistence +and is deferred there too — see MASTER_PLAN.md section 10 for the full +test layer breakdown. ## Release process diff --git a/backend/app/collections/docs/RULES.md b/backend/app/collections/docs/RULES.md index ddb1f43..297ea1a 100644 --- a/backend/app/collections/docs/RULES.md +++ b/backend/app/collections/docs/RULES.md @@ -3,11 +3,13 @@ Owns: the exception rule catalogue, single source of truth for E001-E014. Does not own: anything that isn't a rule. -Status: **14/14 rules implemented** in `validate/rules.py`, registered in -`validate/engine.py`. Coverage asserted in -`tests/validate/test_coverage.py` (lands in the next PR, along with the -reconciliation tests). This file is the single source of truth for the -catalogue and must match the code exactly. +Status: **14/14 rules implemented and complete.** `validate/rules.py` +defines each rule; `validate/engine.py`'s `RULE_REGISTRY` registers all +14; `tests/validate/test_coverage.py` asserts every rule fires on +dataset A and the registry covers exactly E001-E014; +`tests/test_reconcile.py` proves no invoice or payment rupee is lost. +This file is the single source of truth for the catalogue and must match +the code exactly. ## Severity @@ -22,20 +24,24 @@ which describes pipeline-stage health, not business weight. | Code | Category | Severity | Dataset A count | |---|---|---|---| | E001 | Missing due date | error | 1 | +| E002 | Unknown customer reference (invoice or payment) | error | 2 | +| E003 | Unknown invoice reference | error | 1 | | E004 | Non-positive invoice amount | error | 1 | -| E011 | Cancelled invoice | warning | 1 | -| E012 | Credit Note invoice | warning | 1 | -| E013 | Duplicate source system reference | warning | 2 | +| E005 | Non-positive payment amount | error | 2 | | E006 | Invalid GSTIN format | warning | 1 | +| E007 | Payment-to-invoice customer mismatch | error | 1 | | E008 | Missing GSTIN | warning | 1 | -| E005 | Non-positive payment amount | error | 2 | | E009 | Payment after report date | warning | 1 | -| E002 | Unknown customer reference (invoice or payment) | error | 2 | -| E003 | Unknown invoice reference | error | 1 | -| E007 | Payment-to-invoice customer mismatch | error | 1 | | E010 | Payment before invoice date | warning | 1 | +| E011 | Cancelled invoice | warning | 1 | +| E012 | Credit Note invoice | warning | 1 | +| E013 | Duplicate source system reference | warning | 2 | | E014 | Overpayment (payments exceed invoice amount) | warning | 1 | +17 exception rows total against 36 invoice records (~47%) — consistent +with QA_PREP.md Q7's "roughly 16 to 19, about 45%" as an independent +sanity check that this catalogue is calibrated correctly. + ## E001 — Missing due date **Condition:** `Invoice.due_date is None`. @@ -46,6 +52,31 @@ date). **Why:** an invoice with no due date cannot be classified overdue or current; excluding it and flagging it beats guessing a date. +## E002 — Unknown customer reference + +**Condition:** `customer_id` on an invoice *or* a payment is not present +in the Customers sheet. One rule, checked against both sheets, since +either kind of record can carry a dangling customer reference. +**Fires on:** INV-1026 (Customer_ID C999), PAY-2025 (Customer_ID C999). +PAY-2025 also fires E003 independently (its Invoice_ID is unknown too) — +deliberately two separate rows rather than one combined flag, so each +broken reference is visible regardless of whether the other were fixed. +**Excludes:** the invoice from the overdue report; a payment referencing +an unknown customer is also, incidentally, unresolvable (see E003). +**Why:** cannot attribute a Region or a credit position to a customer +that doesn't exist in the master data. + +## E003 — Unknown invoice reference + +**Condition:** `Payment.invoice_id` is not present in the Invoices sheet. +**Fires on:** PAY-2025 (Invoice_ID INV-9999). PAY-2025 also fires E002 +independently (its Customer_ID is C999) — see E002 above. +**Excludes:** the payment is never applied to anything — +`calculate/outstanding.py` indexes payments by real invoice IDs, so there +is nothing to index it under. +**Why:** cash received against a reference that doesn't exist needs a +human to say what it was actually for. + ## E004 — Non-positive invoice amount **Condition:** `Invoice.invoice_amount <= 0`. @@ -55,33 +86,15 @@ current; excluding it and flagging it beats guessing a date. or a credit that hasn't been reclassified as a Credit Note; either way it should not silently net into the overdue total. -## E011 — Cancelled invoice - -**Condition:** `Invoice.status == "Cancelled"`. -**Fires on:** INV-1029. -**Excludes:** the overdue report (`calculate/overdue.py`'s -`ELIGIBLE_STATUS`). -**Why:** correct-by-design exclusion, not defective data — shown here so -nothing silently disappears from the picture. - -## E012 — Credit Note invoice - -**Condition:** `Invoice.status == "Credit Note"`. -**Fires on:** INV-1030. -Same treatment and rationale as E011. - -## E013 — Duplicate source system reference +## E005 — Non-positive payment amount -**Condition:** two or more invoices share a non-blank -`Source_System_Ref`. Blank references are not compared against each -other — a shared "no reference recorded" is not a duplicate. -**Fires on:** INV-1011 and INV-1031, both `EMP-SO-0011`. Emitted as one -row per affected invoice (two rows total), each naming the sibling(s). -**Excludes:** nothing — both invoices remain in the overdue report -independently. -**Why:** a shared ERP reference across two different invoice records can -mean a duplicate entry or a legitimately split sales order; either way it -needs investigation, not an automatic guess. +**Condition:** `Payment.payment_amount <= 0`. +**Fires on:** PAY-2018 (0), PAY-2029 (-5,000). +**Excludes:** the payment from the valid-payments total for its invoice +(`calculate/outstanding.py`'s `is_valid_payment` requires `> 0`). +**Why:** a zero or negative payment amount cannot reduce an outstanding +balance; it is either a reversal that needs its own record or a +recording error. ## E006 — Invalid GSTIN format @@ -96,6 +109,20 @@ math. **Why:** flags master-data quality for whoever owns Customer records; doesn't block collection reporting. +## E007 — Payment-to-invoice customer mismatch + +**Condition:** `Payment.customer_id != Invoice.customer_id` for the +invoice the payment names (only evaluated when that invoice exists — see +E003). +**Fires on:** PAY-2026 — recorded against customer C003, but INV-1002 +belongs to C002. +**Excludes:** the payment from C002's valid paid total, so C002 correctly +still shows overdue rather than being credited with someone else's cash. +**Why (worked example, matches MASTER_PLAN.md section 8 and QA_PREP.md +Q11):** the payment is flagged rather than reassigned, since reassignment +would be a silent data correction moving cash between ledgers on a guess. +Owner: Accounts Receivable. + ## E008 — Missing GSTIN **Condition:** `Customer.gstin is None`. @@ -105,16 +132,6 @@ doesn't block collection reporting. because "missing" and "malformed" call for different remediation (get the value vs. fix the value). -## E005 — Non-positive payment amount - -**Condition:** `Payment.payment_amount <= 0`. -**Fires on:** PAY-2018 (0), PAY-2029 (-5,000). -**Excludes:** the payment from the valid-payments total for its invoice -(`calculate/outstanding.py`'s `is_valid_payment` requires `> 0`). -**Why:** a zero or negative payment amount cannot reduce an outstanding -balance; it is either a reversal that needs its own record or a -recording error. - ## E009 — Payment after report date **Condition:** `Payment.payment_date > report_date`. @@ -128,45 +145,6 @@ business activity, not bad data — hence `warning`, not `error` — but a reviewer looking at why a position didn't move needs to see it, not infer it. -## E002 — Unknown customer reference - -**Condition:** `customer_id` on an invoice *or* a payment is not present -in the Customers sheet. One rule, checked against both sheets, since -either kind of record can carry a dangling customer reference. -**Fires on:** INV-1026 (Customer_ID C999), PAY-2025 (Customer_ID C999). -PAY-2025 also fires E003 independently (its Invoice_ID is unknown too) — -deliberately two separate rows rather than one combined flag, so each -broken reference is visible regardless of whether the other were fixed. -**Excludes:** the invoice from the overdue report; a payment referencing -an unknown customer is also, incidentally, unresolvable (see E003). -**Why:** cannot attribute a Region or a credit position to a customer -that doesn't exist in the master data. - -## E003 — Unknown invoice reference - -**Condition:** `Payment.invoice_id` is not present in the Invoices sheet. -**Fires on:** PAY-2025 (Invoice_ID INV-9999). PAY-2025 also fires E002 -independently (its Customer_ID is C999) — see E002 above. -**Excludes:** the payment is never applied to anything — -`calculate/outstanding.py` indexes payments by real invoice IDs, so there -is nothing to index it under. -**Why:** cash received against a reference that doesn't exist needs a -human to say what it was actually for. - -## E007 — Payment-to-invoice customer mismatch - -**Condition:** `Payment.customer_id != Invoice.customer_id` for the -invoice the payment names (only evaluated when that invoice exists — see -E003). -**Fires on:** PAY-2026 — recorded against customer C003, but INV-1002 -belongs to C002. -**Excludes:** the payment from C002's valid paid total, so C002 correctly -still shows overdue rather than being credited with someone else's cash. -**Why (worked example, matches MASTER_PLAN.md section 8 and QA_PREP.md -Q11):** the payment is flagged rather than reassigned, since reassignment -would be a silent data correction moving cash between ledgers on a guess. -Owner: Accounts Receivable. - ## E010 — Payment before invoice date **Condition:** `Payment.payment_date < Invoice.invoice_date` (only when @@ -179,6 +157,34 @@ README.md Assumptions; this is a data-quality-only flag. pre-payment against a purchase order), but the workbook doesn't authorize excluding it, so the calculation still counts it in full. +## E011 — Cancelled invoice + +**Condition:** `Invoice.status == "Cancelled"`. +**Fires on:** INV-1029. +**Excludes:** the overdue report (`calculate/overdue.py`'s +`ELIGIBLE_STATUS`). +**Why:** correct-by-design exclusion, not defective data — shown here so +nothing silently disappears from the picture. + +## E012 — Credit Note invoice + +**Condition:** `Invoice.status == "Credit Note"`. +**Fires on:** INV-1030. +Same treatment and rationale as E011. + +## E013 — Duplicate source system reference + +**Condition:** two or more invoices share a non-blank +`Source_System_Ref`. Blank references are not compared against each +other — a shared "no reference recorded" is not a duplicate. +**Fires on:** INV-1011 and INV-1031, both `EMP-SO-0011`. Emitted as one +row per affected invoice (two rows total), each naming the sibling(s). +**Excludes:** nothing — both invoices remain in the overdue report +independently. +**Why:** a shared ERP reference across two different invoice records can +mean a duplicate entry or a legitimately split sales order; either way it +needs investigation, not an automatic guess. + ## E014 — Overpayment (payments exceeding invoice amount) **Condition:** sum of valid payments for an invoice (the same @@ -205,4 +211,7 @@ business decision the workbook doesn't define — surfaced, not assumed. nothing. - E014 only evaluates invoices with `invoice_amount > 0`, to avoid a spurious fire against an already-E004-flagged negative-amount invoice - with zero payments against it. + with zero payments against it. It also doesn't distinguish "genuine + overpayment" from "a floored negative amount" in the same way as the + reconciliation identity in `tests/test_reconcile.py` — see that file's + docstring for the subtlety this uncovered. diff --git a/backend/app/collections/tests/test_reconcile.py b/backend/app/collections/tests/test_reconcile.py new file mode 100644 index 0000000..b00c99f --- /dev/null +++ b/backend/app/collections/tests/test_reconcile.py @@ -0,0 +1,148 @@ +"""Reconciliation tests: proves the calculate/ + validate/ layers together +account for every invoice and payment rupee, operationalizing +MASTER_PLAN.md section 10's "invoices = outstanding + valid payments + +excluded amounts, must tie" as checkable properties. + +These are internal-consistency checks -- definitionally true given +compute_outstanding's own max(0, ...) floor -- not the independently +derived recompute promised in MASTER_PLAN.md section 10 (that's Phase +3+'s test_sql_crosscheck.py, once persistence exists to recompute from). + +Note on the invoice-amount identity: an earlier design draft assumed +"overpaid" (paid exceeding invoice_amount) is nonzero only for the E014 +case, INV-1007. That's false: INV-1028's negative invoice_amount with +zero payments against it also produces a nonzero overpaid term under the +same max(0, paid - amount) formula (0 - (-45000) = 45000), since +compute_outstanding floors ANY negative (amount - paid) at zero, not just +genuine overpayments. The identity below is written to hold regardless -- +verified directly against dataset A rather than assumed. +""" + +from decimal import Decimal + +from app.collections.calculate.outstanding import ( + compute_outstanding, + index_payments_by_invoice, + is_valid_payment, + valid_payments_total, +) +from app.collections.calculate.overdue import is_eligible +from app.collections.config import settings +from app.collections.contracts import CanonicalDataset +from app.collections.validate.engine import exceptions_by_rule + +ZERO = Decimal("0") + +EXCLUDED_INVOICE_IDS = {"INV-1026", "INV-1027", "INV-1028", "INV-1029", "INV-1030"} +UNAPPLIED_PAYMENT_IDS = {"PAY-2018", "PAY-2020", "PAY-2025", "PAY-2026", "PAY-2029"} + +# Rule codes that can leave a payment unapplied (excluded from every +# invoice's valid-payments total): unknown customer/invoice reference, +# non-positive amount, customer mismatch, after the report date. +UNAPPLIED_PAYMENT_RULE_CODES = {"E002", "E003", "E005", "E007", "E009"} + + +def test_invoice_amount_identity_reconciles(dataset: CanonicalDataset) -> None: + """For every invoice, outstanding + valid_paid == invoice_amount + + overpaid (compute_outstanding's floor and the overpaid excess are + exact mirror images of the same amount-minus-paid split: neither + term is ever negative, and exactly one is nonzero for any given + invoice). Summed across the full 36-invoice portfolio, no rupee is + created or destroyed.""" + report_date = settings.REPORT_DATE + payments_by_invoice = index_payments_by_invoice(dataset.payments) + + total_amount = ZERO + total_overpaid = ZERO + total_outstanding = ZERO + total_paid = ZERO + for invoice in dataset.invoices: + paid = valid_payments_total(invoice, payments_by_invoice, report_date) + outstanding = compute_outstanding(invoice, payments_by_invoice, report_date) + overpaid = max(ZERO, paid - invoice.invoice_amount) + total_amount += invoice.invoice_amount + total_overpaid += overpaid + total_outstanding += outstanding + total_paid += paid + + assert total_amount + total_overpaid == total_outstanding + total_paid + # Concrete values, not just the identity, so a future change that + # breaks a specific number gets caught precisely: + assert total_amount == Decimal("4352000") + assert total_overpaid == Decimal("67000") # INV-1007 (22,000) + INV-1028 (45,000) + assert total_outstanding == Decimal("2957000") + assert total_paid == Decimal("1462000") + + +def test_excluded_invoices_remain_individually_traceable(dataset: CanonicalDataset) -> None: + """An invoice excluded from the overdue report (unknown customer, + missing due date, non-positive amount, Cancelled, Credit Note) is not + erased -- its outstanding position is still computable, per + AGENTS.md's "never drop a row silently." For the four excluded + invoices with a genuinely positive amount, outstanding ties back to + invoice_amount minus valid payments exactly (all zero in dataset A). + INV-1028's negative amount floors its outstanding at zero; its true + magnitude is captured in the overpaid term of the identity above + instead, and its amount itself is flagged by E004.""" + report_date = settings.REPORT_DATE + payments_by_invoice = index_payments_by_invoice(dataset.payments) + known_customer_ids = {c.customer_id for c in dataset.customers} + + excluded = [i for i in dataset.invoices if not is_eligible(i, known_customer_ids)] + assert {i.invoice_id for i in excluded} == EXCLUDED_INVOICE_IDS + + for invoice in excluded: + paid = valid_payments_total(invoice, payments_by_invoice, report_date) + outstanding = compute_outstanding(invoice, payments_by_invoice, report_date) + if invoice.invoice_amount > ZERO: + assert outstanding == invoice.invoice_amount - paid + else: + assert outstanding == ZERO + + +def test_payment_amount_identity_reconciles(dataset: CanonicalDataset) -> None: + """Every payment rupee is either applied to reduce some invoice's + outstanding, or explicitly unapplied -- never silently vanished.""" + report_date = settings.REPORT_DATE + payments_by_invoice = index_payments_by_invoice(dataset.payments) + invoices_by_id = {i.invoice_id: i for i in dataset.invoices} + + total_payment_amount = sum((p.payment_amount for p in dataset.payments), ZERO) + total_applied = sum( + (valid_payments_total(invoice, payments_by_invoice, report_date) for invoice in dataset.invoices), ZERO + ) + + unapplied_total = ZERO + unapplied_payment_ids: set[str] = set() + for payment in dataset.payments: + invoice = invoices_by_id.get(payment.invoice_id) + if invoice is None or not is_valid_payment(payment, invoice, report_date): + unapplied_total += payment.payment_amount + unapplied_payment_ids.add(payment.payment_id) + + assert total_applied + unapplied_total == total_payment_amount + assert unapplied_payment_ids == UNAPPLIED_PAYMENT_IDS + assert total_payment_amount == Decimal("1549000") + assert total_applied == Decimal("1462000") + assert unapplied_total == Decimal("87000") + + +def test_every_unapplied_payment_has_an_exception_row(dataset: CanonicalDataset) -> None: + """Operationalizes "no payment silently vanishes" as a checkable + property: every unapplied payment is explained by at least one + exception row keyed to its payment_id.""" + report_date = settings.REPORT_DATE + invoices_by_id = {i.invoice_id: i for i in dataset.invoices} + + unapplied_payment_ids: set[str] = set() + for payment in dataset.payments: + invoice = invoices_by_id.get(payment.invoice_id) + if invoice is None or not is_valid_payment(payment, invoice, report_date): + unapplied_payment_ids.add(payment.payment_id) + + grouped = exceptions_by_rule(dataset, report_date) + flagged_payment_ids = { + row.payment_id for code in UNAPPLIED_PAYMENT_RULE_CODES for row in grouped[code] if row.payment_id is not None + } + + assert unapplied_payment_ids <= flagged_payment_ids diff --git a/backend/app/collections/tests/validate/test_coverage.py b/backend/app/collections/tests/validate/test_coverage.py new file mode 100644 index 0000000..c049e78 --- /dev/null +++ b/backend/app/collections/tests/validate/test_coverage.py @@ -0,0 +1,26 @@ +"""Rule coverage: every rule in RULE_REGISTRY fires at least once on +dataset A (MASTER_PLAN.md Phase 2 done-when criterion), and the registry +covers exactly E001-E014. Iterates the registry rather than hardcoding +rule names, so this test can never drift from the catalogue. +""" + +from app.collections.config import settings +from app.collections.contracts import CanonicalDataset +from app.collections.validate import engine + + +def test_every_rule_fires_at_least_once_on_dataset_a(dataset: CanonicalDataset) -> None: + grouped = engine.exceptions_by_rule(dataset, settings.REPORT_DATE) + for code in engine.RULE_REGISTRY: + assert grouped.get(code), f"{code} never fired on dataset A" + + +def test_registry_covers_all_fourteen_codes() -> None: + assert set(engine.RULE_REGISTRY) == {f"E{n:03d}" for n in range(1, 15)} + + +def test_total_exception_row_count_on_dataset_a(dataset: CanonicalDataset) -> None: + """17 rows: every rule fires once except E002 and E013, which fire + twice each (see docs/RULES.md's summary table).""" + all_rows = engine.run_all_rules(dataset, settings.REPORT_DATE) + assert len(all_rows) == 17 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cd768a3..e864fc0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-08-31 + +### Added + +- `contracts.py`: `ExceptionRow` (rule code, category, plain-English + message, severity, natural keys, native-typed detail dict). +- `validate/schemas.py`: declarative `SheetSchema` structural constants + and `sheet_row_counts()` — deliberately not Pandera; reasoning in + `docs/ARCHITECTURE.md`. +- `validate/engine.py`: registry-driven rule runner (`RULE_REGISTRY`, + `run_all_rules`, `exceptions_by_rule`). +- `validate/rules.py`: all 14 exception rules (E001-E014) — missing due + date, unknown customer/invoice references, non-positive invoice/payment + amounts, invalid/missing GSTIN, payment-to-invoice customer mismatch, + payment after report date, payment before invoice date, Cancelled/Credit + Note status, duplicate source system reference, and overpayment (E014, + added beyond the assessment's required list). +- `docs/RULES.md`: complete 14-rule catalogue, one entry per code, with + condition, dataset A trigger, exclusion behavior, and rationale. +- Test suite additions under `backend/app/collections/tests/` (65 new + tests, 107 total): one file per rule with a dataset-A positive case + plus hand-built negative/boundary cases; `tests/validate/factories.py` + shared record builders; `tests/validate/test_coverage.py` (every rule + fires on dataset A, registry covers exactly E001-E014); two + reconciliation identities in `tests/test_reconcile.py` proving no + invoice or payment rupee is lost. + +### Fixed + +- `QA_PREP.md` Q20's answer, which claimed Pandera was already in use for + structural checks — corrected to match the actual Phase 2 design + decision. + ## [0.2.0] - 2026-08-31 ### Added