# Contract → Invoice Auto-generation UI — Implementation Plan

**Spec:** docs/specs/2026-05-31-contract-to-invoice.md  ·  **Slug:** contract-to-invoice  ·  **Wave:** 9
**Depends on:** contracts-esignature, customers-module, foundation-auth-rbac, invoices-core

## Goal
Bridge contract completion to invoice creation. When a contract is fully signed (`contracts.status = 'SIGNED'`), the user can generate an invoice that is pre-filled from the contract (customer + line items) and bi-directionally linked via `invoices.contract_id`. This spec owns the invoice pre-fill UX, the linked-invoice lookup endpoint, the `contract_id` extension to the existing invoice-create endpoint, and the contract-detail Invoice tab. It introduces **no new tables** — `invoices.contract_id` and all `contracts*` tables are owned upstream by `contracts-esignature`.

## Architecture
Pure UI + integration layer over two upstream modules:

- **Upstream `contracts-esignature`** owns: `contracts` table (`id`, `tenant_id`, `customer_id`, `template_id`, `title`, `content JSONB`, `status`, `signed_pdf_r2_key`, …), `contract_signatories`, `contract_audit_log`, the `ALTER TABLE invoices ADD COLUMN contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL` delta, the completion-flow component, and the `/contracts/:id` detail page. We **Modify** these, never redefine them.
- **Upstream `invoices-core`** owns: `invoices` table (`id`, `tenant_id`, `customer_id`, `project_id`, `invoice_number` (NULL until TAX_ISSUED), `proforma_number`, `status`, `total`, …), `invoice_lines`, the `POST /api/invoices` create handler, and the `/invoices/new` create sheet/form. We extend the create handler to accept `contract_id` and extend the form to pre-fill from a contract.

Data flow:
1. A signed contract's detail page (or completion flow) offers "Generate Invoice" → navigates to `/invoices/new?contract_id={id}`.
2. The New Invoice form detects `contract_id`, calls `GET /api/contracts/:id`, and runs `extractInvoicePrefillFromContract()` to seed customer + line items.
3. On save, the form posts `contract_id` in the `POST /api/invoices` body. The server validates the contract is `SIGNED` and same-tenant, then sets `invoices.contract_id`.
4. The contract detail page's **Invoice tab** calls `GET /api/contracts/:id/invoice` to show the linked invoice (or the Generate CTA when none exists and status is SIGNED).

All routes are tenant-scoped via `authMiddleware` + `tenantQuery`. Permissions reuse upstream keys: `contracts:read` (read linked invoice), `invoices:write` (create invoice). No new permission keys.

## Tech Stack
- **apps/zync-api** (Hono on Cloudflare Workers): new route `GET /api/contracts/:id/invoice`; modify `POST /api/invoices` handler.
- **apps/zync-app** (Vite + React): modify `/invoices/new` form (pre-fill island), modify `/contracts/:id` detail (Invoice tab + Generate/View CTA), modify the signing completion flow component.
- **packages/db** (Drizzle): no schema change; consume existing `invoices`, `contracts` table objects.
- **packages/types**: add `ContractInvoiceLink` + `InvoicePrefillFromContract` types; reuse `InvoiceStatus`.
- Libraries: zod (request validation), TanStack Query (client fetch). Bindings: Hyperdrive (`DB`) only. No new bindings, secrets, or cron.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 9a | 1 (types), 2 (prefill helper) | packages/types, packages/db | Yes (independent) |
| 9b | 3 (GET linked invoice), 4 (POST contract_id) | apps/zync-api routes | Yes (after 9a) |
| 9c | 5 (new-invoice prefill UI), 6 (contract Invoice tab + CTA), 7 (completion-flow button) | apps/zync-app | 5/6/7 parallel after 9b |
| 9d | 8 (tests) | apps/zync-api, apps/zync-app tests | After 9c |

## Tasks

### Task 1: Shared types for the contract↔invoice link
**Blocks:** 2, 3, 4, 5, 6  ·  **Blocked by:** —
**Files:**
- Modify: `packages/types/src/invoices.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Add `ContractInvoiceLink` — the shape returned by `GET /api/contracts/:id/invoice`.
- [ ] Add `InvoicePrefillFromContract` — the pre-fill payload the new-invoice form consumes.
- [ ] Reuse the existing `InvoiceStatus` union (do not redefine it).
- [ ] Re-export both from the package barrel.
**Schema / Interfaces:**
```ts
import type { InvoiceStatus } from './invoices' // existing union

