# Expense OCR Correction UX — Implementation Plan

**Spec:** docs/specs/2026-05-31-expense-ocr-correction-ux.md  ·  **Slug:** expense-ocr-correction-ux  ·  **Wave:** 13
**Depends on:** audit-compliance, expense-approval-workflow, expenses-module, foundation-auth-rbac, operational-audit-trail

## Goal
Deliver the full correction workflow on top of the OCR extraction pipeline already built by `expenses-module`. Staff review low-confidence extractions in a dedicated review mode, correct field values (originals captured in `expense_corrections`), re-trigger OCR, and batch-process a `NEEDS_REVIEW` queue. OWNER/ADMIN can correct already-approved expenses (resetting them to `pending`) or void them as a soft-delete that remains for audit/tax history. Every correction/approve/correct/void writes an in-transaction `tenant_audit_log` entry, and a History tab surfaces the full change trail.

## Architecture
This spec owns **no new tables**. It extends two upstream-owned tables via `ALTER TABLE`:
- `expenses` (owned by `expenses-module`, columns added by `expense-approval-workflow`): consumes existing `status` (`PENDING|PROCESSING|COMPLETED|FAILED|NEEDS_REVIEW`), `ocr_confidence NUMERIC(3,2)`, `raw_ocr_text TEXT`, `vendor_name`, `invoice_total`, `amount`, `vat_amount`, `expense_category`, `expense_date`, `approval_status` (`not_required|pending|approved|rejected`), `approved_by`, `approved_at`, `approval_note`, `r2_key`. Adds `correction_note`, `voided_at`, `voided_reason`.
- `expense_corrections` (owned by `expenses-module`): adds `correction_source` discriminant.

Audit flows into `tenant_audit_log` (base table owned by `tenant-audit-log`; `before_state`/`after_state` columns added by `operational-audit-trail`). For `approve`/`correct`/`void` we write the audit row **inside the same Drizzle transaction** via `tx.insert(tenant_audit_log)` (per `require-audit-in-transaction`), not the async `logAuditEvent` queue path.

Data flow:
1. OCR consumer (in `expenses-module`) is extended so confidence drives routing: `≥0.85 → COMPLETED`, `0.60–0.84 / <0.60 / NULL → NEEDS_REVIEW`. This **overrides** the upstream consumer's unconditional `status='COMPLETED'`.
2. Review mode (`/expenses/:id` when `status='NEEDS_REVIEW'`) and the `/expenses/review` queue let staff verify/correct → `POST /api/expenses/:id/approve` writes corrections, updates fields, sets `status='COMPLETED'` in one transaction.
3. `POST /api/expenses/:id/reprocess` re-queues OCR.
4. Post-approval `POST /api/expenses/:id/correct` (OWNER/ADMIN) resets `approval_status='pending'`, NULLs `approved_by`/`approved_at`, sets `correction_note`, audits `expense.corrected`, notifies the original approver.
5. `POST /api/expenses/:id/void` (OWNER/ADMIN) soft-deletes via `voided_at`/`voided_reason`; all aggregates must exclude voided rows via `WHERE voided_at IS NULL`.

**Route reconciliation (critical):** `POST /api/expenses/:id/approve` is **already defined** by `expense-approval-workflow` (sets `approval_status='approved'`, requires approver role). This spec's `/approve` operates on the orthogonal OCR `status` axis (writes corrections, sets `status='COMPLETED'`, requires `expenses:write`). An expense can be `status='NEEDS_REVIEW'` AND `approval_status='pending'` simultaneously. We **extend the single existing handler** to branch on the transition requested — never emit a duplicate route.

