# Payment Reconciliation — Implementation Plan

**Spec:** docs/specs/2026-06-01-payment-reconciliation.md  ·  **Slug:** payment-reconciliation  ·  **Wave:** 12
**Depends on:** foundation-auth-rbac, invoices-core, multi-currency, partial-payment-recording

## Goal
Deliver a staff-facing reconciliation workflow for payments that arrive outside the Zync online payment flow (bank transfer, check, cash, non-integrated gateways). Staff can record a payment against a known invoice, stage bank receipts whose invoice is not yet known in a new `unmatched_payments` table, and match those staged receipts to outstanding invoices from a two-pane `/invoices/reconcile` view. Matching a staged receipt records a real `invoice_payments` row (spec 80 flow), so it triggers the `PAID`/`PARTIALLY_PAID` status transitions and `amount_paid` denorm already defined upstream.

## Architecture
This spec adds ONE new table, `unmatched_payments`, plus five API routes and one React route. It does NOT define its own payment-recording mechanism: recording a payment against a known invoice reuses the existing `POST /api/invoices/:id/payments` from `partial-payment-recording` (spec 80), which writes an `invoice_payments` row and recomputes `invoices.amount_paid` + `invoices.status` in the same transaction.

Data flow:
- `GET /api/invoices/outstanding` reads `invoices` (upstream columns: `invoice_number`, `proforma_number`, `customer_id`, `total`, `amount_paid`, `currency`, `due_date`, `status`) joined to `customers` for `customer_name`, filtered to `status IN (SENT, APPROVED, TAX_ISSUED, PARTIALLY_PAID) AND amount_paid < total`.
- `unmatched_payments` rows enter from (a) `POST /api/reconcile/unmatched` (manual **[Add unmatched payment]**), and (b) `bank-statement-import` (spec 167) "Send to reconcile" — both write the same table; this plan owns the table DDL and the manual path, spec 167 writes rows via the same insert helper.
- `PATCH /api/reconcile/unmatched/:id/match` performs a two-step transaction: insert an `invoice_payments` row (via the upstream payment-recording service) AND set `matched_to_invoice_id`/`matched_at`/`matched_by` on the `unmatched_payments` row.

Upstream tables consumed (exact names): `invoices`, `invoice_payments`, `customers`, `tenants`, `users`. Upstream columns consumed: `invoices.amount_paid` (added by spec 80), `invoices.currency`, `invoices.total`, `invoices.due_date`, `invoices.invoice_number`, `invoices.proforma_number`, `invoices.status`, `invoices.customer_id`. Multi-currency columns (`invoices.ils_exchange_rate`, `invoices.total_ils`) are passed through read-only in serializers; reconciliation itself records payments in the invoice's `currency`.

Upstream exports consumed: `createDb`/`Db`/`DB`, `tenantQuery`, `requirePermission`, `authMiddleware`, `serializeInvoice`, `InvoiceObject`, `InvoiceStatus`, `buildPaginated`, `PaginatedResponse`, `PaginationParams`, `ApiError`, `Env`, `Customer`, and the spec-80 payment-recording route `POST /api/invoices/:id/payments`. Permissions consumed: `invoices:write`, `invoices:read`.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers). New route module `apps/zync-api/src/routes/reconcile.ts`. Drizzle schema in `packages/db`.
- **App:** `apps/zync-app` (Vite + React). New route `apps/zync-app/src/pages/invoices/ReconcilePage.tsx` plus sheets/modals and a TanStack-Query hook module.
- **DB:** Neon Postgres via Cloudflare Hyperdrive, Drizzle ORM. New table `unmatched_payments`.
- **Bindings:** Hyperdrive (`DB`/`HYPERDRIVE`) as already wired in `apps/zync-api`; no new bindings.
- **Validation:** Zod (`require-zod-validation-in-routes`). Audit writes inside the match transaction (`require-audit-in-transaction`). All DB access via `tenantQuery`/`createDb`, never raw Drizzle from routes (`no-raw-drizzle-from-routes`). All pages use design-system components, no raw HTML (`no-raw-html-in-pages`).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 12.1 — Schema | 1 | `packages/db/src/schema/unmatched-payments.ts`, schema index | No (blocks all) |
| 12.2 — Service layer | 2 | `packages/db/src/services/reconcile.ts` | After 12.1 |
| 12.3 — API routes | 3, 4 | `apps/zync-api/src/routes/reconcile.ts`, app mount | After 12.2 |
| 12.4 — App data hooks | 5 | `apps/zync-app/src/features/reconcile/api.ts` | After 12.3 (parallel with 12.5 scaffolding) |
| 12.5 — App UI | 6, 7, 8 | `apps/zync-app/src/pages/invoices/ReconcilePage.tsx`, sheet, match modal, route | After 12.4 |
| 12.6 — Sidebar nav | 9 | Invoices sidebar config | After 12.5 |
| 12.7 — Tests | 10 | `apps/zync-api/test/reconcile.test.ts` | After 12.3 |

