# Invoice Draft Library — Implementation Plan

**Spec:** docs/specs/2026-05-31-invoice-draft-library.md  ·  **Slug:** invoice-draft-library  ·  **Wave:** 7
**Depends on:** customers-module, foundation-auth-rbac, invoices-core

## Goal
Split DRAFT invoices out of the main operational `/invoices` list into a dedicated draft library at `/invoices/drafts`, and add a reusable invoice-template flow. Templates are DRAFT invoices flagged `is_template = true` with a nullable `customer_id`; staff can save a draft as a template, list templates separately, and create a fresh draft from a template (line items, amounts and notes copied, customer cleared). The main list gains a "[X drafts]" pill and excludes DRAFT from its default status group.

## Architecture
This feature is a thin extension of `invoices-core` (table `invoices`, `invoice_lines`). It adds two columns to the existing `invoices` table (`is_template`, plus a relaxed `customer_id` NOT NULL and a guard CHECK), extends the existing `GET /api/invoices` list filtering to honor `status=DRAFT` and `is_template`, adds `POST /api/invoices/from-template/:templateId`, and re-asserts the DRAFT-only `DELETE /api/invoices/:id` semantics from core. The serializer `serializeInvoice` (from invoices-core) gains an `is_template` field on `InvoiceObject`. UI consumes upstream `DataTable`, `EmptyState`, `Button`, `Dialog`/`Sheet`, `Badge`, `Card` from `@zync/ui`, `useCustomerList` from customers-module for the customer picker, and `requirePermission`/`requireModuleEnabled` + `ModuleGuard`/`useModuleEnabled` (module id `'invoices'`) from foundation-auth-rbac + module-management.

Data flow:
- List drafts: `GET /api/invoices?status=DRAFT&is_template=false` → drafts section.
- List templates: `GET /api/invoices?is_template=true` → templates section.
- Use template: `POST /api/invoices/from-template/:templateId` `{ customer_id }` → server copies template + its `invoice_lines` into a new DRAFT invoice (`is_template=false`, supplied `customer_id`, `invoice_number=NULL`), returns the new `InvoiceObject`.
- Delete draft: `DELETE /api/invoices/:id` (hard delete, only when `status='DRAFT'`, else 409).
- Send draft: reuses invoices-core `POST /api/invoices/:id/send` (DRAFT → SENT, assigns proforma number).
- New-template save: existing `POST /api/invoices` with `is_template=true` and `customer_id=null`.

It plugs into `invoices-core` exclusively — no new table is introduced (Architecture Decision: templates are a flag, not a separate `invoice_templates` table).

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers). New/modified route handlers under the invoices router. Postgres via Drizzle over Cloudflare Hyperdrive binding `DB`/`HYPERDRIVE`.
- **DB:** Neon Postgres; Drizzle schema in `packages/db`. Migration adds columns/constraints to `invoices`.
- **Types:** `packages/types` — extend `InvoiceObject` with `is_template: boolean`; add `CreateFromTemplateInput`.
- **App UI:** `apps/zync-app` (Vite + React). New route `/invoices/drafts`, components for drafts/templates sections, create-from-template dialog, and the drafts pill on `/invoices`.
- **UI kit:** `@zync/ui` (`DataTable`, `EmptyState`, `Button`, `Badge`, `Card`, `Dialog`, `Sheet`, `Input`, `Select`, `Form`).
- **Validation:** Zod (`require-zod-validation-in-routes`).
- **Auth/guards:** `requirePermission`, `requireModuleEnabled`, `ModuleGuard`, `useModuleEnabled` (module id `'invoices'`).
- **Data access:** `tenantQuery` (no raw Drizzle from routes — `no-raw-drizzle-from-routes`).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 7a (schema) | 1 | `packages/db` migration + schema | No (blocks all) |
| 7b (types) | 2 | `packages/types` | After 1 |
| 7c (data layer) | 3 | `packages/db` queries | After 1 |
| 7d (API) | 4, 5, 6 | `apps/zync-api` invoices router + zod | After 2,3 — routes parallel to each other |
| 7e (UI) | 7, 8, 9, 10 | `apps/zync-app` invoices pages/components | After 4,5,6 — components parallel |
| 7f (integration) | 11 | `apps/zync-app` main invoices list | After 7 |

## Tasks

