# Expense OCR Correction UX

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 88  
**Tier:** All tiers  
**Depends on:** `expenses-module`, `expense-approval-workflow`, `operational-audit-trail`, `foundation-auth-rbac`, `audit-compliance`  
**Referenced by:** `expenses-module`, `expense-approval-workflow`  
**Consolidates:** spec 90 (`expense-corrections-ui`) — retired; post-approval correction and void flows folded in here

---

## Overview

Spec 17 (`expenses-module`) defines OCR extraction and the `expense_corrections` table but leaves the correction workflow underspecified. This spec defines the full UX for reviewing low-confidence extractions, correcting field values, re-triggering OCR, and bulk-reviewing the `NEEDS_REVIEW` queue.

---

## Confidence Thresholds

`ocr_confidence` (0.00–1.00) drives automatic vs. manual review routing:

| Range | Behavior |
|-------|----------|
| ≥ 0.85 | Auto-completes to `COMPLETED` status; no human required |
| 0.60–0.84 | Status = `NEEDS_REVIEW`; flagged for correction |
| < 0.60 | Status = `NEEDS_REVIEW`; receipt thumbnail shown prominently with "Low confidence" badge |
| NULL | OCR failed; status = `NEEDS_REVIEW`; "OCR failed — enter manually" banner |

---

## Expense Detail: OCR Review Mode

When `status = 'NEEDS_REVIEW'`, the expense detail view enters **review mode**:

```
┌──────────────────────────────────────────────────────────────┐
│  Expense #EXP-0091                    [NEEDS REVIEW]  [···]   │
│                                                              │
│  ⚠ OCR confidence: 71% — please verify highlighted fields   │
│                                                              │
│  ┌───────────────────┐  Vendor name *                        │
│  │                   │  [Supersol Deal___________________]   │
│  │  [Receipt image]  │                                       │
│  │                   │  Date *                               │
│  │  🔍 zoom          │  [2026-05-28_____]                    │
│  └───────────────────┘                                       │
│                                                              │
│  Amount *                 ⚠ Low confidence                   │
│  ₪ [_347.50___________]  (OCR read: 347.50 — verify)        │
│                                                              │
│  VAT amount               ⚠                                  │
│  ₪ [_50.00____________]  (OCR read: 50.00)                  │
│                                                              │
│  Category *                                                  │
│  [Office supplies ▾]                                         │
│                                                              │
│  Deduction %  [66%]   Allocation # [optional_____]           │
│                                                              │
│  [Reject & delete]    [Re-run OCR]    [✓ Approve & save]    │
└──────────────────────────────────────────────────────────────┘
```

**Highlighted fields**: orange border on fields where extracted value has low confidence (field-level confidence from Claude Vision response).

**Zoom**: clicking receipt thumbnail opens full-size image overlay for close inspection.

**Re-run OCR**: `POST /api/expenses/:id/reprocess` — re-queues the receipt for fresh OCR extraction. Replaces extracted fields, resets confidence, status stays `NEEDS_REVIEW` until re-reviewed.

---

## Correction Save Logic

When staff edits an OCR-extracted field and saves:

1. Original value written to `expense_corrections`:
   ```ts
   await tx.insert(expense_corrections).values({
     expense_id: id,
     user_id: actorId,
     field_name: fieldName,
     original_value: String(expense[fieldName]),
     corrected_value: String(newValue),
   })
   ```
2. Field updated on `expenses` row
3. If saving via "Approve & save": `expenses.status` → `'COMPLETED'`
4. Audit entry (spec 28 cross-cutting rule in same transaction)

---

## NEEDS_REVIEW Queue

New view at `/expenses/review` — staff can batch-process all pending reviews:

```
┌──────────────────────────────────────────────────────────────┐
│  Expense Review Queue                  [3 awaiting review]   │
│                                                              │
│  ┌─────┬────────────────┬────────┬──────────┬───────────┐   │
│  │ IMG │ Vendor          │ Amount  │ Confidence│ Action    │   │
│  ├─────┼────────────────┼────────┼──────────┼───────────┤   │
│  │ 🧾  │ Supersol Deal  │ ₪347   │  71%     │ [Review]  │   │
│  │ 🧾  │ ? (unread)     │ ₪120   │  42%     │ [Review]  │   │
│  │ 🧾  │ BP Gas Station │ ₪280   │  OCR fail │ [Review]  │   │
│  └─────┴────────────────┴────────┴──────────┴───────────┘   │
└──────────────────────────────────────────────────────────────┘
```

Clicking "Review" opens the expense detail in review mode (same UI as above). After approval, returns to queue and loads next item.

---

## Receipt Zoom Overlay

