# Invoices: Core — Implementation Plan

**Spec:** docs/specs/2026-05-30-invoices-core.md  ·  **Slug:** invoices-core  ·  **Wave:** 6
**Depends on:** customers-module, foundation-auth-rbac, projects-module, system-i18n, time-management

## Goal
Deliver the Israeli-tax-law-compliant invoice lifecycle: the two-stage חשבונית עסקה (proforma) → חשבונית מס (tax invoice) flow with gap-free sequential numbering assigned atomically at state transitions, point-in-time VAT stamping from `vat_rates`, credit notes, void, and locale-correct (Hebrew/RTL) HTML invoice rendering with an immutable R2 snapshot on tax issue. It exposes the `/api/invoices` route group (list, CRUD, state transitions, HTML render, unbilled-time data source, internal auto-issue), the `@zync/db` invoice query layer, and the React invoice list/detail/create UI. This is the data backbone consumed by invoices-adapters, billing-module, recurring-invoices, contractor-payouts, reports, and tenant-portals.

## Architecture
Three new tables in `packages/db`: `invoices`, `invoice_lines`, `invoice_sequences`. All FKs are UUID→UUID against upstream `tenants(id)`, `customers(id)` (customers-module), `projects(id)` (projects-module), and `users(id)` (foundation-auth-rbac). VAT is read via `getVatRate(db, countryCode, date)` from system-i18n against the seeded `vat_rates` table and stamped immutably onto `invoices.vat_rate` at `DRAFT→SENT`. Time-entry billing reads `time_entries` (time-management) where `invoice_id IS NULL`; the `time_entries.invoice_id` / `billed_at` columns and the full selection UX are **owned by spec 77 (time-to-invoice)** — this spec only provides the `GET /api/invoices/unbilled-time` read endpoint and consumes the inbound `from_time` pre-fill.

Every query goes through the `tenantQuery` factory (tenant-scoped) and the API mounts under auth middleware → `requireModuleEnabled('invoices')` → `requirePermission('invoices:*')`. Cursor pagination uses upstream `encodeCursor`/`decodeCursor`/`buildPaginated`/`clampLimit`. Serialization to the public shape uses `serializeInvoice`/`serializeInvoiceLine` matching the locked `InvoiceObject`/`InvoiceLineObject`/`InvoiceStatus` types (defined in tenant-public-api, re-exported from `@zync/types`). HTML rendering is a string template rendered in-Worker (no Browser Rendering binding); the tax-issued snapshot is written to the R2 `STORAGE` binding.

Sequential numbering is the legal crux: `nextInvoiceNumber` runs `UPDATE invoice_sequences SET last_number = last_number + 1 ... RETURNING` inside the **same transaction** as the status update, guaranteeing gap-free, no-double-assignment numbering.

## Tech Stack
- **DB (`packages/db`):** Drizzle ORM, drizzle-kit, `@neondatabase/serverless` over Hyperdrive binding `DB`. New schema files + `queries/invoices.ts`.
- **Types (`packages/types`):** `InvoiceStatus`, `InvoiceObject`, `InvoiceLineObject`, `InvoiceSource` (already locked upstream; this spec ensures the runtime serializers conform).
- **API (`apps/zync-api`):** Hono route group at `/api/invoices`; uses `authMiddleware`, `requireModuleEnabled`, `requirePermission`, `createDb`, `tenantQuery`, Zod validation (`require-zod-validation-in-routes`).
- **Bindings:** `DB` (Hyperdrive→Neon), `STORAGE` (R2 for HTML snapshots + Heebo font), `QUEUE` (for `invoice.generate` automated-generation job).
- **App (`apps/zync-app`, Vite+React):** `/invoices`, `/invoices/:id`, `/invoices/new` routes; `DataTable` + TanStack Virtual; create/edit `Sheet`; TanStack Query hooks.
- **i18n:** `getVatRate`, `tenants.locale`, `Intl.NumberFormat`/`Intl.DateTimeFormat`, RTL/`dir` handling, Heebo font embed.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 6a — schema | 1, 2 | `packages/db/src/schema/invoices.ts`, migrations, `packages/types` | No (2 after 1) |
| 6b — query layer | 3, 4 | `packages/db/src/queries/invoices.ts` | No (after 1–2) |
| 6c — serializers + HTML | 5, 6 | `packages/db/src/serializers`, `apps/zync-api/src/invoices/html.ts` | Yes (parallel after 3) |
| 6d — API routes | 7, 8, 9, 10 | `apps/zync-api/src/routes/invoices/*` | 7 first, then 8–10 parallel |
| 6e — automated generation | 11 | `apps/zync-api/src/jobs/invoice-generate.ts` | After 7 |
| 6f — UI | 12, 13, 14 | `apps/zync-app/src/features/invoices/*` | 12 first, then 13–14 parallel |
| 6g — verification | 15 | tests across packages | Last |