// Returned by GET /api/contracts/:id/invoice. null when no invoice is linked.
export interface ContractInvoiceLink {
  invoice: {
    id: string
    // invoice_number is NULL until TAX_ISSUED; fall back to proforma_number, else null.
    invoiceNumber: string | null
    total: string            // NUMERIC(12,2) serialized as string
    status: InvoiceStatus
  } | null
}

// Pre-fill payload derived from a SIGNED contract, consumed by /invoices/new.
export interface InvoicePrefillFromContractLine {
  description: string
  quantity: number           // default 1
  unitPrice: string          // NUMERIC(12,2) as string
}
export interface InvoicePrefillFromContract {
  contractId: string
  contractTitle: string
  customerId: string | null  // contracts.customer_id is nullable (ON DELETE SET NULL)
  lines: InvoicePrefillFromContractLine[] // may be empty -> blank line-item grid
}
```
**Acceptance:**
- [ ] `pnpm --filter @zync/types build` passes; both types importable from `@zync/types`.

### Task 2: Contract-content pre-fill extraction helper
**Blocks:** 5  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/contracts/extract-invoice-prefill.ts`
- Modify: `packages/db/src/index.ts` (export `extractInvoicePrefillFromContract`)
**Steps:**
- [ ] Implement `extractInvoicePrefillFromContract(contract)` taking a contract row (`id`, `title`, `customer_id`, `content` JSONB) and returning `InvoicePrefillFromContract`.
- [ ] Primary path (both specs agree): produce a **single line** — description = contract `title`, quantity = 1, unitPrice = the resolved `{{amount}}` variable if present in `content`. The `{{amount}}` value is the substituted currency value stored in the resolved Tiptap `content` (variables are substituted server-side at creation time per contracts-esignature). Parse it from the content text; strip currency symbols/commas to a numeric string.
- [ ] Best-effort structured path: if `content` contains a recognizable `pricing` section (a node/block whose rows expose description + price), emit one line per row instead of the single title line. This is optional — the upstream editor may never emit it; never fail if absent.
- [ ] Fallback: if no amount and no pricing section can be parsed, return `lines: []` (blank grid; spec allows empty).
- [ ] Never throw on malformed `content`; on any parse error return `lines: []`.
- [ ] Set `customerId` from `contract.customer_id` (may be null).
**Schema / Interfaces:**
```ts
import type { InvoicePrefillFromContract } from '@zync/types'

interface ContractRowForPrefill {
  id: string
  title: string
  customer_id: string | null
  content: unknown // resolved Tiptap JSONB (variables already substituted)
}

export function extractInvoicePrefillFromContract(
  contract: ContractRowForPrefill,
): InvoicePrefillFromContract
```
**Acceptance:**
- [ ] Given content with a substituted `{{amount}}` of `₪35,700`, returns one line `{ description: title, quantity: 1, unitPrice: '35700' }`.
- [ ] Given content with no parseable amount, returns `lines: []`.
- [ ] Malformed/empty `content` returns `lines: []` without throwing.

### Task 3: `GET /api/contracts/:id/invoice` — linked invoice lookup
**Blocks:** 6  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/contracts.ts` (add sub-route on the existing contracts router)
**Steps:**
- [ ] Register `GET /:id/invoice` under the contracts router, guarded by `authMiddleware` and `requirePermission('contracts:read')`.
- [ ] Resolve the contract via `tenantQuery` (404 if not found in the caller's tenant — never leak cross-tenant existence).
- [ ] Query `invoices` for `contract_id = :id AND tenant_id = <session tenant>`, newest first, limit 1.
- [ ] If none: return `{ invoice: null }`.
- [ ] If found: serialize to `ContractInvoiceLink.invoice`. `invoiceNumber` = `invoice_number ?? proforma_number ?? null` (invoice_number is NULL until TAX_ISSUED). `total` = `invoice.total` (string). `status` = `invoice.status`.
- [ ] Validate `:id` is a UUID with zod before querying; 400 on malformed id.
**Schema / Interfaces:**
```ts
// Consumed upstream columns (do NOT redefine the tables):
// invoices(id UUID, tenant_id UUID, customer_id UUID, contract_id UUID,
//          invoice_number TEXT, proforma_number TEXT, total NUMERIC(12,2),
//          status TEXT, created_at TIMESTAMPTZ)  -- invoices-core + contracts-esignature delta
// contracts(id UUID, tenant_id UUID, status TEXT, customer_id UUID, content JSONB, title TEXT)  -- contracts-esignature