```
┌──────────────────────────────────────────────────────────────┐
│  Receipt                                          [✕ Close]  │
│                                                              │
│  [Full-size receipt image]                                   │
│                                                              │
│  ← Previous field              Next field →                  │
└──────────────────────────────────────────────────────────────┘
```

The overlay highlights bounding boxes for each extracted field when hovering the corresponding form field (requires Claude Vision to return bounding boxes — stored in `expenses.raw_ocr_text` as JSON).

---

## Post-Approval Corrections

Approved expenses (`approval_status = 'approved'`) are normally locked. OWNER/ADMIN can apply a correction, which resets to `pending` and requires re-approval.

**[Correct]** button on `/expenses/:id` (approved state) → Correction Modal:

```
┌──────────────────────────────────────────────────────────────┐
│  Correct approved expense                             [✕]    │
│                                                              │
│  ⚠ This expense was approved. Correction will reset it       │
│    to pending and require re-approval.                        │
│                                                              │
│  Reason for correction: (required)                           │
│  [Wrong amount entered — receipt shows ₪380, not ₪450___]    │
│                                                              │
│  ─── Fields ────────────────────────────────────────────── │
│  Amount:    [380___]   (was: 450)                            │
│  Category:  [Office ▾]                                       │
│  Date:      [2026-05-20]                                     │
│  Receipt:   [current-receipt.jpg]  [Replace receipt]         │
│                                                              │
│  [Cancel]              [Submit correction]                   │
└──────────────────────────────────────────────────────────────┘
```

On submit: `approval_status → 'pending'`, `approved_by/approved_at → NULL`, `correction_note` set, audit log entry `expense.corrected` with `before_state`/`after_state` (spec 50), notification to original approver.

## Void Expense

**[Void]** on approved expense → confirmation modal requiring a reason. Sets `voided_at = now()`, `voided_reason`. Soft delete — row remains for audit, excluded from all aggregates via `WHERE voided_at IS NULL`. Permanent and cannot be undone.

Warning shown if expense already on an invoice line: "If this expense has an invoice line, it will NOT be removed automatically — remove it from the invoice manually."

## Expense Edit History

**History** tab on expense detail: reads `tenant_audit_log WHERE entity_type = 'expense' AND entity_id = :id`. Shows all changes (created, approved, corrected, voided) with actor, timestamp, and before/after values.

---

## API Extensions

```
POST /api/expenses/:id/reprocess
     → re-queue OCR extraction
       Clears extracted fields, sets status = 'NEEDS_REVIEW'
       Requires: expenses:write

GET /api/expenses/review
     → list expenses with status = 'NEEDS_REVIEW'
       query: from?, limit=20, cursor
       Requires: expenses:read

POST /api/expenses/:id/approve
     → approve reviewed expense (sets status = 'COMPLETED')
       body: { corrections: { [fieldName]: newValue }[] }
       Writes corrections + updates fields + status in one transaction
       Requires: expenses:write

POST /api/expenses/:id/correct
     → submit correction for approved expense; resets to pending
       body: { reason: string, amount?, category?, date?, receiptR2Key? }
       Requires: OWNER or ADMIN

POST /api/expenses/:id/void
     → void expense (soft-delete)
       body: { reason: string }
       Requires: OWNER or ADMIN
```

---

## Schema Delta

```sql
-- expense_corrections table (owned here — both OCR and manual corrections write to it):
-- Already defined in expenses-module (spec 17); this spec extends with correction_source:
ALTER TABLE expense_corrections ADD COLUMN IF NOT EXISTS correction_source TEXT NOT NULL DEFAULT 'ocr'
  CHECK (correction_source IN ('ocr', 'manual'));

-- Post-approval correction columns:
ALTER TABLE expenses ADD COLUMN correction_note TEXT;
ALTER TABLE expenses ADD COLUMN voided_at TIMESTAMPTZ;
ALTER TABLE expenses ADD COLUMN voided_reason TEXT;
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Confidence threshold 0.85 | Not 1.0 | OCR is never 100%; 0.85 catches meaningful errors while avoiding unnecessary review noise |
| Field-level confidence | Not document-level | Amount and VAT are high-stakes; even a high-confidence document may have one wrong amount field |
| Re-run OCR | Not manual-entry-only fallback | Receipt may have been uploaded tilted/dark; reprocessing after orientation fix is common |
| Bounding boxes in `raw_ocr_text` | Not separate table | Bounding boxes are used only for UX highlighting; JSON in raw_ocr_text is sufficient without a new schema entity |
| Correction resets to pending | Not stay approved | Corrected values haven't been reviewed; re-approval ensures financial accuracy |
| Void ≠ delete | Soft delete via `voided_at` | Voided expenses must remain for audit trail (VAT/tax history); hard delete breaks compliance |
| `correction_source` discriminant | Not two separate tables | One `expense_corrections` table handles both OCR-driven and manual corrections; source column disambiguates for reporting |