## Tasks

### Task 1: Invoice schema (`invoices`, `invoice_lines`, `invoice_sequences`)
**Blocks:** 2, 3, 4, 5, 7  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/invoices.ts`
- Modify: `packages/db/src/schema/index.ts` (export new tables)
**Steps:**
- [ ] Define the three Drizzle tables in canonical Postgres (DDL below). Use `uuid().primaryKey().defaultRandom()` for `id`; all FKs `uuid().references(() => <table>.id)`.
- [ ] Add the `invoices_status_check`, `invoices_source_check`, and `invoice_sequences.type` CHECK constraints inline matching the spec verbatim.
- [ ] Add indexes: `invoices (tenant_id, created_at, id)` for cursor pagination; `invoices (tenant_id, status)`; `invoices (tenant_id, customer_id)`; `invoices (tenant_id, project_id)`; partial unique `invoices (tenant_id, invoice_number) WHERE invoice_number IS NOT NULL`; partial unique `invoices (tenant_id, proforma_number) WHERE proforma_number IS NOT NULL`; `invoice_lines (invoice_id, position)`.
- [ ] `invoice_sequences` composite PK `(tenant_id, type)`.
- [ ] Do NOT add an `invoice_id` column to `time_entries` here — owned by spec 77.
**Schema / Interfaces:**
```sql
CREATE TABLE invoices (
  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),
  project_id         UUID REFERENCES projects(id),
  parent_invoice_id  UUID REFERENCES invoices(id),          -- credit-note linkage
  invoice_number     TEXT,                                  -- NULL until TAX_ISSUED; sequential, gap-free
  proforma_number    TEXT,                                  -- assigned at SENT
  status             TEXT NOT NULL DEFAULT 'DRAFT',
  currency           TEXT NOT NULL DEFAULT 'ILS',
  issue_date         DATE,                                  -- date חשבונית עסקה sent; VAT-stamp moment
  tax_issue_date     DATE,                                  -- date חשבונית מס issued
  due_date           DATE,
  vat_rate           NUMERIC(5,4),                          -- stamped at issue time from vat_rates; immutable
  subtotal           NUMERIC(12,2) NOT NULL DEFAULT 0,
  vat_amount         NUMERIC(12,2) NOT NULL DEFAULT 0,
  total              NUMERIC(12,2) NOT NULL DEFAULT 0,
  amount_paid        NUMERIC(12,2) NOT NULL DEFAULT 0,        -- denormalized paid total, OWNED HERE
                                                              -- (base invoice state; project-analytics
                                                              -- reads it, partial-payment-recording maintains it)
  notes              TEXT,
  source             TEXT NOT NULL DEFAULT 'manual',
  void_reason        TEXT,                                  -- populated when status = 'VOID'
  voided_at          TIMESTAMPTZ,
  voided_by          UUID REFERENCES users(id),
  sent_at            TIMESTAMPTZ,
  approved_at        TIMESTAMPTZ,
  tax_issued_at      TIMESTAMPTZ,
  paid_at            TIMESTAMPTZ,
  external_id        TEXT,                                  -- ID in adapter system (Morning, iCount, ...)
  external_provider  TEXT,                                  -- 'morning' | 'icount' | 'rivhit' | ...
  created_by         UUID NOT NULL REFERENCES users(id),
  created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT invoices_status_check CHECK (
    status IN ('DRAFT','SENT','APPROVED','REJECTED','TAX_ISSUED',
               'PAID','PARTIALLY_PAID','VOID','WRITTEN_OFF','BAD_DEBT')
  ),
  CONSTRAINT invoices_source_check CHECK (
    source IN ('manual','retainer','hourly_auto','fixed_deposit','credit_note','auto_charge')
  )
);
-- NOTE: invoices.amount_paid (and PARTIALLY_PAID transition) is owned by spec 80 (partial-payment-recording).
-- NOTE: WRITTEN_OFF / BAD_DEBT collection-outcome transitions are owned by spec 168 (bad-debt-writeoff);
--       the enum value is reserved here so the CHECK does not reject them.

CREATE TABLE invoice_lines (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  invoice_id   UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
  tenant_id    UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  description  TEXT NOT NULL,
  quantity     NUMERIC(10,3) NOT NULL DEFAULT 1,
  unit_price   NUMERIC(12,2) NOT NULL,
  discount_pct NUMERIC(5,2) NOT NULL DEFAULT 0,           -- 0..100
  line_total   NUMERIC(12,2) NOT NULL,                    -- quantity * unit_price * (1 - discount_pct/100)
  taxable      BOOLEAN NOT NULL DEFAULT true,
  position     INTEGER NOT NULL                           -- display order
);