### Task 1: Schema delta — `is_template` + nullable customer + guard CHECK on `invoices`
**Blocks:** 2, 3, 4, 5, 6  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/<timestamp>_invoice_draft_library.sql`
- Modify: `packages/db/src/schema/invoices.ts`
**Steps:**
- [ ] Add migration: drop NOT NULL on `invoices.customer_id`, add `is_template` boolean, add the requires-customer CHECK.
- [ ] Add a partial index to make template/draft listing fast: `CREATE INDEX IF NOT EXISTS idx_invoices_tenant_template ON invoices (tenant_id, is_template, status);`.
- [ ] Update the Drizzle table definition: `customerId` becomes nullable, add `isTemplate` (`boolean('is_template').notNull().default(false)`).
- [ ] Keep the existing `invoices_status_check` constraint from invoices-core untouched.
**Schema / Interfaces:**
```sql
-- Relax the original NOT NULL so templates can omit a customer.
ALTER TABLE invoices ALTER COLUMN customer_id DROP NOT NULL;

-- Flag distinguishing a reusable template from a real draft invoice.
ALTER TABLE invoices
  ADD COLUMN IF NOT EXISTS is_template BOOLEAN NOT NULL DEFAULT false;

-- A non-template invoice must always have a customer; a template may not.
ALTER TABLE invoices
  ADD CONSTRAINT chk_invoice_requires_customer
  CHECK (is_template = true OR customer_id IS NOT NULL);

-- Fast lookup for the drafts library and template list.
CREATE INDEX IF NOT EXISTS idx_invoices_tenant_template
  ON invoices (tenant_id, is_template, status);
```
```ts
// packages/db/src/schema/invoices.ts (Drizzle — modified columns only)
customerId: uuid('customer_id').references(() => customers.id), // nullable now
isTemplate: boolean('is_template').notNull().default(false),
```
**Acceptance:**
- [ ] Migration applies cleanly on a Neon branch; `\d invoices` shows `is_template BOOLEAN NOT NULL DEFAULT false`, `customer_id` nullable, and constraint `chk_invoice_requires_customer`.
- [ ] Inserting a row with `is_template=false` and `customer_id=NULL` fails the CHECK; with `is_template=true` and `customer_id=NULL` succeeds.

### Task 2: Extend `InvoiceObject` type + create-from-template input
**Blocks:** 4, 5, 6, 7, 8, 9  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/types/src/invoices.ts`
**Steps:**
- [ ] Add `is_template: boolean` to the `InvoiceObject` interface (exported by invoices-core).
- [ ] Add `CreateFromTemplateInput` type for the from-template body.
- [ ] Make `customer_id` on `InvoiceObject` `string | null` to reflect template rows.
**Schema / Interfaces:**
```ts
export interface InvoiceObject {
  // existing fields defined by invoices-core remain unchanged;
  // these two are added/modified by this spec:
  customer_id: string | null;   // null only when is_template = true
  is_template: boolean;
}

export interface CreateFromTemplateInput {
  customer_id: string;          // required: the customer for the new draft
}
```
**Acceptance:**
- [ ] `packages/types` builds; `InvoiceObject.is_template` is referenced by serializer and UI without `any`.

### Task 3: Data-layer queries — list drafts/templates, copy-from-template, delete-draft
**Blocks:** 4, 5, 6  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/db/src/queries/invoices.ts`
**Steps:**
- [ ] Extend the existing invoice list query to accept `status?` and `isTemplate?` filters, scoped by `tenantQuery` (tenant_id always applied).
- [ ] Add `createInvoiceFromTemplate(db, tenantId, templateId, customerId, createdBy)`: in one transaction, read the template invoice + its `invoice_lines`; reject if the row is not `is_template=true` or not in tenant; insert a new `invoices` row (`is_template=false`, `status='DRAFT'`, `customer_id=customerId`, `invoice_number=NULL`, `proforma_number=NULL`, copy `currency`, `notes`, `vat_rate`, `subtotal`, `vat_amount`, `total`, `source='manual'`); copy every `invoice_lines` row (new `id`, new `invoice_id`, preserve `description`, `quantity`, `unit_price`, `discount_pct`, `line_total`, `taxable`, `position`); return the new invoice id.
- [ ] Add `deleteDraftInvoice(db, tenantId, invoiceId)`: hard-delete only when `status='DRAFT'`; return a sentinel/throw `OpenInvoicesError`-style conflict when not DRAFT so the route can map to 409.
- [ ] All queries go through `tenantQuery` — never raw Drizzle from routes (`no-raw-drizzle-from-routes`); audit writes wrapped per `require-audit-in-transaction`.
**Schema / Interfaces:**
```ts
export async function listInvoices(
  db: Db, tenantId: string,
  filter: { status?: InvoiceStatus; isTemplate?: boolean; cursor?: string; limit?: number }
): Promise<{ items: InvoiceObject[]; nextCursor: string | null; total: number }>;

