# Bank / Credit Card Statement Import — Implementation Plan

**Spec:** docs/specs/2026-06-01-bank-statement-import.md  ·  **Slug:** bank-statement-import  ·  **Wave:** 10
**Depends on:** expenses-module, invoices-core, foundation-auth-rbac, settings-module

## Goal
Let Israeli freelancers and businesses upload bank / credit-card statements (CSV per-bank layouts, OFX/QFX) and reconcile them against Zync data. The system parses the file asynchronously, auto-matches credits to invoices and debits to existing expenses, and presents a review queue where the user resolves unmatched rows (create expense, match to existing, send incoming credits to the reconcile queue, or ignore). Matching a transaction to an invoice writes the single authoritative `invoice_payments` row (`source='bank_import'`); unresolved incoming credits flow into the shared `unmatched_payments` staging table so they surface in `/invoices/reconcile`.

## Architecture
- **New package `@zync/bank-import`** holds Drizzle schema (`bank_imports`, `bank_transactions`), per-bank parsers, the queue consumer `processBankImport`, and the matching engine (`matchInvoice`, `matchExpense`).
- **Upload path:** API route validates file, writes it to R2 binding `STORAGE` at key `tenants/{tenantId}/bank-imports/{importId}.csv`, inserts a `bank_imports` row (`status='pending'`), and enqueues a `bank.process` message on the `QUEUE` binding — mirroring the expenses-module `expense.process` mechanism exactly (single dispatch queue, message body `type` discriminator).
- **Queue consumer** fetches the file from `STORAGE`, runs the bank-specific parser into normalized `BankRow[]`, inserts one `bank_transactions` row per parsed row, auto-matches, and sets `bank_imports.status='review'`.
- **Consumes upstream tables (do not recreate):** `tenants(id)`, `users(id)`, `invoices(id)` + its `amount_paid`/`paid_at`/`status` columns and `invoice_payments` (owned by partial-payment-recording, spec 80), `expenses(id)` + `amount`/`expense_date`/`status`, and `unmatched_payments` (owned by payment-reconciliation, spec 157).
- **Cross-table writes this plan owns:** `ALTER TABLE expenses ADD COLUMN bank_transaction_id`, extend `invoice_payments.source` CHECK to include `'bank_import'`. It does NOT alter `unmatched_payments` (its pending discriminator is `matched_to_invoice_id IS NULL`).
- **Ownership invariant:** bank-import owns the direct invoice-payment write for txns it matches itself; payment-reconciliation owns resolving `unmatched_payments`. A txn is *either* matched here *or* sent to reconcile, never both — `bank_transactions.match_status` is the discriminator.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers), routes mounted under `/api/bank-imports`, guarded by `authMiddleware` + `requirePermission('expenses:read'|'expenses:write')`, tenant-scoped via `tenantQuery`/`createDb`. OWNER/ADMIN gate via `requirePermission`/role check on complete.
- **Package:** new `packages/bank-import` (`@zync/bank-import`) — parsers, matching, queue consumer, Drizzle schema. Re-exported into `@zync/db` schema barrel for migrations.
- **App UI:** `apps/zync-app` (Vite + React) — upload page, review page, React-Query hooks, against `@zync/ui` components.
- **Cloudflare bindings:** `STORAGE` (R2), `QUEUE` (Cloudflare Queues), `DB` (Neon via Hyperdrive). Rate limiter `RATE_LIMITER_EXPENSE_UPLOAD` reused for upload throttling.
- **Libraries:** OFX/QFX parsed via lightweight XML parsing; CSV parsed with a streaming CSV reader; encoding auto-detect (CP1255 / UTF-8+BOM) before parse.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema | 1, 2 | `packages/bank-import/src/schema.ts`, `packages/db` barrel, migrations | No (1 blocks all) |
| B — package core | 3, 4, 5 | `packages/bank-import/src/parsers/*`, `matching.ts`, `process.ts` | 3 and 4 parallel; 5 after both |
| C — API | 6, 7, 8 | `apps/zync-api/src/routes/bank-imports.ts`, queue consumer wiring | 6 blocks 7,8 |
| D — UI | 9, 10, 11 | `apps/zync-app/.../bank-import/*` | 9 blocks 10,11; 10,11 parallel |
| E — verification | 12 | tests across package + api | After all |

## Tasks