CREATE TABLE invoice_sequences (
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  type        TEXT NOT NULL,                              -- 'invoice' | 'proforma'
  last_number INTEGER NOT NULL DEFAULT 0,
  prefix      TEXT NOT NULL DEFAULT '',                   -- e.g. 'INV-' or '2025-'
  PRIMARY KEY (tenant_id, type),
  CONSTRAINT invoice_sequences_type_check CHECK (type IN ('invoice','proforma'))
);

-- Tenant invoice-creation default, OWNED HERE (invoices-core is the invoice domain owner and
-- the earliest invoice module, wave 6). The base tenant_settings table (id UUID PK,
-- tenant_id UUID UNIQUE → tenants(id), timestamps) is owned by foundation-auth-rbac; this is an
-- additive, idempotent ALTER. Every invoice-creation flow reads it via tenantQuery
-- (bulk-invoice-generation w7, proposal-to-invoice-direct w11, ar-aging-report w11,
-- customer-statement w16); invoice-settings-page (w10) is the editor, not the owner.
ALTER TABLE tenant_settings
  ADD COLUMN IF NOT EXISTS default_payment_terms_days INTEGER NOT NULL DEFAULT 30;
```
**Acceptance:**
- [ ] `drizzle-kit generate` emits Postgres DDL (UUID PKs, TIMESTAMPTZ, BOOLEAN, NUMERIC, JSONB-none) — no SQLite types.
- [ ] All CHECK constraints and partial-unique indexes present in the generated migration.
- [ ] `tenant_settings.default_payment_terms_days INTEGER NOT NULL DEFAULT 30` added idempotently (owned here); the `tenant_settings` base table itself is NOT created here.
- [ ] No `time_entries` alteration in this migration.

### Task 2: Confirm/align invoice types in `@zync/types`
**Blocks:** 5, 7, 12  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/types/src/invoices.ts` (create if absent), `packages/types/src/index.ts`
**Steps:**
- [ ] Ensure `InvoiceStatus`, `InvoiceObject`, `InvoiceLineObject` exist with the exact shape from tenant-public-api (transcribed below) and are exported. If already declared upstream, import-and-re-export; do not duplicate-declare.
- [ ] Add `InvoiceSource` union and `InvoiceListResponse` for the internal `/api/invoices` (cursor) endpoint.
- [ ] Monetary fields are decimal strings (avoid float precision loss).
**Schema / Interfaces:**
```ts
export type InvoiceStatus =
  | 'DRAFT' | 'SENT' | 'APPROVED' | 'REJECTED' | 'TAX_ISSUED'
  | 'PAID' | 'PARTIALLY_PAID' | 'VOID' | 'WRITTEN_OFF' | 'BAD_DEBT';

export type InvoiceSource =
  | 'manual' | 'retainer' | 'hourly_auto' | 'fixed_deposit' | 'credit_note' | 'auto_charge';

export interface InvoiceLineObject {
  id: string;
  description: string;
  quantity: string;       // decimal string
  unit_price: string;     // decimal string
  discount_pct: string;   // e.g. "0.00"
  line_total: string;
  taxable: boolean;
  position: number;
}

export interface InvoiceObject {
  id: string;
  customer_id: string;
  project_id: string | null;
  invoice_number: string | null;
  proforma_number: string | null;
  status: InvoiceStatus;
  currency: string;
  issue_date: string | null;
  tax_issue_date: string | null;
  due_date: string | null;
  vat_rate: string | null;
  subtotal: string;
  vat_amount: string;
  total: string;
  notes: string | null;
  source: InvoiceSource;
  paid_at: string | null;
  lines: InvoiceLineObject[];
  created_at: string;
  updated_at: string;
}

export interface InvoiceListResponse {
  items: InvoiceObject[];
  nextCursor: string | null;   // null = last page
  total: number;
}
```
**Acceptance:**
- [ ] `tsc` passes; no duplicate symbol errors with upstream public-api types.