Upstream exports consumed: `tenantQuery`, `requirePermission`, `requireModuleEnabled` / `ModuleGuard`, `authMiddleware`, `createNotification`, `Pagination`/`buildPaginated`/`encodeCursor`/`decodeCursor`, `Sheet`, `Dialog`, `Button`, `Input`, `Select`, `Badge`, `Spinner`, `EmptyState`, `DataTable`, `toast`, `useDirection`, `LocaleProvider`, `STORAGE` (R2), `QUEUE`, `DO_REALTIME`.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers). Drizzle ORM against Neon Postgres via Hyperdrive. Zod validation on every route (`require-zod-validation-in-routes`). All reads/writes go through `tenantQuery` (`no-raw-drizzle-from-routes`).
- **App:** `apps/zync-app` (Vite + React). TanStack Query for data, `@zync/ui` primitives, `@zync/types` for shared types. RTL via `useDirection`/`LocaleProvider`.
- **DB:** `packages/db` Drizzle schema (extend `expenses` + `expense_corrections` table definitions).
- **Types:** `packages/types` (correction/review DTOs, `CORRECTION_SOURCES`).
- **Bindings:** `STORAGE` (R2 receipts), `QUEUE` (`expense.process` re-enqueue), `DO_REALTIME` (`expense.updated` push), notifications via `createNotification`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema & types | 1, 2 | `packages/db/src/schema/expenses.ts`, migration SQL, `packages/types/src/expenses.ts` | 1 then 2 (2 depends on 1) |
| B — pipeline & API | 3, 4, 5, 6, 7, 8 | `apps/zync-api/src/routes/expenses/*`, OCR consumer | 3 standalone; 4–8 parallel after Task 2 |
| C — UI | 9, 10, 11, 12, 13, 14 | `apps/zync-app/src/features/expenses/*` | parallel after Task 4–8 contracts land |
| D — a11y/RTL hardening | 15 | review-mode, modals, overlay components | after C |

## Tasks

### Task 1: Schema ALTERs (expenses + expense_corrections)
**Blocks:** 2, 3, 4, 5, 6, 7, 8  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/<timestamp>_expense_ocr_correction_ux.sql`
- Modify: `packages/db/src/schema/expenses.ts`
**Steps:**
- [ ] Write the migration adding `correction_source` to `expense_corrections` and `correction_note` / `voided_at` / `voided_reason` to `expenses`. Use `IF NOT EXISTS` on `correction_source` exactly as the spec states (column may already exist in some envs).
- [ ] Do **not** redefine `expenses`, `expense_corrections`, `approval_status`, `approved_by`, `approved_at`, `status`, `ocr_confidence`, or `raw_ocr_text` — they are upstream-owned. Reference them only.
- [ ] Add a partial index supporting the review queue and void-exclusion read paths.
- [ ] Mirror the new columns into the Drizzle table definitions in `expenses.ts` (`correctionSource`, `correctionNote`, `voidedAt`, `voidedReason`).
**Schema / Interfaces:**
```sql
-- expense_corrections: discriminate OCR-review vs manual post-approval corrections.
-- (table owned by expenses-module; ALTER only)
ALTER TABLE expense_corrections
  ADD COLUMN IF NOT EXISTS correction_source TEXT NOT NULL DEFAULT 'ocr'
  CHECK (correction_source IN ('ocr', 'manual'));

-- expenses: post-approval correction note + void (soft-delete) columns.
-- (table owned by expenses-module / expense-approval-workflow; ALTER only)
ALTER TABLE expenses ADD COLUMN correction_note TEXT;
ALTER TABLE expenses ADD COLUMN voided_at       TIMESTAMPTZ;
ALTER TABLE expenses ADD COLUMN voided_reason   TEXT;

-- Backs the NEEDS_REVIEW queue and enforces the void-exclusion read path cheaply.
CREATE INDEX IF NOT EXISTS idx_expenses_review_queue
  ON expenses (tenant_id, status)
  WHERE voided_at IS NULL;
```
Drizzle additions (canonical column ↔ camel field):
```ts
// expense_corrections table builder
correctionSource: text('correction_source').notNull().default('ocr'),
  // CHECK (correction_source IN ('ocr','manual')) enforced in migration