### Task 1: Schema — `bank_imports` and `bank_transactions` tables
**Blocks:** 2,3,5,6  ·  **Blocked by:** —
**Files:**
- Create: `packages/bank-import/src/schema.ts`
- Modify: `packages/db/src/schema/index.ts` (re-export bank-import tables into the migration barrel)
**Steps:**
- [ ] Define both tables in Drizzle matching the DDL below; `unmatched_payment_id` is folded into the `bank_transactions` CREATE (no self-ALTER).
- [ ] Transcribe the spec's enum comments into real `CHECK (col IN (...))` constraints verbatim.
- [ ] Add the two indexes.
- [ ] Re-export from `@zync/db` so `drizzle-kit` picks the tables up for migration generation.
**Schema / Interfaces:**
```sql
CREATE TABLE bank_imports (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  imported_by     UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL,
  file_name       TEXT NOT NULL,
  bank_name       TEXT NOT NULL CHECK (bank_name IN (
                    'hapoalim','leumi','mizrahi','discount','cal','max','isracard','generic_csv','ofx')),
  account_number  TEXT,                         -- masked: last 4 digits
  period_start    DATE,
  period_end      DATE,
  row_count       INTEGER NOT NULL DEFAULT 0,
  matched_count   INTEGER NOT NULL DEFAULT 0,
  unmatched_count INTEGER NOT NULL DEFAULT 0,
  status          TEXT NOT NULL DEFAULT 'pending'
                    CHECK (status IN ('pending','processing','review','complete')),
  r2_key          TEXT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE bank_transactions (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  import_id           UUID NOT NULL REFERENCES bank_imports(id) ON DELETE CASCADE,
  tenant_id           UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  txn_date            DATE NOT NULL,
  description         TEXT NOT NULL,
  amount              NUMERIC(12,2) NOT NULL,   -- positive = credit (income); negative = debit (expense)
  currency            TEXT NOT NULL DEFAULT 'ILS',
  balance_after       NUMERIC(12,2),
  reference           TEXT,
  match_status        TEXT NOT NULL DEFAULT 'unmatched'
                        CHECK (match_status IN (
                          'unmatched','matched_invoice','matched_expense',
                          'ignored','expense_created','sent_to_reconcile')),
  matched_invoice_id  UUID REFERENCES invoices(id) ON DELETE SET NULL,
  matched_expense_id  UUID REFERENCES expenses(id) ON DELETE SET NULL,
  unmatched_payment_id UUID REFERENCES unmatched_payments(id) ON DELETE SET NULL,
  match_confidence    NUMERIC(3,2),             -- 0.00–1.00
  reviewed_by         UUID REFERENCES users(id),
  reviewed_at         TIMESTAMPTZ,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_bank_txns_import ON bank_transactions(import_id);
CREATE INDEX idx_bank_txns_tenant ON bank_transactions(tenant_id, txn_date DESC);
```
**Acceptance:**
- [ ] `drizzle-kit` generates a migration creating both tables with all CHECK constraints and indexes.
- [ ] All FKs are UUID→UUID; `unmatched_payment_id` is part of the CREATE, not a later ALTER.