### Task 3: Query layer — list, detail, CRUD (`queries/invoices.ts`)
**Blocks:** 5, 7, 8  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/invoices.ts`
- Modify: `packages/db/src/queries/index.ts`
**Steps:**
- [ ] All functions take a `tenantQuery`-bound db handle; no statement omits `tenant_id` (`no-raw-drizzle-from-routes`).
- [ ] `listInvoices(db, { tenantId, limit, cursor, filters })`: cursor pagination via `decodeCursor`/`encodeCursor` (cursor = base64 JSON `{ id, createdAt }`). Filters: `status`, `customerId`, `projectId`, `fromDate`/`toDate` (on `created_at`), `source`. Clamp limit with `clampLimit` to **max 100**. Returns rows + `nextCursor` + `total` count.
- [ ] `getInvoiceWithLines(db, tenantId, id)`: invoice + ordered `invoice_lines`. Returns `null` if not found in tenant.
- [ ] `createInvoiceDraft(db, tenantId, createdBy, input)`: insert `invoices` (status `DRAFT`, no number) + `invoice_lines` in one transaction; compute each `line_total = quantity * unit_price * (1 - discount_pct/100)`; compute `subtotal = sum(line_total)`; `vat_amount = 0`, `total = subtotal` at draft time. Auto-assign `position` when omitted.
- [ ] `updateInvoiceDraft(db, tenantId, id, input)`: only when `status='DRAFT'`; replace lines, recompute totals; bump `updated_at`.
- [ ] `deleteInvoiceDraft(db, tenantId, id)`: only when `status='DRAFT'`.
- [ ] `recomputeTotals(lines, vatRate)`: pure helper returning `{ subtotal, vatAmount, total }`; VAT only on `taxable` lines.
**Schema / Interfaces:**
```ts
export interface InvoiceFilters {
  status?: InvoiceStatus;
  customerId?: string;
  projectId?: string;
  source?: InvoiceSource;
  fromDate?: string;   // ISO date, on created_at
  toDate?: string;
}
export function listInvoices(
  db: Db, args: { tenantId: string; limit: number; cursor?: string | null; filters?: InvoiceFilters }
): Promise<{ items: InvoiceRow[]; nextCursor: string | null; total: number }>;
export function getInvoiceWithLines(db: Db, tenantId: string, id: string): Promise<InvoiceWithLines | null>;
export function createInvoiceDraft(db: Db, tenantId: string, createdBy: string, input: CreateInvoiceInput): Promise<InvoiceWithLines>;
export function updateInvoiceDraft(db: Db, tenantId: string, id: string, input: UpdateInvoiceInput): Promise<InvoiceWithLines>;
export function deleteInvoiceDraft(db: Db, tenantId: string, id: string): Promise<void>;
```
**Acceptance:**
- [ ] List never returns >100 rows; `nextCursor` is stable under concurrent inserts (keyset on `(created_at, id)`).
- [ ] Draft mutations reject when status ≠ `DRAFT`.

### Task 4: Query layer — sequential numbering & state transitions
**Blocks:** 7, 9, 11  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/db/src/queries/invoices.ts`
**Steps:**
- [ ] `nextInvoiceNumber(tx, tenantId, type)`: inside a caller-provided transaction, `UPDATE invoice_sequences SET last_number = last_number + 1 WHERE tenant_id=? AND type=? RETURNING prefix, last_number`. If no row exists, insert `(tenant_id, type, last_number=1, prefix='')` and return 1. Return `prefix + padded(last_number, 5)` e.g. `INV-00042`.
- [ ] `sendInvoice(db, tenantId, id, ctx)`: single transaction — assert `status='DRAFT'`; look up VAT via `getVatRate(db, ctx.countryCode, issueDate)`; stamp `vat_rate`, recompute `vat_amount`/`total`; assign `proforma_number = nextInvoiceNumber(tx, tenantId, 'proforma')`; set `status='SENT'`, `issue_date=today`, `sent_at=now()`. Number assignment and status update in the **same tx** (atomicity requirement).
- [ ] `approveInvoice` (`SENT→APPROVED`, set `approved_at`), `rejectInvoice` (`SENT→REJECTED`, store reason), `reopenInvoice` (`REJECTED→DRAFT`).
- [ ] `issueTaxInvoice(db, tenantId, id)`: single transaction — assert `status='APPROVED'`; assign `invoice_number = nextInvoiceNumber(tx, tenantId, 'invoice')`; set `status='TAX_ISSUED'`, `tax_issue_date=today`, `tax_issued_at=now()`. Immutable thereafter. If an invoice line maps to a tracked product with `stock_item_id`, post one inventory commit per line inside the same transaction before any adapter queue enqueue.
- [ ] `recordPayment(db, tenantId, id, { method, amount, date })`: `TAX_ISSUED→PAID` (sets `paid_at`); leaves PARTIALLY_PAID computation to spec 80 hook (call `markPaid` only when full).
- [ ] `voidInvoice(db, tenantId, id, { reason, userId })`: assert `status IN ('DRAFT','SENT')`; reverse any tracked-product inventory movement inside the same transaction; set `status='VOID'`, `void_reason`, `voided_at=now()`, `voided_by=userId`. Throw a typed `InvalidTransitionError` (→ 409) if `TAX_ISSUED` or later.
- [ ] `createCreditNote(db, tenantId, parentId, ctx)`: clone a `TAX_ISSUED` parent into a new invoice with `source='credit_note'`, `parent_invoice_id=parentId`, negated line totals/subtotal/vat/total, then immediately tax-issue it (assigns its own gap-free `invoice_number`).
- [ ] `assertTransition(from, to)`: enforce the legal state machine; reject illegal transitions with `InvalidTransitionError`.
**Schema / Interfaces:**
```ts
export async function nextInvoiceNumber(tx: DbTx, tenantId: string, type: 'invoice' | 'proforma'): Promise<string>;
export function sendInvoice(db: Db, tenantId: string, id: string, ctx: { countryCode: string }): Promise<InvoiceWithLines>;
export function issueTaxInvoice(db: Db, tenantId: string, id: string): Promise<InvoiceWithLines>;
export function voidInvoice(db: Db, tenantId: string, id: string, args: { reason: string; userId: string }): Promise<InvoiceWithLines>;
export function createCreditNote(db: Db, tenantId: string, parentId: string, ctx: { userId: string }): Promise<InvoiceWithLines>;
export class InvalidTransitionError extends Error { from: InvoiceStatus; to: InvoiceStatus; }
```
**Acceptance:**
- [ ] Number assignment + status update share one DB transaction (no gap / double-assignment).
- [ ] Voiding a `TAX_ISSUED` invoice throws `InvalidTransitionError`.
- [ ] Credit note has negative totals and its own sequential `invoice_number`.