// expenses table builder
correctionNote: text('correction_note'),
voidedAt: timestamp('voided_at', { withTimezone: true }),
voidedReason: text('voided_reason'),
```
**Acceptance:**
- [ ] Migration applies cleanly on a Neon branch; `\d expense_corrections` shows `correction_source` with the CHECK; `\d expenses` shows the three new columns.
- [ ] No `CREATE TABLE` statements are introduced.
- [ ] `idx_expenses_review_queue` exists as a partial index with `WHERE voided_at IS NULL`.

### Task 2: Shared types & constants
**Blocks:** 4, 5, 6, 7, 8, 9, 10, 11, 12, 13  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/types/src/expenses.ts`
**Steps:**
- [ ] Add `CORRECTION_SOURCES` constant and `CorrectionSource` type.
- [ ] Add DTOs for the four new endpoints' bodies and the review-queue item.
- [ ] Add confidence-threshold constants used by both the OCR consumer (Task 3) and the review UI (Task 9).
**Schema / Interfaces:**
```ts
export const CORRECTION_SOURCES = ['ocr', 'manual'] as const;
export type CorrectionSource = (typeof CORRECTION_SOURCES)[number];

// Confidence routing thresholds (single source of truth for consumer + UI).
export const OCR_CONFIDENCE_AUTO_COMPLETE = 0.85;  // >= -> COMPLETED
export const OCR_CONFIDENCE_REVIEW_FLOOR = 0.60;   // [floor, 0.84] -> NEEDS_REVIEW (flagged)
                                                   // < floor or NULL -> NEEDS_REVIEW (low-confidence/failed banner)

export interface ExpenseCorrectionInput {
  fieldName: string;     // e.g. 'vendor_name' | 'amount' | 'vat_amount' | 'expense_category'
  newValue: string;
}

// POST /api/expenses/:id/approve  (OCR-review approval; status axis)
export interface ApproveReviewedExpenseBody {
  corrections: Record<string, string>; // { [fieldName]: newValue }
}

// POST /api/expenses/:id/correct  (post-approval correction; OWNER/ADMIN)
export interface CorrectExpenseBody {
  reason: string;             // required, non-empty
  amount?: number;
  category?: string;          // expense_category id
  date?: string;              // YYYY-MM-DD -> expense_date
  receiptR2Key?: string;      // replacement receipt R2 key
}

// POST /api/expenses/:id/void  (OWNER/ADMIN)
export interface VoidExpenseBody {
  reason: string;             // required, non-empty
}

export interface ReviewQueueItem {
  id: string;
  receiptThumbUrl: string | null;
  vendorName: string | null;
  amount: number | null;
  currency: string;
  ocrConfidence: number | null; // null => "OCR fail"
  status: string;               // 'NEEDS_REVIEW'
}

export interface ReviewQueueResponse {
  items: ReviewQueueItem[];
  nextCursor: string | null;
  total: number;
}
```
**Acceptance:**
- [ ] `@zync/types` compiles and re-exports the new symbols from its barrel.
- [ ] `CORRECTION_SOURCES` values exactly match the DB CHECK (`'ocr'`, `'manual'`).

