# Bulk Invoice Generation — Implementation Plan

**Spec:** docs/specs/2026-05-31-bulk-invoice-generation.md  ·  **Slug:** bulk-invoice-generation  ·  **Wave:** 7
**Depends on:** foundation-auth-rbac, invoices-core, projects-module, time-management

## Goal
Add an end-of-period billing-run wizard at `/invoices/generate` that creates DRAFT invoices in bulk for many customers at once, drawing from unbilled time entries, approved/billable expenses, and completed (uninvoiced) project milestones for a selected period. Small batches (≤20 customers) generate inline and return invoice numbers; large batches (>20) dispatch to a Cloudflare Queue job tracked in the existing `import_jobs` table and surface a pollable job-status screen plus a completion notification.

## Architecture
- **Data:** Creates NO new table. Reuses upstream `invoices`, `invoice_lines`, `invoice_sequences` (invoices-core), `time_entries` (time-management, with `invoice_id`/`billed_at` columns added by spec 77), `expenses` (expenses-module, with `billed_at`/`invoice_id`/`project_id`), `project_milestones` (spec 132), and `projects` (projects-module, `customer_id`/`hourly_rate`/`billing_type`).
- **Job tracking:** Reuses the `import_jobs` table from `data-import` (spec 40) with `type = 'bulk_action'` and `meta.action = 'invoice_generate'`. **Critical build-order note:** at wave 7 this plan does NOT depend on `data-import` or `bulk-operations` (wave 8), so the `import_jobs` table may exist (created by data-import) but WITHOUT the `bulk_action` type variant or a `meta` column, and with `original_filename`/`r2_key` declared `NOT NULL` (a billing run has no file). Therefore this plan owns an **idempotent migration delta** (Task 1) that: adds `meta JSONB`, adds `'bulk_action'` to the `type` CHECK, and relaxes `original_filename`/`r2_key` to nullable. The delta is written idempotently (`ADD COLUMN IF NOT EXISTS`, DROP/ADD CONSTRAINT) so it is safe even if `bulk-operations` later re-touches the same table.
- **Service layer:** A shared in-package line-item builder `buildBillingRunLines()` constructs invoice lines per customer using invoices-core + time/expense/milestone primitives directly (spec 77's service layer is not a declared dependency, so the grouping logic is built here from primitives and named for reuse).
- **Per-customer invoice creation** reuses the same primitives as a manual create: `nextInvoiceNumber` is NOT called (invoices are DRAFT, no number assigned — so no number prefix is read), the VAT rate is read via `getVatRate`, and the due-date offset `default_payment_terms_days` (owned by `invoices-core`, wave 6) is read via `tenantQuery`. Each created invoice marks its source rows billed (`time_entries.invoice_id`/`billed_at`, `expenses.invoice_id`/`billed_at`, `project_milestones.invoice_id`).
- **Flow:** Wizard step 1 (period + include toggles) → `POST /api/invoices/bulk-generate/preview` groups billable items by customer and flags `has_open_invoice` → step 2 preview table with smart pre-deselection → `POST /api/invoices/bulk-generate` either creates inline (≤20) and returns invoices, or enqueues a queue job (>20) and returns `job_id`. The queue consumer processes per-customer, writes per-customer results into `import_job_results`, and fires a completion notification (spec 97). A job-status screen at `/invoices/generate/jobs/:jobId` polls `GET /api/invoices/bulk-generate/jobs/:jobId`.
- **Batch filter:** "Go to invoices →" filters the invoices list to the generated batch via `status=DRAFT&job_id=:jobId`. There is no `job_id` column on `invoices`; the list endpoint resolves `job_id` by joining `import_job_results.entity_id` (= created invoice id) where `import_job_id = :jobId`. Inline (≤20) batches with no job use `status=DRAFT&created_at>=today`.
- All routes gated `invoices:write` + admin, tenant-scoped via `tenantQuery`.

## Tech Stack
- **apps/zync-api** (Hono on Cloudflare Workers): new routes under `/api/invoices/bulk-generate/*`, a Queue consumer, Zod schemas, the shared line-builder service.
- **apps/zync-app** (Vite + React): wizard pages and job-status screen, TanStack Query hooks.
- **packages/db** (Drizzle): migration delta for `import_jobs`; query helpers.
- **packages/types**: shared request/response types.
- **Cloudflare bindings:** `QUEUE` (bulk-action queue, shared with bulk-operations spec 42), `DB`/Hyperdrive (Neon Postgres), notifications via `createNotification`/`deliverNotification`.
- Reused exports: `authMiddleware`, `requirePermission`, `tenantQuery`, `buildPaginated`, `createNotification`, `getVatRate`, `DataTable`, `Dialog`, `Sheet`, `Progress`, `Button`, `Checkbox`, `Badge`, `EmptyState`, `Spinner`, `toast`, `useDirection`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema + types | 1, 2 | packages/db migration, packages/types | Task 1 and 2 parallel |
| B — service layer | 3, 4 | apps/zync-api service + queries | After A; 3 then 4 |
| C — API routes + queue | 5, 6, 7 | apps/zync-api routes, queue consumer | After B; 5,6 parallel, 7 after 6 |
| D — UI | 8, 9, 10 | apps/zync-app pages + hooks | After C; hooks (8) then pages (9,10) |
| E — wiring + tests | 11, 12 | route registry, tests | After D |

## Tasks

### Task 1: `import_jobs` migration delta (idempotent)
**Blocks:** 4, 6, 7  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/<timestamp>_bulk_invoice_import_jobs_delta.sql`
- Modify: `packages/db/src/schema/import-jobs.ts` (Drizzle schema for `import_jobs` to reflect `meta` + nullable file columns; create the file if data-import's schema is not yet present, mirroring its DDL)
**Steps:**
- [ ] Write an idempotent migration that makes `import_jobs` usable for a file-less bulk-action job.
- [ ] Add `meta JSONB` column if absent.
- [ ] Add `'bulk_action'` to the `type` CHECK constraint via DROP/ADD (preserving existing variants).
- [ ] Relax `original_filename` and `r2_key` to nullable (billing runs have no file).
- [ ] Ensure the Drizzle schema object exports `importJobs` and `importJobResults` matching the post-delta shape (`meta` typed as `jsonb`, file columns nullable).
**Schema / Interfaces:**
```sql
-- Idempotent delta. Safe whether or not data-import (spec 40) / bulk-operations (spec 42)
-- have already created/altered import_jobs. import_jobs and import_job_results CREATE TABLE
-- are owned by data-import (spec 40); this delta only adapts them.

ALTER TABLE import_jobs ADD COLUMN IF NOT EXISTS meta JSONB;

-- Re-define type CHECK to include 'bulk_action' (superset of data-import's variants).
ALTER TABLE import_jobs DROP CONSTRAINT IF EXISTS import_jobs_type_check;
ALTER TABLE import_jobs ADD CONSTRAINT import_jobs_type_check
  CHECK (type IN ('customers', 'invoices', 'products', 'time_entries', 'csv_import', 'bulk_action'));

-- Billing runs are file-less: relax NOT NULL on file columns.
ALTER TABLE import_jobs ALTER COLUMN original_filename DROP NOT NULL;
ALTER TABLE import_jobs ALTER COLUMN r2_key DROP NOT NULL;
```
```ts
// packages/db/src/schema/import-jobs.ts — post-delta shape (Drizzle, pgTable)
export const importJobs = pgTable('import_jobs', {
  id: uuid('id').primaryKey().defaultRandom(),
  tenantId: uuid('tenant_id').notNull().references(() => tenants.id, { onDelete: 'cascade' }),
  createdBy: uuid('created_by').notNull().references(() => users.id),
  type: text('type').notNull(), // CHECK includes 'bulk_action'
  status: text('status').notNull().default('pending'), // 'pending'|'processing'|'completed'|'failed'
  originalFilename: text('original_filename'),           // nullable post-delta
  r2Key: text('r2_key'),                                 // nullable post-delta
  columnMapping: jsonb('column_mapping'),
  meta: jsonb('meta'),                                   // added by this delta
  totalRows: integer('total_rows'),
  rowsProcessed: integer('rows_processed').notNull().default(0),
  successCount: integer('success_count').notNull().default(0),
  skippedCount: integer('skipped_count').notNull().default(0),
  errorCount: integer('error_count').notNull().default(0),
  errorMessage: text('error_message'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  startedAt: timestamp('started_at', { withTimezone: true }),
  completedAt: timestamp('completed_at', { withTimezone: true }),
});

export const importJobResults = pgTable('import_job_results', {
  id: uuid('id').primaryKey().defaultRandom(),
  importJobId: uuid('import_job_id').notNull().references(() => importJobs.id, { onDelete: 'cascade' }),
  tenantId: uuid('tenant_id').notNull(),
  rowNumber: integer('row_number').notNull(),
  status: text('status').notNull(), // 'success'|'skipped'|'error'
  message: text('message'),
  originalData: text('original_data'),
  entityId: uuid('entity_id'),       // = created invoice id for bulk_action invoice_generate
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
```
**Acceptance:**
- [ ] Running the migration twice in a row succeeds with no error (idempotent).
- [ ] After migration, `INSERT INTO import_jobs (tenant_id, created_by, type, status, meta) VALUES (..., 'bulk_action', 'pending', '{}'::jsonb)` succeeds with NULL `original_filename`/`r2_key`.

### Task 2: Shared request/response types
**Blocks:** 5, 6, 7, 8  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/bulk-invoice.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define the preview request, preview row, generate request, generate response, and job-status response types.
- [ ] Define the `meta` shape stored on `import_jobs` for `invoice_generate` jobs.
**Schema / Interfaces:**
```ts
export interface BulkGeneratePreviewRequest {
  period_start: string; // ISO date 'YYYY-MM-DD'
  period_end: string;   // ISO date
  include_time: boolean;
  include_expenses: boolean;
  include_milestones: boolean;
}
export interface BulkGeneratePreviewRow {
  customer_id: string;
  customer_name: string;
  time_hours: number;       // sum of billable unbilled hours in period
  expense_total: number;    // sum of billable unbilled approved expenses
  milestone_total: number;  // sum of completed uninvoiced milestones
  invoice_total: number;    // subtotal pre-VAT across included items
  has_open_invoice: boolean; // existing DRAFT/SENT/APPROVED/TAX_ISSUED/PARTIALLY_PAID invoice overlapping period
  lines_preview: BulkGenerateLinePreview[]; // for [▾ Expand] row breakdown
}
export interface BulkGenerateLinePreview {
  description: string;
  quantity: number;
  unit_price: number;
  line_total: number;
  source: 'time' | 'expense' | 'milestone';
}
export interface BulkGeneratePreviewResponse { customers: BulkGeneratePreviewRow[] }

export interface BulkGenerateRequest {
  period_start: string;
  period_end: string;
  customer_ids: string[];
  include_time: boolean;
  include_expenses: boolean;
  include_milestones: boolean;
}
export interface BulkGenerateInlineResult { id: string; customer_name: string; number: string | null }
export interface BulkGenerateResponse {
  job_id?: string;                          // present for batches > 20 customers
  invoices?: BulkGenerateInlineResult[];    // present for batches <= 20 customers
}

export type BulkGenerateJobStatus = 'pending' | 'processing' | 'completed' | 'failed';
export interface BulkGenerateJobResultRow {
  customer_name: string;
  status: 'created' | 'skipped' | 'error';
  invoice_number?: string; // DRAFT invoices have no number; carries invoice id label when present
  reason?: string;
}
export interface BulkGenerateJobResponse {
  status: BulkGenerateJobStatus;
  total: number;
  processed: number;
  created: number;
  skipped: number;
  failed: number;
  results: BulkGenerateJobResultRow[];
}

// import_jobs.meta shape for type='bulk_action', action='invoice_generate'
export interface InvoiceGenerateJobMeta {
  action: 'invoice_generate';
  period_start: string;
  period_end: string;
  include_time: boolean;
  include_expenses: boolean;
  include_milestones: boolean;
  customer_ids: string[];
  results?: BulkGenerateJobResultRow[]; // populated incrementally by the consumer
}
```
**Acceptance:**
- [ ] Types compile and are exported from `@zync/types`.

### Task 3: Billable-item aggregation queries
**Blocks:** 4, 5  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/src/services/bulk-invoice/queries.ts`
**Steps:**
- [ ] Implement `getBillableTimeByCustomer(db, tenantId, periodStart, periodEnd)` → rows of `{ customer_id, project_id, task_id, project_name, task_name, hourly_rate, total_seconds }` for `time_entries` where `billable = true AND invoice_id IS NULL AND stopped_at IS NOT NULL` and `started_at` within `[periodStart, periodEnd]`, joined to `projects` with `customer_id IS NOT NULL`.
- [ ] Implement `getBillableExpensesByCustomer(db, tenantId, periodStart, periodEnd)` → rows of `{ customer_id, expense_id, description, amount }` for `expenses` where `status = 'COMPLETED' AND billed_at IS NULL AND project_id IS NOT NULL` and `expense_date` within period, joined to `projects` for `customer_id`.
- [ ] Implement `getUninvoicedMilestonesByCustomer(db, tenantId, periodStart, periodEnd)` → rows of `{ customer_id, milestone_id, name, amount }` for `project_milestones` where `completed_at IS NOT NULL AND invoice_id IS NULL` and `completed_at` within period, joined to `projects` for `customer_id`.
- [ ] Implement `getCustomersWithOpenInvoiceInPeriod(db, tenantId, periodStart, periodEnd)` → set of `customer_id` having any `invoices` row with `status IN ('DRAFT','SENT','APPROVED','TAX_ISSUED','PARTIALLY_PAID')` whose `issue_date` (or `created_at` when `issue_date` NULL) falls within the period.
- [ ] All queries scoped via `tenantQuery` (no raw Drizzle from routes; per `no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```ts
export interface TimeAggRow { customer_id: string; project_id: string; task_id: string | null;
  project_name: string; task_name: string | null; hourly_rate: string; total_seconds: number }
export interface ExpenseAggRow { customer_id: string; expense_id: string; description: string; amount: string }
export interface MilestoneAggRow { customer_id: string; milestone_id: string; name: string; amount: string }
export function getBillableTimeByCustomer(db: Db, tenantId: string, start: string, end: string): Promise<TimeAggRow[]>;
export function getBillableExpensesByCustomer(db: Db, tenantId: string, start: string, end: string): Promise<ExpenseAggRow[]>;
export function getUninvoicedMilestonesByCustomer(db: Db, tenantId: string, start: string, end: string): Promise<MilestoneAggRow[]>;
export function getCustomersWithOpenInvoiceInPeriod(db: Db, tenantId: string, start: string, end: string): Promise<Set<string>>;
```
**Acceptance:**
- [ ] Each query returns only the tenant's rows and respects the unbilled/uninvoiced filters.
- [ ] Time entries with `billable = false` or `invoice_id IS NOT NULL` are excluded.

### Task 4: Shared line-item builder + per-customer invoice creation
**Blocks:** 5, 7  ·  **Blocked by:** 1, 3
**Files:**
- Create: `apps/zync-api/src/services/bulk-invoice/build-lines.ts`
- Create: `apps/zync-api/src/services/bulk-invoice/create-invoice.ts`
**Steps:**
- [ ] Implement `buildBillingRunLines(input)`: from the per-customer aggregation rows, produce ordered `invoice_lines` payloads following the spec's content rules:
  - **Time:** group by project → task; one line per group (description = `{project_name} — {task_name|'General'}`, quantity = rounded hours = `total_seconds / 3600`, unit_price = project `hourly_rate`, line_total = quantity × unit_price). If `tenant_settings.invoice_time_grouping = 'per_entry'` (when present), one line per entry instead.
  - **Expenses:** one line per approved expense (description = expense description, quantity = 1, unit_price = amount, `expense_id` set, line_total = amount).
  - **Milestones:** one line per completed uninvoiced milestone (description = `name`, quantity = 1, unit_price = amount, line_total = amount).
  - Assign sequential `position` across all lines; mark `taxable = true`.
- [ ] Implement `createBillingRunInvoice(db, tenantId, userId, customerId, lines, defaults)` inside a single DB transaction:
  - Read the due-date offset `tenant_settings.default_payment_terms_days` (owned by `invoices-core`) via `tenantQuery`, defaulting to `30` if the row is absent; read the VAT rate via `getVatRate`. Do NOT read any invoice-number prefix — DRAFT invoices carry no number.
  - Compute `subtotal = Σ line_total`, `vat_amount = subtotal × vat_rate`, `total = subtotal + vat_amount`.
  - INSERT `invoices` with `status = 'DRAFT'`, `source = 'bulk_generate'`, `currency` from tenant default, `due_date = period_end + payment_terms_days`, `vat_rate` snapshot, `created_by = userId`. **No invoice_number assigned** (DRAFT).
  - INSERT `invoice_lines`.
  - Mark source rows billed in the SAME transaction: `UPDATE time_entries SET invoice_id, billed_at = now()` for billed entries; `UPDATE expenses SET invoice_id, billed_at = now()`; `UPDATE project_milestones SET invoice_id` for billed milestones.
  - Return `{ id, customer_name }`.
- [ ] Add `'bulk_generate'` to the in-app set of accepted `invoices.source` values used for validation (the `source` column is free TEXT in invoices-core; document the value, no DDL change needed).
- [ ] If a selected customer resolves to zero lines, do NOT create an invoice — return a skip marker (`reason: 'no billable items'`).
**Schema / Interfaces:**
```ts
export interface BuiltLine { description: string; quantity: number; unit_price: number;
  line_total: number; position: number; taxable: boolean; expense_id?: string;
  _billed_time_entry_ids: string[]; _billed_expense_id?: string; _billed_milestone_id?: string }
export interface BuildLinesInput {
  time: TimeAggRow[]; expenses: ExpenseAggRow[]; milestones: MilestoneAggRow[];
  perEntryGrouping: boolean;
}
export function buildBillingRunLines(input: BuildLinesInput): BuiltLine[];

export interface CreatedInvoiceRef { id: string; customer_name: string }
export function createBillingRunInvoice(
  db: Db, tenantId: string, userId: string, customer: { id: string; name: string },
  lines: BuiltLine[],
): Promise<CreatedInvoiceRef | { skipped: true; reason: string }>;
```
**Acceptance:**
- [ ] A created invoice is `DRAFT`, has `invoice_number = NULL`, correct subtotal/VAT/total, and its source time entries/expenses/milestones are all marked billed atomically.
- [ ] A customer with no billable items yields a skip, not an empty invoice.
- [ ] Re-running generation for the same period does not double-bill (already-billed rows are excluded by Task 3 filters).

### Task 5: Preview endpoint `POST /api/invoices/bulk-generate/preview`
**Blocks:** 8  ·  **Blocked by:** 2, 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/invoices/bulk-generate.ts`
- Modify: `apps/zync-api/src/routes/invoices/index.ts` (mount sub-router)
**Steps:**
- [ ] Add Zod schema `bulkGeneratePreviewSchema` validating `period_start`/`period_end` (ISO date, end ≥ start), and three booleans (per `require-zod-validation-in-routes`).
- [ ] Gate with `authMiddleware`, `requirePermission('invoices:write')`, and admin-role check.
- [ ] Run the three aggregation queries (respecting include flags) + `getCustomersWithOpenInvoiceInPeriod`.
- [ ] Group results by `customer_id`, build `lines_preview` via `buildBillingRunLines`, compute `time_hours`/`expense_total`/`milestone_total`/`invoice_total` (pre-VAT subtotal), and set `has_open_invoice`.
- [ ] Return `{ customers: BulkGeneratePreviewRow[] }` sorted by `customer_name`.
**Schema / Interfaces:**
```
POST /api/invoices/bulk-generate/preview
  body: BulkGeneratePreviewRequest
  → 200 BulkGeneratePreviewResponse
  Requires: invoices:write, admin
```
**Acceptance:**
- [ ] Customers with an existing open invoice in the period return `has_open_invoice: true`.
- [ ] Excluded item types (toggled off) contribute zero to their totals.
- [ ] Non-admin or missing `invoices:write` → 403.

### Task 6: Generate endpoint `POST /api/invoices/bulk-generate` (inline ≤20 / enqueue >20)
**Blocks:** 8  ·  **Blocked by:** 1, 2, 3, 4
**Files:**
- Modify: `apps/zync-api/src/routes/invoices/bulk-generate.ts`
**Steps:**
- [ ] Add Zod schema `bulkGenerateSchema` (`customer_ids` non-empty string array of UUIDs + the preview fields).
- [ ] Gate `authMiddleware` + `requirePermission('invoices:write')` + admin.
- [ ] **Branch on `customer_ids.length`** using THIS spec's threshold of **20**:
  - **≤ 20:** loop customers, run aggregation + `createBillingRunInvoice` per customer, collect `BulkGenerateInlineResult[]` (skips omitted from `invoices`), return `{ invoices }`.
  - **> 20:** INSERT one `import_jobs` row: `type='bulk_action'`, `status='pending'`, `total_rows = customer_ids.length`, `created_by = userId`, `original_filename = NULL`, `r2_key = NULL`, `meta = InvoiceGenerateJobMeta{ action:'invoice_generate', period..., include..., customer_ids }`. Then `QUEUE.send({ jobId })`. Return `{ job_id }`.
- [ ] Wrap inline per-customer creation so one customer's failure does not abort the others (collect, continue).
**Schema / Interfaces:**
```
POST /api/invoices/bulk-generate
  body: BulkGenerateRequest
  → 200 BulkGenerateResponse  ({ invoices } inline when customer_ids.length <= 20, else { job_id })
  Requires: invoices:write, admin
```
**Acceptance:**
- [ ] A request with ≤20 customers creates DRAFT invoices synchronously and returns them.
- [ ] A request with >20 customers creates an `import_jobs` row with `type='bulk_action'` + `meta.action='invoice_generate'`, enqueues `{ jobId }`, and returns `{ job_id }` (no invoices inline).
- [ ] The 21-vs-20 boundary uses this spec's threshold (>20 queued), not 100.

### Task 7: Queue consumer + job-status endpoint
**Blocks:** 9, 10  ·  **Blocked by:** 1, 4, 6
**Files:**
- Create: `apps/zync-api/src/queues/bulk-invoice-generate.ts`
- Modify: `apps/zync-api/src/index.ts` (register queue consumer branch for `meta.action='invoice_generate'`)
- Modify: `apps/zync-api/src/routes/invoices/bulk-generate.ts` (add GET jobs route)
**Steps:**
- [ ] Implement the queue consumer: load the `import_jobs` row by `jobId`; if missing, `ack`. Set `status='processing'`, `started_at=now()`.
- [ ] For each `customer_id` in `meta.customer_ids`: run aggregation (Task 3) for the job's period/flags, `buildBillingRunLines`, `createBillingRunInvoice`. Record a per-customer outcome (`created` with invoice id, `skipped` with reason, or `error` with reason) into an `import_job_results` row (`entity_id` = created invoice id on success, `status` mapped to `success|skipped|error`, `message` = reason).
- [ ] Increment `rows_processed`, and `success_count`/`skipped_count`/`error_count` accordingly; also append the human-facing result to `meta.results` so the status endpoint can return per-customer rows without an extra join when convenient.
- [ ] On completion: set `status='completed'`, `completed_at=now()`. On unhandled job failure: `status='failed'`, `error_message`.
- [ ] Fire a completion notification via `createNotification`/`deliverNotification` (spec 97): title "{created} invoices generated for {period label}", deep link `/invoices/generate/jobs/:jobId`.
- [ ] Implement `GET /api/invoices/bulk-generate/jobs/:jobId`: gate `invoices:write` + admin + tenant ownership (job's `tenant_id` must equal caller tenant, else 404). Read the `import_jobs` row + `import_job_results` rows; map to `BulkGenerateJobResponse` (`total=total_rows`, `processed=rows_processed`, `created=success_count`, `skipped=skipped_count`, `failed=error_count`, `results` from `import_job_results` joined to customer names).
**Schema / Interfaces:**
```
GET /api/invoices/bulk-generate/jobs/:jobId
  → 200 BulkGenerateJobResponse
  Requires: invoices:write, admin (job must belong to caller's tenant; else 404)

// consumer entry (registered in apps/zync-api/src/index.ts queue() handler):
export async function handleInvoiceGenerateJob(jobId: string, env: Env): Promise<void>;
```
**Acceptance:**
- [ ] A >20-customer job progresses pending → processing → completed and creates one DRAFT invoice per billable customer.
- [ ] `GET …/jobs/:jobId` returns accurate `created`/`skipped`/`failed` counts and a `results` row per customer.
- [ ] Accessing another tenant's job returns 404.
- [ ] On completion a notification is delivered with the deep link.

### Task 8: TanStack Query hooks
**Blocks:** 9, 10  ·  **Blocked by:** 2, 5, 6, 7
**Files:**
- Create: `apps/zync-app/src/features/invoices/bulk-generate/api.ts`
**Steps:**
- [ ] `useBulkGeneratePreview()` — mutation POSTing to `/api/invoices/bulk-generate/preview`.
- [ ] `useBulkGenerate()` — mutation POSTing to `/api/invoices/bulk-generate`; on success returning `job_id`, route to job-status; on inline success, route to invoices list filtered to the batch.
- [ ] `useBulkGenerateJob(jobId)` — query GETting `/api/invoices/bulk-generate/jobs/:jobId` with polling (`refetchInterval` while `status` ∈ {pending, processing}, stop on completed/failed); invalidated by the spec-42 WebSocket completion event when present.
**Acceptance:**
- [ ] Hooks are typed with `@zync/types` and stop polling on terminal status.

### Task 9: Wizard pages `/invoices/generate` (3 steps)
**Blocks:** 11  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/features/invoices/bulk-generate/GenerateWizard.tsx`
- Create: `apps/zync-app/src/features/invoices/bulk-generate/StepPeriod.tsx`
- Create: `apps/zync-app/src/features/invoices/bulk-generate/StepPreview.tsx`
- Create: `apps/zync-app/src/features/invoices/bulk-generate/StepGenerating.tsx`
- Modify: `apps/zync-app/src/features/invoices/InvoiceListHeader.tsx` (add admin-only **[Generate invoices]** button → `/invoices/generate`)
**Steps:**
- [ ] **Step 1 (StepPeriod):** radio for billing period (Last month / This month / Custom range with two date inputs); checkboxes for include time / include billable expenses / include milestones (milestones default off). "Next: Preview →" calls `useBulkGeneratePreview`.
- [ ] **Step 2 (StepPreview):** `DataTable` with a leading checkbox column, columns Customer / Hours / Expenses / Milestones / Total. **Smart pre-deselection:** rows with `has_open_invoice` start unchecked and show a warning sub-row ("already has an open invoice for this period — skip recommended"); they remain selectable. Per-row **[▾ Expand]** reveals `lines_preview` breakdown. Footer shows "Selected: N invoices · Total: ₪X". "Generate N invoices →" calls `useBulkGenerate` with the checked `customer_ids`.
- [ ] **Step 3 (StepGenerating):** for inline (≤20) results, show per-customer "✓ {customer} — created" list and a `Progress` bar, then redirect to invoices list filtered `status=DRAFT&created_at>=today`. For queued (`job_id`) results, show the "Generating in background" card with **[View progress →]** (→ `/invoices/generate/jobs/:jobId`) and **[Go to invoices →]**.
- [ ] Use design-system primitives only (no hardcoded colors/spacing/radius; honor `useDirection` for RTL; respect `prefers-reduced-motion` for the progress animation). Currency formatted via `Intl.NumberFormat` with tenant locale.
- [ ] Header button visible only to admins with `invoices:write`.
**Acceptance:**
- [ ] The three steps navigate forward/back; preview reflects API data; pre-deselected rows are unchecked but still selectable.
- [ ] Generating ≤20 redirects to the filtered DRAFT batch; >20 shows the background card.
- [ ] Layout is correct under RTL (Hebrew) and respects reduced-motion.

### Task 10: Job-status screen `/invoices/generate/jobs/:jobId`
**Blocks:** 11  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/features/invoices/bulk-generate/JobStatusPage.tsx`
**Steps:**
- [ ] Render the billing-run job header with the status badge (pending/processing/completed/failed) and a `Progress` bar showing `processed of total`.
- [ ] List per-customer results: ✓ created (with invoice number/label), ⚠ skipped (with reason), ✗ error (with reason). Footer summary "Created: X · Skipped: Y · Failed: Z".
- [ ] **[Go to invoices →]** enabled only on `completed`; routes to the invoices list filtered `status=DRAFT&job_id=:jobId`.
- [ ] Poll via `useBulkGenerateJob`; stop polling on terminal status; reachable from the in-progress card and the completion notification deep link.
**Acceptance:**
- [ ] Screen polls while processing and stops when completed/failed.
- [ ] "Go to invoices →" is disabled until completion and then filters to the batch via `job_id`.

### Task 11: Route registration + invoices-list `job_id` filter
**Blocks:** 12  ·  **Blocked by:** 9, 10
**Files:**
- Modify: `apps/zync-app/src/router.tsx` (or the app route registry) — register `/invoices/generate` and `/invoices/generate/jobs/:jobId`
- Modify: `apps/zync-api/src/routes/invoices/index.ts` — extend `GET /api/invoices` to accept `job_id` query param
**Steps:**
- [ ] Register both page routes (per spec: `/invoices/generate/jobs/:jobId` is owned by spec 133 and MUST be in the route registry).
- [ ] Extend the invoices list endpoint to accept `?job_id=` and, when present, restrict results to invoices whose ids appear in `import_job_results.entity_id WHERE import_job_id = :jobId AND status = 'success'` (there is no `job_id` column on `invoices`; resolve via the results join). Keep cursor pagination and the ≤100-row invariant from invoices-core.
**Acceptance:**
- [ ] Navigating to both routes renders the wizard and the job-status page.
- [ ] `GET /api/invoices?status=DRAFT&job_id=:jobId` returns exactly the batch's created invoices.

### Task 12: Tests
**Blocks:** —  ·  **Blocked by:** 11
**Files:**
- Create: `apps/zync-api/src/services/bulk-invoice/__tests__/build-lines.test.ts`
- Create: `apps/zync-api/src/routes/invoices/__tests__/bulk-generate.test.ts`
**Steps:**
- [ ] Unit-test `buildBillingRunLines`: time grouping (project→task and per-entry), one line per expense, one per milestone, correct positions and totals.
- [ ] Integration-test preview: aggregation filters (unbilled/uninvoiced only), include-flag gating, `has_open_invoice` detection.
- [ ] Integration-test generate: ≤20 inline path creates DRAFT invoices and marks rows billed atomically; >20 path creates `import_jobs` (`type='bulk_action'`) and enqueues; no double-billing on re-run.
- [ ] Auth tests: non-admin / missing `invoices:write` → 403; cross-tenant job fetch → 404.
**Acceptance:**
- [ ] All tests pass; line-builder and both API paths are covered.