export async function createInvoiceFromTemplate(
  db: Db, tenantId: string, templateId: string, customerId: string, createdBy: string
): Promise<{ invoiceId: string }>;

export async function deleteDraftInvoice(
  db: Db, tenantId: string, invoiceId: string
): Promise<{ deleted: true } | { conflict: 'NOT_DRAFT' }>;
```
**Acceptance:**
- [ ] `listInvoices({ status:'DRAFT', isTemplate:false })` returns only non-template DRAFT rows for the tenant.
- [ ] `createInvoiceFromTemplate` produces a DRAFT with `is_template=false`, the supplied `customer_id`, `invoice_number=NULL`, and N copied lines matching the template's lines (positions preserved).
- [ ] `deleteDraftInvoice` deletes a DRAFT and its lines; returns `{ conflict:'NOT_DRAFT' }` for a SENT/TAX_ISSUED invoice (no deletion).

### Task 4: List route filters — `status=DRAFT`, `is_template`
**Blocks:** 7  ·  **Blocked by:** 2, 3
**Files:**
- Modify: `apps/zync-api/src/routes/invoices.ts`
**Steps:**
- [ ] Extend the existing `GET /api/invoices` query-param schema (Zod) to accept `status` (one of `InvoiceStatus`) and `is_template` (`'true'|'false'` coerced to boolean).
- [ ] Pass `status` and `isTemplate` into `listInvoices`. When `is_template` is omitted, default to `isTemplate=false` for the drafts/operational views; when `is_template=true` is supplied, status filtering is optional (templates are always DRAFT-shaped but excluded from operational lists).
- [ ] Guard with `requirePermission('invoices:read')`, `requireModuleEnabled('invoices')`.
- [ ] Serialize each row with `serializeInvoice` (now includes `is_template`).
**Schema / Interfaces:**
```
GET /api/invoices?status=DRAFT&is_template=false   → list non-template DRAFT invoices  (invoices:read)
GET /api/invoices?is_template=true                 → list invoice templates            (invoices:read)
```
```ts
const listQuerySchema = z.object({
  status: z.enum(['DRAFT','SENT','APPROVED','REJECTED','TAX_ISSUED','PAID','PARTIALLY_PAID','VOID','WRITTEN_OFF','BAD_DEBT']).optional(),
  is_template: z.enum(['true','false']).optional().transform(v => v === undefined ? undefined : v === 'true'),
  cursor: z.string().optional(),
  limit: z.coerce.number().int().min(1).max(100).optional(),
});
```
**Acceptance:**
- [ ] `GET /api/invoices?status=DRAFT&is_template=false` returns only DRAFT non-templates; response items carry `is_template:false`.
- [ ] `GET /api/invoices?is_template=true` returns only templates.
- [ ] Request without `invoices:read` → 403; with `invoices` module disabled → module-disabled response.

### Task 5: `POST /api/invoices/from-template/:templateId`
**Blocks:** 9  ·  **Blocked by:** 2, 3
**Files:**
- Modify: `apps/zync-api/src/routes/invoices.ts`
**Steps:**
- [ ] Add route `POST /api/invoices/from-template/:templateId`, guarded by `requirePermission('invoices:write')`, `requireModuleEnabled('invoices')`.
- [ ] Validate body with Zod (`customer_id` required UUID); verify the customer exists in tenant.
- [ ] Call `createInvoiceFromTemplate(db, tenantId, templateId, customer_id, session.userId)`; if the template id is not a template / not found → 404.
- [ ] Load the new invoice + lines and return `serializeInvoice(...)` (DRAFT, status 201).
**Schema / Interfaces:**
```
POST /api/invoices/from-template/:templateId   (invoices:write)
  body: { customer_id: string }
  → 201 InvoiceObject (status='DRAFT', is_template=false, line items copied)
  → 404 if :templateId is not an is_template=true invoice in the tenant
