# Invoice Receipt Document (קבלה) — Implementation Plan

**Spec:** docs/specs/2026-06-01-invoice-receipt-document.md  ·  **Slug:** invoice-receipt-document  ·  **Wave:** 12
**Depends on:** hebrew-locale-dates, invoice-pdf-customization, invoices-core, ita-einvoice, multi-currency, partial-payment-recording

## Goal
Israeli tax law mandates a legal **receipt (קבלה)** whenever a business receives money — a distinct document from the tax invoice (חשבונית מס). Today `invoices-core` issues only `invoice`/`proforma` and recording a payment produces only a confirmation email, not a legal receipt. This spec delivers two new legally-mandated document types — the standalone **קבלה** (issued against an already-issued invoice on payment) and the combined **חשבונית מס/קבלה** (single document that is both invoice and receipt for immediate-payment sales) — with their own gap-free per-type numbering, payment-method line detail (D120 structured fields), Hebrew RTL PDF, void lifecycle, a `/receipts` list + detail UI, and an invoice-detail Receipts tab.

## Architecture
- **New tables (`packages/db`):** `receipts`, `receipt_payment_lines`, `receipt_sequences`. The `invoice_payments.receipt_id` column already exists (created nullable by `partial-payment-recording`); this plan only wires its FK to the new `receipts` table.
- **System of record:** `invoices-core` is the native issuer of sequences; the receipt obligation is Zync's, not an external adapter's. Receipts link to `invoices(id)` (the paid/combined invoice) and to `invoice_payments(id)` per payment line.
- **Consumes upstream exactly:** `invoices` (id, `invoice_number`, `status`, `total`, `amount_paid`, `currency`), `invoice_payments` (id, `amount`, `paid_at`, `source`, `reference`), `customers` (id, name), `tenants`, `users`, `tenant_settings.exchange_rates` (multi-currency snapshot source) and `tenant_settings.invoice_pdf_*` columns (PDF customization). Reuses query/serializer/route conventions established by `invoices-core`: `tenantQuery`-bound handles, `serialize*` in `packages/db/src/serializers`, Hono groups behind `authMiddleware` → `requireModuleEnabled('invoices')` → `requirePermission`.
- **Atomicity:** Issue (standalone & combined) and void each run in a single DB transaction that also writes the audit record (per spec 28, `require-audit-in-transaction`) and updates the linked payment / invoice balance.
- **Number assignment:** sequential `receipt_number` is assigned only at ISSUE via a select-for-update increment of `receipt_sequences`, never in DRAFT; voided numbers are retained (gap-free).
- **PDF:** pure string-template Hebrew RTL renderer inside the API Worker (no puppeteer / no Browser Rendering binding), mirroring `invoices-core` html render; reuses `tenant_settings.invoice_pdf_*` config and Heebo `@font-face`. Output stored in R2 (`pdf_r2_key`) at issue, served signed via `GET /api/receipts/:id/pdf`.
- **UI (`apps/zync-app`):** `/receipts` list (DataTable + TanStack Query), `/receipts/:id` detail, and a Receipts tab on invoice detail; the payment-record modal (owned by `partial-payment-recording`) gains an "Issue receipt now" toggle and an invoice-editor "Mark paid + issue invoice-receipt" action.