## Tasks

### Task 1: `unmatched_payments` table (Drizzle schema + migration)
**Blocks:** 2, 3  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/unmatched-payments.ts`
- Modify: `packages/db/src/schema/index.ts` (re-export the new table)
- Create: `packages/db/migrations/<timestamp>_unmatched_payments.sql`
**Steps:**
- [ ] Define the `unmatched_payments` Drizzle table mirroring the canonical DDL below.
- [ ] Add the two indexes including the partial index on unmatched rows.
- [ ] Export `unmatchedPayments` (Drizzle table) and `UnmatchedPaymentRow` (inferred select type) and `NewUnmatchedPayment` (inferred insert type) from the schema barrel.
- [ ] Generate the SQL migration; ensure FK targets `tenants(id)`, `invoices(id)`, `users(id)` are UUID→UUID.
**Schema / Interfaces:**
```sql
CREATE TABLE unmatched_payments (
  id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id             UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  amount                NUMERIC(12,2) NOT NULL CHECK (amount > 0),
  currency              TEXT NOT NULL DEFAULT 'ILS' CHECK (currency IN ('ILS','USD','EUR')),
  paid_at               DATE NOT NULL,
  payment_method        TEXT NOT NULL DEFAULT 'bank_transfer'
                          CHECK (payment_method IN ('bank_transfer','credit_card','check','cash','other')),
  reference             TEXT,
  notes                 TEXT,
  payer_name            TEXT,
  matched_to_invoice_id UUID REFERENCES invoices(id) ON DELETE SET NULL,
  matched_at            TIMESTAMPTZ,
  matched_by            UUID REFERENCES users(id) ON DELETE SET NULL,
  created_at            TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_unmatched_payments_tenant ON unmatched_payments(tenant_id, paid_at DESC);
CREATE INDEX idx_unmatched_payments_unmatched ON unmatched_payments(tenant_id)
  WHERE matched_to_invoice_id IS NULL;
```
```typescript
export interface UnmatchedPaymentRow {
  id: string; tenantId: string; amount: string; currency: 'ILS' | 'USD' | 'EUR';
  paidAt: string; paymentMethod: 'bank_transfer' | 'credit_card' | 'check' | 'cash' | 'other';
  reference: string | null; notes: string | null; payerName: string | null;
  matchedToInvoiceId: string | null; matchedAt: string | null; matchedBy: string | null;
  createdAt: string;
}
```
**Acceptance:**
- [ ] `pnpm --filter @zync/db build` succeeds; `unmatchedPayments` exported from `@zync/db`.
- [ ] Migration applies cleanly on a fresh Neon branch; both indexes present; partial index predicate is `matched_to_invoice_id IS NULL`.
- [ ] `currency` and `payment_method` CHECK constraints reject out-of-enum values.

### Task 2: Reconcile service layer
**Blocks:** 3, 4  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/services/reconcile.ts`
- Modify: `packages/db/src/index.ts` (re-export service functions + types)
**Steps:**
- [ ] Implement `listOutstandingInvoices(db, tenantId, params)` — queries `invoices` joined to `customers`, filtered to `status IN ('SENT','APPROVED','TAX_ISSUED','PARTIALLY_PAID') AND amount_paid < total`; supports `search` (invoice_number / proforma_number / customer name ILIKE), `sort` (`oldest_due` default, `newest_due`, `amount`, `customer`), and pagination; returns a `PaginatedResponse<OutstandingInvoice>` built via the upstream `buildPaginated` helper.
- [ ] Implement `listUnmatchedPayments(db, tenantId)` — selects `unmatched_payments WHERE matched_to_invoice_id IS NULL` ordered by `paid_at DESC`; returns `UnmatchedPaymentObject[]`.
- [ ] Implement `createUnmatchedPayment(db, tenantId, input)` — inserts a row; returns the serialized object. This is the shared insert helper that `bank-statement-import` (spec 167) also calls.
- [ ] Implement `deleteUnmatchedPayment(db, tenantId, id)` — deletes only when `matched_to_invoice_id IS NULL`; throws `ApiError(409, 'already_matched')` otherwise.
- [ ] Implement `markUnmatchedPaymentMatched(db, tenantId, id, invoiceId, userId)` — sets `matched_to_invoice_id`, `matched_at = now()`, `matched_by = userId`; guard: throws `ApiError(409)` if already matched. Designed to run inside the caller's transaction alongside the spec-80 payment insert.
- [ ] Implement `serializeUnmatchedPayment(row)` and `serializeOutstandingInvoice(row)` to camelCase DTOs.
- [ ] All queries go through `tenantQuery` so `tenant_id` scoping is enforced (`no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```typescript
export interface OutstandingInvoice {
  id: string; invoiceNumber: string | null; proformaNumber: string | null;
  customerName: string; total: string; amountPaid: string; outstanding: string;
  currency: 'ILS' | 'USD' | 'EUR'; dueDate: string | null; status: InvoiceStatus;
}
export interface UnmatchedPaymentObject {
  id: string; amount: string; currency: 'ILS' | 'USD' | 'EUR'; paidAt: string;
  paymentMethod: 'bank_transfer' | 'credit_card' | 'check' | 'cash' | 'other';
  reference: string | null; payerName: string | null; notes: string | null;
}
export interface ListOutstandingParams {
  search?: string; sort?: 'oldest_due' | 'newest_due' | 'amount' | 'customer';
  page?: number; perPage?: number;
}
export interface CreateUnmatchedPaymentInput {
  amount: string; currency?: 'ILS' | 'USD' | 'EUR'; paidAt: string;
  paymentMethod: 'bank_transfer' | 'credit_card' | 'check' | 'cash' | 'other';
  reference?: string; payerName?: string; notes?: string;
}
export function listOutstandingInvoices(db: Db, tenantId: string, params: ListOutstandingParams): Promise<PaginatedResponse<OutstandingInvoice>>;
export function listUnmatchedPayments(db: Db, tenantId: string): Promise<UnmatchedPaymentObject[]>;
export function createUnmatchedPayment(db: Db, tenantId: string, input: CreateUnmatchedPaymentInput): Promise<UnmatchedPaymentObject>;
export function deleteUnmatchedPayment(db: Db, tenantId: string, id: string): Promise<void>;
export function markUnmatchedPaymentMatched(db: Db, tenantId: string, id: string, invoiceId: string, userId: string): Promise<void>;
```
**Acceptance:**
- [ ] `outstanding = total - amount_paid` computed per row; never negative.
- [ ] `listOutstandingInvoices` excludes `DRAFT`, `PAID`, `REJECTED` and any invoice where `amount_paid >= total`.
- [ ] `deleteUnmatchedPayment` rejects matched rows with 409.
- [ ] Functions exported from `@zync/db`.

### Task 3: Reconcile API routes
**Blocks:** 5  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/reconcile.ts`
- Modify: `apps/zync-api/src/app.ts` (mount `reconcile` router under `/api`)
**Steps:**
- [ ] Create a Hono router with `authMiddleware`; every route guarded by `requirePermission('invoices:write')`.
- [ ] `GET /api/invoices/outstanding` → parse query with `outstandingQuerySchema` (search, sort, page, per_page) → `listOutstandingInvoices` → return `PaginatedResponse<OutstandingInvoice>`.
- [ ] `GET /api/reconcile/unmatched` → `listUnmatchedPayments` → `{ items: UnmatchedPaymentObject[] }`.
- [ ] `POST /api/reconcile/unmatched` → validate `createUnmatchedPaymentSchema` → `createUnmatchedPayment` → 201 with the object.
- [ ] `DELETE /api/reconcile/unmatched/:id` → `deleteUnmatchedPayment`; 204 on success, 409 if matched, 404 if not found.
- [ ] `PATCH /api/reconcile/unmatched/:id/match` → validate `matchSchema` (`{ invoice_id }`) → see Task 4 for transaction logic.
- [ ] All handlers use Zod (`require-zod-validation-in-routes`) and return errors via `ApiError`.
**Schema / Interfaces:**
```typescript
const paymentMethodEnum = z.enum(['bank_transfer','credit_card','check','cash','other']);
const currencyEnum = z.enum(['ILS','USD','EUR']);
const createUnmatchedPaymentSchema = z.object({
  amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
  currency: currencyEnum.optional(),
  paid_at: z.string().date(),
  payment_method: paymentMethodEnum,
  reference: z.string().max(200).optional(),
  payer_name: z.string().max(200).optional(),
  notes: z.string().max(2000).optional(),
});
const outstandingQuerySchema = z.object({
  search: z.string().optional(),
  sort: z.enum(['oldest_due','newest_due','amount','customer']).default('oldest_due'),
  page: z.coerce.number().int().min(1).default(1),
  per_page: z.coerce.number().int().min(1).max(100).default(25),
});
const matchSchema = z.object({ invoice_id: z.string().uuid() });
```
```
GET    /api/invoices/outstanding   → PaginatedResponse<OutstandingInvoice>   (invoices:write)
GET    /api/reconcile/unmatched     → { items: UnmatchedPaymentObject[] }      (invoices:write)
POST   /api/reconcile/unmatched     → 201 UnmatchedPaymentObject               (invoices:write)
DELETE /api/reconcile/unmatched/:id → 204 | 409 already_matched | 404          (invoices:write)
PATCH  /api/reconcile/unmatched/:id/match → 200 { payment, unmatched }         (invoices:write)
```
**Acceptance:**
- [ ] Unauthenticated request → 401; authenticated without `invoices:write` → 403.
- [ ] `POST` with `amount: "0"` or negative → 400 (Zod) and DB CHECK never reached.
- [ ] Cross-tenant id in any path param → 404 (tenant scoping via `tenantQuery`).
- [ ] Routes mounted and reachable in `apps/zync-api/src/app.ts`.

### Task 4: Match transaction (record payment + mark matched + audit)
**Blocks:** 5  ·  **Blocked by:** 2, 3
**Files:**
- Modify: `apps/zync-api/src/routes/reconcile.ts` (PATCH `/match` handler body)
- Modify: `packages/db/src/services/reconcile.ts` (transactional `matchUnmatchedPayment` orchestrator)
**Steps:**
- [ ] Implement `matchUnmatchedPayment(db, tenantId, unmatchedId, invoiceId, userId)` running a single DB transaction:
  - [ ] Load the unmatched payment (tenant-scoped); 404 if missing; 409 if already matched.
  - [ ] Load the target invoice (tenant-scoped); 404 if missing; reject (422) if invoice `status` not in `SENT|APPROVED|TAX_ISSUED|PARTIALLY_PAID`.
  - [ ] Insert an `invoice_payments` row using the upstream spec-80 service (`amount`, `currency`, `paid_at = unmatched.paid_at`, `source = 'bank_transfer'`, `reference = unmatched.reference`, `recorded_by = userId`, `note = unmatched.notes`); this recomputes `invoices.amount_paid` + `invoices.status` in the SAME transaction per spec 80 Invoice Balance Logic.
  - [ ] Call `markUnmatchedPaymentMatched` to set `matched_to_invoice_id`, `matched_at`, `matched_by` on the unmatched row.
  - [ ] Write an audit entry inside the same transaction (`require-audit-in-transaction`): action `reconcile.match`, target invoice id, captured amount + unmatched payment id.
- [ ] Return `{ payment, unmatched }` (the new `invoice_payments` DTO and updated unmatched DTO).
**Schema / Interfaces:**
```typescript
export function matchUnmatchedPayment(
  db: Db, tenantId: string, unmatchedId: string, invoiceId: string, userId: string,
): Promise<{ payment: InvoicePaymentObject; unmatched: UnmatchedPaymentObject }>;
```
**Acceptance:**
- [ ] After match where `amount == outstanding`, invoice `status` becomes `PAID` and `amount_paid == total` (spec-80 transition fires).
- [ ] After match where `amount < outstanding`, invoice `status` becomes `PARTIALLY_PAID`.
- [ ] The unmatched row is marked matched in the same transaction; on payment-insert failure, no partial state persists (single transaction rollback).
- [ ] An audit row is written with action `reconcile.match`.
- [ ] Matching an already-matched unmatched payment → 409.

### Task 5: App data hooks (TanStack Query)
**Blocks:** 6, 7, 8  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-app/src/features/reconcile/api.ts`
- Create: `apps/zync-app/src/features/reconcile/types.ts`
**Steps:**
- [ ] Define TS DTO types mirroring `OutstandingInvoice` and `UnmatchedPaymentObject`.
- [ ] `useOutstandingInvoices(params)` — query `GET /api/invoices/outstanding`.
- [ ] `useUnmatchedPayments()` — query `GET /api/reconcile/unmatched`.
- [ ] `useCreateUnmatchedPayment()` — mutation `POST /api/reconcile/unmatched`; invalidates unmatched list.
- [ ] `useDeleteUnmatchedPayment()` — mutation `DELETE /api/reconcile/unmatched/:id`; invalidates unmatched list.
- [ ] `useMatchUnmatchedPayment()` — mutation `PATCH /api/reconcile/unmatched/:id/match`; invalidates unmatched list AND outstanding list.
- [ ] `useRecordInvoicePayment()` — mutation `POST /api/invoices/:id/payments` (spec 80) used by the per-row Record-payment sheet; invalidates outstanding list.
**Acceptance:**
- [ ] All hooks typed; query keys namespaced `['reconcile','unmatched']` and `['invoices','outstanding']`.
- [ ] Match + record mutations invalidate the correct caches so both panes refresh.

### Task 6: Reconcile page (two-pane layout)
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/pages/invoices/ReconcilePage.tsx`
- Modify: `apps/zync-app/src/router.tsx` (add `/invoices/reconcile`, guarded by `invoices:write`)
**Steps:**
- [ ] Build the two-pane layout: top header with **[+ Record payment]** and **[Import bank statement]** (links to `bank-statement-import` route); "Outstanding Invoices" pane and "Unmatched Bank Receipts" pane.
- [ ] Outstanding pane: search input + sort `Select` (`Oldest first` default); `DataTable`/list of rows showing invoice number (or proforma number when `invoiceNumber` is null), customer name, formatted `outstanding` amount with currency, due-date relative label, and `status` `Badge`; per-row **[Record payment]** button opening the sheet (Task 7).
- [ ] Unmatched pane: **[+ Add unmatched payment]** button (opens the same sheet without an invoice); rows showing amount, payer name (or "unknown"), `paid_at`, payment method; per-row **[Match to invoice]** button (opens modal, Task 8) and a **[⋯ Delete]** action for unmatched rows.
- [ ] Use design-system components only (`Button`, `Card`, `DataTable`, `Badge`, `Select`, `EmptyState`, `Sheet`, `Dialog`); no raw HTML (`no-raw-html-in-pages`), no hardcoded colors/spacing (`no-hardcoded-colors`, `no-hardcoded-spacing`).
- [ ] Currency/amount formatting via the i18n locale + invoice `currency` (Intl.NumberFormat); honor RTL/Hebrew direction (`useDirection`).
- [ ] Empty states: outstanding pane uses `EmptyState` ("No outstanding invoices"); unmatched pane uses `EmptyState` ("No unmatched receipts").
- [ ] Respect `prefers-reduced-motion` for the slide-in sheet/modal transitions; correct aria roles on the two panes and dialogs.
**Acceptance:**
- [ ] Route renders only for users with `invoices:write`; others see access-denied/redirect.
- [ ] Both panes populate from their hooks; "exact match ★" affordance deferred to the match modal.
- [ ] Sort changes re-query outstanding list; search filters by customer/invoice number.
- [ ] No raw-HTML/hardcoded-color lint violations.

### Task 7: Record-payment sheet
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/pages/invoices/RecordPaymentSheet.tsx`
**Steps:**
- [ ] Build a `Sheet` with fields: Amount received (currency-aware numeric), Payment date (date, default today), Payment method `Select` (Bank transfer · Credit card · Check · Cash · Other), Reference / transaction ID, Notes.
- [ ] Two modes: (a) **invoice-bound** (opened from an outstanding row) — header shows `INV-xxxx` + customer + "Outstanding: ₪x"; amount defaults to outstanding; on confirm calls `useRecordInvoicePayment()` (`POST /api/invoices/:id/payments`, body `{ amount, paidAt, source, reference?, note? }`). (b) **unmatched** (opened from **[+ Add unmatched payment]**) — no invoice; on confirm calls `useCreateUnmatchedPayment()` (`POST /api/reconcile/unmatched`).
- [ ] Validate amount > 0 and ≤ outstanding (invoice-bound mode) client-side; map server 4xx to inline errors.
- [ ] Map the UI method labels to the API enum values (`bank_transfer`,`credit_card`,`check`,`cash`,`other`); for invoice-bound recording, pass the corresponding spec-80 `source` (`manual` for non-bank methods, `bank_transfer` for bank transfer).
- [ ] Use `Form`, `FormField`, `FormLabel`, `Input`, `Select`, `Textarea`, `Button`; reduced-motion-aware open animation.
**Acceptance:**
- [ ] Invoice-bound confirm with amount == outstanding flips the invoice to `PAID` (verified via outstanding list disappearing on refetch).
- [ ] Unmatched confirm adds a row to the Unmatched pane.
- [ ] Method dropdown lists exactly the five methods; reference + notes optional.

### Task 8: Match-to-invoice modal
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/pages/invoices/MatchPaymentModal.tsx`
**Steps:**
- [ ] Build a `Dialog` titled "Match ₪{amount} from {payerName} to Invoice".
- [ ] Search input querying `useOutstandingInvoices({ search })`; render result rows (invoice number, customer, amount, status).
- [ ] Mark "exact match ★" on any row where `outstanding == unmatchedPayment.amount` (string-decimal compare); sort exact matches first.
- [ ] On **[Match & record payment]** call `useMatchUnmatchedPayment()` (`PATCH /api/reconcile/unmatched/:id/match`, body `{ invoice_id }`); on success close, toast, and let cache invalidation refresh both panes.
- [ ] Disable confirm until an invoice is selected; map 409 (already matched) / 422 (bad invoice status) to inline errors.
- [ ] Reduced-motion-aware; aria-modal dialog; results list has correct list/listitem roles.
**Acceptance:**
- [ ] Exact-balance invoice shows the ★ marker and floats to the top.
- [ ] Confirm records a payment and removes the receipt from the Unmatched pane.
- [ ] Concurrent double-match surfaces a 409 inline error, not a silent failure.

### Task 9: Sidebar navigation entry
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/src/components/nav/InvoicesNavSection.tsx` (or the app-shell sidebar config that owns the Invoices section)
**Steps:**
- [ ] Add **Reconcile** → `/invoices/reconcile` under the Invoices sidebar section, after **Approvals** and before **Receipts**, visible to `invoices:write`.
- [ ] Add **Receipts** → `/receipts` entry (owned by spec 179) visible to `invoices:read` if not already present, preserving the spec's ordering (All invoices, Approvals, Reconcile, Receipts, Drafts & Templates, Recurring).
**Acceptance:**
- [ ] **Reconcile** link appears for `invoices:write` users in the correct position and routes to the page.
- [ ] Hidden for users lacking `invoices:write`.

### Task 10: API integration tests
**Blocks:** —  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/test/reconcile.test.ts`
**Steps:**
- [ ] Test `GET /api/invoices/outstanding` filters to the four statuses and excludes fully-paid/draft/rejected; verifies `outstanding` math and sort options.
- [ ] Test the unmatched CRUD lifecycle: create → list (appears) → delete (gone); delete-after-match → 409.
- [ ] Test the match flow: exact-amount match flips invoice to `PAID`; partial match → `PARTIALLY_PAID`; unmatched row marked matched with `matched_by` set; audit row written.
- [ ] Test authz: 401 unauthenticated, 403 without `invoices:write`, 404 cross-tenant.
**Acceptance:**
- [ ] All tests pass against a Neon test branch; transaction rollback verified on forced payment-insert failure (no orphaned matched flag).