### Task 5: Serializers (`serializeInvoice`, `serializeInvoiceLine`)
**Blocks:** 7, 8  ·  **Blocked by:** 2, 3
**Files:**
- Create: `packages/db/src/serializers/invoice.ts`
- Modify: `packages/db/src/serializers/index.ts`
**Steps:**
- [ ] `serializeInvoiceLine(row)` → `InvoiceLineObject`: NUMERIC columns to fixed-precision decimal strings (`quantity` 3dp, `unit_price`/`line_total` 2dp, `discount_pct` 2dp).
- [ ] `serializeInvoice(row, lines)` → `InvoiceObject`: dates to ISO date strings, timestamps to ISO 8601, `vat_rate` as 4dp decimal string or null, monetary fields as 2dp decimal strings, `lines` ordered by `position`.
- [ ] Never emit floats; format from the DB NUMERIC string representation.
**Schema / Interfaces:**
```ts
export function serializeInvoiceLine(row: InvoiceLineRow): InvoiceLineObject;
export function serializeInvoice(row: InvoiceRow, lines: InvoiceLineRow[]): InvoiceObject;
```
**Acceptance:**
- [ ] Output validates against the `InvoiceObject`/`InvoiceLineObject` types from Task 2.
- [ ] `vat_rate` renders as `"0.1800"`-style string; monetary fields as 2-decimal strings.

### Task 6: Invoice HTML rendering + R2 snapshot (locale-aware, RTL)
**Blocks:** 9  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/invoices/html-template.ts`
- Create: `apps/zync-api/src/invoices/render.ts`
**Steps:**
- [ ] Pure string-template renderer in the Worker (no puppeteer, no Browser Rendering binding). Inputs: invoice + lines + tenant business details (name, business ID ח.פ./ע.מ., logo, address) + customer details.
- [ ] Determine locale from `tenants.locale`: Hebrew → `<html dir="rtl" lang="he">` and `he-IL`; else `<html dir="ltr" lang="en">` and `en-US`.
- [ ] Format money with `new Intl.NumberFormat(locale, { style:'currency', currency: invoice.currency })`; dates with `new Intl.DateTimeFormat(locale, { dateStyle:'long' })`.
- [ ] Embed Heebo via `@font-face` in print CSS, `src: url('{R2_PUBLIC_URL}/_static/fonts/heebo-variable.woff2')`, `font-display: block`; `body { font-family:'Heebo', Arial, sans-serif }`. Worker fetches font once at cold start, caches in module memory.
- [ ] RTL table: `dir="rtl"` on `<table>` for Hebrew; logical column order (description first), `dir` handles visual reversal. Include business name, business ID, customer details, issue date, sequential number, line items with VAT, total inc. VAT, VAT rate (IL legal fields).
- [ ] `snapshotToR2(env, invoice, html, lang)`: on `TAX_ISSUED`, write immutable HTML to R2 `STORAGE` at key `{tenantId}/invoices/{invoiceId}/tax-invoice-{number}-{lang}.html`.
- [ ] Render response served with `Content-Type: text/html` and `Content-Disposition: inline; filename="invoice-{number}.html"`. Preserve the app CSP — no inline event handlers; print CSS in a `<style>` block (style-src allows it).
**Schema / Interfaces:**
```ts
export function renderInvoiceHtml(args: { invoice: InvoiceRow; lines: InvoiceLineRow[]; tenant: TenantBilling; customer: CustomerObject; locale: 'he-IL' | 'en-US' }): string;
export function invoiceSnapshotKey(tenantId: string, invoiceId: string, type: 'tax-invoice' | 'proforma', number: string, lang: 'he' | 'en'): string;
```
**Acceptance:**
- [ ] Hebrew tenant output has `dir="rtl" lang="he"`, Heebo embedded, currency/date in `he-IL`.
- [ ] On tax issue, HTML snapshot exists in R2 under the lang-tagged key.
- [ ] No Browser Rendering binding referenced anywhere.

### Task 7: API route group scaffold + list/detail/CRUD
**Blocks:** 8, 9, 10, 11  ·  **Blocked by:** 2, 3, 5
**Files:**
- Create: `apps/zync-api/src/routes/invoices/index.ts`
- Modify: `apps/zync-api/src/app.ts` (mount `/api/invoices`)
**Steps:**
- [ ] Mount the Hono group at `/api/invoices` behind `authMiddleware` → `requireModuleEnabled('invoices')`. Each route then runs `requirePermission(...)` per the permission table.
- [ ] `GET /api/invoices` (`invoices:read`): Zod-parse `cursor`, `limit` (clamp ≤100), `status`, `customer`, `project`, `date_from`, `date_to`, `source`; call `listInvoices`; return `InvoiceListResponse` (`{ items, nextCursor, total }`).
- [ ] `POST /api/invoices` (`invoices:write`): Zod-validate `CreateInvoiceBody`; verify `customer_id` (and `project_id` if present) belong to tenant (404 if not); ≥1 line (422 if empty); `createInvoiceDraft`; return `201 InvoiceObject`.
- [ ] `GET /api/invoices/:id` (`invoices:read`): `getInvoiceWithLines` → `serializeInvoice`; 404 if not in tenant.
- [ ] `PATCH /api/invoices/:id` (`invoices:write`): `updateInvoiceDraft`; 409 if not `DRAFT`.
- [ ] `DELETE /api/invoices/:id` (`invoices:delete`): `deleteInvoiceDraft`; 409 if not `DRAFT`.
- [ ] All routes use `require-zod-validation-in-routes`; no raw Drizzle in routes (`no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```ts
// CreateInvoiceBody (zod): { customer_id, project_id?, due_date?, currency?, notes?, lines: [{ description, quantity?, unit_price, discount_pct?, taxable?, position? }] (>=1) }
// Routes registered: GET /api/invoices, POST /api/invoices, GET /api/invoices/:id, PATCH /api/invoices/:id, DELETE /api/invoices/:id
```
**Acceptance:**
- [ ] Unauthenticated → 401; missing permission → 403; module disabled → 403/404 per middleware.
- [ ] Empty lines → 422; foreign customer → 404; list capped at 100.