### Task 2: Cross-table ALTERs (expenses link + invoice_payments source extension)
**Blocks:** 5,6,8  ·  **Blocked by:** 1
**Files:**
- Create: `packages/bank-import/migrations/*_bank_import_cross_table.sql` (or fold into the package's generated migration)
**Steps:**
- [ ] Add `bank_transaction_id` to `expenses` (this plan owns the column).
- [ ] Extend the `invoice_payments.source` CHECK to include `'bank_import'` — this is a **deliberate cross-spec enum extension**, not a typo. The base enum from partial-payment-recording is `manual|gateway|bank_transfer|auto_billing`.
- [ ] Mirror the same `'bank_import'` value in the Drizzle column definition of `invoice_payments` in `@zync/db` so the type and the DB constraint agree.
**Schema / Interfaces:**
```sql
ALTER TABLE expenses
  ADD COLUMN bank_transaction_id UUID REFERENCES bank_transactions(id) ON DELETE SET NULL;

-- deliberate cross-spec extension of the existing source enum
ALTER TABLE invoice_payments DROP CONSTRAINT IF EXISTS invoice_payments_source_check;
ALTER TABLE invoice_payments ADD CONSTRAINT invoice_payments_source_check
  CHECK (source IN ('manual','gateway','bank_transfer','auto_billing','bank_import'));
```
**Acceptance:**
- [ ] `expenses.bank_transaction_id` exists and references `bank_transactions(id)`.
- [ ] Inserting an `invoice_payments` row with `source='bank_import'` passes the CHECK.

### Task 3: Bank-specific parsers
**Blocks:** 5  ·  **Blocked by:** 1
**Files:**
- Create: `packages/bank-import/src/parsers/index.ts`
- Create: `packages/bank-import/src/parsers/hapoalim.ts`, `leumi.ts`, `mizrahi.ts`, `discount.ts`, `cal.ts`, `max.ts`, `isracard.ts`, `generic-csv.ts`, `ofx.ts`
- Create: `packages/bank-import/src/parsers/encoding.ts`
**Steps:**
- [ ] Implement `detectEncoding(bytes: ArrayBuffer): 'cp1255' | 'utf-8'` (sniff UTF-8 BOM; default CP1255 for Hebrew CSVs) and `decode(bytes, encoding): string`.
- [ ] Implement each per-bank CSV parser handling Hebrew headers natively (no transliteration) and `DD/MM/YYYY` dates; normalize separate debit/credit columns into a single signed `amount` (positive = credit, negative = debit).
- [ ] Implement `parseGenericCsv` (auto-detect columns by header name, Hebrew or English).
- [ ] Implement `parseOFX` for standard OFX/QFX XML (`<STMTTRN>` blocks → date `DTPOSTED`, `TRNAMT`, `NAME`/`MEMO`, `FITID` as reference).
- [ ] Export the `PARSERS` registry and a `parseBankFile(text, bankName): BankRow[]` dispatcher.
**Schema / Interfaces:**
```ts
export interface BankRow {
  date: Date
  description: string
  amount: number      // positive = credit, negative = debit
  balance?: number
  reference?: string
  currency: string    // default 'ILS'
}
export type BankName =
  'hapoalim'|'leumi'|'mizrahi'|'discount'|'cal'|'max'|'isracard'|'generic_csv'|'ofx'
export type BankParser = (text: string) => BankRow[]
export const PARSERS: Record<BankName, BankParser>
export function parseBankFile(text: string, bankName: BankName): BankRow[]
```
**Acceptance:**
- [ ] Each parser converts a sample fixture (Hebrew CP1255 CSV and an OFX file) into normalized `BankRow[]` with correct signs and parsed dates.
- [ ] `parseGenericCsv` correctly maps a header-labeled CSV with Hebrew column names.

### Task 4: Matching engine
**Blocks:** 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/bank-import/src/matching.ts`
**Steps:**
- [ ] Implement `matchInvoice(txn, tenantId, db)` per spec: find invoices where `amount_paid ≈ |txn.amount|` within ₪1 tolerance, `paid_at` within ±5 days of `txn_date`, `status IN ('TAX_ISSUED','PARTIALLY_PAID')`; boost confidence when the customer name appears in `txn.description`.
- [ ] Implement `matchExpense(txn, tenantId, db)` per spec: find expenses where `amount ≈ |txn.amount|` within ₪1, `expense_date` within ±3 days of `txn_date`, `status IN ('PENDING','COMPLETED')`.
- [ ] Return a `{ confidence: number; invoice_id?: string; expense_id?: string }` result; confidence in `0.0–1.0`.
**Schema / Interfaces:**
```ts
export interface MatchResult {
  confidence: number          // 0.0–1.0
  invoice_id?: string
  expense_id?: string
}
export function matchInvoice(txn: BankTransaction, tenantId: string, db: Db): Promise<MatchResult>
export function matchExpense(txn: BankTransaction, tenantId: string, db: Db): Promise<MatchResult>
```
**Acceptance:**
- [ ] An exact-amount, in-window invoice returns confidence ≥ 0.85 (auto-accept threshold).
- [ ] An exact-amount, in-window expense returns confidence ≥ 0.80.
- [ ] Out-of-window or out-of-status candidates are excluded.

### Task 5: Queue consumer `processBankImport`
**Blocks:** 8  ·  **Blocked by:** 1,2,3,4
**Files:**
- Create: `packages/bank-import/src/process.ts`
- Create: `packages/bank-import/src/db.ts` (data-access helpers: `getImport`, `insertTransaction`, `markMatched`, `updateImportStatus`)
- Create: `packages/bank-import/src/index.ts` (package barrel)
**Steps:**
- [ ] Implement `processBankImport(importId, env)`: set `bank_imports.status='processing'`; fetch the file via `env.STORAGE.get(imp.r2_key)`; decode + `parseBankFile`.
- [ ] For each row: insert a `bank_transactions` row (`match_status='unmatched'`).
- [ ] Credits (`amount > 0`): run `matchInvoice`; if `confidence >= 0.85`, `markMatched(txn.id, 'matched_invoice', match.invoice_id, confidence)` — NOTE: at parse time this only sets `match_status`/`matched_invoice_id`; the authoritative `invoice_payments` write happens only when the user confirms in the review UI (Task 8), preserving the no-double-write invariant.
- [ ] Debits (`amount < 0`): run `matchExpense`; if `confidence >= 0.80`, `markMatched(txn.id, 'matched_expense', match.expense_id, confidence)`.
- [ ] Update `row_count`, `matched_count`, `unmatched_count`; set `bank_imports.status='review'`.
**Schema / Interfaces:**
```ts
export async function processBankImport(importId: string, env: Env): Promise<void>
// db.ts helpers (tenant-scoped)
export function getImport(db: Db, importId: string): Promise<BankImport>
export function insertTransaction(db: Db, importId: string, tenantId: string, row: BankRow): Promise<BankTransaction>
export function markMatched(db: Db, txnId: string, status: 'matched_invoice'|'matched_expense', linkId: string | null, confidence: number): Promise<void>
export function updateImportStatus(db: Db, importId: string, status: 'pending'|'processing'|'review'|'complete', counts?: { row_count: number; matched_count: number; unmatched_count: number }): Promise<void>
```
**Acceptance:**
- [ ] Processing a fixture import inserts N transactions, auto-matches per thresholds, and leaves `status='review'`.
- [ ] The consumer reads the file from the `STORAGE` binding (not `R2`) and from `QUEUE` message body type `bank.process`.

### Task 6: API — upload + list + status routes
**Blocks:** 7,8  ·  **Blocked by:** 1,2
**Files:**
- Create: `apps/zync-api/src/routes/bank-imports.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router; register `bank.process` in the queue consumer switch)
- Modify: `apps/zync-api/src/queue.ts` (add `case 'bank.process'` → `processBankImport`)
**Steps:**
- [ ] Mount router at `/api/bank-imports` behind `authMiddleware`.
- [ ] `GET /api/bank-imports` (`expenses:read`): paginated import history via `buildPaginated`/`clampLimit`, tenant-scoped.
- [ ] `POST /api/bank-imports` (`expenses:write`): zod-validate (`bank_name` in the 9 enum values, optional `account_number`, file ≤ 5 MB, `.csv/.ofx/.qfx`); apply `RATE_LIMITER_EXPENSE_UPLOAD`; create `bank_imports` row (`status='pending'`); `STORAGE.put('tenants/{tenantId}/bank-imports/{importId}.csv', file)`; enqueue `QUEUE.send({ type: 'bank.process', importId, tenantId })`.
- [ ] `GET /api/bank-imports/:id` (`expenses:read`): import status + stats (`row_count`, `matched_count`, `unmatched_count`, `status`).
- [ ] `GET /api/bank-imports/:id/transactions` (`expenses:read`): list transactions, filterable by `match_status` query param.
- [ ] Register `bank.process` in the queue consumer dispatch the same way `expense.process` is registered.
**Schema / Interfaces:**
```ts
// zod
const createBankImportSchema = z.object({
  bank_name: z.enum(['hapoalim','leumi','mizrahi','discount','cal','max','isracard','generic_csv','ofx']),
  account_number: z.string().max(4).optional(),
})
// routes
GET    /api/bank-imports
POST   /api/bank-imports
GET    /api/bank-imports/:id
GET    /api/bank-imports/:id/transactions
```
**Acceptance:**
- [ ] Upload stores the file in `STORAGE` at the spec key and enqueues a `bank.process` job; oversized / wrong-type files are rejected by zod with 400.
- [ ] All four routes enforce `expenses:read`/`expenses:write` and are tenant-scoped.