## Tech Stack
- **DB:** Drizzle ORM, drizzle-kit, `@neondatabase/serverless` over Hyperdrive binding `DB`. Neon Postgres (NOT D1/SQLite). New schema in `packages/db/src/schema/receipts.ts`.
- **Types:** `packages/types` — `ReceiptObject`, `ReceiptPaymentLineObject`, `ReceiptDocType`, `ReceiptStatus`, `ReceiptPaymentMethod`.
- **API:** `apps/zync-api` (Hono on Cloudflare Workers). Bindings: `DB` (Hyperdrive→Neon), `STORAGE` (R2 for PDFs), `QUEUE` (email enqueue). Zod validation; `authMiddleware`, `requireModuleEnabled('invoices')`, `requirePermission('invoices:read'|'invoices:write')`.
- **App:** `apps/zync-app` (Vite + React). `DataTable`, `Badge`, `Dialog`, `DropdownMenu`, `Button`, `useDirection`, `formatCurrency`/`formatDate` from `hebrew-locale-dates`.
- **PDF:** string-template renderer in the API Worker; Heebo variable font from R2; `window.print()` on portal/public view.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 12a — schema & types | 1, 2 | `packages/db/src/schema/receipts.ts`, migration, `packages/types/src/receipts.ts` | 2 after 1 |
| 12b — query layer | 3 | `packages/db/src/queries/receipts.ts` | After 1 |
| 12c — serializers & sequence | 4 | `packages/db/src/serializers/receipt.ts`, `packages/db/src/queries/receipts.ts` | After 3 |
| 12d — PDF renderer | 5 | `apps/zync-api/src/receipts/render.ts` | After 4 |
| 12e — issue/void services | 6 | `apps/zync-api/src/receipts/service.ts` | After 4 (5 for PDF) |
| 12f — API routes | 7, 8 | `apps/zync-api/src/routes/receipts/*`, `apps/zync-api/src/app.ts` | 7 first, then 8 |
| 12g — UI list/detail/tab | 9, 10, 11 | `apps/zync-app/src/features/receipts/*` | 9 first, then 10–11 parallel |

## Tasks

