# FI-022 — AI Bill Processing (Claude Vision · Python · Django)

**Status:** Spec · Ready for senior developer
**Roadmap card:** Comes after FI-021 (Technician CoPilot). Reference workflow live at [fi-ai-styling-guide/workflow](https://fi-ai-styling-guide-3413f27b698e.herokuapp.com/workflow.html).
**Last updated:** 2026-04-24
**Owner:** Paul
**Related specs:** fi-021-technician-copilot, voice-stack-standard, admin-dashboard-phase-3
**Reference implementation (prototype):** `fi-supplier-po` → https://fi-supplier-po-9e72ddd27f18.herokuapp.com
**Design pattern:** AI Styling Pattern v0.7.0+ · Two-Star canonical mark, three placements, approved labels

> **Research note.** This spec was written from the FieldInsight prototype behaviour (`fi-supplier-po` app), the workflow mockups in `fi-ai-styling-guide`, and iterative product discussion — **not** from inspecting the live FieldInsight codebase. Field names (`bill.status`, `PurchaseOrderLineItem.received_qty`, URL patterns, error envelope shapes, serialiser conventions) are best-guess reconstructions that match the prototype's observed behaviour. 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* (what happens, in what order, under what condition) is correct; only the *identifiers* may need renaming.

> **Data-model approach — canonical rule for this spec and every future FI spec.** FieldInsight has an existing postgres data model already in production. This spec does NOT invent new standalone Django models. For every data need this feature introduces, the senior developer must:
>
> 1. **Find the existing FI table** that already owns the concept (e.g. the existing `Bill` / invoice table, the existing `PurchaseOrder` table, the existing `Attachment` / file table).
> 2. **Prefer extending that table with a new column** over creating a new table — lightest migration, simplest query path.
> 3. **Add a new table only when the data is high-volume or naturally a separate concept** (e.g. per-attachment parse state history). If you add one, it should have a foreign key into the existing FI table, not replace it.
> 4. Section 7 below lists the *concepts* this feature needs from the data layer, with a suggested home (extend-X or new-table-linked-to-X) for each one. Those suggestions are starting points for a design-review conversation, not final schemas.
>
> This rule applies to every future requirement spec in this library — don't redesign the data layer, extend it.

---

## 1. Requirements Document

### 1.1 Functional requirements

Each requirement carries a **Scope** tag. The taxonomy:

- **Bill (invoice)** — applies specifically to the Bill workflow intaking a supplier *invoice*
- **PO (quote)** — applies specifically to the PO workflow intaking a supplier *quote*
- **Bill only** — applies to any Bill context (incl. manual item entry, Xero push)
- **PO only** — applies to any PO context (PO-side data writes from parsing). Reconciliation-specific behaviours live in FI-023.
- **Both** — applies to Bill AND PO contexts identically

| # | Scope | Requirement | Priority |
|---|---|---|---|
| FR-1 | **Both** | User can attach one or many PDF files to a Bill or a PO without triggering a parse. | MUST |
| FR-2 | **Bill (invoice)** | Bill drop-zone parses **immediately** on drop. Confirmation modals (if any) appear **after** parsing completes, so the user can see what was extracted (item count, supplier, totals) before deciding merge / replace / attach. Parsed items are NOT persisted to the Bill until the user confirms in the post-parse modal (exception: empty-Bill happy path in FR-3, which auto-persists). | MUST |
| FR-3 | **Bill (invoice)** | Bill drop-zone post-parse behaviour depends on Bill state: **(a)** empty Bill (0 line items) + no supplier conflict → parsed items persist automatically, provenance tag + toast appear, no modal; **(b)** Bill already has line items (same supplier) → `Parse result — merge / replace / just attach` modal appears with a preview of parsed items; **(c)** parsed supplier differs from the Bill's existing supplier → stricter `Parse result — replace supplier & items / Cancel (discard parse)` modal. | MUST |
| FR-4 | **PO (quote)** | PO drop-zone default behaviour: drag-drop AUTO-PARSES the file as a Supplier Quote and updates the PO in place. No new Bill is ever created from a PO drop. No modal for the common case. | MUST |
| FR-5 | **Both** | `+ Add File` button (every drop-zone): attaches the file WITHOUT parsing. Identical behaviour on Bill and PO surfaces. | MUST |
| FR-6 | **Both** | `✨ Parse File` button (every drop-zone): runs the scenario's primary parse action directly, bypassing any modal. Equivalent to the PO default-drop behaviour, but now also explicit on Bills. | MUST |
| FR-7 | **Both** | Supplier-match check runs as a **mandatory part of every parse** (which now happens automatically on every Bill drop and every PO drop). Attach-only uploads (`+ Add File`) do NOT trigger a parse and therefore do NOT run the check. When mismatch is detected post-parse, the stricter `Replace supplier & items / Cancel (discard parse)` modal blocks item persistence until the user resolves it. | MUST |
| FR-9 | **Bill (invoice)** | Bill with items + different supplier: merging is blocked. Only `Parse and replace supplier — use <Vendor>` or `Cancel`. | MUST |
| FR-10 | **PO only** | PO → new Bill (from invoice) is NOT a drag-drop operation. The PO page exposes **two** button paths for Bill creation, each with a distinct post-click destination: **(a)** `+ New Bill Received` — creates a Bill linked to the PO, parses the invoice in the background, navigates to the Bill detail page (quick-path for simple registration; no reconcile UI); **(b)** `✨ Reconcile Supplier Invoice` — creates a blank Bill linked to the PO and navigates to the reconcile page for live drop-parse + side-by-side matching. Both button paths are first-class and coexist (see FI-023 FR-ENTRY-1 for the full four-button PO-page entry set). | MUST |
| FR-11 | **Both** | Each parsed line item stores the source attachment ID so the UI can show provenance (`AI parsed N items from X.pdf`). | MUST |
| FR-12 | **Both** | User can trash any attachment. If it was the source of current items (Bill or PO), only those items are removed; manual items and items from other attachments stay. | MUST |
| FR-14 | **Bill only** | Bill supports `Draft → Approved` state transitions. Approved Bills push line items to Xero. | MUST |
| FR-15 | **Both** | Model name (Claude, Anthropic, GPT, etc.) must never appear in user-facing UI. Use brand voice ("Alfred is reading your invoice…"). | MUST |
| FR-16 | **Both** | **Pre-parse UI** (the processor panel while Alfred is still reading the file) shows only filename, file size, and the current processor stage — never fabricated / predicted content. **Post-parse modals** (on Bills, after parse completes) MAY show a summary of what Alfred extracted (item count, supplier, total) so the user can make an informed merge / replace / attach decision — that's the whole point of parsing first, then asking. | MUST |
| FR-17 | **Both** | User sees a staged processor animation during parse (`Uploading → Reading structure → Extracting line items → Detecting GST`). | SHOULD |
| FR-18 | **Both** | Duplicate file detection by content hash — refuse to re-attach the same PDF twice to the same owner (Bill or PO). | SHOULD |
| FR-19 | **Both** | Retry-safe: same `Idempotency-Key` on re-submit must not double-charge Claude API. | SHOULD |
| FR-20 | **Bill only** | Provenance on a mixed Bill (AI + manual items) lives at the **group level only**, never per-row. The UI surfaces provenance via **(a)** a single banner under the line-items table reading `AI parsed N items from <file> · M items added manually`, **(b)** a split in the totals panel showing `AI-parsed` subtotal vs `Manual` subtotal, and **(c)** a `Mixed provenance` status pill on the Bill breadcrumb. **No per-row sparkle. No per-row Source chip.** Per-row markers are banned as visual glitter. Manual rows are still preserved in the data model (`source_attachment=NULL`) through re-parses and attachment trash — this data is exposed in the filter panel and API, not in the table itself. | MUST |
| FR-21 | **PO (quote)** | PO pricing-refresh: when a supplier quote is parsed against an existing PO, matched line items get `unit_price_ex_gst` and `ordered_qty` refreshed. `received_qty` is preserved (never reset). New codes become new `PurchaseOrderLineItem` rows. | MUST |
| FR-22 | **PO only** | When all PO line items reach `received_qty >= ordered_qty` via linked Bill approvals, PO transitions `Receiving → Closed` atomically. | MUST |
| FR-23 | **PO only** | Server MUST reject any API call that attempts to create a Bill as the direct result of a PO drag-drop (`400 drop_on_po_must_be_quote`). Bill creation from a PO page is allowed only through the explicit `/purchase-orders/{id}/attachments/{att_id}/create-bill-from-invoice` endpoint. | MUST |
| FR-24 | **Bill only** | Bill drop-zone messaging must state: "Bill workflow · drag-drop parses immediately. Alfred will ask merge / replace / attach if items already exist." The rule is visible in the UI, not hidden. | SHOULD |
| FR-25 | **PO only** | PO drop-zone messaging must state: "PO workflow · drop = Supplier Quote updating this PO (never a new Bill). Bills come in via `✨ Reconcile Supplier Invoice` in the PO header." The rule is visible in the UI, not hidden. | SHOULD |

### 1.2 Non-functional requirements

| # | Requirement | Target |
|---|---|---|
| NFR-1 | Median end-to-end parse latency (upload → items visible in UI). | < 10 s |
| NFR-2 | P95 parse latency. | < 25 s |
| NFR-4 | Concurrent parses per worker. | ≥ 20 (async I/O bound) |
### 1.3 Out of scope (this ticket)

- **Voice-triggered parse** — see FI-021
- **Xero OAuth and posting logic** — separate spec
- **PO ↔ Bill reconciliation page, matching algorithm, variance detection, approval transaction, PO close** — moved to FI-023. FI-022 stops at parse + supplier-match + write. Everything downstream (match, variance, approve) is FI-023.
- **Reconciliation approval workflows for CFO-threshold Bills** — covered in admin-dashboard-phase-3
- **Training a custom vision model** — using off-the-shelf Claude Vision only
- **Inbound email → bill auto-attach** — future phase

### 1.4 Constraints

- Python 3.12 · Django 5.2 · **FieldInsight custom API framework** (in-house, built on Django views + middleware — NOT Django REST Framework)
- Claude Vision via Anthropic SDK `anthropic>=0.39.0` using `claude-sonnet-4-5` (vision-capable) for extraction; `claude-haiku-4-5` fallback for re-parses
- PostgreSQL 15 · Redis 7 (Celery broker + result backend)
- S3 for PDF storage (local MinIO in dev)
---

## 2. Summary

Users drop supplier invoice PDFs onto Bills (or Purchase Orders) in the FieldInsight app. Claude Vision reads each PDF, extracts line items, matches to existing PO data, and writes structured Bill records to the database. **Drops trigger parsing immediately — no pre-parse modal.** Alfred asks questions *after* the parse completes, and only when there's a conflict to resolve (existing items on the Bill, or a supplier mismatch). This is faster (fewer clicks for the empty-Bill happy path) and more informative (the user sees what was extracted before deciding merge / replace / attach). The system also handles multi-file attachment and discard-on-conflict resolution.

The PO ↔ Bill **reconciliation view** (side-by-side match, variance detection, approval transaction, PO auto-close) is a separate spec — see FI-023. FI-022 ends when a parsed Bill is sitting in `draft` with a linked PO. FI-023 picks up from there.

Three primary user scenarios (A, B, C) are covered in Section 4. The visual pattern, button labels, and modal copy are locked by the AI Styling Pattern.

---

## 3. Architecture overview

```
┌──────────────┐    POST /api/bills/{id}/attachments       ┌──────────────┐
│  Django UI   │ ────────────────────────────────────────▶ │  Django API  │
│ (HTMX / Vue) │                                           │  (FI API)    │
│              │ ◀─ SSE /api/bills/{id}/events ─────────── │              │
└──────┬───────┘                                           └───────┬──────┘
       │                                                           │
       │ upload PDF                                                │ schedule task
       ▼                                                           ▼
┌──────────────┐                                           ┌──────────────┐
│  S3 bucket   │ ◀─── presigned PUT ──────── Django ──────▶│ Celery queue │
│ (encrypted)  │                                           │  (Redis)     │
└──────────────┘                                           └───────┬──────┘
                                                                   │
                                                                   │ worker picks up
                                                                   ▼
                                                           ┌──────────────┐
                                                           │  Celery task │
                                                           │  parse_file()│
                                                           └───────┬──────┘
                                                                   │
                                                                   │ multimodal call
                                                                   ▼
                                                           ┌──────────────┐
                                                           │ Anthropic    │
                                                           │ Claude Vision│
                                                           └───────┬──────┘
                                                                   │
                                                                   │ structured JSON
                                                                   ▼
                                                           ┌──────────────┐
                                                           │ PostgreSQL   │
                                                           │ Bill, Item,  │
                                                           │ Attachment   │
                                                           └──────────────┘
```

**Key principles:**

- **Separation of attach and parse.** Attachment upload is synchronous (fast, no LLM). Parse is async (slow, costs money, requires user confirmation).
- **One fact, one source.** The `Attachment.id` is the canonical reference; all extracted items point to it via a foreign key.
- **Idempotent parses.** A stable `request_id` (hash of `attachment_id + parse_mode`) lets the client safely retry without double-charging.
- **Brand-voice only.** The API returns Alfred-voiced status strings. The client never sees "Claude" or "Anthropic".

---

## 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 breaks into the concrete steps the user and system take to complete it. Server behaviour is captured in §4c (Acceptance Criteria) and §6 (Algorithms).

**Personas.** Three people share this feature's journey:

- **Mia** — office admin, creates Bills from supplier invoices.
- **Dave** — project manager, runs POs and reconciles deliveries.
- **Sam** — technician on a job site, adds manual items.

---

### §4.0 — Main Job · Turn a supplier PDF into structured Bill / PO data

> **When** a supplier sends us a PDF (invoice on a Bill, quote on a PO),
> **I want to** drop it in and have Alfred extract the line items accurately without asking me twenty questions first,
> **so that** I can spend seconds — not minutes — per document and stay on the real work.

**Success looks like:** Line items populated from the PDF with no manual typing · original PDF attached to the right record · provenance tag showing what Alfred parsed · zero silent data loss when there's a conflict to resolve.

**Pain points it solves:**
- No more re-typing line items from PDFs into forms
- No more "did Alfred parse it yet or not?" mystery — the processor panel shows exactly where it's up to
- No accidental overwrites when a Bill already has items or a PO is mid-receive

This main job has seven sub-jobs (§4.1 – §4.7), each mapping to one drop-zone surface or user moment. The decision matrix below is the canonical reference for *where* each sub-job fires; deviations are bugs.

---

### §4.0.1 · Bill vs PO — drop-zone behaviour decision table

| Context | Document the drop represents | `+ Add File` | `✨ Parse File` | Drag-drop (default) |
|---|---|---|---|---|
| **Bill drop-zone** | Supplier **invoice** | Attach only (no parse) | Same as drag-drop — parse immediately | **Parses immediately.** If the Bill has existing items OR the parsed supplier doesn't match, a post-parse modal asks merge / replace / attach. On an empty Bill with no conflict, items populate automatically (no modal). |
| **PO drop-zone** | Supplier **Quote** — updates this PO (never creates a new Bill) | Attach only (no parse) | Same as drag-drop — parse immediately | **Parses immediately.** Updates the PO in place. Supplier-mismatch modal if needed. Same flow as Parse File. |
| **PO header · `✨ Reconcile Supplier Invoice` button** | Supplier **invoice** — button click, not a drop | n/a | n/a | Creates a blank Bill linked to this PO, navigates to the reconcile page. User drops the invoice there. See FI-023 §4.1 for the full flow. |

Three principles lock this matrix:

1. **Parse first, ask second.** Drag-drop on a Bill or a PO parses immediately. Confirmation modals (merge / replace / attach on a populated Bill; supplier-replace on any mismatch) appear after the parse completes so the user can see what was extracted before deciding. On an empty Bill with no conflict, no modal appears — items populate and a toast confirms.
2. **PO drop ≠ new Bill.** Never. Bill-creation from a PO requires an explicit click on `✨ Reconcile Supplier Invoice` (see FI-023 FR-ENTRY-3). Eliminates the "was that a quote or an invoice?" ambiguity.
3. **Supplier-match only runs when Alfred actually parses.** Attach-only uploads via `+ Add File` don't trigger a parse and therefore don't run the supplier-match check (FR-7).

---

### §4.1 — Parse an invoice onto an empty Bill (happy path)

> **When** I've just created a Bill and an invoice has arrived from the supplier,
> **I want to** drop the PDF once and have line items populate automatically,
> **so that** the simplest case takes zero clicks beyond the drop.

**Persona:** Mia (admin).
**Pre-condition:** Empty Bill (0 line items, no supplier set yet).

**Steps:**
1. Mia opens a Bill. The page is dominated by a hero drop zone: *"Drop your first supplier invoice here — Alfred parses it straight away."*
2. She drags `reece-invoice-38472.pdf` from her desktop onto the drop zone.
3. Alfred parses immediately. A staged processor panel appears under the Bill header: *"Alfred is reading your invoice…"* with four ticking stages — **Uploading → Reading structure → Extracting line items → Detecting GST.**
4. After a few seconds the stages complete.
5. Because the Bill was empty and there's no supplier conflict, four line items populate automatically — no modal.
6. A soft purple tag appears below the table: *"AI parsed 4 items from reece-invoice-38472.pdf."*
7. A brief toast confirms *"Bill updated — 4 items added."*
8. The Bill's supplier field fills in from the invoice letterhead.

**Variations:**
- **Attach only** — Mia clicks `+ Add File` instead of dragging. PDF attaches; no parse; she can trigger `Parse` from the attachment row later.

**Success:** Bill has four parsed line items, provenance tag visible, PDF attached, supplier populated — zero clicks beyond the drop.

**Edge cases:**
- Corrupt / scanned-blank PDF — no items written; attachment row shows *"Alfred couldn't read this — try again?"* pill.

**AC references:** AC-A1, AC-A2, AC-A3, AC-A4, AC-A5.

---

### §4.2 — Add another invoice to a Bill that already has items

> **When** my Bill already has line items and a second invoice arrives for the same job,
> **I want to** see what Alfred extracted from the new file and decide whether to merge, replace, or discard it,
> **so that** I never lose existing items to a silent overwrite.

**Persona:** Mia.
**Pre-condition:** Bill has ≥ 1 existing line items (e.g. 3 items from an earlier Reece invoice).

**Steps:**
1. Mia drops `reece-invoice-38475.pdf` onto the Bill.
2. Alfred parses immediately — same as §4.1. Processor panel runs through its four stages.
3. Parse completes. Alfred spots the conflict (Bill already has 3 items) and pauses before writing.
4. A post-parse modal opens:
   > **Alfred parsed 2 items from reece-invoice-38475.pdf · Supplier: Reece**
   > This Bill already has 3 items from reece-invoice-38472.pdf.
   > - **Merge — add these 2 to the existing 3** (primary)
   > - **Replace — wipe the existing 3 and use these 2 instead** (secondary)
   > - **Just attach file — discard the parse, keep the PDF on file** (ghost)
5. Mia picks `Merge — add these 2 to the existing 3`.
6. The modal closes. The two new items drop in under the existing three.
7. The provenance tag updates to *"AI parsed 5 items from 2 files: reece-invoice-38472.pdf, reece-invoice-38475.pdf."*

**Variations:**
- **Replace** — Mia picks `Replace`. Existing 3 items are deleted; the 2 new items are inserted in their place. Previous attachments are demoted to `parsed_state=none` so she can re-parse them if needed.
- **Discard** — Mia picks `Just attach file — discard the parse`. Parsed items are thrown away; the PDF stays on the Bill as an unparsed attachment.

**Why merge is the default:** replacing is destructive — Mia should opt in to it, never arrive there by accident.

**Why the modal shows parsed counts:** the whole point of parsing before asking is that Mia decides with full information. *"Alfred found 2 items, merge?"* is a better question than a blind *"parse this file?"*

**Success:** Bill has 5 items (merge), 2 items (replace), or 3 items + discarded-parse attachment (just-attach), per Mia's choice.

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

---

### §4.3 — Refresh a PO's pricing from a supplier quote

> **When** the supplier sends me a revised quote for a PO (new prices, new substitute products),
> **I want to** drop the PDF on the PO and have it update the PO in place,
> **so that** my PO always reflects the latest agreed supply terms without manual re-entry.

**Persona:** Dave (PM).
**Pre-condition:** PO in `authorised` or `receiving` state with ≥ 1 line item.

**Steps:**
1. Dave drags the supplier's revised quote PDF onto the PO's drop-zone.
2. No modal appears — Alfred trusts the PO context and parses immediately. Processor panel runs its four stages.
3. When parse completes, each affected row on the PO briefly flashes brand-purple (about 1.8 seconds) so the change is visible.
4. Prices on three rows update · one new row appears for a substitute product · one item's `ordered_qty` bumps from 4 to 6.
5. A toast confirms *"PO-2026-020 updated from supplier quote — 3 prices refreshed, 1 item added."*
6. The quote PDF sits in the PO's attachments for audit later.

**Variations:**
- **Attach only** — Dave clicks `+ Add File`. PDF attaches silently; no items change. Useful for "hold this for reference, I'll look at it tomorrow."
- **Richer choice via `✨ Parse File`** — opens a post-parse modal offering *Parse and update pricing / Parse and replace all PO items / Just attach file — don't parse*.
- **Supplier mismatch** — the parsed quote's letterhead doesn't match `PurchaseOrder.supplier`. Alfred pauses before writing: stricter modal *"This quote is from [Other Vendor], not [PO Vendor]. Replace supplier on this PO?"* with `Parse and replace supplier` / `Cancel`. No silent overwrite, ever.

**Success:** PO has refreshed prices, any new substitute items, a recomputed subtotal, and the quote PDF attached.

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

---

### §4.4 — Quick-path Bill creation from a PO (New Bill Received)

> **When** I'm on a PO page and a supplier invoice has arrived, and I don't need the side-by-side reconciliation view right now,
> **I want to** register the Bill with one click and keep moving,
> **so that** I can capture the invoice against the PO without stopping for a line-by-line match.

**Persona:** Mia (admin).
**Pre-condition:** PO in `authorised` or `receiving` state with ≥ 1 line item. Mia has the supplier invoice PDF ready.

**Steps:**

1. Mia clicks `+ New Bill Received` on the PO header.
2. A confirm modal appears: *"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)
3. Mia picks the primary. A file picker opens (or she drops the PDF directly onto the modal).
4. Alfred silently does three things: creates the Bill linked to the PO; attaches the PDF; parses the invoice into line items.
5. When parse finishes, Mia is **navigated to the new Bill's detail page** — NOT the reconcile page. She sees parsed items already populated, the PDF attached, and the PO link in the Bill header.
6. On the PO page (if she navigates back), the received-quantity counters have incremented for every matched line item.

**Variations:**

- **Just attach — I'll create the Bill later** — the PDF attaches to the PO as a pending-to-bill file. No Bill created yet. Mia can convert it later.

**When to use this vs `✨ Reconcile Supplier Invoice`:**

- `+ New Bill Received` (this sub-job) — quick registration. Post-click destination: the new Bill's detail page.
- `✨ Reconcile Supplier Invoice` (see FI-023 §4.1) — deliberate reconciliation. Creates a blank Bill and sends the user to the reconcile page for a live drop-parse + match + approve flow.

Both paths are first-class. Same data outcome (Bill linked to PO), different UX flow.

**Success:** Bill created, linked, parsed, visible on the Bill detail page. PO received counters updated.

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

---

### §4.5 — Authorise a fresh PO by dropping the first supplier quote

> **When** I've created a blank PO and the supplier has just sent me a quote,
> **I want to** authorise the PO by dropping the quote once and confirming,
> **so that** the PO moves from Draft to Authorised with correct items + pricing in one deliberate step.

**Persona:** Dave.
**Pre-condition:** PO in `Draft` with 0 line items and no accepted quote. Supplier Bills card + Reconcile PO button are disabled (nothing to receive against yet).

**Steps:**
1. A prominent drop zone fills the PO: *"Drop your supplier quote here to authorise this PO."*
2. Dave drops `quote-reece-2026-07-14.pdf` onto the hero drop zone.
3. Because the PO is empty, Alfred asks for confirmation before committing (this is the one PO case that shows a modal — empty POs need explicit authorisation).
4. The modal reads *"Parse this supplier quote to authorise PO items?"* with three choices:
   - **Parse and authorise PO** (primary)
   - **Just attach quote — authorise later** (secondary)
   - **Cancel** (ghost)
5. Dave clicks `Parse and authorise PO`. Processor panel runs.
6. When it finishes, the PO's status chip changes from **Draft** to **Authorised**, the line items table populates from the quote, the supplier field fills in, and the Supplier Bills card enables — the PO is now ready to receive Bills against it.

**Variations:**
- **Just attach** — Dave picks secondary. The quote sits on the PO; nothing authorises. PO stays Draft. Useful when the supplier has sent a draft quote Dave wants to review before committing.

**Success:** PO is authorised with items, supplier set, ready to receive goods.

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

---

### §4.6 — Refresh pricing on a PO that's already mid-receiving

> **When** a supplier sends a revised quote mid-delivery (prices changed for outstanding items, or a substitute was offered),
> **I want to** refresh the PO's pricing without affecting the items I've already received,
> **so that** receiving progress is preserved and only the future deliveries reflect the new terms.

**Persona:** Dave.
**Pre-condition:** PO in `receiving` state. Some line items fulfilled (`received_qty > 0`), some outstanding (`received_qty = 0`). Example: PO-2026-014 with 5 of 8 received.

**Steps:**
1. Dave drops the revised quote PDF onto the PO's drop-zone. Same flow as §4.3 — no modal, parse immediately.
2. The processor runs through its four stages. Affected rows flash brand-purple.
3. On the three outstanding rows: prices refresh · one item's quantity bumps · one row is replaced by the new substitute product.
4. **Critically:** the `Rcvd` column on the five already-received rows doesn't move. Receiving progress is preserved — only pricing and `ordered_qty` change.
5. Toast: *"PO-2026-014 refreshed from revised quote — 3 prices updated, 1 item substituted. Receiving progress preserved."*

**Why the drop is still a quote, not an invoice for outstanding items:** even on a half-received PO, a drop on the PO is still a quote-update. Bills for the outstanding items come in through the `✨ Reconcile Supplier Invoice` button (FI-023 §4.1). One PO page, one drop behaviour, consistent everywhere.

**Success:** PO still in Receiving · 5 items still green-ticked · 3 outstanding items reflect new pricing · revised quote PDF attached for audit.

**AC references:** AC-F1, AC-F2, AC-F3, AC-F4.

---

### §4.7 — Add manual items to a Bill without polluting AI provenance

> **When** I need to add items to a Bill that aren't on the supplier invoice (consumables, on-site extras, rags, etc.),
> **I want to** type them in as rows alongside the AI-parsed rows, without the table becoming a messy sea of provenance chips,
> **so that** the Bill stays readable while the provenance of each item remains auditable when I need it.

**Persona:** Sam (technician on site).
**Pre-condition:** Bill has AI-parsed line items with `source_attachment` set. Sam wants to add manual items.

**Steps:**
1. Sam taps `+ Item` below the line items table. An empty row appears inline.
2. He types "Shop rags — pack of 10", qty 1, unit price $12. Saves.
3. He adds another row: "M6 machine screws — box", qty 2, unit price $6.50. Saves.
4. The table now has five rows. **Every row looks the same in the table** — no sparkle chip on the AI rows, no "Manual" chip on Sam's rows. Visual parity inside the table body is deliberate.
5. Below the table, the provenance banner updates: *"AI parsed 3 items from reece-invoice-38472.pdf · 2 items added manually."*
6. The totals panel on the right splits its subtotals: *AI-parsed $1,162.00 · Manual $25.00 · Subtotal $1,187.00.*
7. The Bill's breadcrumb gains a small pill — **Mixed provenance** — so anyone opening the Bill later knows at a glance it's a hybrid.

**What Sam does NOT see:** sparkle icons on every AI row · AI / Manual chips on any row · an extra "Source" column. Per-row provenance markers are deliberately banned (FR-20) because they turn into visual noise.

**If Alfred re-parses the same attachment later** (e.g. Mia spots a mistake and re-runs the parse): only the AI rows are replaced. Sam's rags and screws stay untouched. Manual items are first-class — never collateral damage from a re-parse.

**Success:** One Bill, five line items, zero visual difference between AI and manual rows inside the table, a clear banner + split-totals that explain provenance at the group level.

**AC references:** AC-G1, AC-G2, AC-G3, AC-G4, AC-G5, AC-G6.

---

## 4c. Acceptance Criteria

Given / When / Then format, testable via Playwright or pytest + Django test client. Each AC maps directly to an FR and a use case.

### AC-A · Empty Bill (UC-A)

| # | Given | When | Then | FR |
|---|---|---|---|---|
| AC-A1 | Bill is Draft with 0 line items, 0 attachments | User drops `reece-invoice-INV-38472.pdf` | **Parse starts immediately.** Processor panel visible. NO pre-parse modal opens. One Claude Vision call issued. | FR-2, FR-3 |
| AC-A2 | Empty Bill · parse completes · no supplier conflict (parsed supplier fits a blank Bill) | Observer | Within 15 s of drop: N LineItems persisted to Bill, Bill.supplier populated from the invoice, provenance tag visible, success toast fires. NO modal shown — items auto-apply. | FR-3, NFR-1 |
| AC-A3 | User clicks `+ Add File` instead of dragging | Server | Attachment persists with `parsed_state='none'`. Zero Claude API calls. Zero LineItems created. | FR-5 |
| AC-A4 | Parse returns no extractable content (corrupt / scanned-blank PDF) | Observer | No LineItems created. Attachment row shows "Alfred couldn't read this — try again?" pill. Bill stays empty. | FR-2 |
| AC-A5 | Attachment is in `none` state (earlier `+ Add File`) | User clicks `Extract Line Items` button on the attachment row | Same flow as a drag-drop: parse runs immediately, then the appropriate Story A/B/C logic applies based on the current Bill state. | FR-2, FR-3 |
### AC-B · Bill with items (UC-B)

| # | Given | When | Then | FR |
|---|---|---|---|---|
| AC-B1 | Bill has 3 items from supplier X | User drops new PDF from supplier X | **Parse runs immediately.** When it finishes, post-parse modal opens titled *"Alfred parsed N items — merge, replace, or attach?"* showing the parsed item count + supplier. No items written yet. | FR-2, FR-3 |
| AC-B2 | Post-parse merge/replace modal open (same-supplier) | User clicks `Merge — add these N to the existing M` | Parsed LineItems appended (FK → same Bill, different Attachment). Provenance tag lists both files. Duplicates (by `(product_code, desc.lower(), qty)` triple) are skipped. | FR-3 |
| AC-B3 | Post-parse merge/replace modal open | User clicks `Replace — wipe the existing M and use these N instead` | Existing LineItems deleted, parsed items inserted. Previously-processed attachments go `processed → none` so they can be re-parsed. | FR-3 |
| AC-B4 | Post-parse merge/replace modal open | User clicks `Just attach file — discard the parse` | Parsed items DISCARDED (not persisted). Attachment remains with `parsed_state='discarded'`. Bill items unchanged. | FR-3 |
| AC-B5 | Bill has items from supplier X | User drops a PDF that parses as **supplier Y** | After parse completes, stricter supplier-mismatch modal opens titled *"Supplier mismatch — replace or cancel?"* offering `Replace supplier & items — use Y` or `Cancel (discard parse)`. Merge is NOT offered. | FR-7, FR-9 |
| AC-B6 | Supplier-mismatch modal open | User clicks `Replace supplier & items — use Y` | `Bill.supplier` updated to Y, all LineItems deleted, new items inserted from the new file. Previously-processed attachments demoted to `none`. | FR-7, FR-9 |
| AC-B7 | Supplier-mismatch modal open | User clicks `Cancel (discard parse)` | Parsed items DISCARDED. Bill.supplier unchanged. Bill items unchanged. Attachment demoted to `parsed_state='discarded'`. | FR-7, FR-9 |

### AC-C · PO with items (UC-C)

| # | Given | When | Then | FR |
|---|---|---|---|---|
| AC-C1 | PO in `Authorised` with ≥ 1 line item | User drops a supplier invoice PDF on the PO drop-zone (NOT the quote zone) | Decision context classifies `doc_type_hint='invoice'`. UI opens Case C modal. | FR-6 |
| AC-C2 | Case C modal open | User clicks `Parse and create new Bill & link to PO` | New Bill row created with `bill.purchase_order=po`, attachment reparented to the new Bill, parse enqueued against the Bill. | FR-6 |
| AC-C3 | PO in `Authorised` | User drops a supplier quote PDF on the PO line-items area (not the Supplier Bills card) | Decision context classifies `doc_type_hint='quote'`. UI opens poQuote modal with Parse-and-update-pricing / Parse-and-replace-items / Just-attach. | FR-6 |
| AC-C4 | poQuote modal, `Parse and update pricing` chosen | Worker completes parse | Only `unit_price_ex_gst` is updated on matched PurchaseOrderLineItems. `ordered_qty` unchanged. No new Bill created. | FR-6 |

### AC-E · Empty PO (UC-E)

| # | Given | When | Then | FR |
|---|---|---|---|---|
| AC-E1 | PO in `Draft` with 0 line items | User loads the PO page | Supplier Bills card + Reconcile PO button are disabled (`aria-disabled=true`, visually greyed). Empty-state hero drop-zone is prominent. | UC-E |
| AC-E2 | Empty PO | User drops a quote and picks `Parse and authorise PO` | Worker creates PurchaseOrderLineItem rows, sets PO.supplier, transitions PO.status `Draft → Authorised`. The disabled controls enable. | UC-E |
| AC-E3 | Empty PO | User picks `Just attach quote — authorise later` | Quote attached, `parsed_state='none'`. PO remains `Draft`. Controls remain disabled. | UC-E |

### AC-F · PO partially fulfilled (UC-F)

| # | Given | When | Then | FR |
|---|---|---|---|---|
| AC-F1 | PO has 5 of 8 line items with `received_qty > 0`, 3 with `received_qty = 0` | User loads the PO page | UI shows PO status chip `Receiving` + orange `Partial` badge. Received rows have green-tick Rcvd column, outstanding rows have red-tinted background and `0/N` Rcvd. | UC-F |
| AC-F2 | Partial PO | User drops new supplier invoice and completes Parse-and-create-new-Bill | For each matched LineItem (by `product_code`), `PurchaseOrderLineItem.received_qty` increments. Unmatched Bill items flagged on the Bill. | UC-F, FR-9 |
| AC-F3 | After AC-F2 | Every PurchaseOrderLineItem has `received_qty >= ordered_qty` | PO status transitions `Receiving → Closed` atomically with the Bill's approval. | UC-F, FR-22 |
| AC-F4 | Partial PO with supplier X | User drops invoice whose vendor resolves to supplier Y | Server returns `supplier_match=false` on decision-context. UI blocks the create-Bill flow and shows supplier-mismatch modal. | FR-5 |

### AC-G · Bill partial items — AI + manual mix (UC-G)

| # | Given | When | Then | FR |
|---|---|---|---|---|
| AC-G1 | Bill has 3 AI-parsed LineItems (with `source_attachment` set) | User clicks `+ Item` and enters a new row by hand | LineItem row created with `source_attachment=NULL`. Table body contains NO per-row chip of any kind — visual parity between AI and manual rows. No `Source` column header exists. | UC-G, FR-20 |
| AC-G2 | Bill has mixed AI + manual items | User views the Bill | Provenance is surfaced in three group-level places only: (a) banner under table reads `AI parsed N items from <file> · M items added manually`; (b) totals panel shows `AI-parsed $X` and `Manual $Y` subtotals; (c) breadcrumb shows a `Mixed provenance` pill. | UC-G, FR-20 |
| AC-G3 | Bill has mixed AI + manual items | Automated test inspects table DOM | No `<span>` with class containing `source-ai`, `source-manual`, `ai-chip`, or similar inside any table `<tbody> <tr> <td>` — per-row chips are banned. | UC-G, FR-20 |
| AC-G4 | Bill has mixed AI + manual items | User re-parses the same attachment (mode=replace) | Only rows with `source_attachment = that_attachment` are deleted. Manual rows (null source_attachment) are preserved. | UC-G, FR-11 |
| AC-G5 | Bill has mixed items | User trashes the attachment | Only AI-parsed rows sourced from that attachment are deleted. Manual rows remain. | UC-G, FR-12 |
| AC-G6 | Bill has mixed items | User approves the Bill | Xero push includes all rows equally. No visible difference between AI and manual lines downstream. | UC-G, FR-10 |

### AC-Multi-file · Scenario D (unchanged for reference)

| # | Given | When | Then | FR |
|---|---|---|---|---|
| AC-D1 | Bill has 3 attached PDFs, 0 parsed | User clicks Extract on file 1 | File 1 parses, Bill gets items, file 1 state = processed. Files 2 + 3 remain unprocessed. | FR-1, FR-3 |
| AC-D2 | File 1 processed, Bill has items | User clicks Extract on file 2 (same supplier) | Merge/Replace/Cancel modal opens. | FR-4 |
| AC-D3 | File 1 processed, Bill has items | User clicks Extract on file 3 (DIFFERENT supplier) | Supplier-mismatch modal opens — only Replace-supplier / Cancel. Merge not offered. | FR-5 |
| AC-D4 | Any state | User trashes a processed attachment | LineItems sourced from that attachment are deleted. Bill totals recalculate. Other attachments untouched. | FR-12 |

### AC-Buttons · + Add File and ✨ Parse File (every drop-zone)

| # | Given | When | Then | FR |
|---|---|---|---|---|
| AC-BTN1 | Any drop-zone (Bill or PO) | User clicks `+ Add File` | Attachment created with `parsed_state='none'`. Zero Claude API calls. Zero supplier-match checks. | FR-5, FR-7 |
| AC-BTN2 | Any drop-zone | User clicks `✨ Parse File` | Parse runs immediately — identical behaviour to a drag-drop on that surface. On Bills with existing items: post-parse modal. On empty Bill: direct populate. On PO: in-place update. | FR-6 |
| AC-BTN3 | `+ Add File` click on a PO | Observer | Attachment saved against the PO. No `PurchaseOrderLineItem` rows created, no `Bill` created. | FR-5, FR-10 |
| AC-BTN4 | `✨ Parse File` click on a PO | Observer | Document parsed as a Supplier Quote and written to `PurchaseOrderLineItem`. No Bill created. | FR-4, FR-10 |
| AC-BTN5 | `✨ Parse File` click on a Bill with existing items from a DIFFERENT supplier | Observer | Parse runs; post-parse supplier-mismatch modal opens. User picks `Replace supplier & items` or `Cancel (discard parse)`. No items written until they decide. | FR-7, FR-9 |

### AC-Bill-vs-PO · drag-drop split

| # | Given | When | Then | FR |
|---|---|---|---|---|
| AC-BP1 | Empty Bill | User drag-drops a PDF on the Bill drop-zone | Parse runs immediately. On empty-Bill happy path (parse succeeds, no supplier conflict): items persist, no modal. | FR-2, FR-3 |
| AC-BP2 | Populated Bill | User drag-drops a PDF on the Bill drop-zone | Parse runs immediately. Post-parse modal opens (merge/replace/attach OR supplier-mismatch) before any items are written. | FR-2, FR-3 |
| AC-BP3 | PO (any state) | User drag-drops a PDF on the PO drop-zone | Default-parses as Supplier Quote. Claude Vision called immediately, supplier-match runs, updates PurchaseOrderLineItem. Modal NOT shown in the happy path. | FR-4 |
| AC-BP4 | PO with items | User drag-drops a PDF whose parsed supplier matches the PO supplier | PO pricing updates in place. Flash animation on updated rows (1.8 s). Toast: "PO pricing refreshed". No Bill is ever created. | FR-4, FR-10 |
| AC-BP5 | PO with items | User drag-drops a PDF whose parsed supplier differs from the PO supplier | Supplier-mismatch modal opens BEFORE any write. `parsed_state='pending_user_resolution'` on the attachment until user decides. | FR-7 |
| AC-BP6 | PO in any state | The UI attempts to create a new Bill as a result of a drop | Server rejects with 400 `drop_on_po_must_be_quote`. (Bill creation from a PO page is button-only per `✨ Reconcile Supplier Invoice` / FI-023 FR-ENTRY-3.) | FR-10 |

### AC-Global · Non-functional + brand rules

| # | Given | When | Then | FR / NFR |
|---|---|---|---|---|
| AC-N1 | Any user-facing UI view (modal, pill button, toast, provenance tag, processor step) | Developer inspects the DOM | Zero occurrences of the strings "Claude", "Anthropic", "GPT", or any model name. | FR-11 |
| AC-N2 | Processor panel visible (parse in progress) | Developer inspects | Zero reference to line-item counts, totals, or extracted supplier in the processor panel DOM. Only filename, file size, and the current processor stage may appear. | FR-16 |
| AC-N3 | Post-parse modal (merge/replace/attach or supplier-mismatch) | Developer inspects | Parsed summary (item count, supplier, total) IS allowed and expected — the whole point of the post-parse modal is to inform the user's decision with what Alfred extracted. | FR-16 |
| AC-N3 | Load test: 100 concurrent parses | Run for 60 s | P95 end-to-end latency < 25 s. Queue depth < 10 in steady state. No 5xx from the API. | NFR-2, NFR-4 |
---

## 6. Algorithms

### 6.1 Supplier vendor-hint detection (pre-parse, no LLM cost)

Purpose: give the decision-context endpoint a supplier hint **before** the expensive Claude call, so the correct modal can be rendered.

```python
# services/vendor_hint.py

VENDOR_FILENAME_PATTERNS = {
    r'(?i)reece':       'Reece Plumbing',
    r'(?i)l\&?h|lh.*electrical': 'L&H Electrical',
    r'(?i)bunning':     'Bunnings Trade',
    # Extend as the supplier directory grows. Source of truth is the
    # Supplier model — this dict is a cached regex snapshot rebuilt nightly.
}

def vendor_hint_from_filename(filename: str) -> str | None:
    """O(n) over supplier patterns. Returns vendor name or None."""
    for pattern, vendor in VENDOR_FILENAME_PATTERNS.items():
        if re.search(pattern, filename):
            return vendor
    return None


def vendor_hint_from_header_ocr(pdf_path: str) -> str | None:
    """
    Cheap fallback: extract just the first 200 px of page 1 using pdfplumber,
    run through the same regex set. Avoids full Claude call but gets vendor
    from 'TAX INVOICE — Reece Plumbing' style headers when filename is hash-named.
    """
    with pdfplumber.open(pdf_path) as pdf:
        first_page = pdf.pages[0]
        top_band = first_page.crop((0, 0, first_page.width, 200))
        text = top_band.extract_text() or ""
    for pattern, vendor in VENDOR_FILENAME_PATTERNS.items():
        if re.search(pattern, text):
            return vendor
    return None
```

### 6.2 Parse dispatch (decides which mode to call)

```python
# services/parse_dispatch.py

def decide_parse_mode(bill: Bill, new_attachment: Attachment, user_choice: str) -> ParseMode:
    """
    user_choice ∈ {'parse_add', 'parse_merge', 'parse_replace', 'parse_replace_supplier'}
    """
    if bill.line_items.count() == 0:
        return ParseMode.REPLACE  # empty Bill — replace == add

    existing_supplier = bill.supplier
    new_vendor_hint = vendor_hint_from_filename(new_attachment.filename) \
                   or vendor_hint_from_header_ocr(new_attachment.s3_path)

    if existing_supplier and new_vendor_hint and existing_supplier != new_vendor_hint:
        # Supplier mismatch — only allowed action is replace_supplier (blocks merge)
        if user_choice == 'parse_replace_supplier':
            return ParseMode.REPLACE_SUPPLIER
        raise ConflictError("Supplier mismatch — must replace supplier or cancel")

    if user_choice == 'parse_merge':    return ParseMode.MERGE
    if user_choice == 'parse_replace':  return ParseMode.REPLACE
    raise ValueError(f"Unknown user choice: {user_choice}")
```

### 6.3 Claude Vision extraction (the actual parse)

```python
# services/claude_extract.py

import anthropic

EXTRACTION_PROMPT = """
You are Alfred, extracting line items from a supplier invoice PDF for an
accounting system. Return a JSON object with the following shape:

{
  "has_content": bool,
  "supplier_name": string,
  "invoice_number": string,
  "invoice_date": "YYYY-MM-DD",
  "currency": "AUD",
  "gst_treatment": "inclusive" | "exclusive" | "mixed",
  "subtotal_ex_gst": decimal,
  "gst_total": decimal,
  "grand_total_inc_gst": decimal,
  "line_items": [
    {
      "line_number": int,
      "description": string,
      "product_code": string | null,
      "quantity": decimal,
      "unit": string,
      "unit_price_ex_gst": decimal,
      "line_total_ex_gst": decimal,
      "gst_applies": bool
    }
  ]
}

Rules:
- Do not hallucinate line items. If a value is unclear, use null.
- Flatten sub-lines (e.g. "discount applied") into the parent item.
- Return "has_content": false if the PDF is blank, scanned-illegibly, or not an invoice.
- Do not include a preamble or commentary — return JSON only.
"""

def extract_invoice_json(pdf_bytes: bytes) -> dict:
    """
    Synchronous — called inside a Celery task. Retries handled at task level.
    """
    client = anthropic.Anthropic()
    msg = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=4096,
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "document",
                        "source": {
                            "type": "base64",
                            "media_type": "application/pdf",
                            "data": base64.b64encode(pdf_bytes).decode(),
                        },
                    },
                    {"type": "text", "text": EXTRACTION_PROMPT},
                ],
            }
        ],
    )
    raw = msg.content[0].text
    return json.loads(strip_json_fences(raw))
```

### 6.4 Line-item persistence (replace vs merge)

```python
# services/bill_items.py
from django.db import transaction

@transaction.atomic
def apply_parsed_items(bill: Bill, attachment: Attachment, parsed: dict, mode: ParseMode):
    if mode == ParseMode.REPLACE:
        # Delete all existing line items; keep attachment records.
        bill.line_items.all().delete()
    elif mode == ParseMode.REPLACE_SUPPLIER:
        bill.line_items.all().delete()
        # Demote previously-processed attachments so user can re-parse
        bill.attachments.filter(parsed_state='processed').update(parsed_state='none')

    bill.supplier = parsed["supplier_name"]
    bill.invoice_number = parsed["invoice_number"]
    bill.invoice_date = parsed["invoice_date"]
    bill.save()

    # Merge case: skip duplicates by (product_code, description, qty) triple
    existing_keys = set()
    if mode == ParseMode.MERGE:
        existing_keys = {
            (li.product_code, li.description.lower(), li.quantity)
            for li in bill.line_items.all()
        }

    new_items = []
    for raw in parsed["line_items"]:
        key = (raw["product_code"], raw["description"].lower(), raw["quantity"])
        if key in existing_keys:
            continue  # duplicate — skip silently
        new_items.append(LineItem(
            bill=bill,
            source_attachment=attachment,  # provenance
            **sanitise(raw),
        ))
    LineItem.objects.bulk_create(new_items)

    attachment.parsed_state = 'processed'
    attachment.save()
```

### 6.5 Duplicate attachment detection (content hash)

```python
# services/attachment_upload.py
import hashlib

def sha256_stream(fileobj, chunk_size=1024*1024) -> str:
    h = hashlib.sha256()
    for chunk in iter(lambda: fileobj.read(chunk_size), b""):
        h.update(chunk)
    fileobj.seek(0)
    return h.hexdigest()

def attach_or_reuse(bill: Bill, upload) -> Attachment:
    digest = sha256_stream(upload)
    existing = bill.attachments.filter(content_hash=digest).first()
    if existing:
        raise DuplicateAttachment(existing_id=existing.id)
    s3_key = f"bills/{bill.id}/{digest}{pathlib.Path(upload.name).suffix}"
    s3.upload_fileobj(upload, BUCKET, s3_key, ExtraArgs={"ServerSideEncryption": "aws:kms"})
    return Attachment.objects.create(
        bill=bill, filename=upload.name, s3_key=s3_key,
        size_bytes=upload.size, content_hash=digest, parsed_state='none',
    )
```

---

## 7. Suggested data model extensions

> FieldInsight has an existing postgres data model in production — the `Bill`, `PurchaseOrder`, `LineItem`, `PurchaseOrderLineItem`, `Supplier`, and file-attachment concepts already exist as real tables. This section deliberately **does not prescribe Django model classes or migrations.** It lists the *concepts* this feature needs from the data layer and suggests, for each one, whether it should **extend an existing FI table with a new column** or **add a new table with a foreign key into an existing FI table**. Every suggestion below is a starting point for a design-review conversation, not a final schema. The senior dev should map each concept to the real FI table and column names before writing a migration.

### 7.1 Guiding principle — there is no "Supplier Invoice" entity

In this spec, **a supplier invoice is a Bill.** Any time the spec, workflow prototype, or UI says "supplier invoice", it's referring to a `Bill` row — optionally linked to a `PurchaseOrder` via a foreign key. Do not introduce a separate `SupplierInvoice` table. One entity, two names (internal name = Bill, external / user-facing name = supplier invoice when it has a PO link, or just "Bill" when it doesn't).