// Response body: ContractInvoiceLink (from @zync/types)
// 200 { invoice: { id, invoiceNumber, total, status } | null }
// 400 invalid id ; 401 unauth ; 403 missing contracts:read ; 404 contract not in tenant
```
**Acceptance:**
- [ ] Signed contract with a linked invoice returns the invoice with a non-null `invoiceNumber` fallback when only a proforma number exists.
- [ ] Contract with no linked invoice returns `{ invoice: null }`.
- [ ] Request for a contract in another tenant returns 404.
- [ ] Caller lacking `contracts:read` gets 403.

### Task 4: Extend `POST /api/invoices` to accept and validate `contract_id`
**Blocks:** 5  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/invoices.ts` (existing create handler from invoices-core)
- Modify: the zod create schema in the same file (or `apps/zync-api/src/routes/invoices.schema.ts` if split)
**Steps:**
- [ ] Add optional `contractId: z.string().uuid().optional()` to the existing invoice-create zod schema (camelCase in API body; mapped to the `contract_id` column).
- [ ] When `contractId` is present, before inserting the invoice: load the contract via `tenantQuery` and assert (a) it exists in the caller's tenant, (b) `contracts.status = 'SIGNED'`. Reject with 422 `{ error: 'contract_not_signed' }` if not SIGNED; 404 if not found / cross-tenant.
- [ ] Set `invoices.contract_id = contractId` on the inserted row within the same create transaction the handler already uses.
- [ ] When `contractId` is absent, behavior is unchanged (do not regress manual create or other inbound pre-fill callers: `proposal_id`, `project_id&from_time`, milestone, retainer).
- [ ] Include `contract_id` in the serialized create response (the existing `serializeInvoice` output) so the client can render the linked-contract chip immediately.
**Schema / Interfaces:**
```ts
// Body delta (added to the existing create schema):
//   contractId?: string (uuid)
// Validation: contract must exist in tenant AND status === 'SIGNED'.
// On success: set invoices.contract_id = contractId on the inserted row (same tx as invoice create).
// Consumed: contracts(id, tenant_id, status) ; invoices.contract_id (upstream-owned column).
// 422 { error: 'contract_not_signed' } when status !== 'SIGNED'
// 404 when contract not found in tenant
```
**Acceptance:**
- [ ] Creating an invoice with a SIGNED contract's `contractId` links it (`invoices.contract_id` set; echoed in response).
- [ ] `contractId` for a DRAFT/SENT/VIEWED/VOIDED contract returns 422.
- [ ] `contractId` for another tenant's contract returns 404.
- [ ] Omitting `contractId` creates an unlinked invoice exactly as before (no regression).

### Task 5: New-invoice form pre-fill from `?contract_id=`
**Blocks:** —  ·  **Blocked by:** 2, 4
**Files:**
- Modify: `apps/zync-app/src/routes/invoices/new.tsx` (the upstream invoices-core New Invoice form/sheet)
- Create: `apps/zync-app/src/features/invoices/useContractPrefill.ts` (TanStack Query hook)
**Steps:**
- [ ] Read `contract_id` from the URL search params on the New Invoice route.
- [ ] When present, `useContractPrefill(contractId)` calls `GET /api/contracts/:id` (existing endpoint) and runs `extractInvoicePrefillFromContract` on the returned contract to compute customer + lines. (Extraction may run client-side via the exported helper, or the hook may request a server-computed prefill — use the exported helper to avoid duplicating logic.)
- [ ] Pre-fill the customer select from `customerId` (leave editable; if null, leave unselected).
- [ ] **Project:** leave blank / user-selected. The `contracts` table has no `project_id` — there is no contract→project link to pre-fill from. Do NOT invent one.
- [ ] Pre-fill the line-item grid from `lines`; if `lines` is empty, render the normal blank line-item grid (one empty row).
- [ ] Render a read-only **"Linked contract: {title}"** chip with an `[×]` detach control. Detaching clears `contract_id` from the in-memory form state so it is NOT sent on save (the invoice will be created unlinked).
- [ ] Show the header context line "From contract: {title}" when linked.
- [ ] On save (Save Draft or Send), include `contractId` in the `POST /api/invoices` body **only if** still linked (not detached).
- [ ] Show a loading skeleton while the contract fetch is in flight; show an inline error (reuse `ErrorState`) if the contract fetch fails, with the form still usable as a blank create.
- [ ] Respect `prefers-reduced-motion` for any chip/transition animation (no motion when reduced); the linked-contract chip's `[×]` must be a real `<button>` with `aria-label="Detach contract"`.
**Schema / Interfaces:**
```ts
// useContractPrefill(contractId: string | null):
//   { data: InvoicePrefillFromContract | undefined, isLoading, error }
// GET /api/contracts/:id -> contract { id, title, customer_id, content, status }
// -> extractInvoicePrefillFromContract(contract) -> InvoicePrefillFromContract
```
**Acceptance:**
- [ ] Visiting `/invoices/new?contract_id={signedId}` pre-fills customer and line items and shows the linked-contract chip + header context.
- [ ] Clicking `[×]` removes the chip and the saved invoice has no `contract_id`.
- [ ] A contract with no extractable amount yields a blank line grid (one empty row), form still usable.
- [ ] Saving links the invoice (verified via Task 3 endpoint returning it).