### Task 7: API — transaction actions (manual match / ignore / create-expense)
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-api/src/routes/bank-imports.ts`
**Steps:**
- [ ] `PATCH /api/bank-imports/:id/transactions/:tid` (`expenses:write`): update `match_status` for manual actions — `ignored`, `matched_expense` (with `matched_expense_id`), `expense_created`; set `reviewed_by`/`reviewed_at`. Validate the new status is in the enum.
- [ ] `POST /api/bank-imports/:id/transactions/:tid/create-expense` (`expenses:write`): debits only — call expenses-module `createExpense` pre-populated from the txn (`amount = |txn.amount|`, `expense_date = txn_date`, `description`, `status='COMPLETED'`, `source='upload'`); set `expenses.bank_transaction_id = txn.id` and `bank_transactions.match_status='expense_created'`, `matched_expense_id` = new expense id. Single DB transaction.
- [ ] "Send to reconcile" (credits only) handled in the matched-invoice route (Task 8) per the shared discriminator.
- [ ] Recompute `bank_imports` counts after each action.
**Schema / Interfaces:**
```ts
const patchTxnSchema = z.object({
  match_status: z.enum(['unmatched','matched_invoice','matched_expense','ignored','expense_created','sent_to_reconcile']),
  matched_expense_id: z.string().uuid().optional(),
})
PATCH  /api/bank-imports/:id/transactions/:tid
POST   /api/bank-imports/:id/transactions/:tid/create-expense
```
**Acceptance:**
- [ ] Create-expense on a debit creates an expense linked via `bank_transaction_id` and flips the txn to `expense_created` atomically.
- [ ] `create-expense` rejects (409/400) a credit transaction.

### Task 8: API — match-to-invoice (single-write invariant) + send-to-reconcile + complete
**Blocks:** —  ·  **Blocked by:** 5,6
**Files:**
- Modify: `apps/zync-api/src/routes/bank-imports.ts`
**Steps:**
- [ ] **Match to invoice** (via `PATCH .../transactions/:tid` with `match_status='matched_invoice'` + `matched_invoice_id`): in **one DB transaction with a row lock (`SELECT ... FOR UPDATE`)** — set `bank_transactions.match_status='matched_invoice'`, `matched_invoice_id`; insert exactly one `invoice_payments` row (`source='bank_import'`, `amount=|txn.amount|`, `paid_at=txn_date`, `reference=txn.reference`, `recorded_by=user`); recompute `invoices.amount_paid` and update `invoices.status` (→ `PAID` or `PARTIALLY_PAID`) using the partial-payment-recording balance CTE. **Never** also insert an `unmatched_payments` row for this txn.
- [ ] **Send to reconcile** (credits only, via `match_status='sent_to_reconcile'`): insert an `unmatched_payments` row using ONLY existing columns — `amount = txn.amount`, `paid_at = txn_date`, `payer_name` (from description), `reference`, `payment_method='bank_transfer'`, `tenant_id`. Do NOT add/set any status column on `unmatched_payments` (its pending discriminator is `matched_to_invoice_id IS NULL`). Set `bank_transactions.unmatched_payment_id` FK and `match_status='sent_to_reconcile'`. A txn is *either* matched-invoice *or* sent-to-reconcile — never both.
- [ ] `POST /api/bank-imports/:id/complete` (**OWNER/ADMIN only** — enforce via `requirePermission`/role check): require all transactions resolved (`matched_invoice|matched_expense|expense_created|ignored|sent_to_reconcile`); set `bank_imports.status='complete'`; else return 409 with the unresolved count.
**Schema / Interfaces:**
```ts
POST   /api/bank-imports/:id/complete   // OWNER/ADMIN
// invoice balance recompute (partial-payment-recording CTE)
// WITH new_amount AS (SELECT COALESCE(SUM(amount),0) total_paid FROM invoice_payments WHERE invoice_id=:id)
// UPDATE invoices SET amount_paid = (SELECT total_paid FROM new_amount),
//   status = CASE WHEN total_paid >= total THEN 'PAID'
//                 WHEN total_paid > 0 THEN 'PARTIALLY_PAID' ELSE status END
//   WHERE id = :id;
```
**Acceptance:**
- [ ] Matching a txn to an invoice writes exactly one `invoice_payments` row with `source='bank_import'` and updates invoice status, all in one locked transaction; no `unmatched_payments` row is created for that txn.
- [ ] Send-to-reconcile inserts an `unmatched_payments` row (existing columns only) and sets `bank_transactions.unmatched_payment_id` + `match_status='sent_to_reconcile'`; the row appears in `/invoices/reconcile` (its `matched_to_invoice_id IS NULL`).
- [ ] `complete` is rejected for non-OWNER/ADMIN (403) and for imports with unresolved transactions (409).

### Task 9: UI — React-Query hooks + types
**Blocks:** 10,11  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/features/bank-import/api.ts`
- Create: `apps/zync-app/src/features/bank-import/types.ts`
**Steps:**
- [ ] Define TS types `BankImport`, `BankTransaction`, `BankName` mirroring the schema.
- [ ] Implement hooks `useBankImports` (list), `useBankImport(id)` (status, polls while `processing`), `useBankTransactions(id, filter)`, `useUploadBankImport`, `usePatchTransaction`, `useCreateExpenseFromTxn`, `useCompleteImport`.
**Schema / Interfaces:**
```ts
export interface BankImport { id: string; bank_name: BankName; status: 'pending'|'processing'|'review'|'complete'; row_count: number; matched_count: number; unmatched_count: number; period_start?: string; period_end?: string; file_name: string; created_at: string }
export interface BankTransaction { id: string; txn_date: string; description: string; amount: number; currency: string; match_status: 'unmatched'|'matched_invoice'|'matched_expense'|'ignored'|'expense_created'|'sent_to_reconcile'; matched_invoice_id?: string; matched_expense_id?: string; match_confidence?: number }
```
**Acceptance:**
- [ ] Hooks call the Task 6–8 endpoints; `useBankImport` auto-refetches while `status==='processing'`.