### 7.2 Concepts this feature needs from the data layer

| # | Concept | Suggested home | New data points | Why |
|---|---|---|---|---|
| 1 | **Bill ↔ PO link** | Extend existing `Bill` | Nullable `purchase_order_id` FK into existing PO table | One Bill → 0..1 POs; one PO → many Bills. Enables reconciliation. |
| 2 | **Bill supplier** | Extend existing `Bill` | Nullable `supplier_id` FK into existing `Supplier` table | Mirrors the invoice's vendor; nullable until a parse confirms it. |
| 3 | **Line-item provenance** | Extend existing `LineItem` (or FI equivalent) | Nullable `source_attachment_id` FK | Null for manually-entered rows; set for AI-parsed rows. Enables the provenance banner + attachment-trash cascade (FR-11, FR-12, FR-20). |
| 4 | **PO receiving progress** | Extend existing `PurchaseOrderLineItem` | `received_qty` (default 0), `ordered_qty` (if not already present) | Increments on Bill approval (FI-023). Preserved across supplier-quote re-parses (FR-21). |
| 5 | **Attachment parse lifecycle** | Extend existing `Attachment` (or file) table | `parsed_state` enum {`none`, `parsing`, `processed`, `error`}, nullable `parsed_at`, `parse_error` text, `content_hash` (SHA-256) | Drives the processor panel, duplicate detection (FR-18), and error-retry pills. |
| 6 | **Attachment owner** | Existing `Attachment` | Mutually exclusive FKs — one to `Bill`, one to `PurchaseOrder`. CHECK constraint: exactly one is non-null. | A file lives against a Bill XOR a PO, not both. Prevents ghost records. |
| 7 | **Duplicate detection** | Existing `Attachment` | Partial unique index on `(owner_fk, content_hash)` | Refuse re-attaching the same PDF to the same Bill / PO (FR-18). |
| 8 | **Bill `Mixed provenance` status** | Derived, NOT stored | Query: "this Bill has at least one LineItem with `source_attachment_id IS NULL` AND at least one with it set" | Renders the breadcrumb pill (FR-20) without a stored boolean that can drift. |
| 9 | **Supplier-mismatch audit** | Could extend existing `Bill` with a flag, or append to an existing audit log | `supplier_override_at`, `supplier_override_by` *(if FI already has audit infra, use it instead)* | Track when a user said "yes replace supplier & items". Forensic trail. |