### Task 3: OCR consumer confidence routing
**Blocks:** —  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `apps/zync-api/src/queue/expense-process.consumer.ts` (the `expense.process` consumer defined in `expenses-module`)
**Steps:**
- [ ] After computing `ocr_confidence`, branch the final status instead of unconditionally setting `COMPLETED`.
- [ ] `ocr_confidence >= OCR_CONFIDENCE_AUTO_COMPLETE` → `status='COMPLETED'`.
- [ ] `OCR_CONFIDENCE_REVIEW_FLOOR <= ocr_confidence < OCR_CONFIDENCE_AUTO_COMPLETE` → `status='NEEDS_REVIEW'`.
- [ ] `ocr_confidence < OCR_CONFIDENCE_REVIEW_FLOOR` OR `ocr_confidence IS NULL` (OCR failed) → `status='NEEDS_REVIEW'` (UI distinguishes low-confidence vs OCR-failed banner from the value/NULL).
- [ ] Preserve all existing consumer behavior: seed `expense_date`/`amount`, run deductibility eval, fire `expense.processed` webhook, push `{ op: 'expense.updated', expenseId }` over `DO_REALTIME`.
- [ ] Keep field-level confidence (from Claude Vision) and any bounding boxes inside `raw_ocr_text` (TEXT, per the spec's explicit decision — do not migrate to JSONB).
**Schema / Interfaces:**
```ts
import {
  OCR_CONFIDENCE_AUTO_COMPLETE,
  OCR_CONFIDENCE_REVIEW_FLOOR,
} from '@zync/types';

function routeStatusFromConfidence(conf: number | null): 'COMPLETED' | 'NEEDS_REVIEW' {
  if (conf != null && conf >= OCR_CONFIDENCE_AUTO_COMPLETE) return 'COMPLETED';
  return 'NEEDS_REVIEW'; // includes [floor,0.84], < floor, and NULL (OCR failed)
}
```
**Acceptance:**
- [ ] A receipt with computed confidence 0.92 lands `COMPLETED`; 0.71 and 0.42 land `NEEDS_REVIEW`; a NULL-confidence (OCR-failed) extraction lands `NEEDS_REVIEW`.
- [ ] `expense.processed` webhook and realtime push still fire for all branches.

### Task 4: `POST /api/expenses/:id/reprocess`
**Blocks:** 10  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-api/src/routes/expenses/reprocess.ts`
- Modify: `apps/zync-api/src/routes/expenses/index.ts` (mount)
**Steps:**
- [ ] Guard: `authMiddleware`, `requireModuleEnabled('expenses')`, `requirePermission('expenses:write')`.
- [ ] Load the expense via `tenantQuery`; 404 if missing or `voided_at IS NOT NULL`.
- [ ] Clear extracted fields and reset confidence; set `status='NEEDS_REVIEW'` (it stays NEEDS_REVIEW until re-reviewed — re-queued OCR may flip it via Task 3 routing).
- [ ] Re-enqueue `expense.process` on `QUEUE` with the existing `r2_key`.
- [ ] Return the updated expense row.
**Schema / Interfaces:**
```ts
// POST /api/expenses/:id/reprocess  — no body
// Requires: expenses:write
// Effect: clears OCR-extracted fields, sets status='NEEDS_REVIEW', re-enqueues expense.process
// Response: { expense: Expense }
```
**Acceptance:**
- [ ] Returns 403 without `expenses:write`; 404 for a voided expense.
- [ ] After call, `status='NEEDS_REVIEW'` and a new `expense.process` message is enqueued for the same `r2_key`.

### Task 5: `GET /api/expenses/review` (NEEDS_REVIEW queue)
**Blocks:** 10  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-api/src/routes/expenses/review.ts`
- Modify: `apps/zync-api/src/routes/expenses/index.ts` (mount)
**Steps:**
- [ ] Guard: `authMiddleware`, `requireModuleEnabled('expenses')`, `requirePermission('expenses:read')`.
- [ ] Zod-validate query: `from?` (date), `limit` default 20 (clamp ≤100 via `clampLimit`), `cursor?`.
- [ ] Query `tenantQuery` for `status='NEEDS_REVIEW' AND voided_at IS NULL`, ordered by `created_at DESC`, cursor-paginated via `encodeCursor`/`decodeCursor`.
- [ ] Map rows to `ReviewQueueItem` (signed R2 thumbnail URL via `STORAGE`, vendor, amount, currency, ocr_confidence).
- [ ] Return `ReviewQueueResponse` with `nextCursor` + `total` (count of open reviews).
**Schema / Interfaces:**
```ts
// GET /api/expenses/review?from=&limit=20&cursor=
// Requires: expenses:read
// Response: ReviewQueueResponse  (see Task 2)
```
**Acceptance:**
- [ ] Only `NEEDS_REVIEW`, non-voided expenses appear.
- [ ] `limit` is clamped to ≤100; pagination cursor round-trips.

### Task 6: `POST /api/expenses/:id/approve` (extend existing route — OCR-review approval)
**Blocks:** 9, 10  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `apps/zync-api/src/routes/expenses/approve.ts` (route already created by `expense-approval-workflow`)
**Steps:**
- [ ] **Do not create a second route.** Extend the single existing `POST /api/expenses/:id/approve` handler to branch on the requested transition.
- [ ] Accept a unified Zod body that supports both shapes: the approval-workflow `{ note? }` (approval-gate path) and this spec's `{ corrections: Record<string,string> }` (OCR-review path). Branch: if `corrections` present → OCR-review path; else → approval-gate path (unchanged upstream behavior).
- [ ] OCR-review path guard: `requirePermission('expenses:write')`. Approval-gate path keeps its approver-role guard. Both guards are evaluated for their respective branch only.
- [ ] OCR-review path runs in one Drizzle transaction:
  - For each `[fieldName, newValue]` in `corrections`: insert into `expense_corrections` with `correction_source='ocr'`, capturing the current value as `original_value`.
  - Update each corrected field on the `expenses` row.
  - Set `expenses.status = 'COMPLETED'`.
  - Insert a `tenant_audit_log` row **in the same tx** (`event_type='expense.reviewed'`, `entity_type='expense'`, `entity_id=:id`, before/after = changed fields).
- [ ] Return the updated expense.
**Schema / Interfaces:**
```ts
// Unified body (Zod):
const approveBody = z.union([
  z.object({ corrections: z.record(z.string(), z.string()) }), // OCR-review path (this spec)
  z.object({ note: z.string().optional() }),                   // approval-gate path (expense-approval-workflow)
]);

// OCR-review path, inside tx:
for (const [fieldName, newValue] of Object.entries(corrections)) {
  await tx.insert(expenseCorrections).values({
    expenseId: id,
    userId: actorId,
    fieldName,
    originalValue: String(expense[fieldName] ?? ''),
    correctedValue: String(newValue),
    correctionSource: 'ocr',
  });
}
await tx.update(expenses).set({ ...fieldUpdates, status: 'COMPLETED' }).where(eq(expenses.id, id));
await tx.insert(tenantAuditLog).values({
  tenantId, userId: actorId,
  eventType: 'expense.reviewed', entityType: 'expense', entityId: id,
  beforeState: originalFields, afterState: fieldUpdates,
});
```
**Acceptance:**
- [ ] Only ONE `/approve` route exists in the codebase after this task.
- [ ] OCR-review call writes one `expense_corrections` row (`correction_source='ocr'`) per changed field, updates the row, sets `status='COMPLETED'`, and writes a `tenant_audit_log` row in the same transaction.
- [ ] Approval-gate behavior from `expense-approval-workflow` (`approval_status='approved'`, `approved_by`, `approved_at`) is unchanged when `corrections` is absent.

### Task 7: `POST /api/expenses/:id/correct` (post-approval correction)
**Blocks:** 11, 13  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-api/src/routes/expenses/correct.ts`
- Modify: `apps/zync-api/src/routes/expenses/index.ts` (mount)
**Steps:**
- [ ] Guard: `authMiddleware`, `requireModuleEnabled('expenses')`, OWNER or ADMIN role (not just `expenses:write`).
- [ ] Zod-validate `CorrectExpenseBody`; `reason` required non-empty.
- [ ] Reject (409) if expense is voided or not `approval_status='approved'` (only approved expenses are corrected here).
- [ ] In one Drizzle transaction:
  - Capture `before_state` (changed fields only) from current row.
  - Apply `amount` / `expense_category` / `expense_date` / `r2_key` (from `receiptR2Key`) updates.
  - Set `correction_note = reason`; set `approval_status='pending'`; NULL `approved_by` and `approved_at`.
  - Insert each changed field into `expense_corrections` with `correction_source='manual'`.
  - Insert `tenant_audit_log` row in the same tx: `event_type='expense.corrected'`, `entity_type='expense'`, `entity_id=:id`, `before_state`/`after_state`.
- [ ] After commit, notify the original approver (the pre-correction `approved_by` user) via `createNotification`: "Expense correction requires re-approval".
**Schema / Interfaces:**
```ts
// POST /api/expenses/:id/correct
// Requires: OWNER or ADMIN
// body: CorrectExpenseBody  (reason required)
// Effect (one tx): apply field edits, correction_note=reason, approval_status='pending',
//                  approved_by=NULL, approved_at=NULL, write expense_corrections
//                  (correction_source='manual'), audit 'expense.corrected' before/after.
// After commit: createNotification(originalApproverId, reApprovalPayload)
```
**Acceptance:**
- [ ] Non-OWNER/ADMIN → 403; non-approved expense → 409.
- [ ] After success: `approval_status='pending'`, `approved_by/approved_at` NULL, `correction_note=reason`, manual `expense_corrections` rows written, audit `expense.corrected` row present with before/after in the same transaction.
- [ ] Original approver receives a re-approval notification.

### Task 8: `POST /api/expenses/:id/void` (soft-delete)
**Blocks:** 12, 13  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-api/src/routes/expenses/void.ts`
- Modify: `apps/zync-api/src/routes/expenses/index.ts` (mount)
**Steps:**
- [ ] Guard: `authMiddleware`, `requireModuleEnabled('expenses')`, OWNER or ADMIN role.
- [ ] Zod-validate `VoidExpenseBody`; `reason` required non-empty.
- [ ] Reject (409) if already voided (`voided_at IS NOT NULL`) — void is permanent and cannot be undone.
- [ ] In one Drizzle transaction: set `voided_at = now()`, `voided_reason = reason`; insert `tenant_audit_log` row (`event_type='expense.voided'`, `entity_type='expense'`, `entity_id=:id`, `before_state` = `{ voided_at: null }`, `after_state` = `{ voided_at, voided_reason }`).
- [ ] Response includes the `invoiceLineWarning` flag if the expense is referenced by an invoice line (so the UI can show the manual-removal warning). Do not auto-remove invoice lines.
- [ ] **Do not hard-delete.** The row remains for audit/VAT/tax history.
**Schema / Interfaces:**
```ts
// POST /api/expenses/:id/void
// Requires: OWNER or ADMIN
// body: VoidExpenseBody  (reason required)
// Effect (one tx): voided_at=now(), voided_reason=reason, audit 'expense.voided'.
// Response: { expense: Expense, invoiceLineWarning: boolean }
// INVARIANT: every downstream aggregate MUST filter `WHERE voided_at IS NULL`.
```
**Acceptance:**
- [ ] Non-OWNER/ADMIN → 403; already-voided → 409.
- [ ] After success: `voided_at` set, `voided_reason=reason`, audit `expense.voided` row in the same tx; row still present (soft delete).
- [ ] `invoiceLineWarning: true` when an invoice line references the expense.

### Task 9: Expense Detail — OCR Review Mode
**Blocks:** 15  ·  **Blocked by:** 2, 6
**Files:**
- Create: `apps/zync-app/src/features/expenses/components/OcrReviewMode.tsx`
- Modify: `apps/zync-app/src/features/expenses/components/ExpenseDetailSheet.tsx`
**Steps:**
- [ ] When `expense.status === 'NEEDS_REVIEW'`, render review mode instead of the standard detail body.
- [ ] Confidence banner driven by `ocr_confidence`: `0.60–0.84` → "OCR confidence: {pct}% — please verify highlighted fields"; `< 0.60` → prominent receipt thumbnail + "Low confidence" badge; `NULL` → "OCR failed — enter manually" banner.
- [ ] Editable fields: Vendor name*, Date*, Amount* (₪), VAT amount, Category*, Deduction %, Allocation #. Required fields marked.
- [ ] Highlight low-confidence fields with an orange border driven by field-level confidence parsed from `raw_ocr_text`; each highlighted field shows "(OCR read: {value} — verify)".
- [ ] Receipt thumbnail (left) with a zoom affordance opening the overlay (Task 14). PDF via `<object>`/`<iframe>`, image via `<img>`.
- [ ] Actions: **[Reject & delete]** (soft delete via existing `DELETE /api/expenses/:id`), **[Re-run OCR]** (`POST /api/expenses/:id/reprocess`), **[✓ Approve & save]** (`POST /api/expenses/:id/approve` with `{ corrections }` of only the changed fields).
- [ ] On approve success: invalidate the expense + review-queue queries; toast; if launched from the queue, advance to the next item (Task 10).
**Schema / Interfaces:**
```ts
// Build corrections payload from dirty fields only:
const corrections: Record<string, string> = {};
for (const f of dirtyFields) corrections[f] = String(form[f]);
await api.post(`/api/expenses/${id}/approve`, { corrections });
```
**Acceptance:**
- [ ] Review mode renders only for `status='NEEDS_REVIEW'`.
- [ ] Each confidence tier shows its correct banner; low-confidence fields get the orange highlight + "OCR read" hint.
- [ ] Approve sends only changed fields and transitions the row to `COMPLETED`.

### Task 10: NEEDS_REVIEW Queue page (`/expenses/review`)
**Blocks:** 15  ·  **Blocked by:** 5, 6, 9
**Files:**
- Create: `apps/zync-app/src/features/expenses/pages/ReviewQueuePage.tsx`
- Modify: `apps/zync-app/src/router.tsx` (route `/expenses/review`)
**Steps:**
- [ ] Fetch `GET /api/expenses/review` (TanStack Query, cursor pagination).
- [ ] Header shows "{n} awaiting review" count.
- [ ] `DataTable` columns: receipt thumbnail (IMG), Vendor, Amount (₪), Confidence (pct, or "OCR fail" when NULL), Action ([Review]).
- [ ] [Review] opens the expense detail in review mode (Task 9). After approve, return to queue and auto-load the next item.
- [ ] Empty state via `EmptyState` when no pending reviews.
**Acceptance:**
- [ ] Queue lists only `NEEDS_REVIEW`, non-voided expenses with correct confidence display (incl. "OCR fail").
- [ ] Approving an item removes it and advances to the next.

### Task 11: Post-Approval Correction Modal
**Blocks:** 15  ·  **Blocked by:** 2, 7
**Files:**
- Create: `apps/zync-app/src/features/expenses/components/CorrectExpenseModal.tsx`
- Modify: `apps/zync-app/src/features/expenses/pages/ExpenseDetailPage.tsx` (add **[Correct]** button when `approval_status='approved'`)
**Steps:**
- [ ] **[Correct]** button visible only when `approval_status='approved'` and current user is OWNER/ADMIN.
- [ ] Modal (`Dialog`) warns: "This expense was approved. Correction will reset it to pending and require re-approval."
- [ ] Required **Reason for correction** textarea; editable Amount (shows "(was: {old})"), Category select, Date, and **[Replace receipt]** (uploads to R2, passes `receiptR2Key`).
- [ ] **[Submit correction]** → `POST /api/expenses/:id/correct` with `{ reason, amount?, category?, date?, receiptR2Key? }`; disabled until `reason` non-empty.
- [ ] On success: invalidate expense + history queries; toast; close.
**Acceptance:**
- [ ] Button hidden for non-approved expenses and non-OWNER/ADMIN.
- [ ] Submit blocked until reason is provided; on success the row shows `approval_status='pending'`.

### Task 12: Void Confirmation Modal
**Blocks:** 15  ·  **Blocked by:** 2, 8
**Files:**
- Create: `apps/zync-app/src/features/expenses/components/VoidExpenseModal.tsx`
- Modify: `apps/zync-app/src/features/expenses/pages/ExpenseDetailPage.tsx` (add **[Void]** button)
**Steps:**
- [ ] **[Void]** button on approved expenses (OWNER/ADMIN).
- [ ] Modal (`Dialog`) requires a non-empty reason; states the action is permanent and cannot be undone.
- [ ] If the response/precheck indicates the expense is on an invoice line, show: "If this expense has an invoice line, it will NOT be removed automatically — remove it from the invoice manually."
- [ ] **[Void]** → `POST /api/expenses/:id/void` with `{ reason }`.
- [ ] On success: invalidate lists/detail; toast; the expense becomes excluded from aggregates (server enforces `voided_at IS NULL`).
**Acceptance:**
- [ ] Submit blocked until reason provided.
- [ ] On success `voided_at` is set and the invoice-line warning is shown when applicable.

### Task 13: Expense Edit History tab
**Blocks:** 15  ·  **Blocked by:** 7, 8
**Files:**
- Create: `apps/zync-app/src/features/expenses/components/ExpenseHistoryTab.tsx`
- Modify: `apps/zync-app/src/features/expenses/pages/ExpenseDetailPage.tsx` (add History tab)
**Steps:**
- [ ] Add a **History** tab on the expense detail view.
- [ ] Reads the audit trail filtered to this expense: `tenant_audit_log WHERE entity_type='expense' AND entity_id=:id`, newest first. Reuse the audit-log list component from `tenant-audit-log` / `operational-audit-trail` (same `before_state`/`after_state` diff rendering).
- [ ] Show created, approved, reviewed, corrected, voided events with actor (`actor_name`), timestamp (tenant locale), and before/after values.
- [ ] If no API endpoint exists for per-entity audit, consume the audit-log list API filtered by `entity_type=expense&entity_id=:id` (cursor-paginated).
**Schema / Interfaces:**
```ts
// Reuses tenant_audit_log columns: user_id, actor_name, event_type,
// entity_type='expense', entity_id, before_state, after_state, created_at.
// Filter: entity_type = 'expense' AND entity_id = :id
```
**Acceptance:**
- [ ] History tab lists every audited change for the expense (created/reviewed/approved/corrected/voided) with actor, timestamp, and a before/after diff.

### Task 14: Receipt Zoom Overlay
**Blocks:** 15  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/features/expenses/components/ReceiptZoomOverlay.tsx`
**Steps:**
- [ ] Full-size receipt image overlay opened from the review-mode thumbnail; **[✕ Close]**.
- [ ] **← Previous field / Next field →** navigation cycling the extracted fields.
- [ ] When a form field is hovered/focused, highlight that field's bounding box on the receipt; bounding boxes are parsed from `expenses.raw_ocr_text` (TEXT/JSON-encoded, per spec decision — do not introduce a new table or JSONB column).
- [ ] If `raw_ocr_text` has no bounding boxes, render the overlay without box highlighting (graceful degradation).
**Acceptance:**
- [ ] Overlay opens from the thumbnail and closes via the close control and Escape.
- [ ] Hovering a field with a stored bounding box highlights the corresponding region; absence of boxes does not break the overlay.

### Task 15: a11y / RTL / reduced-motion hardening
**Blocks:** —  ·  **Blocked by:** 9, 10, 11, 12, 13, 14
**Files:**
- Modify: `apps/zync-app/src/features/expenses/components/OcrReviewMode.tsx`, `ReceiptZoomOverlay.tsx`, `CorrectExpenseModal.tsx`, `VoidExpenseModal.tsx`, `ExpenseHistoryTab.tsx`
**Steps:**
- [ ] Modals (`CorrectExpenseModal`, `VoidExpenseModal`, zoom overlay) use `role="dialog"` `aria-modal="true"`, focus trap, focus restoration, and Escape-to-close.
- [ ] Zoom overlay Previous/Next-field controls are keyboard-operable (`button` elements, arrow-key support), with `aria-label`s.
- [ ] Low-confidence/highlighted fields: pair the orange border with `aria-describedby` pointing to the "OCR read: … — verify" hint and (for `<0.60`) an `aria-label` conveying "Low confidence".
- [ ] OCR-failed banner uses `role="alert"`; the confidence banner uses `role="status"`.
- [ ] Apply `prefers-reduced-motion` to the zoom overlay open/close and any field-navigation transitions (no motion when reduced).
- [ ] RTL: all currency (₪) and Hebrew vendor/category text honor `useDirection`; field layout mirrors correctly under `dir="rtl"`.
**Acceptance:**
- [ ] Keyboard-only users can open/operate/close every modal and the zoom overlay, including field navigation.
- [ ] Screen reader announces the OCR-failed banner (alert) and confidence status; low-confidence fields announce their state.
- [ ] With `prefers-reduced-motion: reduce`, overlay transitions are suppressed; layout is correct under RTL.
