# FI-023 — PO ↔ Bill Reconciliation (Python · Django)

**Status:** Spec · Ready for senior developer
**Roadmap card:** Comes after FI-022 (AI Bill Processing). Reference mockup live at [fi-ai-styling-guide/reconcile](https://fi-ai-styling-guide-3413f27b698e.herokuapp.com/reconcile.html).
**Last updated:** 2026-04-24
**Owner:** Paul
**Related specs:** fi-022-ai-bill-processing, admin-dashboard-phase-3, voice-stack-standard
**Reference implementation (prototype):** `fi-supplier-po` → https://fi-supplier-po-9e72ddd27f18.herokuapp.com
**Design pattern:** AI Styling Pattern v0.7.0+

> **Scope note.** This spec owns the **PO ↔ Bill reconciliation page and API only**. Parsing invoices and quotes (the AI half) lives in FI-022. Xero OAuth and approval workflows for the CFO live in admin-dashboard-phase-3.

> **Research note.** This spec was written from the FieldInsight prototype behaviour (`fi-supplier-po` app), the reconciliation mockup in `fi-ai-styling-guide/reconcile.html`, and iterative product discussion — **not** from inspecting the live FieldInsight codebase. Field names (`bill.status`, `po.status`, `PurchaseOrderLineItem.received_qty`, URL patterns, error envelope shapes) are best-guess reconstructions. The senior developer should cross-reference the real FI codebase on first read and adjust identifiers/paths to match existing FI conventions where they diverge. The *logic* of the reconciliation (matching, variance detection, approval transaction, PO close) is correct; only the *identifiers* may need renaming.

---

## 1. Requirements Document

### 1.1 Functional requirements

| # | Requirement | Priority |
|---|---|---|
| FR-ENTRY-1 | **PO page exposes four action buttons** as the canonical entry points into document + Bill workflows. Two are neutral (`+ Add File` and `+ New Bill Received`), two are AI-pills (`✨ Parse PO Quote` and `✨ Reconcile Supplier Invoice`). All four sit in the PO header action row (or equivalent prominent slot), in left-to-right order: `+ Add File`, `✨ Parse PO Quote`, `+ New Bill Received`, `✨ Reconcile Supplier Invoice`. Each has a single, distinct outcome (summarised in FR-ENTRY-2 through FR-ENTRY-2d). No drag-drop zone on the PO page competes with these buttons. | MUST |
| FR-ENTRY-2a | **`+ Add File` button** attaches a file to the PO without parsing and without any further action. Clicking opens a file picker (or accepts a drop into the button); the file lands on the PO as an attachment in `parsed_state=none`. No Claude call. No Bill created. No navigation. Useful when the user wants to hold a document against the PO for later reference. | MUST |
| FR-ENTRY-2b | **`✨ Parse PO Quote` button** is a button-equivalent of dropping a supplier quote on the PO's drop zone. Clicking opens a file picker; the file is parsed and applied to the PO *in place* exactly as per the PO-drop behaviour in FI-022 FR-4. Prices refresh, new items added as new PO lines, supplier-match check runs. No Bill is created. No navigation. Stays on the PO page. | MUST |
| FR-ENTRY-2c | **`+ New Bill Received` button** is the **quick-path Bill creation flow**. Clicking opens a confirm modal *"Create a new Bill from this invoice?"* with three choices: `Parse and create new Bill & link to PO` (primary), `Just attach to PO — I'll create the Bill later` (secondary), `Cancel` (ghost). On the primary path, a new `Bill` is created linked to this PO, the attachment is reparented to the new Bill, the invoice is parsed into the Bill's line items in the background, and the user is navigated to the new Bill's detail page — **NOT** the reconcile page. Use this when the user wants to register a Bill without engaging the side-by-side reconciliation UI. | MUST |
| FR-ENTRY-2d | **Why `+ New Bill Received` and `✨ Reconcile Supplier Invoice` coexist.** They serve different motivations. `+ New Bill Received` is for quick Bill registration — "here's the invoice, make me a Bill, I'll review later." `✨ Reconcile Supplier Invoice` is for deliberate reconciliation — "I want to match this invoice to the PO line-by-line before approving." Both routes create a Bill linked to the PO; the difference is what happens after creation (Bill page vs reconcile page). The user picks the flow that matches the moment, not the other way around. | MUST |
| FR-ENTRY-3 | **`✨ Reconcile Supplier Invoice` button** creates a new **blank Bill** linked to this PO (`bill.purchase_order = this_po`, `bill.status = draft`, 0 line items, `bill.supplier = null`) and **navigates the user to the reconciliation page for that Bill** (conceptual URL: `/bills/{id}/reconcile`). Unlike `+ New Bill Received`, no invoice is attached at button-click time — the user drops the invoice on the reconcile page so the live match animation is visible. The blank Bill is persisted before navigation so refreshing the reconcile page is safe. | MUST |
| FR-ENTRY-4 | **The reconcile page supports live invoice drop-parse.** On arrival from FR-ENTRY-3, the Bill column is blank with a prominent drop zone *"Drop the supplier invoice here"*. Dropping a PDF triggers parse-immediately per FI-022 FR-2/FR-3 (auto-parse on drop for Bills). As items land on the Bill, the line-matcher (FR-3), variance detector (FR-4/FR-5), and status columns re-render live. The user doesn't leave the reconcile page through the full journey — drop → parse → match → acknowledge → approve. | MUST |
| FR-ENTRY-5 | **Cancelling a blank reconciliation.** If the user navigates away from the reconcile page without dropping a file (so the Bill is still blank, 0 line items, no attachments), a confirm prompt asks *"Leave without reconciling? This Bill has no items yet — it will stay as a Draft Bill on this PO until you come back or delete it."*. No auto-delete; the user can return and drop the invoice later, or explicitly trash the Draft Bill from the PO's Bill list. | SHOULD |
| FR-PDF-1 | **PDF preview panel on the reconcile page.** After the supplier invoice is parsed, the reconcile page shows a left-hand column rendering the original invoice PDF page-by-page. The user can visually spot-check parsed items against what's actually on the invoice without leaving the page or opening a separate window. Use an embedded PDF renderer consistent with the rest of the FieldInsight app (existing FI PDF preview component if one exists; otherwise a lightweight PDF.js embed). | MUST |
| FR-PDF-2 | **Expand / collapse states for the PDF preview.** The preview panel has three user-controllable states, toggled via a small control on the panel's right edge: **Expanded** (default after a parse — preview takes ~40% of the page width, PO + Bill columns take the remaining ~60%); **Collapsed to sidebar** (a thin vertical strip on the left showing just the file icon + page count + an `expand` chevron; PO + Bill columns take near-full width); **Hidden** (preview removed from the layout entirely; toggleable from a header button labelled `Show PDF`). The state is persisted per user so their preference survives reloads. | SHOULD |
| FR-PDF-3 | **PDF preview reflects the current parsed file.** If the Bill has multiple attachments, the preview shows the one Alfred most recently parsed. A small attachment switcher (filename dropdown or tab strip) lets the user flip between attachments without changing the reconcile data. Scrolling inside the preview pans the PDF; clicking a parsed line item on the right *may* (future enhancement) scroll the preview to the matching region — MVP is independent scrolling. | SHOULD |
| FR-1 | A Bill (in `Draft` state) that is linked to a `PurchaseOrder` renders a side-by-side reconciliation view: PO items on the left, Bill items on the right, with per-line match status. | MUST |
| FR-2 | Each PO line item shows its match status: `matched` (green `✓`, received_qty ≥ ordered_qty), `partial` (yellow), `outstanding` (red-tinted, `0/N` Rcvd column). | MUST |
| FR-3 | **Line-matching algorithm runs server-side** on page load and on every Bill edit: primary key = `PurchaseOrderLineItem.product_code == LineItem.product_code`; fallback = fuzzy match on description (Levenshtein ratio ≥ 0.85) gated by quantity parity. Unmatched Bill items appear in a "Not on PO" row group beneath the Bill table. | MUST |
| FR-4 | **Price variance detection**: when a matched Bill item's `unit_price_ex_gst` differs from the PO line's `unit_price_ex_gst` by more than ±1.0%, the row is flagged with an amber variance badge showing `Δ $X.XX (±Y%)`. Variance does not block approval — it prompts acknowledgement. | MUST |
| FR-5 | **Quantity variance detection**: when `bill_qty > po_outstanding` on a matched line, the row is flagged `Over-invoiced` (red badge) and the excess qty is highlighted. | MUST |
| FR-6 | **Outstanding-item rendering**: PO line items not present on this Bill remain in the left-hand table with a red-tinted background and an explanatory sub-row `⚠ Not on this invoice — outstanding`. These rows stay visible until the PO closes. | MUST |
| FR-7 | Approving a reconciled Bill (`draft → approved`) increments `PurchaseOrderLineItem.received_qty` by the `LineItem.qty` of each matched Bill item, atomically inside the approval transaction. | MUST |
| FR-8 | `Save` action persists the reconciliation draft: line matches, user acknowledgements on variance flags, and any manual overrides (e.g. user marks a "Not on PO" item as "add to PO"). Does NOT change PO or Bill state. | MUST |
| FR-9 | `Approve` action atomically: (a) validates all variance flags acknowledged; (b) sets `bill.status = approved`; (c) increments `received_qty` on the PO; (d) if all PO items now satisfied → transitions PO `receiving → closed` (see fi-022-ai-bill-processing#FR-22); (e) enqueues Xero posting. | MUST |
| FR-10 | **Supplier-mismatch block**: if `bill.supplier != bill.purchase_order.supplier`, the reconciliation view is disabled and a full-width banner explains the mismatch with a single action `Unlink PO and reconcile later`. No silent overwrite. | MUST |
| FR-11 | **Partial-bill flow**: if a Bill covers fewer line items than the PO, approval still runs. Matched items get `received_qty` incremented; PO stays in `receiving` state; outstanding items stay red-tinted for the next Bill. | MUST |
| FR-12 | **Explicit Close PO** button appears on the PO page once `received_qty >= ordered_qty` for all line items — this is confirmation, not auto-close. Clicking runs the `receiving → closed` transition and surfaces a toast `PO-XXXX closed. 8 of 8 items received.` | SHOULD |
| FR-13 | **Bill Markup widget** on the reconciliation header shows `(Bill Total − PO Total) / PO Total × 100` as a percentage with a `Sales $` quick-action that pushes the computed markup to the linked Job's sell-price. | SHOULD |
| FR-14 | **`Copy to SRF` action** on the approved Bill: copies reconciled line items (matched only, excludes "Not on PO") to the linked Service Request Form. Shows toast `Line items copied to SRF-XXXX`. | SHOULD |
| FR-15 | **PO ↔ Bill link cardinality**: a Bill links to 0 or 1 POs. A PO has many Bills (one per physical invoice received). Enforced at the DB layer via `bill.purchase_order` FK + unique constraint on `(bill.id)`. | MUST |
| FR-16 | **"Not on PO" items** (items on the Bill with no matching PO line): user can either `Add to PO` (creates a new `PurchaseOrderLineItem` with `ordered_qty = bill_qty, received_qty = bill_qty`) or `Keep on Bill only` (default — Bill-only cost, no PO linkage). | MUST |
| FR-17 | **Variance acknowledgement** is tracked per-line in the reconciliation draft: `reconciliation_meta.acknowledged_variances = [line_id, ...]`. Approval is blocked until every amber/red variance badge is acknowledged or the user explicitly clicks `Approve with variances`. | MUST |
| FR-18 | **Read-only view** for non-PM roles: technicians see the reconciliation layout but cannot click `Approve`. The Approve button is replaced with an `Awaiting PM approval` chip. | SHOULD |
| FR-19 | **Markup recomputes live** as the user toggles line-item inclusion (via the right-column checkbox per Bill row). Excluded rows don't count toward Bill subtotal. | SHOULD |
| FR-20 | **Progressive disclosure**: if the Bill has 0 linked-PO items (pure Bill-only, no reconciliation needed), the reconciliation section is hidden entirely; the page reverts to a plain Bill layout. | MUST |

### 1.2 Non-functional requirements

| # | Requirement | Target |
|---|---|---|
| NFR-1 | Reconciliation view initial render (page load → interactive). | < 600 ms server-side; < 1.5 s total TTI with 100 line items |
| NFR-2 | Line-matching algorithm runtime for a PO with 200 items vs a Bill with 200 items. | < 100 ms (pure server-side, no DB round-trips after initial fetch) |
| NFR-3 | Approval transaction (status update + received_qty increments + PO close check + Xero enqueue). | < 300 ms P95, single DB transaction |
| NFR-4 | Concurrent reconciliation sessions on the same PO: last-writer-wins with optimistic lock warning on conflict (reload required). | MUST |
| NFR-5 | Variance detection must be deterministic — same inputs produce identical flag set every time. | MUST |
| NFR-6 | Audit log entry written on every reconciliation approval: who, when, which lines varied, amount variance, user acknowledgements. | MUST |

### 1.3 Out of scope (this ticket)

- **Parsing supplier invoices / quotes** — covered in FI-022
- **Xero OAuth and posting logic** — separate spec
- **Approval workflows for Bills over threshold $** — covered in admin-dashboard-phase-3
- **PO creation / authorisation workflow** — existing `fi-supplier-po` feature, not revisited here
- **Multi-currency reconciliation** — single-currency AUD only in v1

### 1.4 Constraints

- Python 3.12 · Django 5.2 · **FieldInsight custom API framework** (in-house, built on Django views + middleware — NOT Django REST Framework)
- PostgreSQL 15 · Redis 7 (for async Xero enqueue + Celery)
- Must consume the existing `Bill`, `PurchaseOrder`, `LineItem`, `PurchaseOrderLineItem` models defined in FI-022 §9. This spec adds only what is needed for reconciliation; no destructive schema changes.

---

## 2. Summary

When a supplier delivers a job's materials, they also send a Bill. The project manager must reconcile that Bill against the originating Purchase Order to confirm: "did we receive everything we ordered, at the price we agreed?" This is the **gate between procurement and payables**. If the reconciliation checks out, the Bill is approved, pushed to Xero, and the PO inches closer to `closed`. If there's a price variance or a missing item, the PM resolves it here — not by flipping between screens.

The reconciliation page is one view with two columns: PO on the left, Bill on the right. A match algorithm runs on every render. Variances (price, qty, missing, over-invoiced) surface as badges on the exact line that caused them. One click to approve, one click to acknowledge a variance, one click to close the PO when all items are received.

Most of the heavy lifting — Bill creation, AI parsing, supplier matching — is done upstream by FI-022. This spec owns what happens after parsing: the match, the variance, the approve, the PO close.

---

## 3. Architecture overview

### 3.0 · End-to-end entry flow (PO → reconciliation → close)

```
   PO page
   ──────────────────────────────────────────────
   │  PO-1648 · Receiving · 8 items ordered    │
   │                                            │
   │  [ ✨ Parse PO ]  [ ✨ Reconcile           │
   │                     Supplier Invoice ]    │
   │                                            │
   │                          ▼
   │                          │ click (FR-ENTRY-3)
   │                          │
   │                          ├─→ create blank Bill
   │                          │      bill.purchase_order = PO-1648
   │                          │      bill.status = 'draft'
   │                          │      bill.supplier = null
   │                          │      0 line items
   │                          │
   │                          └─→ redirect to reconcile page
   │
   ▼
   Reconcile page  (/bills/{id}/reconcile)
   ──────────────────────────────────────────────
   │  ┌──── PO column ────┐  ┌─── Bill column ──┐  │
   │  │ PO-1648            │  │ BL-0200 · Draft  │  │
   │  │ 8 items, 0 Rcvd    │  │                  │  │
   │  │                    │  │  Drop the        │  │
   │  │                    │  │  supplier        │  │
   │  │                    │  │  invoice here    │  │
   │  └────────────────────┘  └──────────────────┘  │
   │
   │                          ▼
   │                          │ drop PDF (FR-ENTRY-4)
   │                          │
   │                          ├─→ parse immediately (FI-022 FR-2)
   │                          │
   │                          ├─→ populate Bill items
   │                          │
   │                          ├─→ run LineMatcher live
   │                          │
   │                          └─→ render variance badges
   │
   ▼
   Same page, now populated
   ──────────────────────────────────────────────
   │  PO column: green ✓ on matched rows         │
   │  Bill column: 4 items + variance badges     │
   │  [Save]  [Approve]                          │
   │
   │       Story A — no variances → approve clean
   │       Story B — partial → approve, PO stays open
   │       Story C — price variance → acknowledge → approve
   │       Story D — supplier mismatch → block, unlink PO
   │       Story E — over-invoice → choose path → approve
   │       Story G — Not on PO items → Add to PO / Keep on Bill
   │
   ▼
   After Approve
   ──────────────────────────────────────────────
   │  Bill status → approved                    │
   │  PO received_qty incremented atomically     │
   │  If all PO lines fulfilled → PO closed      │
   │  (Story F — auto or manual close)           │
```

### 3.1 · Reconcile-page layout

```
┌─────────────────────────────────────────────────────────────┐
│  Reconcile page (/bills/{id}/reconcile)                     │
│  ┌──────────────────┐    ┌──────────────────────────────┐   │
│  │ PO-1648          │    │ Supplier Invoice             │   │
│  │ PurchaseOrder    │    │ (this Bill · BL-0200)        │   │
│  │ Receiving        │    │                              │   │
│  │                  │    │ ☑ Code   Desc   Qty  Price   │   │
│  │ Code Desc Ord Rcvd │  │                              │   │
│  │ ...  ...  2  2 ✓ │    │ (checkboxes per row)         │   │
│  │ ...  ...  1 0/1 ⚠│    │                              │   │
│  └──────────────────┘    └──────────────────────────────┘   │
│  [Save] [Approve] ── Bill Markup 40% · Sales $              │
└─────────────────────────────────────────────────────────────┘
```

**Runtime flow:**

```
GET /bills/{id} with ?view=reconcile
     │
     ▼
  BillReconciliationView
     │
     ├──► LineMatcher.match(po_items, bill_items)
     │        returns: [(po_line, bill_line | None, match_type, variances), ...]
     │
     ├──► VarianceDetector.flag(matches)
     │        returns: [VarianceFlag(line_id, kind, delta, ack_required), ...]
     │
     ├──► MarkupCalculator.compute(po_total, bill_total)
     │        returns: {markup_pct, variance_pct, bill_ex_gst, po_ex_gst}
     │
     ▼
  render reconciliation.html with matches + flags + markup
```

**Approval flow (atomic):**

```
POST /bills/{id}/approve
     │
     ▼
  BillApprovalService.approve_with_reconciliation(bill_id, user, acknowledgements)
     │
     ├──► validate: all amber/red variances acknowledged OR override flag set
     ├──► DB TX start
     │    ├──► bill.status = 'approved'
     │    ├──► for each matched line: po_line.received_qty += bill_line.qty
     │    ├──► check: all po_line.received_qty >= ordered_qty?
     │    │       YES → po.status = 'closed'
     │    │       NO  → (no change)
     │    └──► write AuditLog entry
     ├──► DB TX commit
     │
     ▼
  enqueue xero_post_bill.delay(bill_id)
     │
     ▼
  return 200 { status, po_status, audit_id }
```

---

## 4. Jobs To Be Done

This spec organises its user-facing behaviour as **Jobs To Be Done (JTBD)**. Each job answers *"what is the user hiring this feature to do?"* and is broken into the concrete **steps** the user and system take to complete it. The matching server behaviour is captured in §4a (Acceptance Criteria), §6 (API Endpoints), and §7 (Algorithms).

**Persona:** Dave, the project manager. He owns every PO from authorisation through close, and he's the gate between "goods received" and "Bill approved → Xero posted."

---

### §4.0 — Main Job · Reconcile a supplier invoice against its PO

> **When** goods and an invoice arrive from a supplier,
> **I want to** confirm every item I ordered is on the invoice at the agreed price,
> **so that** I can approve the Bill for payment and know exactly what's still outstanding on the PO.

**Success looks like:** Bill in `approved` state · `PurchaseOrderLineItem.received_qty` updated · outstanding items visible on the PO for the next delivery · audit trail captured · (if last delivery) PO transitioned to `closed`.

**Pain points it solves:**
- No flipping between PO and Bill pages to cross-check items manually
- Price variances surfaced automatically, not missed in manual review
- Silent Bill approval against the wrong supplier or missing items becomes impossible

This main job is composed of ten sub-jobs (§4.1 – §4.10). The core happy path is §4.1 → §4.2 → §4.7. Everything else is conflict resolution.

---

### §4.1 — Start reconciliation from the PO context

> **When** I'm on a PO page and the supplier's invoice arrives,
> **I want to** start the reconciliation in one click, without having to first create a Bill and link it,
> **so that** I stay in flow and don't lose context.

**Pre-condition:** Dave is on a PO page in `authorised` or `receiving` state.

**Steps:**
1. Dave opens PO-1648.
2. He sees two prominent AI-pill buttons in the PO header: `✨ Parse PO` and `✨ Reconcile Supplier Invoice`.
3. Dave clicks `✨ Reconcile Supplier Invoice`.
4. The system creates a blank Bill linked to this PO: `bill.purchase_order = PO-1648`, `bill.status = draft`, 0 line items, `bill.supplier = null`.
5. The system commits the Bill to the database before navigating.
6. Dave lands on the reconcile page at `/bills/BL-0200/reconcile`.
7. On arrival he sees two columns: PO-1648 fully populated on the left, and a blank Bill with a prominent drop zone *"Drop the supplier invoice here — Alfred parses it straight away"* on the right.

**Variations:**
- **Dave only wants to refresh PO pricing** — he clicks `✨ Parse PO` instead. Opens a file picker. The supplier quote parses in place against the PO. No Bill created, no navigation. See FI-022 Story C.

**Success:** Reconcile page open with PO column populated and Bill column ready to receive a drop, in one click from the PO page.

**Edge cases:**
- Dave already started a reconciliation on this PO earlier today but didn't drop a file. Handled in §4.9 (duplicate detection).
- Dave navigates away without dropping. Handled in §4.10 (cancel cleanly).

**AC references:** AC-ENTRY-1, AC-ENTRY-2, AC-ENTRY-3, AC-ENTRY-4, AC-ENTRY-5.

---

### §4.2 — Approve a clean match quickly

> **When** the supplier delivered exactly what I ordered at the agreed price,
> **I want to** approve the Bill without reviewing every line,
> **so that** I can move on to the next PO and not spend time on happy-path work.

**Pre-condition:** Dave has completed §4.1. The invoice has just been dropped on the reconcile page and parsed. Every Bill line matches a PO line by product code, at the PO price, with qty ≤ ordered.

**Steps:**
1. The processor panel completes. Bill items land on the right column.
2. Line-matcher runs live. Every PO line on the left gets a green `✓ Rcvd`.
3. Every Bill line on the right shows a green status dot. No amber or red badges anywhere.
4. The `Approve` button at the bottom of the page becomes enabled.
5. Dave scans both columns — a 10-second visual check.
6. Dave clicks `Approve`.
7. The page re-renders: Bill status flips from `Draft` to `Approved`.
8. A toast confirms the outcome, e.g. *"Bill BL-0200 approved. PO-1648 closed — 8 of 8 items received."*
9. Behind the scenes: Xero posting queued; received-qty updated on matched PO lines; if all PO lines fulfilled, PO status transitions `receiving → closed` atomically (see §4.7).

**Success:** Bill approved, PO progress updated, zero loose ends. One click, one atomic action.

**AC references:** AC-A1, AC-A2, AC-A3, AC-Audit-1, AC-Global-1.

---

### §4.3 — Approve a partial delivery and keep the PO open

> **When** the supplier has only delivered some of what I ordered on this invoice,
> **I want to** approve what's arrived and still see what's outstanding,
> **so that** the next delivery picks up from where this one left off.

**Pre-condition:** §4.1 complete. Invoice parsed. The PO has 8 ordered items; the invoice covers 5 of them.

**Steps:**
1. Line-matcher pairs 5 of the 8 PO rows with matching Bill rows — all green.
2. The remaining 3 PO rows render with a red-tinted background, `0/1 Rcvd` in the received column, and a sub-row *"⚠ Not on this invoice — outstanding."*
3. No amber badges on the 5 matched lines (prices and qtys all within tolerance).
4. The `Approve` button is enabled.
5. Dave clicks `Approve`.
6. Toast: *"Bill BL-0210 approved. 3 items still outstanding on PO-2026-014."*
7. The 5 matched PO lines now show green-tick `Rcvd`. The PO stays in `receiving` state.

**Success:** Bill approved; partial PO progress recorded; 3 outstanding items remain visible on the PO page for the next invoice.

**AC references:** AC-B1, AC-B2.

---

### §4.4 — Resolve a price variance before approving

> **When** the supplier has billed me more than we agreed on the PO,
> **I want to** see the variance, decide whether to accept it, and record my decision,
> **so that** I never silently pay above agreed price and so I have an audit trail if procurement asks why.

**Pre-condition:** §4.1 complete. Invoice parsed. One line — a `COMP-35KW` compressor — is priced at $1042 on the invoice but was $1000 on the PO. A 4.2% increase.

**Steps:**
1. Line-matcher pairs the line. Variance detector flags it.
2. The Bill row renders with an **amber variance badge** on its right edge: *"Δ $42.00 (+4.2%)"*.
3. The `Approve` button greys out, with helper text *"1 variance to acknowledge before approving."*
4. Dave clicks the amber badge. A tooltip opens showing PO price vs Bill price, delta in dollars and percent, plus an `Acknowledge` button.
5. Dave reads it, decides the price rise is acceptable (supplier warned him last week), and clicks `Acknowledge`.
6. The badge turns grey with a small tick. The `Approve` button re-enables.
7. Dave clicks `Approve`. Toast: *"Bill BL-0215 approved with 1 acknowledged variance."*
8. An audit-log row records who acknowledged, when, what the delta was, and that the Bill was approved despite it.

**Success:** Bill approved with the price rise documented; acknowledgement trail survives indefinitely.

**Edge cases:**
- Multiple lines with price variances — every amber badge must be acknowledged before `Approve` enables.
- Dave clicks `Approve with variances` (override) without acknowledging — the system blocks and surfaces the list of unacknowledged lines.

**AC references:** AC-C1, AC-C2, AC-C3.

---

### §4.5 — Block approval when the invoice supplier doesn't match the PO supplier

> **When** the invoice I dropped is from a different supplier than this PO,
> **I want to** be stopped before anything is written, and told clearly why,
> **so that** I don't pollute this PO's received-qty counters with a wrong-supplier invoice.

**Pre-condition:** §4.1 complete on PO-2026-030 (supplier: CNW). The invoice Dave dropped parses as supplier = Reece.

**Steps:**
1. Parse completes. Supplier-mismatch detector fires *before* items are written to the Bill.
2. The reconcile page shows a full-width red banner *"⚠ Supplier on this invoice (Reece) doesn't match PO-2026-030 (CNW). Reconciliation is blocked."*
3. The two reconciliation columns are not rendered. The `Approve` button is hidden.
4. A single action is available: **Unlink PO and reconcile later**.
5. Dave realises he grabbed the wrong invoice from the paper pile. He clicks `Unlink PO and reconcile later`.
6. The system clears `bill.purchase_order` (Bill becomes a standalone Draft, no PO link).
7. The page redirects to the plain Bill view — no reconciliation section.
8. An audit row is written: `bill_unlinked_supplier_mismatch`.

**Success:** Bill exists as a standalone Draft with no PO linkage; PO-2026-030 is untouched; audit records the unlink reason.

**Why a block, not a warning:** silently reconciling a Reece invoice against a CNW PO would corrupt the received-qty counters and mislead everyone downstream. The block is non-negotiable.

**AC references:** AC-D1, AC-D2.

---

### §4.6 — Resolve an over-invoiced line (the tech took extra)

> **When** the supplier has billed me for more of a product than this PO ordered,
> **I want to** choose how to handle the excess (bill it to the job, or cap it back to ordered qty),
> **so that** the PO's received counters don't overflow and the job cost captures reality.

**Pre-condition:** §4.1 complete. PO ordered 2 × `CU-22-COPPER`; the invoice lists 5 × `CU-22-COPPER`. The tech took three extra lengths for next week's job.

**Steps:**
1. Parse completes; line-matcher pairs the row; variance detector flags it.
2. The Bill row shows a **red variance badge**: *"Over-invoiced — 5 billed vs 2 ordered (+3)."*
3. The `Approve` button greys out with *"1 variance to resolve."*
4. Dave clicks the red badge. A decision modal opens with three options:
   - **Ack and approve — extra billed to job** (keeps the 5-unit total on the Bill; PO receives 2)
   - **Ack and adjust — reduce Bill qty to 2, trash extras** (edits the Bill row down to 2; raw PDF stays on file)
   - **Cancel — don't approve yet** (dismisses the modal; badge stays; Dave revisits later)
5. Dave picks `Ack and approve — extra billed to job` (he confirmed with the tech that the extras went to a real job).
6. The badge turns grey with a tick. The `Approve` button re-enables.
7. Dave clicks `Approve`. The Bill approves at the full 5-unit total. The PO's received-qty for copper caps at its ordered-qty of 2 — the excess 3 are billed but not treated as PO fulfilment.
8. An audit row captures the path taken.

**Success:** Bill approved at the real billed total; PO fulfilment doesn't overflow; the path Dave chose (absorb vs cap) is recorded for procurement review.

**AC references:** AC-E1, AC-E2.

---

### §4.7 — Close the PO when it's fully received

> **When** the last Bill against this PO is approved,
> **I want to** have the PO close without any extra click,
> **so that** the PO list stays clean and no one can accidentally attach another Bill to a fulfilled PO.

**Pre-condition:** §4.2 (or §4.3 as last Bill) is approving. All PO line items now satisfy `received_qty >= ordered_qty`.

**Steps (auto-close, common case):**
1. The approval transaction (from §4.2 or §4.3) is running.
2. Inside the same atomic transaction, the system checks every `PurchaseOrderLineItem` on the PO.
3. All lines satisfy `received_qty >= ordered_qty`.
4. PO status transitions `receiving → closed` inside the same transaction — no separate trigger.
5. The toast at the end of §4.2 / §4.3 reads *"PO-1648 closed — 8 of 8 items received."*
6. Dave navigates to the PO (optional). It shows the `Closed` chip.

**Variation — auto-close disabled (edge case, CFO-governed tenants):**
1. Approval transaction completes without the close.
2. The PO still shows `Receiving` but carries a green sub-chip *"All items received — ready to close."*
3. A `Close PO` button is enabled in the PO header.
4. Dave clicks `Close PO`. Confirm modal: *"Close PO-1648? This stops accepting new Bills against this PO."*
5. Dave confirms. PO status flips to `closed`. Toast: *"PO-1648 closed manually — 8 of 8 items received."*

**Success:** PO locked. No further Bills can link to it. Audit records who / when / how (auto vs manual).

**AC references:** AC-F1.

---

### §4.8 — Handle items on the invoice that aren't on the PO

> **When** the invoice includes items that were never on the PO (substitutes, consumables, supplier extras),
> **I want to** decide row-by-row whether each unmatched item should become a PO line, or stay on the Bill only,
> **so that** the PO reflects actual agreed supply and Bill-only costs (like on-site consumables) stay on the Bill.

**Pre-condition:** §4.1 complete on PO-2026-055. The invoice parses to 6 line items — 4 match PO codes cleanly, 2 have codes that don't exist on the PO at all.

**Steps:**
1. Line-matcher pairs the 4 matched lines with their PO counterparts in the main right-hand table — all green.
2. The 2 unmatched lines drop into a separate **"Not on PO"** row group below the main Bill table, with a light-amber background and the sub-heading *"Items on this invoice that don't match any PO line."*
3. Each unmatched row shows two actions on its right edge:
   - **Add to PO** (primary) — promotes the row to a real PO line item
   - **Keep on Bill only** (secondary, default) — leaves it as a Bill-only cost
4. The `Approve` button is enabled — unmatched items don't block approval.
5. For the first unmatched line (a substitute product the supplier agreed with Dave yesterday), Dave clicks `Add to PO`. The row jumps up to the main table. A new `PurchaseOrderLineItem` is created with `ordered_qty = bill_qty`, `received_qty = bill_qty`, `unit_price_ex_gst = bill_unit_price`. The row turns green.
6. For the second unmatched line (a pack of rags the tech added on site), Dave leaves the default `Keep on Bill only`. The row stays in the "Not on PO" group.
7. Dave clicks `Approve`. Bill approves; 4 matched + 1 newly-added-to-PO rows increment `received_qty`; the rags are billed but don't touch the PO.

**Success:** Bill approved with all 6 items; PO now has 9 line items (8 original + 1 added substitute); rags stay as a Bill-only cost; audit trail records both paths.

**AC references:** AC-G1, AC-G2, AC-G3.

---

### §4.9 — Recover from accidentally starting reconciliation twice on the same PO

> **When** I've already started a reconciliation on this PO but didn't finish (so there's a blank Draft Bill sitting there), and I click `Reconcile Supplier Invoice` again,
> **I want to** be asked whether to continue the existing blank Bill or start a new one,
> **so that** the PO doesn't accumulate ghost Draft Bills I've forgotten about.

**Pre-condition:** Earlier today Dave clicked `✨ Reconcile Supplier Invoice` on PO-1648 but got pulled into a meeting before dropping the invoice. BL-0260 exists as `Draft · 0 items · 0 attachments` linked to PO-1648. Dave comes back an hour later, forgets, and clicks the button again.

**Steps:**
1. Before creating a second blank Bill, the server scans the PO for existing `Draft` Bills with 0 line items and 0 attachments.
2. It finds BL-0260.
3. Instead of creating a new Bill, the system shows a modal:
   > **You already started reconciling this PO**
   > BL-0260 · Draft · 0 items · started 1 hr ago by Dave
   > - **Continue that one** (primary) — navigates to `/bills/BL-0260/reconcile`
   > - **Start a new Bill anyway** (secondary, ghost) — creates a fresh blank Bill and navigates there; BL-0260 stays on the PO as a second Draft
4. Dave clicks `Continue that one`. He's taken to BL-0260's reconcile page and drops the invoice.
5. BL-0260 proceeds through §4.2 (or whichever path matches the invoice).

**Variation — two genuine invoices for the same PO:**
If the supplier split the order and Dave genuinely wants two parallel Bills on PO-1648, he picks `Start a new Bill anyway`. BL-0260 stays as a second Draft; a new Bill is created and opened. Rare, but supported.

**Why not silently reuse:** Some invoices *should* result in a second parallel Bill. Auto-reusing the existing blank would hide that case. Asking is the safer default.

**Success:** No mystery duplicate Drafts cluttering the PO's Bill list. Either Dave finished the one he started, or he deliberately started a second alongside it.

**AC references:** AC-H1, AC-H2, AC-H3.

---

### §4.10 — Cancel a reconciliation cleanly before dropping an invoice

> **When** I clicked `Reconcile Supplier Invoice` but realise I grabbed the wrong PO,
> **I want to** leave the reconcile page without leaving corrupted data behind,
> **so that** the PO's Bill list doesn't fill with half-started Drafts.

**Pre-condition:** Dave clicked `✨ Reconcile Supplier Invoice` on PO-2026-070, a blank BL-0270 was created, the reconcile page is open, and he's realised the invoice is actually for a different job.

**Steps:**
1. Dave clicks the browser back button (or the breadcrumb link back to the PO page).
2. A confirm prompt appears: *"Leave without reconciling? This Bill has no items yet — it will stay as a Draft Bill on this PO until you come back or delete it."* with two buttons — **Stay on this page** and **Leave — I'll come back later**.
3. Dave picks `Leave — I'll come back later`.
4. He lands back on PO-2026-070's page.
5. BL-0270 sits in the PO's Supplier Bills list as a muted row: *"BL-0270 · Draft · 0 items · started 5 min ago."*
6. Hovering the row reveals a trash icon. Dave clicks it. Confirm: *"Delete this blank Draft Bill?"* → `Delete` / `Cancel`.
7. Dave clicks `Delete`. The Bill row is hard-deleted. The PO returns to its prior state.

**Variation — he changes his mind at the confirm:**
Dave clicks `Stay on this page`. Nothing changes; he can now drop the correct invoice.

**Variation — populated Bill, not blank:**
If Dave navigates away from a reconcile page that *has* been parsed (Bill has line items), the confirm prompt doesn't fire — unsaved-work semantics are handled by the `Save` button per FR-8 instead.

**Success:** PO list stays clean. No approval happened. No items anywhere. Clean abort.

**AC references:** AC-ENTRY-7, AC-ENTRY-8, AC-I1, AC-I2.

---

### §4.11 — Review the original PDF alongside the parsed items

> **When** Alfred has parsed the invoice and the reconciliation matches don't look right,
> **I want to** see the original PDF side-by-side with the parsed items without leaving the page,
> **so that** I can spot-check whether the parse is wrong, the PO is wrong, or Alfred just put a character in the wrong column.

**Persona:** Dave.
**Pre-condition:** The invoice has been parsed (the user completed §4.1 and the drop in §4.2/§4.3/§4.4/§4.5/§4.6/§4.8 has finished).

**Steps:**

1. The reconcile page renders three columns after parse: **PDF preview** (left, ~40%), **PO items** (middle, ~30%), **Bill items** (right, ~30%).
2. The PDF preview shows page 1 of the parsed invoice rendered natively. Dave can scroll, zoom, and page through the PDF inside the panel.
3. A small control sits on the panel's right edge — a three-state toggle.
4. Dave clicks it once. The preview collapses to a thin vertical sidebar (icon + page count + an `expand` chevron). PO and Bill columns widen to fill the reclaimed space.
5. He clicks the sidebar's chevron. The preview re-expands to 40%.
6. He clicks the toggle a second time from expanded. The preview hides entirely. A small `Show PDF` button appears in the page header as a way back in.
7. His choice is persisted per user, so the state survives reloads for the next Bill he reconciles.

**Variations:**

- **Multiple attachments on the Bill** — a small filename switcher at the top of the preview panel (dropdown or tab strip) lets Dave flip between attached PDFs. Switching doesn't alter reconciliation data.
- **PDF fails to render** — the panel falls back to a plain text *"Preview unavailable — open the original PDF"* with a link to download the file.

**Success:** Dave can confirm a parsed line against the original without losing the match view; if he doesn't need the preview, he hides it and the reconciliation columns get full width.

**Edge cases:**

- Very long PDFs (> 20 pages) — only the first page renders at a time to keep performance acceptable; page navigation controls handle the rest.
- Non-PDF attachments (images, CSVs) — preview shows the first-page image for images; CSV attachments fall back to the `Preview unavailable` message.

**AC references:** AC-PDF-1, AC-PDF-2, AC-PDF-3 (new — see §4a).

---

## 4a. Acceptance criteria

| # | Given | When | Then | Links |
|---|---|---|---|---|
| AC-ENTRY-1 | User loads a PO page (any status `authorised` or `receiving`) | DOM inspection | Two AI-pill buttons are visible in the PO header action row: `✨ Parse PO` and `✨ Reconcile Supplier Invoice`. No other entry-point controls for reconciliation exist on this surface. | FR-ENTRY-1 |
| AC-ENTRY-2 | User clicks `✨ Parse PO` | Observer | File picker opens. On selection, supplier-quote parse runs (FI-022 FR-4 flow). Stays on the PO page. No Bill created. No navigation. | FR-ENTRY-2 |
| AC-ENTRY-3 | User clicks `✨ Reconcile Supplier Invoice` | Server | A new `Bill` row is created with `purchase_order_id = this_po`, `status = 'draft'`, 0 `LineItem`s, `supplier_id = NULL`. The Bill is committed to the database before any redirect. | FR-ENTRY-3 |
| AC-ENTRY-4 | After AC-ENTRY-3 | Same request cycle | Response is a 302 (or framework-equivalent) redirect to the reconcile page for the newly-created Bill. URL pattern: `/bills/{new_bill_id}/reconcile`. | FR-ENTRY-3 |
| AC-ENTRY-5 | User arrives on the reconcile page for a blank Bill (0 line items) | DOM inspection | PO column is fully populated from the PO. Bill column shows a prominent drop zone with the copy "Drop the supplier invoice here" and an AI-pill `✨ Parse File` button. No Bill line-items rendered yet. No variance badges. Approve button is disabled (there's nothing to approve). | FR-ENTRY-4 |
| AC-ENTRY-6 | Reconcile page with blank Bill | User drops a PDF on the Bill-column drop zone | Parse triggers immediately (no pre-parse modal). Processor panel visible inside the drop zone. Within 15 s: Bill line items populate live; LineMatcher runs and decorates PO rows; variance detector runs and adds badges; Approve button enables if no unacknowledged variances remain. | FR-ENTRY-4, FR-3, FR-4, FR-5 |
| AC-ENTRY-7 | User on reconcile page for a blank Bill navigates away (back button / breadcrumb / direct URL change) | Browser beforeunload / router hook | Confirm prompt shown: "Leave without reconciling? This Bill has no items yet — it will stay as a Draft Bill on this PO until you come back or delete it." with options `Stay on this page` and `Leave — I'll come back later`. | FR-ENTRY-5 |
| AC-ENTRY-8 | User on reconcile page for a Bill WITH at least 1 line item navigates away | Browser beforeunload / router hook | No confirm prompt (unsaved-work pattern applies through `Save` semantics in FR-8 instead). | FR-ENTRY-5 |
| AC-H1 | PO-X has one Draft Bill with 0 line items and 0 attachments | User clicks `Reconcile Supplier Invoice` on PO-X again | Server detects the existing blank Draft Bill before creating a new one. Modal shown with two options — `Continue that one` (redirects to the existing Bill's reconcile page) and `Start a new Bill anyway` (creates a fresh blank Bill, both Drafts now coexist). | Story H, FR-ENTRY-3 |
| AC-H2 | Modal from AC-H1 | User picks `Continue that one` | Redirect to `/bills/{existing_bill_id}/reconcile`. No new Bill created. | Story H |
| AC-H3 | Modal from AC-H1 | User picks `Start a new Bill anyway` | New blank Bill created. Redirect to new Bill's reconcile page. Existing blank Draft remains on the PO. | Story H |
| AC-G1 | Parsed invoice has 2 items whose product codes don't exist on the linked PO | Reconciliation renders | Those 2 items render in a `Not on PO` row group below the main Bill table. Each row has two actions: `Add to PO` (primary) and `Keep on Bill only` (secondary, default). Approve button is enabled (unmatched items don't block approval). | Story G, FR-16 |
| AC-G2 | `Not on PO` row shown | User clicks `Add to PO` | A new `PurchaseOrderLineItem` is created with `ordered_qty = bill_qty`, `received_qty = bill_qty`, `unit_price_ex_gst = bill_unit_price`. The row moves from the `Not on PO` group into the main matched table. PO totals recompute. | Story G, FR-16 |
| AC-G3 | `Not on PO` row shown | User clicks `Keep on Bill only` (or leaves the default) | No PO line item created. Row stays in the `Not on PO` group. Bill totals include it; PO totals don't. On approval, `received_qty` is NOT incremented for this row. | Story G, FR-16 |
| AC-I1 | User on reconcile page for a blank Bill (0 line items, 0 attachments) | User confirms `Leave — I'll come back later` on the navigation prompt | Browser navigates away. Bill is NOT deleted. Bill appears in PO's Supplier Bills list as a `Draft · 0 items` row. | Story I, FR-ENTRY-5 |
| AC-I2 | PO's Supplier Bills list shows a `Draft · 0 items` Bill | User hovers the row | A trash icon appears on the right. Clicking it opens a confirm: "Delete this blank Draft Bill?" with `Delete` / `Cancel`. `Delete` hard-deletes the Bill row and its empty audit trail; returns focus to the PO's Bill list. | Story I |
| AC-A1 | Bill linked to PO, all Bill items match PO items, no variances | PM opens reconciliation view | Every PO line shows green `✓ Rcvd`. Every Bill line shows green status. No variance badges. Approve button enabled. | UC-A, FR-2 |
| AC-A2 | Bill from AC-A1 | PM clicks `Approve` | Response 200. Bill status → `approved`. `received_qty` increments on all matched PO lines. Audit log entry written. | UC-A, FR-9 |
| AC-A3 | After AC-A2, all PO lines `received_qty >= ordered_qty` | (during the same transaction) | PO status → `closed` atomically. Xero post enqueued. | UC-A, FR-9 |
| AC-B1 | PO has 8 ordered items, Bill covers 5 | PM opens reconciliation | 5 Bill lines render matched. 3 PO lines render red-tinted with `0/N Rcvd` and `⚠ Not on this invoice — outstanding` sub-row. | UC-B, FR-6 |
| AC-B2 | Bill from AC-B1 | PM clicks `Approve` | 5 matched PO lines get `received_qty` incremented. PO stays `receiving`. 3 outstanding lines remain visible on the PO page. | UC-B, FR-11 |
| AC-C1 | Bill line unit $1042, PO line unit $1000 | Reconciliation renders | Bill row shows amber variance badge `Δ $42.00 (+4.2%)`. Approve button disabled until variance is acknowledged. | UC-C, FR-4, FR-17 |
| AC-C2 | Variance shown as per AC-C1 | PM clicks badge → `Acknowledge` | `reconciliation_meta.acknowledged_variances` adds this line_id. Approve button re-enables. | UC-C, FR-17 |
| AC-C3 | Bill approved with acknowledged variance | Audit log entry is written | Log entry contains `{kind: price_variance, delta_amount, delta_pct, acknowledged_by, acknowledged_at}`. | UC-C, NFR-6 |
| AC-D1 | `bill.supplier != bill.purchase_order.supplier` | PM loads reconciliation | Full-width red banner rendered. Approve button disabled. Only visible action: `Unlink PO`. | UC-D, FR-10 |
| AC-D2 | Banner from AC-D1 | PM clicks `Unlink PO` | `bill.purchase_order = null`. Audit log `bill_unlinked_supplier_mismatch`. Page redirects to plain Bill view. PO unchanged. | UC-D, FR-10 |
| AC-E1 | Bill qty 5, PO outstanding 2 (ordered 2, received 0) | Reconciliation renders | Red `Over-invoiced` badge on Bill row. Tooltip shows `5 billed vs 2 ordered (+3)`. | UC-E, FR-5 |
| AC-E2 | Variance from AC-E1, PM chooses `Ack and approve — extra billed to job` | Approval transaction runs | Bill approved. `received_qty` on PO line = min(ordered_qty, bill_qty) = 2. Audit log captures `qty_variance_over_invoiced_accepted` with excess=3. | UC-E, FR-7 |
| AC-F1 | PO has all lines `received_qty >= ordered_qty` after a Bill approval | The same Bill approval transaction | PO transitions `receiving → closed` inside the transaction. No separate trigger needed. | UC-F, FR-9 |
| AC-Match-1 | Bill item has `product_code = "CU-22-COPPER"`, PO has matching code | `LineMatcher.match()` runs | Items paired. `match_type = 'code'`. | FR-3 |
| AC-Match-2 | Bill item code differs, but description Levenshtein ratio ≥ 0.85 AND quantity parity (±0) | `LineMatcher.match()` runs | Items paired with `match_type = 'fuzzy'`. UI shows amber `Fuzzy match` sub-chip prompting human verification. | FR-3 |
| AC-Match-3 | Bill item has no PO counterpart | `LineMatcher.match()` runs | Item appears in `Not on PO` row group with `Add to PO` and `Keep on Bill only` actions. | FR-3, FR-16 |
| AC-NFR-1 | PO with 200 items, Bill with 200 items | Reconciliation page loads | Initial render < 600 ms server-side. Match algorithm < 100 ms. Page TTI < 1.5 s. | NFR-1, NFR-2 |
| AC-Audit-1 | Any reconciliation approval | Audit log is inspected | Exactly one entry exists with user_id, bill_id, po_id, timestamp, variance_list, acknowledgements. | NFR-6 |
| AC-Global-1 | Any approval that would close a PO | Transaction completes | No intermediate state is visible where `bill.status=approved` but `po.status` is stale. Both changes commit atomically or neither does. | FR-9, NFR-3 |

---

## 5. Data model additions

Reconciliation adds **no destructive schema changes**. It reads from `Bill`, `PurchaseOrder`, `LineItem`, `PurchaseOrderLineItem` (defined in FI-022 §9) and writes to one new support table plus two existing columns.

### 5.1 New table — `ReconciliationMeta`

```python
class ReconciliationMeta(models.Model):
    """
    Per-Bill reconciliation state. Created lazily on first render
    of the reconciliation view; updated on Save / Approve.
    """
    bill = models.OneToOneField(
        Bill, related_name='reconciliation_meta',
        on_delete=models.CASCADE, primary_key=True,
    )
    acknowledged_variances = models.JSONField(default=list)
    # list of { line_id: int, kind: 'price'|'qty'|'missing', ack_by: user_id, ack_at: iso8601 }

    manual_matches = models.JSONField(default=dict)
    # { bill_line_id: po_line_id } — overrides the auto-matcher

    not_on_po_decisions = models.JSONField(default=dict)
    # { bill_line_id: 'add_to_po' | 'keep_on_bill_only' }

    last_saved_at = models.DateTimeField(null=True, blank=True)
    last_saved_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)

    approved_with_variances = models.BooleanField(default=False)

    class Meta:
        db_table = 'fi_reconciliation_meta'
```

### 5.2 New audit table — `ReconciliationAuditLog`

```python
class ReconciliationAuditLog(models.Model):
    """
    Immutable ledger of every reconciliation action. One row per
    state-changing event: save, approve, unlink, close-PO.
    """
    id = models.BigAutoField(primary_key=True)
    bill = models.ForeignKey(Bill, related_name='recon_audits', on_delete=models.PROTECT)
    purchase_order = models.ForeignKey(
        PurchaseOrder, related_name='recon_audits',
        on_delete=models.PROTECT, null=True, blank=True,
    )
    actor = models.ForeignKey(User, on_delete=models.PROTECT)
    action = models.CharField(
        max_length=40,
        choices=[
            ('save',         'save'),
            ('approve',      'approve'),
            ('unlink',       'unlink'),
            ('close_po',     'close_po'),
            ('ack_variance', 'ack_variance'),
        ],
    )
    payload = models.JSONField(default=dict)
    # kind-specific payload, e.g. { variance_kind, line_id, delta_amount, delta_pct }

    created_at = models.DateTimeField(auto_now_add=True, db_index=True)

    class Meta:
        db_table = 'fi_reconciliation_audit_log'
        indexes = [models.Index(fields=['bill', 'created_at'])]
```

### 5.3 Touches to existing models — none destructive

- `Bill` — no schema change. Uses existing `purchase_order` FK (defined in FI-022).
- `PurchaseOrderLineItem` — no schema change. `received_qty` is incremented by this service. Field already exists.

---

## 6. API endpoints

All endpoints are built with the **FieldInsight custom API framework** (in-house on top of Django views + middleware — DRF is NOT used) under `/api/v1/`. Auth via session or bearer token. CSRF enforced for cookie sessions. Follow existing FieldInsight API conventions for request/response shape, serialisation, error envelopes, and pagination — do not introduce DRF serializers, routers, or viewsets.

| Method | Path | Purpose |
|---|---|---|
| `GET`    | `/bills/{id}/reconciliation`                     | Returns the full reconciliation payload: PO items, Bill items, matches, variances, markup. Idempotent. |
| `POST`   | `/bills/{id}/reconciliation/save`                | Persists draft reconciliation state (`acknowledged_variances`, `manual_matches`, `not_on_po_decisions`). No approval. |
| `POST`   | `/bills/{id}/reconciliation/acknowledge-variance`| Body: `{ line_id, kind }`. Appends an ack to `reconciliation_meta.acknowledged_variances`. |
| `POST`   | `/bills/{id}/approve`                            | Runs the full approval transaction (FR-9). Body: `{ override_variances: bool }`. Returns new `bill.status` + `po.status`. |
| `POST`   | `/bills/{id}/unlink-po`                          | Sets `bill.purchase_order = null`. Writes audit entry. Requires body `{ reason: 'supplier_mismatch' \| 'manual' }`. |
| `POST`   | `/purchase-orders/{id}/close`                    | Explicit `receiving → closed` transition (FR-12). 409 if any `received_qty < ordered_qty`. |
| `POST`   | `/bills/{id}/reconciliation/add-to-po`           | Body: `{ line_id }`. Creates a new `PurchaseOrderLineItem` from this Bill line. |
| `POST`   | `/bills/{id}/copy-to-srf`                        | Copies reconciled Bill line items into the linked SRF. |

### 6.1 `GET /bills/{id}/reconciliation` response shape

```jsonc
{
  "bill": {
    "id": 200,
    "bill_id": "BL-0200",
    "status": "draft",
    "supplier": { "id": 12, "name": "Reece" },
    "subtotal_ex_gst": "17.65",
    "purchase_order_id": 1648
  },
  "purchase_order": {
    "id": 1648,
    "po_id": "PO-1648",
    "status": "receiving",
    "supplier": { "id": 12, "name": "Reece" },
    "subtotal_ex_gst": "477.86"
  },
  "supplier_match": true,
  "matches": [
    {
      "po_line_id": 9001,
      "bill_line_id": 5001,
      "match_type": "code",
      "po_line": { "code": "CLI264/3SMgry", "ordered_qty": 2, "received_qty": 2, "unit_price_ex_gst": "2.76" },
      "bill_line": { "code": "CLI264/3SMgry", "qty": 2, "unit_price_ex_gst": "2.76" },
      "variances": []
    },
    {
      "po_line_id": 9004,
      "bill_line_id": null,
      "match_type": "outstanding",
      "po_line": { "code": "QSW-SVC", "ordered_qty": 1, "received_qty": 0, "unit_price_ex_gst": "585.00" },
      "bill_line": null,
      "variances": [{ "kind": "missing", "ack_required": false }]
    }
  ],
  "not_on_po": [],
  "markup": {
    "bill_total_ex_gst": "17.65",
    "po_total_ex_gst":   "477.86",
    "markup_pct": 40.0
  },
  "approval_blocked_reason": null
}
```

### 6.2 `POST /bills/{id}/approve` response

```jsonc
{
  "bill": { "id": 200, "status": "approved" },
  "purchase_order": { "id": 1648, "status": "closed" },
  "audit_log_id": 78211,
  "xero_task_id": "3f1a...",
  "toast": "Bill BL-0200 approved. PO-1648 closed. 8 of 8 items received."
}
```

Error shapes:

| HTTP | `error_code` | Cause |
|---|---|---|
| 400 | `variances_not_acknowledged` | One or more amber/red variances lack an ack, and `override_variances=false` |
| 400 | `supplier_mismatch`          | Bill supplier ≠ PO supplier (FR-10) |
| 409 | `bill_already_approved`      | Bill status was not `draft` at transaction start |
| 409 | `po_not_receiving`           | PO status was `closed` before approval completed |

---

## 7. Algorithms

### 7.1 `LineMatcher.match(po_items, bill_items, manual_matches=None)`

```python
from dataclasses import dataclass
from difflib import SequenceMatcher
from decimal import Decimal
from typing import Literal

@dataclass
class Match:
    po_line_id: int | None
    bill_line_id: int | None
    match_type: Literal['code', 'fuzzy', 'outstanding', 'not_on_po', 'manual']
    po_line: 'PurchaseOrderLineItem | None'
    bill_line: 'LineItem | None'

def match(po_items, bill_items, manual_matches=None):
    """
    Returns a list of Match objects: one per PO line (matched or outstanding)
    + one per Bill line that has no PO counterpart (not_on_po).
    """
    manual_matches = manual_matches or {}
    bill_by_id = {b.id: b for b in bill_items}
    used_bill_ids = set()
    results = []

    for po in po_items:
        # 1. Manual override wins.
        if po.id in manual_matches.values():
            bill_id = next(k for k, v in manual_matches.items() if v == po.id)
            results.append(Match(po.id, bill_id, 'manual', po, bill_by_id[bill_id]))
            used_bill_ids.add(bill_id)
            continue

        # 2. Exact product_code match.
        exact = next(
            (b for b in bill_items
             if b.product_code == po.product_code and b.id not in used_bill_ids),
            None,
        )
        if exact:
            results.append(Match(po.id, exact.id, 'code', po, exact))
            used_bill_ids.add(exact.id)
            continue

        # 3. Fuzzy match — description Levenshtein ratio >= 0.85 AND qty parity.
        fuzzy = None
        best_ratio = 0.0
        for b in bill_items:
            if b.id in used_bill_ids or b.qty != po.ordered_qty - po.received_qty:
                continue
            ratio = SequenceMatcher(None, po.description.lower(), b.description.lower()).ratio()
            if ratio >= 0.85 and ratio > best_ratio:
                fuzzy = b
                best_ratio = ratio
        if fuzzy:
            results.append(Match(po.id, fuzzy.id, 'fuzzy', po, fuzzy))
            used_bill_ids.add(fuzzy.id)
            continue

        # 4. Outstanding.
        results.append(Match(po.id, None, 'outstanding', po, None))

    # 5. Bill items not paired → not_on_po.
    for b in bill_items:
        if b.id not in used_bill_ids:
            results.append(Match(None, b.id, 'not_on_po', None, b))

    return results
```

### 7.2 `VarianceDetector.flag(matches)`

```python
PRICE_VARIANCE_THRESHOLD = Decimal('0.01')  # 1%

@dataclass
class Variance:
    line_id: int
    kind: Literal['price', 'qty_over', 'qty_under', 'missing', 'not_on_po', 'fuzzy_match']
    delta_amount: Decimal
    delta_pct: Decimal
    ack_required: bool

def flag(matches):
    flags = []
    for m in matches:
        if m.match_type == 'outstanding':
            flags.append(Variance(m.po_line_id, 'missing', Decimal(0), Decimal(0), ack_required=False))
            continue
        if m.match_type == 'not_on_po':
            flags.append(Variance(m.bill_line_id, 'not_on_po', Decimal(0), Decimal(0), ack_required=True))
            continue
        if m.match_type == 'fuzzy':
            flags.append(Variance(m.bill_line_id, 'fuzzy_match', Decimal(0), Decimal(0), ack_required=True))

        po_price = m.po_line.unit_price_ex_gst
        bill_price = m.bill_line.unit_price_ex_gst
        delta_amount = bill_price - po_price
        delta_pct = (delta_amount / po_price) if po_price else Decimal(0)
        if abs(delta_pct) > PRICE_VARIANCE_THRESHOLD:
            flags.append(Variance(m.bill_line_id, 'price', delta_amount, delta_pct * 100, ack_required=True))

        outstanding = m.po_line.ordered_qty - m.po_line.received_qty
        if m.bill_line.qty > outstanding:
            excess = m.bill_line.qty - outstanding
            flags.append(Variance(m.bill_line_id, 'qty_over', excess, Decimal(0), ack_required=True))

    return flags
```

### 7.3 `BillApprovalService.approve_with_reconciliation(bill_id, user, override=False)`

```python
from django.db import transaction

class BillApprovalError(Exception):
    def __init__(self, code, detail=''):
        self.code = code
        self.detail = detail

def approve_with_reconciliation(bill_id, user, override=False):
    with transaction.atomic():
        bill = Bill.objects.select_for_update().get(pk=bill_id)
        if bill.status != 'draft':
            raise BillApprovalError('bill_already_approved')
        po = bill.purchase_order

        if po and bill.supplier_id != po.supplier_id:
            raise BillApprovalError('supplier_mismatch')

        matches = LineMatcher.match(po.line_items.all() if po else [], bill.line_items.all())
        variances = VarianceDetector.flag(matches)
        acked_ids = {v['line_id'] for v in bill.reconciliation_meta.acknowledged_variances}
        unacked = [v for v in variances if v.ack_required and v.line_id not in acked_ids]
        if unacked and not override:
            raise BillApprovalError('variances_not_acknowledged', detail=[v.__dict__ for v in unacked])

        bill.status = 'approved'
        bill.save(update_fields=['status', 'updated_at'])

        if po:
            po_line_updates = []
            for m in matches:
                if m.match_type in ('code', 'fuzzy', 'manual') and m.po_line and m.bill_line:
                    outstanding = m.po_line.ordered_qty - m.po_line.received_qty
                    increment = min(m.bill_line.qty, outstanding)
                    m.po_line.received_qty += increment
                    po_line_updates.append(m.po_line)
            PurchaseOrderLineItem.objects.bulk_update(po_line_updates, ['received_qty'])

            if po.status == 'receiving' and all(
                p.received_qty >= p.ordered_qty for p in po.line_items.all()
            ):
                po.status = 'closed'
                po.save(update_fields=['status', 'updated_at'])

        audit = ReconciliationAuditLog.objects.create(
            bill=bill, purchase_order=po, actor=user, action='approve',
            payload={
                'variances': [v.__dict__ for v in variances],
                'overridden': override,
                'po_closed': po.status == 'closed' if po else False,
            },
        )

    xero_post_bill.delay(bill_id)
    return { 'bill': bill, 'po': po, 'audit_id': audit.id }
```

### 7.4 `MarkupCalculator.compute(po_total, bill_total)`

```python
def compute(po_total: Decimal, bill_total: Decimal) -> dict:
    if po_total == 0:
        return {'markup_pct': Decimal(0), 'variance_pct': Decimal(0),
                'bill_ex_gst': bill_total, 'po_ex_gst': po_total}
    variance = bill_total - po_total
    markup = (variance / po_total) * 100
    return {
        'markup_pct':   round(markup, 2),
        'variance_pct': round(markup, 2),
        'bill_ex_gst':  bill_total,
        'po_ex_gst':    po_total,
    }
```

---

## 10. Rollout plan

| Phase | Scope | Effort | Flag |
|---|---|---|---|
| 1 | `GET /reconciliation` endpoint + basic side-by-side view (no variance detection) | 3 days | `FI_023_RECON_V1` |
| 2 | Variance detection (price + qty) + amber badges + acknowledgement flow | 3 days | `FI_023_RECON_VARIANCE` |
| 3 | Approval transaction (bulk `received_qty` updates, PO auto-close, audit log) | 3 days | `FI_023_RECON_APPROVE` |
| 4 | Supplier mismatch block + unlink flow + `not_on_po` handling | 2 days | `FI_023_RECON_EDGE_CASES` |
| 5 | Markup widget + Copy-to-SRF + explicit Close PO button | 2 days | `FI_023_RECON_UTILITIES` |

Total: **~13 engineering days**. Shippable in 3 sprints, each phase behind its own flag.

---

## 11. Appendix — Design reference

Visual reference implementation: [fi-ai-styling-guide/reconcile](https://fi-ai-styling-guide-3413f27b698e.herokuapp.com/reconcile.html).

The mockup shows the approved final layout: PO column left, Bill column right, red-tinted outstanding rows, amber variance badges, markup widget, Save + Approve buttons.