### Task 8: API — state-transition routes
**Blocks:** 12  ·  **Blocked by:** 4, 5, 7
**Files:**
- Create: `apps/zync-api/src/routes/invoices/transitions.ts`
**Steps:**
- [ ] `POST /api/invoices/:id/send` (`invoices:write`): `sendInvoice` (passes tenant `country_code`); 409 on bad transition.
- [ ] `POST /api/invoices/:id/approve` (`invoices:write`): `approveInvoice`.
- [ ] `POST /api/invoices/:id/reject` (`invoices:write`): body `{ reason }`; `rejectInvoice`. (`REJECTED→DRAFT` reopen also handled here via `POST /api/invoices/:id/reopen`.)
- [ ] `POST /api/invoices/:id/issue-tax` (`invoices:write`): `issueTaxInvoice`; inline tracked inventory posting in the same tx; then render + `snapshotToR2`; only after commit may adapter sync enqueue.
- [ ] `POST /api/invoices/:id/record-payment` (`invoices:write`): body `{ method, amount, date }`; `recordPayment`.
- [ ] `POST /api/invoices/:id/credit-note` (`invoices:write`): `createCreditNoteDraft` → DRAFT credit note; issue via `POST /:creditNoteId/issue` (see invoice-credit-notes spec).
- [ ] `POST /api/invoices/:id/void` (`invoices:write` + **OWNER or ADMIN** role): Zod body `{ reason: string }` (required); reverse tracked inventory inline and then `voidInvoice` in the same tx; return **409** if `TAX_ISSUED` or later, with message directing to credit note.
- [ ] Every transition maps `InvalidTransitionError` → `409 { error:'invalid_transition' }`.
**Schema / Interfaces:**
```ts
// Routes: POST /api/invoices/:id/{send|approve|reject|reopen|issue-tax|record-payment|credit-note|void}
// void: requires OWNER|ADMIN, body { reason } required, 409 when status >= TAX_ISSUED
```
**Acceptance:**
- [ ] `send` stamps VAT + proforma number atomically; `issue-tax` assigns gap-free invoice number + writes R2 snapshot.
- [ ] `void` on a tax-issued invoice → 409; void by non-owner/non-admin → 403.
- [ ] Illegal transitions → 409 `invalid_transition`.