### Task 6: Contract detail — Invoice tab + Generate/View CTA
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-app/src/routes/contracts/[id].tsx` (the upstream contracts-esignature detail page)
- Create: `apps/zync-app/src/features/contracts/ContractInvoiceTab.tsx`
**Steps:**
- [ ] Add an **"Invoice"** tab to the existing `[Details] [Signatories]` tab set on `/contracts/:id` (reuse the upstream `Tabs` component).
- [ ] In the tab, call `GET /api/contracts/:id/invoice` (TanStack Query).
- [ ] If `invoice` is present: render `Invoice #{invoiceNumber ?? 'Draft'} · {formatted total} · {status badge}` with a **"View Invoice →"** link to `/invoices/{id}`. Never render a raw null number — fall back to "Draft" when `invoiceNumber` is null.
- [ ] If `invoice` is null AND `contracts.status === 'SIGNED'`: render the **"Generate Invoice"** CTA (a `Button` linking to `/invoices/new?contract_id={id}`).
- [ ] If `invoice` is null AND status is not SIGNED: render an `EmptyState` ("Invoice available after the contract is signed").
- [ ] Mirror the CTA in the contract detail **action bar** for the SIGNED state: show **"Generate Invoice"** when unlinked, **"View Invoice →"** when linked (single source of truth: the same `GET /api/contracts/:id/invoice` query).
- [ ] Status badge uses the existing invoice status badge styling (reuse `Badge`); CTA buttons are real links with discernible text for screen readers.
**Schema / Interfaces:**
```ts
// Query: GET /api/contracts/:id/invoice -> ContractInvoiceLink
// Branch: invoice ? "View Invoice ->" (/invoices/:invoiceId)
//         : status === 'SIGNED' ? "Generate Invoice" (/invoices/new?contract_id=:id)
//         : EmptyState
// total formatted via the app's currency formatter (ILS, tenant locale)
```
**Acceptance:**
- [ ] SIGNED contract with no invoice shows "Generate Invoice" in both the tab and the action bar.
- [ ] SIGNED contract with a linked invoice shows "View Invoice →" and the number (or "Draft" when number is null) + total + status badge.
- [ ] Non-SIGNED contract shows the empty state, no Generate CTA.

### Task 7: Signing completion-flow "Generate Invoice" button
**Blocks:** —  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-app/src/features/contracts/ContractCompletionFlow.tsx` (the upstream "Contract signed by all parties" panel from contracts-esignature)
**Steps:**
- [ ] In the all-signed completion panel, add a **"Generate Invoice →"** action between "Download PDF" and "Done".
- [ ] The button navigates to `/invoices/new?contract_id={id}` (same target as Task 6's CTA).
- [ ] Only render the button when the completion flow's contract status is `SIGNED` (the panel already gates on all-signed).
- [ ] Button is a real link/`Button`, keyboard-focusable, with text label "Generate Invoice".
**Schema / Interfaces:** —
**Acceptance:**
- [ ] After the final signature, the completion panel shows "Download PDF", "Generate Invoice →", and "Done".
- [ ] "Generate Invoice →" navigates to `/invoices/new?contract_id={id}`.

### Task 8: Tests
**Blocks:** —  ·  **Blocked by:** 5, 6, 7
**Files:**
- Create: `apps/zync-api/test/contracts-invoice-link.test.ts`
- Create: `apps/zync-api/test/invoices-create-contract-id.test.ts`
- Create: `packages/db/test/extract-invoice-prefill.test.ts`
**Steps:**
- [ ] `extract-invoice-prefill.test.ts`: single-line from `{{amount}}`; empty lines on no amount; no-throw on malformed content; structured pricing section yields per-row lines.
- [ ] `contracts-invoice-link.test.ts`: linked invoice returns object with proforma fallback number; unlinked returns `{ invoice: null }`; cross-tenant → 404; missing `contracts:read` → 403.
- [ ] `invoices-create-contract-id.test.ts`: SIGNED contract links; DRAFT/SENT contract → 422; cross-tenant → 404; absent `contractId` → unlinked (no regression).
**Acceptance:**
- [ ] `pnpm test` passes for the three new files.
- [ ] No regression in the existing invoices-core create tests.