### Task 1: Receipts schema + `invoice_payments` delta
**Blocks:** 2,3,4,5,6,7,8,9,10,11  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/receipts.ts`
- Modify: `packages/db/src/schema/index.ts` (export new tables), `packages/db/migrations/` (new SQL migration)
**Steps:**
- [ ] Define the three tables in Drizzle, mirroring the canonical DDL below. `invoice_payments.receipt_id` already exists (owned by `partial-payment-recording`); add the `.references(() => receipts.id)` FK wiring to the existing Drizzle column — do NOT redeclare the column.
- [ ] Add the migration SQL (raw) so it applies in order after `invoices-core` and `partial-payment-recording` migrations.
- [ ] Export `receipts`, `receiptPaymentLines`, `receiptSequences` from the schema index.
- [ ] Verify every FK is UUID→UUID; booleans none here; money is NUMERIC; timestamps TIMESTAMPTZ.
**Schema / Interfaces:**
```sql
CREATE TABLE receipts (
  id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id          UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  customer_id        UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
  doc_type           TEXT NOT NULL CHECK (doc_type IN ('receipt', 'invoice_receipt')),
                       -- 'receipt' = standalone קבלה; 'invoice_receipt' = combined חשבונית מס/קבלה
  receipt_number     TEXT,                 -- sequential; assigned at ISSUE (never in DRAFT)
  status             TEXT NOT NULL DEFAULT 'DRAFT'
                       CHECK (status IN ('DRAFT','ISSUED','VOIDED')),
  invoice_id         UUID REFERENCES invoices(id) ON DELETE RESTRICT,
                       -- standalone: paid invoice. invoice_receipt: combined invoice row (may be NULL per spec)
  currency           TEXT NOT NULL DEFAULT 'ILS',
  amount             NUMERIC(12,2) NOT NULL,            -- total received on this receipt
  ils_exchange_rate  NUMERIC(10,4),                     -- snapshot at ISSUE (multi-currency)
  amount_ils         NUMERIC(12,2),                     -- amount in ILS at ISSUE (IL law)
  issued_at          TIMESTAMPTZ,
  issued_by          UUID REFERENCES users(id) ON DELETE SET NULL,
  pdf_r2_key         TEXT,
  void_reason        TEXT,
  voided_at          TIMESTAMPTZ,
  voided_by          UUID REFERENCES users(id) ON DELETE SET NULL,
  created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_receipts_tenant ON receipts(tenant_id, status, issued_at);
CREATE INDEX idx_receipts_invoice ON receipts(invoice_id);

CREATE TABLE receipt_payment_lines (
  id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  receipt_id         UUID NOT NULL REFERENCES receipts(id) ON DELETE CASCADE,
  method             TEXT NOT NULL CHECK (method IN ('cash','bank_transfer','cheque','credit_card','other')),
  amount             NUMERIC(12,2) NOT NULL,
  cheque_number      TEXT,
  cheque_bank        TEXT,
  cheque_branch      TEXT,
  cheque_account     TEXT,
  cheque_due_date    DATE,
  card_last_four     TEXT,
  card_brand         TEXT,
  reference          TEXT,                 -- bank ref / confirmation no.
  invoice_payment_id UUID REFERENCES invoice_payments(id) ON DELETE SET NULL
);

CREATE TABLE receipt_sequences (
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  doc_type    TEXT NOT NULL CHECK (doc_type IN ('receipt','invoice_receipt')),
  prefix      TEXT NOT NULL DEFAULT '',
  next_number INTEGER NOT NULL DEFAULT 1,
  PRIMARY KEY (tenant_id, doc_type)
);

-- invoice_payments.receipt_id already exists (plain nullable UUID, created by
-- partial-payment-recording at wave 11). This plan only wires the FK to the new receipts table.
ALTER TABLE invoice_payments
  ADD CONSTRAINT invoice_payments_receipt_id_fkey
  FOREIGN KEY (receipt_id) REFERENCES receipts(id) ON DELETE SET NULL;
```
**Acceptance:**
- [ ] Migration applies cleanly on a Neon branch; three tables exist with the exact CHECK constraints above, and the `invoice_payments.receipt_id` FK to `receipts(id)` is wired (the column itself pre-exists from `partial-payment-recording`).
- [ ] All FKs reference UUID PKs; `gen_random_uuid()` default present on each PK.

### Task 2: Types in `@zync/types`
**Blocks:** 4,7,9,10,11  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/receipts.ts`
- Modify: `packages/types/src/index.ts` (export new types)
**Steps:**
- [ ] Define string-literal unions and serialized object shapes matching the schema enums and serializer output.
- [ ] Export from package index.
**Schema / Interfaces:**
```ts
export type ReceiptDocType = 'receipt' | 'invoice_receipt';
export type ReceiptStatus = 'DRAFT' | 'ISSUED' | 'VOIDED';
export type ReceiptPaymentMethod = 'cash' | 'bank_transfer' | 'cheque' | 'credit_card' | 'other';

export interface ReceiptPaymentLineObject {
  id: string;
  method: ReceiptPaymentMethod;
  amount: string;                 // fixed-precision decimal string
  chequeNumber: string | null;
  chequeBank: string | null;
  chequeBranch: string | null;
  chequeAccount: string | null;
  chequeDueDate: string | null;   // ISO date
  cardLastFour: string | null;
  cardBrand: string | null;
  reference: string | null;
  invoicePaymentId: string | null;
}

export interface ReceiptObject {
  id: string;
  docType: ReceiptDocType;
  receiptNumber: string | null;
  status: ReceiptStatus;
  customerId: string;
  customerName: string;
  invoiceId: string | null;
  invoiceNumber: string | null;   // joined from invoices for display/deep-link
  currency: string;
  amount: string;                 // fixed-precision decimal string
  ilsExchangeRate: string | null;
  amountIls: string | null;
  issuedAt: string | null;        // ISO 8601
  issuedByName: string | null;
  voidReason: string | null;
  voidedAt: string | null;
  voidedByName: string | null;
  createdAt: string;
  paymentLines: ReceiptPaymentLineObject[];
}
```
**Acceptance:**
- [ ] `import { ReceiptObject, ReceiptDocType, ReceiptStatus, ReceiptPaymentMethod, ReceiptPaymentLineObject } from '@zync/types'` resolves.

### Task 3: Query layer (`packages/db/src/queries/receipts.ts`)
**Blocks:** 4,6,7  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/receipts.ts`
- Modify: `packages/db/src/queries/index.ts`
**Steps:**
- [ ] `listReceipts(db, { docType?, status?, customerId?, from?, to?, cursor?, limit })`: tenant-scoped, joins `customers` (name) and `invoices` (`invoice_number`), default sort `issued_at DESC NULLS LAST, created_at DESC`; cursor pagination via `encodeCursor`/`decodeCursor`; clamp `limit ≤ 100` via `clampLimit`.
- [ ] `getReceiptWithLines(db, id)`: tenant-scoped row + `receipt_payment_lines` ordered by insert; joins customer name, invoice number, issuer/voider user names; returns `null` if outside tenant.
- [ ] `insertReceipt`, `insertReceiptPaymentLines`, `setReceiptIssued` (sets number/status/issued_*/pdf_r2_key/ils snapshot), `setReceiptVoided` (status/void_reason/voided_*).
- [ ] `nextReceiptNumber(db, docType, tx)`: inside a transaction, upsert default row if missing (prefix `REC-` / `TIR-` per doc_type), then atomically `UPDATE receipt_sequences SET next_number = next_number + 1 … RETURNING prefix, last_number` (Postgres serializes concurrent callers — no SELECT FOR UPDATE / TOCTOU); return `prefix || zero-padded number`.
- [ ] Every statement uses the `tenantQuery`-bound handle; no statement omits `tenant_id` (`no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```ts
export function listReceipts(db: Db, params: { docType?: ReceiptDocType; status?: ReceiptStatus; customerId?: string; from?: string; to?: string; cursor?: string; limit?: number }): Promise<{ rows: ReceiptRow[]; nextCursor: string | null }>;
export function getReceiptWithLines(db: Db, id: string): Promise<{ receipt: ReceiptRow; lines: ReceiptPaymentLineRow[] } | null>;
export function nextReceiptNumber(db: Db, docType: ReceiptDocType): Promise<string>; // call inside tx
```
**Acceptance:**
- [ ] Two concurrent `nextReceiptNumber` calls for the same `(tenant, doc_type)` never return the same number (verified with `FOR UPDATE`).
- [ ] `listReceipts` filters and cursor pagination return correct slices; no cross-tenant leakage.

### Task 4: Serializer + sequence helper
**Blocks:** 6,7  ·  **Blocked by:** 2,3
**Files:**
- Create: `packages/db/src/serializers/receipt.ts`
- Modify: `packages/db/src/serializers/index.ts`
**Steps:**
- [ ] `serializeReceiptPaymentLine(row) → ReceiptPaymentLineObject`: NUMERIC `amount` to fixed-precision decimal string; `cheque_due_date` to ISO date; pass-through nullable strings.
- [ ] `serializeReceipt(receiptRow, lines, joins) → ReceiptObject`: NUMERIC money fields to fixed-precision strings; timestamps to ISO 8601; map joined customer/invoice/user display names; nest serialized payment lines.
**Schema / Interfaces:**
```ts
export function serializeReceiptPaymentLine(row: ReceiptPaymentLineRow): ReceiptPaymentLineObject;
export function serializeReceipt(row: ReceiptRow, lines: ReceiptPaymentLineRow[]): ReceiptObject;
```
**Acceptance:**
- [ ] All NUMERIC columns serialize to strings (no float drift); DRAFT receipts serialize `receiptNumber: null`, `issuedAt: null`.

### Task 5: Receipt PDF renderer (Worker)
**Blocks:** 6  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/receipts/render.ts`, `apps/zync-api/src/receipts/template.ts`
**Steps:**
- [ ] Pure string-template Hebrew RTL renderer (no puppeteer, no Browser Rendering binding). Header label by `docType`: **קבלה** (`receipt`) / **חשבונית מס/קבלה** (`invoice_receipt`).
- [ ] Render: receipt number, issue date (`formatDate` he-IL per `tenant_settings.invoice_pdf_date_format`), customer block, business details, currency + `ils_exchange_rate` snapshot line (mandatory for non-ILS), originating invoice number, amount, **amount in words (Hebrew)**, and the `receipt_payment_lines` breakdown (method + amount + reference / cheque / card detail).
- [ ] Reuse `tenant_settings.invoice_pdf_*` config (layout/accent/date format) and embed Heebo via `@font-face` `src: url('{R2_PUBLIC_URL}/_static/fonts/heebo-variable.woff2')`, same as `invoices-core` render.
- [ ] Include `window.print()` hook for the portal/public view (`print-layouts`); honor `prefers-reduced-motion` (no animated print transitions).
- [ ] Implement `amountInWordsHe(amount, currency)` helper (Hebrew number-to-words) co-located in `template.ts`.
**Schema / Interfaces:**
```ts
export function renderReceiptHtml(receipt: ReceiptObject, ctx: { businessDetails: BusinessDetails; pdfConfig: TenantInvoicePdfConfig }): string;
```
**Acceptance:**
- [ ] For `doc_type='invoice_receipt'` the header reads "חשבונית מס/קבלה"; for `receipt` it reads "קבלה".
- [ ] Non-ILS receipt HTML contains the exchange-rate-at-issue line and the ILS amount; payment-method detail rows render per line.

### Task 6: Issue / void services (transactional)
**Blocks:** 7  ·  **Blocked by:** 3,4,5
**Files:**
- Create: `apps/zync-api/src/receipts/service.ts`
**Steps:**
- [ ] `issueStandaloneReceipt(db, env, { invoiceId, paymentInput, lines, actorId })` — single transaction (Flow A): (1) insert `invoice_payments` row + advance invoice status (`PARTIALLY_PAID`/`PAID`) reusing the `partial-payment-recording` balance recompute; (2) insert `receipts(doc_type='receipt', status='ISSUED')` with `receipt_number = nextReceiptNumber('receipt')`, snapshot `ils_exchange_rate`/`amount_ils` from `tenant_settings.exchange_rates`; (3) insert `receipt_payment_lines` from method detail; (4) set `invoice_payments.receipt_id`; (5) render+store PDF (`pdf_r2_key` in R2 `STORAGE`) and enqueue customer email via `QUEUE`; (6) write audit record in the same transaction.
- [ ] `issueInvoiceReceipt(db, env, { invoiceId, paymentInput, lines, actorId })` — single transaction (Flow B): transition the invoice to `TAX_ISSUED` (assign invoice number via `invoices-core` sequence path) AND insert `receipts(doc_type='invoice_receipt', status='ISSUED')` with number from the `invoice_receipt` sequence; invoice `type` stays `'invoice'`; record the payment + payment lines + audit atomically. PDF + email as in Flow A.
- [ ] `voidReceipt(db, env, { receiptId, reason, actorId })` — single transaction (Flow C): require `status='ISSUED'` and non-empty `reason`; set `status='VOIDED'`, `void_reason`, `voided_*`; reverse the linked `invoice_payments` row (recompute `amount_paid` + invoice status, re-opening balance); retain the receipt number (gap-free); write audit. Reject (409) if already DRAFT/VOIDED.
- [ ] PDF rendering uses `renderReceiptHtml`; persist to R2 keyed `receipts/{tenant_id}/{receipt_id}.html`.
**Schema / Interfaces:**
```ts
export function issueStandaloneReceipt(db: Db, env: Env, input: IssueReceiptInput): Promise<ReceiptObject>;
export function issueInvoiceReceipt(db: Db, env: Env, input: IssueReceiptInput): Promise<ReceiptObject>;
export function voidReceipt(db: Db, env: Env, input: { receiptId: string; reason: string; actorId: string }): Promise<ReceiptObject>;
```
**Acceptance:**
- [ ] Issuing a standalone receipt sets `invoice_payments.receipt_id` and advances the invoice balance/status in the same transaction; a failure at any step rolls back all of it (no orphan number, no half-issued receipt).
- [ ] Voiding re-opens the invoice balance and reverses the payment atomically; the number is retained.
- [ ] Every issue/void path writes an audit row inside the transaction (`require-audit-in-transaction`).

### Task 7: API routes (`/api/receipts`, invoice-scoped issue)
**Blocks:** 8,9,10,11  ·  **Blocked by:** 4,6
**Files:**
- Create: `apps/zync-api/src/routes/receipts/index.ts`
- Modify: `apps/zync-api/src/app.ts` (mount group), `apps/zync-api/src/routes/invoices/index.ts` (add issue sub-routes)
**Steps:**
- [ ] Mount `/api/receipts` behind `authMiddleware` → `requireModuleEnabled('invoices')`. Zod-validate every input (`require-zod-validation-in-routes`).
- [ ] `POST /api/invoices/:id/receipts` (`invoices:write`): body `{ payment: { amount, paidAt, currency?, reference?, note? }, lines: ReceiptPaymentLine[] }` → `issueStandaloneReceipt`; returns serialized `ReceiptObject`.
- [ ] `POST /api/invoices/:id/invoice-receipt` (`invoices:write`): body `{ payment, lines }` → `issueInvoiceReceipt`.
- [ ] `GET /api/receipts` (`invoices:read`): Zod-parse `doc_type`, `status`, `customer`, date range (`from`,`to`), `cursor`, `limit` → `listReceipts` → `buildPaginated`.
- [ ] `GET /api/receipts/:id` (`invoices:read`): `getReceiptWithLines` → `serializeReceipt`; 404 if not in tenant. Include PDF url.
- [ ] `GET /api/receipts/:id/pdf` (`invoices:read`): serve signed PDF/HTML from R2 (`pdf_r2_key`) via short-lived signed token; render on the fly for DRAFT if `pdf_r2_key` null.
- [ ] `POST /api/receipts/:id/void` (`invoices:write`): Zod `{ reason: string (min 1) }` → `voidReceipt`; 409 if not ISSUED.
- [ ] Use `timingSafeEqual` for any signed-token comparison on the PDF route; set CSP/security headers consistent with other API routes.
**Schema / Interfaces:**
```
POST   /api/invoices/:id/receipts           → issue standalone receipt        (invoices:write)
POST   /api/invoices/:id/invoice-receipt    → issue combined חשבונית מס/קבלה  (invoices:write)
GET    /api/receipts                         → list (doc_type,status,customer,from,to,cursor,limit)  (invoices:read)
GET    /api/receipts/:id                     → detail + payment lines + pdf url (invoices:read)
GET    /api/receipts/:id/pdf                 → signed PDF                       (invoices:read)
POST   /api/receipts/:id/void                → void (reason required)          (invoices:write)
```
**Acceptance:**
- [ ] Each route enforces the stated permission; unauthenticated/insufficient-scope requests get 401/403.
- [ ] Issue returns 200 with serialized receipt incl. assigned `receiptNumber`; void on a DRAFT/VOIDED returns 409.
- [ ] PDF signed-token comparison uses `timingSafeEqual` (`no-string-equality-for-tokens`).

### Task 8: Route registry + sidebar registration
**Blocks:** 9  ·  **Blocked by:** 7
**Files:**
- Modify: route registry module (`apps/zync-app/src/routes/registry.ts` or equivalent), invoices sidebar config
**Steps:**
- [ ] Register `/receipts` and `/receipts/:id` in the app route registry (owned by spec 179) with `invoices:read` guard and `requireModuleEnabled('invoices')`.
- [ ] Add the **Receipts** link under the invoices sidebar group (sidebar defined by `payment-reconciliation`, spec 157).
**Acceptance:**
- [ ] `/receipts` appears in the route registry and renders the list page; sidebar link navigates to it.

### Task 9: Receipts list page (`/receipts`)
**Blocks:** —  ·  **Blocked by:** 7,8
**Files:**
- Create: `apps/zync-app/src/features/receipts/ReceiptsListPage.tsx`, `apps/zync-app/src/features/receipts/api.ts` (`useReceiptList` hook)
**Steps:**
- [ ] `useReceiptList` (TanStack Query) → `GET /api/receipts` with filters `doc_type`, `status`, `customer`, date range; cursor pagination.
- [ ] `DataTable` columns: `receiptNumber` (em-dash when DRAFT), `docType` Hebrew label (קבלה / חשבונית מס/קבלה), customer name, date (`issuedAt` or `createdAt` for drafts via `formatDate`), `amount` (`formatCurrency`), status `Badge` (DRAFT grey / ISSUED green / VOIDED struck-through red). Default sort `issued_at DESC`.
- [ ] Filter controls: Type, Status, Customer search, Date range. `[Export ▾]` reuses the standard list-export menu (PDF list / CSV).
- [ ] Row click → `/receipts/:id`. RTL-correct via `useDirection`; respect `prefers-reduced-motion`; `aria` table semantics on `DataTable`.
**Acceptance:**
- [ ] Filters map 1:1 to query params and update the list; DRAFT rows show `—` for number; VOIDED rows render struck-through red badge.
- [ ] Page renders correctly in RTL (Hebrew) layout.

### Task 10: Receipt detail page (`/receipts/:id`)
**Blocks:** —  ·  **Blocked by:** 7,8
**Files:**
- Create: `apps/zync-app/src/features/receipts/ReceiptDetailPage.tsx`
**Steps:**
- [ ] `useReceipt(id)` → `GET /api/receipts/:id`. Read-only document view.
- [ ] Header: Hebrew doc-type label + number, status badge, "Issued {date} by {name}" metadata. Customer block; "For invoice #… →" deep-link to invoice detail; currency + `ilsExchangeRate` snapshot.
- [ ] Payment-lines table: method, amount, reference / cheque / card detail; total received row.
- [ ] `[Download PDF]` → `GET /api/receipts/:id/pdf`. `⋯` menu shows **[Void receipt]** only when `status==='ISSUED'` → confirm `Dialog` requiring a reason → `POST /api/receipts/:id/void`; hidden on DRAFT/VOIDED.
- [ ] VOIDED receipts show a banner: "Voided {date} by {name} — {reason}".
- [ ] `aria` roles on the menu/dialog; reduced-motion respected; RTL layout via `useDirection`.
**Acceptance:**
- [ ] Void action visible only on ISSUED; reason is required and the call re-renders the receipt as VOIDED with the banner.
- [ ] Deep-link to the originating invoice works; PDF download succeeds.

### Task 11: Invoice-detail Receipts tab + payment-modal/editor hooks
**Blocks:** —  ·  **Blocked by:** 7,8
**Files:**
- Modify: `apps/zync-app/src/features/invoices/InvoiceDetailPage.tsx` (add Receipts tab), the payment-record modal (owned by `partial-payment-recording`), invoice editor actions
**Steps:**
- [ ] Add a **Receipts** tab on invoice detail listing this invoice's receipts (filtered `GET /api/receipts` by invoice via a per-invoice query), each row rendering from the same `GET /api/receipts/:id` view; link to `/receipts/:id`.
- [ ] In the payment-record modal add an **"Issue receipt (קבלה) now"** toggle (default ON) and method-detail fields (method select + reference/cheque/card inputs); on confirm with toggle ON call `POST /api/invoices/:id/receipts`, else the existing payment-only flow.
- [ ] On the invoice editor (DRAFT/SENT invoices) add **"Mark paid + issue invoice-receipt"** → `POST /api/invoices/:id/invoice-receipt`.
- [ ] Flag payments recorded without an issued receipt in the UI (`invoice_payments.receipt_id IS NULL`).
**Acceptance:**
- [ ] Recording a payment with the toggle ON issues a standalone receipt and links it (`receipt_id` set); a payment without a receipt is visibly flagged.
- [ ] "Mark paid + issue invoice-receipt" on a DRAFT/SENT invoice produces a `TAX_ISSUED` invoice and a paired `invoice_receipt` receipt atomically.