### Task 9: API — HTML render route + unbilled-time data source
**Blocks:** 13  ·  **Blocked by:** 6, 7
**Files:**
- Create: `apps/zync-api/src/routes/invoices/html.ts`
- Create: `apps/zync-api/src/routes/invoices/unbilled-time.ts`
**Steps:**
- [ ] `GET /api/invoices/:id/html` (`invoices:read`): if `status='TAX_ISSUED'`, stream the immutable R2 snapshot; otherwise render live via `renderInvoiceHtml`. Serve `text/html` + `Content-Disposition: inline; filename="invoice-{number}.html"`.
- [ ] `GET /api/invoices/unbilled-time?projectId={id}` (`invoices:read`): return `time_entries` for the project where `invoice_id IS NULL AND billable=true`, with `hours = duration_seconds/3600` (2dp). This is the read-only data source only; the selection UX, `from_time` entry, and `billedEntryIds[]` POST field are owned by spec 77 — do not implement them here.
**Schema / Interfaces:**
```ts
// GET /api/invoices/:id/html -> text/html (R2 snapshot when TAX_ISSUED, live render otherwise)
// GET /api/invoices/unbilled-time?projectId -> { entries: [{ id, description, hours, started_at }] }
```
**Acceptance:**
- [ ] Tax-issued invoice HTML comes from the R2 snapshot, not a fresh render.
- [ ] `unbilled-time` excludes already-billed entries (`invoice_id IS NOT NULL`).

### Task 10: API — internal `auto-issue` endpoint
**Blocks:** 11  ·  **Blocked by:** 4, 7
**Files:**
- Create: `apps/zync-api/src/routes/invoices/auto-issue.ts`
**Steps:**
- [ ] `POST /api/invoices/auto-issue` (internal; `invoices:write`, called by billing module): single transaction — create `DRAFT` with `source='auto_charge'`, stamp VAT, assign `invoice_number` via `nextInvoiceNumber('invoice')`, transition straight to `TAX_ISSUED` (skip SENT/APPROVED — payment plan is the pre-approval). Body `{ customerId, projectId?, lines[], paymentMethodId }`. Returns `{ invoiceId, invoiceNumber }`. Write R2 snapshot.
**Schema / Interfaces:**
```ts
// POST /api/invoices/auto-issue
// body: { customerId: string; projectId?: string; lines: CreateInvoiceLineBody[]; paymentMethodId: string }
// returns: { invoiceId: string; invoiceNumber: string }
```
**Acceptance:**
- [ ] Creates a `TAX_ISSUED` invoice with a gap-free number in one transaction; no SENT/APPROVED intermediate states persisted.

### Task 11: Automated generation — `invoice.generate` queue consumer
**Blocks:** —  ·  **Blocked by:** 4, 7, 10
**Files:**
- Create: `apps/zync-api/src/jobs/invoice-generate.ts`
- Modify: `apps/zync-api/src/queue.ts` (route `invoice.generate` messages)
**Steps:**
- [ ] Enqueue `invoice.generate` to the `QUEUE` binding when a retainer hour-bank is depleted (`retainer_months.hours_used >= hours_included` and `billing_config.auto_invoice=true`).
- [ ] Consumer creates an invoice `source='retainer'` with lines: `"Retainer: {project.name} — {month}"` at `monthly_amount`; if `hour_bank_overflow_action='invoice'`, add a second line for overtime hours × hourly rate.
- [ ] Status: `DRAFT` (default) or directly `SENT` (configurable per project).
- [ ] Task-status trigger: when a task reaches the configured status in `/settings/integrations/invoicing`, generate a deposit/final invoice for fixed-price projects.
- [ ] Fixed-price deposit: when `billing_config.deposit_pct > 0`, support auto-creating a draft deposit invoice for `total_amount × deposit_pct / 100`.
**Schema / Interfaces:**
```ts
// Queue message: { type: 'invoice.generate'; tenantId; projectId; reason: 'retainer_depleted' | 'task_status' | 'fixed_deposit'; month?: string }
export async function handleInvoiceGenerate(env: Env, msg: InvoiceGenerateMessage): Promise<void>;
```
**Acceptance:**
- [ ] Retainer depletion produces a `source='retainer'` invoice with the correct lines and configurable initial status.
- [ ] Fixed-price deposit creates a draft for the deposit amount.

### Task 12: UI — invoice list (`/invoices`) with cursor pagination + virtual scroll
**Blocks:** —  ·  **Blocked by:** 2, 8
**Files:**
- Create: `apps/zync-app/src/features/invoices/InvoiceListPage.tsx`
- Create: `apps/zync-app/src/features/invoices/useInvoiceList.ts`
- Create: `apps/zync-app/src/features/invoices/InvoiceStatusBadge.tsx`
**Steps:**
- [ ] `DataTable` columns: Number, Customer, Project, Amount, Status, Issue date, Due date, Actions. Color-coded `InvoiceStatusBadge` per state.
- [ ] Filters: status, customer, project, date range, source. Sort: issue date, amount, number.
- [ ] `useInvoiceList` (TanStack Query, infinite): consumes `GET /api/invoices` cursor pagination; never requests >100/page; shows "Showing X of N" from `total`.
- [ ] Virtual scroll: when list > 200 rows activate TanStack Virtual `useVirtualizer` — `estimateSize: () => 56`, `overscan: 5`, fixed 56px rows, scroll element = table container ref.
- [ ] "Create invoice" button opens the create `Sheet` (Task 13).
- [ ] a11y: status badge has accessible text label (not color-only); table rows keyboard-navigable; respect existing reduced-motion settings.
**Acceptance:**
- [ ] >200 rows renders virtualized; ≤200 renders normally.
- [ ] Status conveyed by text + color (not color alone).