```
```ts
const fromTemplateSchema = z.object({ customer_id: z.string().uuid() });
```
**Acceptance:**
- [ ] Posting a valid `templateId` + `customer_id` returns 201 with a new DRAFT invoice whose lines equal the template's lines and whose `customer_id` is the supplied one.
- [ ] Posting a non-template invoice id returns 404.
- [ ] Without `invoices:write` → 403.

### Task 6: `DELETE /api/invoices/:id` — DRAFT-only hard delete (re-assert)
**Blocks:** 7  ·  **Blocked by:** 2, 3
**Files:**
- Modify: `apps/zync-api/src/routes/invoices.ts`
**Steps:**
- [ ] Ensure the existing `DELETE /api/invoices/:id` calls `deleteDraftInvoice` and returns 409 when the result is `{ conflict:'NOT_DRAFT' }` (status != 'DRAFT').
- [ ] Permission: `invoices:write` (per this spec's API table). Note: invoices-core's own delete-draft table references `invoices:delete`; this spec's draft-library API section specifies `invoices:write` for the draft delete used by the library — gate on `invoices:write` and additionally accept `invoices:delete` holders so both core and library callers pass.
- [ ] Hard delete (no soft delete for drafts) — remove `invoice_lines` then the `invoices` row in one transaction.
**Schema / Interfaces:**
```
DELETE /api/invoices/:id   (invoices:write)
  → 204 on delete (only when status='DRAFT')
  → 409 { error: 'INVOICE_NOT_DRAFT' } when status != 'DRAFT'