### 7.3 What this spec deliberately does NOT prescribe

- Django model class names, app layout, migration file numbering
- Exact column types (`DecimalField` precision, `CharField` max_length, etc.)
- Whether `Attachment` is one polymorphic table or two
- Whether `parsed_state` is an enum, a string, or a FK into a lookup table
- Index strategy beyond the content-hash dedup
- Whether any of these columns should be nullable vs required (depends on FI's existing column conventions)

These are architecture decisions the senior dev should make consistent with FieldInsight's existing patterns. If a concept above conflicts with the way FI already models something, the existing FI model wins — this spec defers.

---

## 8. API endpoints

> **Use the existing FieldInsight API surface — don't invent new routes.** The tables below list the *capabilities* this feature needs from the API layer. They use placeholder paths (`/bills/{id}/...`) only to illustrate the operation; the senior dev should map each capability to the real FieldInsight endpoint (existing or to-be-added), keeping error envelopes, auth model, pagination, and response shape consistent with the existing FI API.
>
> **For future specs in this library:** once FieldInsight's live API architecture reference is consolidated in Paul's Brain (a catalogue of existing endpoints, their payload shapes, the canonical error envelope, the auth model), link it here and delete §8.1 – §8.3 — they exist only as a placeholder until that reference is available. Future requirement specs should point at the catalogue rather than re-inventing route tables.

All API work uses the **FieldInsight custom API framework** (in-house on top of Django views + middleware — DRF is NOT used). All state-changing POSTs should accept an `Idempotency-Key` header for retry-safety. Do not introduce DRF serializers, routers, or viewsets.

### 8.1 Bill endpoints

| Method | Path | Purpose |
|---|---|---|
| `GET`    | `/bills/{id}/`                           | Retrieve Bill with line items and attachments |
| `POST`   | `/bills/`                                | Create new empty Bill |
| `PATCH`  | `/bills/{id}/`                           | Update supplier, invoice#, etc. |
| `POST`   | `/bills/{id}/approve`                    | Transition `draft → approved` + post to Xero |
| `GET`    | `/bills/{id}/events` (SSE)               | Server-sent events for parse progress |

### 8.2 Attachment endpoints

| Method | Path | Purpose |
|---|---|---|
| `POST`   | `/bills/{id}/attachments`                | Upload a file (multipart). Returns attachment with `parsed_state="none"`. |
| `GET`    | `/bills/{id}/attachments`                | List all attachments (incl. removed if `?include_removed=1`) |
| `GET`    | `/bills/{id}/attachments/{att_id}/decision-context` | Returns JSON the UI needs to render the correct modal (`bill_has_items`, `existing_supplier`, `vendor_hint`, `supplier_match`, `doc_type_hint`) |
| `POST`   | `/bills/{id}/attachments/{att_id}/parse` | Body: `{ "mode": "replace"\|"merge"\|"replace_supplier" }`. Returns `{ "task_id": "celery-uuid", "status": "queued" }`. |
| `DELETE` | `/bills/{id}/attachments/{att_id}`       | Soft-delete (sets `parsed_state="removed"`, triggers line-item cascade if it was the source). |

### 8.3 Purchase Order endpoints

| Method | Path | Purpose |
|---|---|---|
| `GET`    | `/purchase-orders/{id}/`                 | Retrieve PO |
| `POST`   | `/purchase-orders/{id}/attachments`      | Upload file against a PO |
| `POST`   | `/purchase-orders/{id}/attachments/{att_id}/create-bill-from-invoice` | Creates a Bill, links to PO, enqueues parse. Returns `{ "bill_id": ..., "task_id": ... }` |
| `POST`   | `/purchase-orders/{id}/attachments/{att_id}/apply-quote` | Parses a supplier quote and writes back to `PurchaseOrderLineItem.unit_price`. Body: `{ "mode": "update_pricing"\|"replace_items" }` |
---

## 10. Icons & artefacts

All SVGs are canonical per AI Styling Pattern. Do not substitute or hand-edit.

### 10.1 Brand marks

| Artefact | Spec | Where used |
|---|---|---|
| **Two-Star sparkle** (canonical, ≥ 16 px) | `<svg viewBox="-50 -50 100 100"><path d="M 0 -36 L 8 -8 L 36 0 L 8 8 L 0 36 L -8 8 L -36 0 L -8 -8 Z"/><path d="M -32 -32 L -29 -22 L -19 -20 L -29 -18 L -32 -8 L -35 -18 L -45 -20 L -35 -22 Z"/></svg>` fill `#39006B` (or `#fff` on brand surfaces) | Favicon, nav logo, AI pill buttons, circular badges, hero panels, drop-zone drag-drop copy |
| **Single sparkle** (fallback, ≤ 14 px) | `<svg viewBox="0 0 24 24"><path d="M12 0 L14 9 L24 12 L14 15 L12 24 L10 15 L0 12 L10 9 Z"/></svg>` | Inline chips, tooltip icons, compact menu items, provenance tags |
| **Favicon** | 32 × 32 SVG, Two-Star in white on brand-purple `rx="22"` rounded square. See `fi-ai-styling-guide/public/favicon.svg`. | Browser tab |

### 10.2 UI components (respect placements and colour)

| Component | Purpose | CSS class | Constraints |
|---|---|---|---|
| `.ai-pill-btn` | Primary AI action button | brand-purple fill, white text, Two-Star 14 px | Labels from approved list only (see 10.3) |
| `.ai-suggest` | Mid-page AI suggestion strip | Brand-light → white gradient, 3 px left border, sparkle left | One per logical group |
| `.ai-parsed-tag` | Provenance chip under populated items | Brand-light bg, brand fg, 10 px bold, sparkle left | One per populated set, not per row |
| Circular badge | Hero / empty-state anchor | 64–80 px brand-purple disc, white Two-Star centred | Max one per page |
| Drop zone `.hero-empty` | First-use affordance | Pulsing border, 64 px badge, AI-voiced copy | Scenario A empty state only |
| Drop zone compact | Repeat-use affordance | 18 px sparkle inline with `+ drag and drop files here` | All other drop surfaces |

### 10.3 Approved button labels (never deviate; no model names)

- `Ask AI`
- `Re-read`
- `Re-generate`
- `Scan bill`, `Scan receipt`
- `Parse File` (pill button on every drop zone — fires the same flow as a drag-drop)
- `Add File` (neutral button on every drop zone — attach without parsing)
- Post-parse merge/replace/attach modal (Bill with existing items, same supplier):
  - `Merge — add these N to the existing M` (primary)
  - `Replace — wipe the existing M and use these N instead` (secondary)
  - `Just attach file — discard the parse` (ghost)
- Post-parse supplier-mismatch modal (Bill OR PO, any conflict):
  - `Replace supplier & items — use <VendorName>` (primary)
  - `Cancel (discard parse)` (ghost)
- PO header — two primary AI-pill buttons (FI-023 FR-ENTRY-1):
  - `✨ Parse PO` — update the PO from a supplier quote (button-equivalent of drop-on-PO)
  - `✨ Reconcile Supplier Invoice` — create a blank Bill linked to this PO and open the reconcile page (see FI-023 Story 0)
- `Extract Line Items` (pill on an attachment row — triggers a re-parse of an already-attached file)
- `Post to job →`, `Post to Xero →`

### 10.4 Processor step strings (brand-voiced, not model-voiced)

Shown during the simulated parse animation. Never mention Claude, Anthropic, GPT, or any model name.

| Scenario | Step 1 | Step 2 | Step 3 | Step 4 |
|---|---|---|---|---|
| Clean / merge | Uploading invoice to Alfred… | Reading document structure… | Extracting line items… | Detecting GST treatment… |
| Bill has items | Uploading invoice to Alfred… | Reading document structure… | Extracting line items… | Checking for duplicates… |
| PO invoice | Uploading invoice to Alfred… | Reading document structure… | Extracting line items… | Matching to PO-{number}… |
| PO quote | Uploading quote to Alfred… | Reading document structure… | Extracting supplier pricing… | Matching to existing PO items… |

---

## 13. Open questions

- **Fuzzy matching confidence threshold — applies to Supplier, Job, and email-to-Job mapping.** Supplier match is currently an exact string compare (e.g. `Bill.supplier == parsed_supplier`); the same question applies to matching a parsed document to an existing Job, and to routing an inbound email to a Job. For all three: should fuzzy matching (e.g. `"L&H Electrical"` ≈ `"L & H Electrical Pty Ltd"`, Levenshtein ratio ≥ 0.85) be supplier-directory / job-directory driven? What's the confidence threshold at which we auto-match vs ask the user to confirm? This is a single product-level decision that should be answered once and applied across Supplier / Job / email-to-Job.

---

## 14. Decision log

| Date | Decision | Rationale |
|---|---|---|
| 2026-04-24 | **Auto-parse on drop for every document, every surface.** A drop on a Bill triggers parse immediately; a drop on a PO triggers parse immediately. No pre-parse confirmation modal. Post-parse modals appear only when there's a conflict to resolve (Bill with existing items → merge/replace/attach; supplier mismatch → replace/cancel). | Faster (fewer clicks on the empty-Bill happy path) and more informative (users see what was extracted before deciding). Supersedes the earlier "no auto-parse" decision locked on an earlier pass. |

---

## 15. References

- **Live reference implementation (styling + interactions):** https://fi-ai-styling-guide-3413f27b698e.herokuapp.com/workflow.html
- **Live prototype backend (current AI parsing):** https://fi-supplier-po-9e72ddd27f18.herokuapp.com
- **AI Styling Pattern (canonical):** ../../../Open Claw Alpha/ai-styling-pattern — decisions locked, three placements, approved labels
- **Anthropic Claude API docs:** https://docs.anthropic.com/claude/reference/messages_post
- **Related spec:** fi-021-technician-copilot — on-device Gemma for field techs (not cloud Claude)