### Task 13: UI — create/edit sheet + inbound pre-fill
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/features/invoices/InvoiceFormSheet.tsx`
- Create: `apps/zync-app/src/features/invoices/useInvoiceMutations.ts`
- Create: `apps/zync-app/src/routes/invoices.new.tsx`
**Steps:**
- [ ] Sheet form: Customer (required), Project (optional), Issue date, Due date, Notes; line-item editor (description, quantity, unit price, discount %, computed line total) with drag-to-reorder updating `position`.
- [ ] "Add time entries": fetch `GET /api/invoices/unbilled-time?projectId=` and append lines (description=entry description, quantity=hours, unit_price=project hourly rate). Note: the full grouping UX + `billedEntryIds[]` posting is owned by spec 77; here just consume the data source and seed lines.
- [ ] `/invoices/new` accepts inbound pre-fill query params and seeds the same sheet: `?contract_id=` (spec 76), `?proposal_id=` (spec 107), `?project_id=&from_time=true` (spec 77), milestone → invoice (spec 132 seeds customer + a milestone line), `source=retainer` auto-gen. Staff can edit before saving.
- [ ] Mutations call `POST /api/invoices` (create) and `PATCH /api/invoices/:id` (edit draft) via TanStack Query; invalidate the list.
- [ ] a11y/i18n: labelled form fields, RTL-aware layout for Hebrew locale, `aria-live` on computed totals.
**Acceptance:**
- [ ] Line total recomputes client-side as `quantity*unit_price*(1-discount_pct/100)`.
- [ ] Inbound query params correctly seed customer/lines.

### Task 14: UI — invoice detail (`/invoices/:id`) with state-driven actions
**Blocks:** —  ·  **Blocked by:** 8, 9
**Files:**
- Create: `apps/zync-app/src/features/invoices/InvoiceDetailPage.tsx`
- Create: `apps/zync-app/src/features/invoices/InvoiceStatusTimeline.tsx`
**Steps:**
- [ ] Preview panel (PDF-like) with logo, business + customer details, line items, VAT breakdown, totals.
- [ ] Status timeline: DRAFT → SENT → APPROVED → TAX_ISSUED → PAID with timestamps; VOID shown as terminal from DRAFT/SENT.
- [ ] State-driven action set per spec: `DRAFT` (Edit, Send, Delete, Void); `SENT` (Approve, Reject, Resend, Preview, Void); `APPROVED` (Issue Tax Invoice); `TAX_ISSUED` (Record payment, Issue Credit Note, Open/Print); `PARTIALLY_PAID` (Record additional payment, Issue Credit Note); `PAID` (Open/Print, Issue Credit Note); `VOID` (read-only, shows reason); `REJECTED` (Reopen to DRAFT).
- [ ] "Open/Print Invoice" opens `GET /api/invoices/:id/html` in a new tab → browser print → save as PDF.
- [ ] Void action surfaces a required-reason dialog; only shown to OWNER/ADMIN.
- [ ] a11y: actions are real buttons with labels; timeline conveys state textually; reduced-motion respected.
**Acceptance:**
- [ ] Actions shown exactly match the current status.
- [ ] Tax-issued invoice opens the R2 snapshot HTML.

### Task 15: Verification — query, transition, numbering & locale tests
**Blocks:** —  ·  **Blocked by:** 3, 4, 5, 6, 8, 10
**Files:**
- Create: `packages/db/src/queries/invoices.test.ts`
- Create: `apps/zync-api/src/routes/invoices/invoices.test.ts`
**Steps:**
- [ ] Sequential numbering: concurrent `send`/`issue-tax` produce gap-free, non-duplicated numbers (transactional `nextInvoiceNumber`).
- [ ] State machine: every legal transition succeeds; every illegal one throws `InvalidTransitionError`/409. Void on `TAX_ISSUED` → 409.
- [ ] VAT stamping: `vat_rate` set at `DRAFT→SENT` from `getVatRate`, immutable through `TAX_ISSUED`.
- [ ] Credit note: negative totals, own sequential number, `parent_invoice_id` linkage.
- [ ] Serializer output validates against `InvoiceObject`; monetary fields are decimal strings.
- [ ] List API: never >100 rows; cursor stable under inserts.
- [ ] HTML: Hebrew tenant → `dir="rtl" lang="he"` + Heebo embed; tax-issued served from R2 snapshot.
**Acceptance:**
- [ ] All tests pass; `tsc`/lint clean across `packages/db`, `packages/types`, `apps/zync-api`.