### Task 10: UI — Upload page `/expenses/bank-import`
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/features/bank-import/BankImportUploadPage.tsx`
- Modify: `apps/zync-app/src/features/expenses/ExpensesHeader.tsx` (add "Import statement" link)
- Modify: app router config (add `/expenses/bank-import` route)
**Steps:**
- [ ] Build the form with `@zync/ui` `Select` (bank/card: the 9 options with Hebrew labels), `Input` (optional account #), and a drag-and-drop file picker (`.csv,.ofx,.qfx`, max 5 MB) with `aria-label`s and keyboard support.
- [ ] On submit call `useUploadBankImport`; on success navigate to the review page for the new import.
- [ ] RTL-aware layout; `prefers-reduced-motion` respected for any drag animation; client-side size/type validation mirrors server zod.
**Acceptance:**
- [ ] Selecting a bank + file and uploading creates an import and routes to `/expenses/bank-import/:id/review`.
- [ ] Oversized/wrong-type files show an inline error before upload.

### Task 11: UI — Review page `/expenses/bank-import/:importId/review`
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/features/bank-import/BankImportReviewPage.tsx`
- Create: `apps/zync-app/src/features/bank-import/TransactionRow.tsx`
- Create: `apps/zync-app/src/features/bank-import/CreateExpenseDialog.tsx`
- Create: `apps/zync-app/src/features/bank-import/MatchPickerDialog.tsx`
- Modify: app router config (add review route)
**Steps:**
- [ ] Header: bank + period + summary (`N transactions · M matched · K to review`) and a `[Complete]` button (disabled until all resolved; shown only to OWNER/ADMIN).
- [ ] Filter control (`All` / `Unmatched`); transaction table with `role="table"` semantics, date / description / signed amount / status chip.
- [ ] Per-row actions: debits → `[Create expense]` (opens `CreateExpenseDialog` pre-filled), `[Match to existing]` (expense), `[Ignore]`; credits → `[Send to reconcile]`, `[Match to invoice]` (opens `MatchPickerDialog`), `[Ignore]`.
- [ ] Progress indicator `resolved / total`; on `[Complete]` call `useCompleteImport`, then redirect to import history; show toast.
- [ ] Status chips and confidence are screen-reader readable; RTL and `prefers-reduced-motion` honored.
**Acceptance:**
- [ ] Each action calls the correct endpoint and updates the row + summary counts optimistically.
- [ ] "Send to reconcile" appears only on credits; "Create expense" only on debits.
- [ ] `[Complete]` is enabled only when all rows are resolved and the user is OWNER/ADMIN.

### Task 12: Verification — parser, matching, and invariant tests
**Blocks:** —  ·  **Blocked by:** 5,7,8
**Files:**
- Create: `packages/bank-import/test/parsers.test.ts`
- Create: `packages/bank-import/test/matching.test.ts`
- Create: `packages/bank-import/test/fixtures/*` (CP1255 Hapoalim CSV, OFX sample, generic CSV)
- Create: `apps/zync-api/test/bank-imports.test.ts`
**Steps:**
- [ ] Parser tests: each bank fixture → correct `BankRow[]` (signs, dates, encoding).
- [ ] Matching tests: threshold behavior for invoice (≥0.85) and expense (≥0.80); window exclusions.
- [ ] API/invariant tests: match-to-invoice writes exactly one `invoice_payments` (`source='bank_import'`) and no `unmatched_payments`; send-to-reconcile writes one `unmatched_payments` (existing columns only) + sets FK; `complete` gated by OWNER/ADMIN and unresolved-count 409.
**Acceptance:**
- [ ] All tests pass; the no-double-write invariant is asserted explicitly (exactly one payment row, zero unmatched rows for a directly-matched txn).