```
**Acceptance:**
- [ ] Deleting a DRAFT returns 204 and removes the row and its lines.
- [ ] Deleting a SENT/TAX_ISSUED invoice returns 409 and does not delete.

### Task 7: `/invoices/drafts` page — drafts + templates sections
**Blocks:** 11  ·  **Blocked by:** 4, 6
**Files:**
- Create: `apps/zync-app/src/pages/invoices/DraftsLibraryPage.tsx`
- Create: `apps/zync-app/src/features/invoices/useDraftInvoices.ts`
- Modify: `apps/zync-app/src/router.tsx` (register `/invoices/drafts`)
**Steps:**
- [ ] Add route `/invoices/drafts` wrapped in `<ModuleGuard moduleId="invoices">`.
- [ ] `useDraftInvoices` hook: query `GET /api/invoices?status=DRAFT&is_template=false`; `useInvoiceTemplates` hook: query `GET /api/invoices?is_template=true`.
- [ ] Render header `Invoices > Drafts`, `[+ New invoice]` button, and a `[Search drafts...]` input that filters the loaded drafts client-side by description/customer.
- [ ] Drafts section: `Card` per draft showing the UI-only label `INV-DRAFT-{sequential}` (derived from list position, never persisted), description, customer name, formatted total, "Created … · Last edited …" relative times (`created_at`/`updated_at`), and `[Edit]` `[Send]` `[Delete]` actions. Count badge `(N)` next to "Drafts".
- [ ] `[Edit]` → navigate to existing invoice editor (`/invoices/:id/edit`). `[Send]` → call existing `POST /api/invoices/:id/send`, then refetch. `[Delete]` → confirm dialog → `DELETE /api/invoices/:id`, then refetch; show toast on success, surface 409 as an error toast.
- [ ] Templates section: list templates with a ★ marker, name, and "(N line items)" count; `[+ New template]` button → open editor in template mode (Task 8); each template row offers `[Use template]` (Task 9 dialog).
- [ ] Empty states via `EmptyState` (no drafts / no templates) with `emptyStateCatalog` context where applicable.
- [ ] A11y: list rendered with `role="list"`/`role="listitem"`; action buttons have accessible labels; respect `prefers-reduced-motion` on any transitions; RTL-safe via logical CSS (no hardcoded left/right) — honor `useDirection`.
**Acceptance:**
- [ ] Visiting `/invoices/drafts` shows two sections with correct counts; drafts list excludes templates and excludes non-DRAFT invoices.
- [ ] `[Delete]` removes a draft and updates the count; deleting a non-draft (race) shows an error toast from the 409.
- [ ] `[Send]` transitions the draft and it disappears from the drafts list on refetch.
- [ ] Page passes an axe/a11y check (roles, labels, contrast) and renders correctly under `dir="rtl"`.

### Task 8: "New template" editor mode
**Blocks:** —  ·  **Blocked by:** 7
**Files:**
- Modify: `apps/zync-app/src/features/invoices/InvoiceEditor.tsx` (or the existing create/edit sheet)
**Steps:**
- [ ] Add a `templateMode` prop to the existing invoice editor; when true, hide/disable the Customer field and submit with `is_template=true`, `customer_id=null` via existing `POST /api/invoices`.
- [ ] `[+ New template]` (from Task 7) opens the editor in `templateMode`.
- [ ] On save, return to `/invoices/drafts` and refetch templates.
**Schema / Interfaces:**
```ts
// POST /api/invoices body when saving a template:
// { is_template: true, customer_id: null, currency, notes?, lines: InvoiceLineInput[] }
```
**Acceptance:**
- [ ] Saving a new template creates an invoice with `is_template=true`, `customer_id=NULL`, and the entered line items; it appears in the Templates section.
- [ ] Customer field is not required (and not shown) in template mode.

### Task 9: Create-from-template flow (Use template + New-invoice "Start from")
**Blocks:** —  ·  **Blocked by:** 5, 7
**Files:**
- Create: `apps/zync-app/src/features/invoices/UseTemplateDialog.tsx`
- Modify: `apps/zync-app/src/features/invoices/NewInvoiceDialog.tsx` (the spec-15 New invoice "Start from…" step)
**Steps:**
- [ ] `UseTemplateDialog`: customer picker (`useCustomerList`/customer search) → on confirm, `POST /api/invoices/from-template/:templateId` with `{ customer_id }` → navigate to the new draft's editor (`/invoices/:id/edit`).
- [ ] Wire `[Use template]` on each template row (Task 7) to open `UseTemplateDialog` for that template.
- [ ] Extend the New invoice dialog with a "Start from…" radio group: `● Blank invoice` / `○ Template: [select ▾]`. When a template is chosen plus a customer, `[Continue]` posts to from-template and routes to the editor; Blank keeps the existing blank-create path.
- [ ] A11y: radio group is a proper `radiogroup`; labels associated; keyboard navigable.
**Schema / Interfaces:**
```
POST /api/invoices/from-template/:templateId  { customer_id }  → new DRAFT InvoiceObject
```
**Acceptance:**
- [ ] `[Use template]` with a chosen customer creates a DRAFT with copied lines and the chosen customer, then opens its editor.
- [ ] New invoice → "Template" + customer + Continue routes to a pre-filled DRAFT editor.
- [ ] Selecting "Blank invoice" preserves the original blank create flow.

### Task 10: Serializer + InvoiceObject wiring on the client
**Blocks:** 7, 9  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/serializers/invoice.ts` (`serializeInvoice`)
**Steps:**
- [ ] Include `is_template` in `serializeInvoice` output so list and detail responses expose it.
- [ ] Ensure `customer_id` serializes as `null` for templates (no crash on missing customer join).
**Acceptance:**
- [ ] Every invoice API response includes `is_template`; template rows return `customer_id: null` without error.

### Task 11: Main `/invoices` list integration — exclude DRAFT + drafts pill
**Blocks:** —  ·  **Blocked by:** 7
**Files:**
- Modify: `apps/zync-app/src/pages/invoices/InvoiceListPage.tsx`
**Steps:**
- [ ] Default the main list query to `is_template=false` and remove `DRAFT` from the default "All statuses" filter group (DRAFT only shown if a user explicitly selects it).
- [ ] Above the table, render a pill: `{N} drafts → [View drafts]` linking to `/invoices/drafts`, where `N` comes from `GET /api/invoices?status=DRAFT&is_template=false` (request `limit=1` and read `total`).
- [ ] Hide the pill when `N === 0`.
- [ ] A11y: the pill is a link with text "View N drafts"; RTL-safe arrow/icon placement via logical properties.
**Acceptance:**
- [ ] Main `/invoices` list no longer shows DRAFT rows by default; templates never appear.
- [ ] The drafts pill shows the correct count and navigates to `/invoices/drafts`; it is hidden when there are zero drafts.
