# Recurring Invoices — Implementation Plan

**Spec:** docs/specs/2026-05-31-recurring-invoices.md  ·  **Slug:** recurring-invoices  ·  **Wave:** 7
**Depends on:** customers-module, foundation-auth-rbac, invoices-core, notification-center, zync-subscription

## Goal
Deliver recurring invoice templates: a tenant stores a customer, line items, and a schedule (weekly / monthly / quarterly / yearly), and a daily Cloudflare Cron Trigger auto-generates due invoices, optionally auto-sends them, and advances the next generation date. This eliminates manual work for retainer / subscription billing and is gated to Business tier and above (Business = max 20 active templates, Enterprise = unlimited).

## Architecture
- **New table** `recurring_invoice_templates` (tenant-scoped) holds the template: customer, JSONB `line_items` (same shape as invoices-core line rows), currency, VAT rate, frequency, schedule window, and generation bookkeeping (`next_generation_date`, `last_generated_at`, `generated_count`, `status`).
- **Cross-wave modify of `invoices`** (owned upstream by invoices-core, wave 6): add nullable `recurring_template_id UUID REFERENCES recurring_invoice_templates(id) ON DELETE SET NULL` so generated invoices link back to their template. This ALTER lives in *this* plan's migration, not invoices-core's.
- **Generation job** runs from a new internal cron route `POST /api/cron/recurring-invoice-generate`, registered as a Cloudflare cron `0 6 * * *`. It coexists with the existing `POST /api/cron/subscription-trial-check` (zync-subscription); the worker `scheduled()` handler dispatches by `event.cron` string to the correct internal route, and each route is guarded by a `CRON_SECRET` timing-safe header check (same pattern as subscription-trial-check).
- **Invoice creation** reuses invoices-core: generated rows are written into `invoices` + `invoice_lines` with `source = 'manual'` semantics carried by the template (status `DRAFT` when `auto_send=false`, full send flow when `auto_send=true`). Auto-send reuses the invoices-core `POST /api/invoices/:id/send` flow (proforma number assignment + email).
- **Tier gating** uses `requireTier('business')` (foundation-auth-rbac); active-template limit enforced server-side by counting `status='active'` rows.
- **Notifications** on auto-complete use `createNotification(db, { tenantId, userId, type, titleKey, bodyKey?, params })` (system-communications-notifications) addressed to the tenant OWNER.
- **UI** adds a `Recurring` tab to the invoices module (`/invoices/recurring`), a create/edit form with a live preview + next-3-dates panel, and a filtered generated-invoices view (`/invoices?recurring_template_id={id}`).

Upstream tables/exports consumed: `tenants`, `customers`, `users`, `invoices`, `invoice_lines`, `vat_rates`, `requireTier`, `requirePermission`, `tenantQuery`, `rateLimit`, `createNotification`, `InvoiceLineObject`, `InvoiceStatus`, `PaginatedResponse`, `buildPaginated`, `encodeCursor`, `decodeCursor`, `DataTable`, `Badge`, `Sheet`, `Dialog`, `Select`, `Input`, `Textarea`, `Switch`, `Button`, `Form`, `toast`, `useTierGate`, `useUpgradeModal`.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers). New route file `routes/recurring-invoices.ts`; cron handler `cron/recurring-invoice-generate.ts`; generation/date logic in `packages/db` query module + a pure date helper.
- **DB:** Neon Postgres via Cloudflare Hyperdrive, Drizzle ORM. New Drizzle table in `packages/db/src/schema`, SQL migration in `packages/db/migrations`.
- **App:** `apps/zync-app` (Vite + React). New pages/components under `src/pages/invoices/recurring/`, react-query hooks.
- **Bindings/env:** Hyperdrive (Postgres), `CRON_SECRET` (internal cron auth), `RATELIMIT_KV` / `rateLimit` for generate-now throttling, email send path via invoices-core send flow.
- **Packages:** `@zync/db`, `@zync/types` (template + serializer types), `@zync/ui` (components), `@zync/auth` (`requireTier`, `requirePermission`).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A (schema) | 1 | `packages/db/src/schema/recurring-invoice-templates.ts`, `packages/db/migrations/*`, `packages/db/src/schema/invoices.ts` | No (foundation for all) |
| B (types + date logic + query layer) | 2, 3 | `packages/types/src/recurring-invoice.ts`, `packages/db/src/queries/recurring-invoices.ts`, `packages/db/lib/recurring-date.ts` | Yes (2 and 3 independent) |
| C (API routes) | 4, 5 | `apps/zync-api/src/routes/recurring-invoices.ts` | No (5 builds on 4 helpers) |
| D (cron + generation) | 6 | `apps/zync-api/src/cron/recurring-invoice-generate.ts`, `apps/zync-api/src/index.ts`, `apps/zync-api/wrangler.toml` | No (uses Task 3 generation fn) |
| E (UI) | 7, 8, 9 | `apps/zync-app/src/pages/invoices/recurring/*`, invoices nav, hooks | Partly (7 list, 8 form, 9 filtered view independent after hooks) |

## Tasks

### Task 1: Schema — `recurring_invoice_templates` table + `invoices` link column
**Blocks:** 2, 3, 4, 6  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/recurring-invoice-templates.ts` (Drizzle table)
- Create: `packages/db/migrations/00XX_recurring_invoice_templates.sql`
- Modify: `packages/db/src/schema/invoices.ts` (add `recurringTemplateId` column to the Drizzle `invoices` table definition)
- Modify: `packages/db/src/schema/index.ts` (export new table)
**Steps:**
- [ ] Write the SQL migration creating `recurring_invoice_templates` with all columns, CHECK constraints, defaults, and the three indexes exactly as in the DDL below.
- [ ] Append the `ALTER TABLE invoices ADD COLUMN recurring_template_id ...` to the same migration (this column belongs to this feature; invoices-core does not define it).
- [ ] Mirror the table in Drizzle (`pgTable`) using `uuid`, `text`, `jsonb`, `numeric`, `integer`, `boolean`, `date`, `timestamp` column builders; declare the FKs and the `gen_random_uuid()` default.
- [ ] Add `recurringTemplateId: uuid('recurring_template_id').references(() => invoices_recurring? )` — reference `recurringInvoiceTemplates.id` with `onDelete: 'set null'` on the `invoices` Drizzle table.
**Schema / Interfaces:**
```sql
CREATE TABLE recurring_invoice_templates (
  id                   UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id            UUID         NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  customer_id          UUID         NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
  title                TEXT         NOT NULL,
  description          TEXT,
  line_items           JSONB        NOT NULL DEFAULT '[]'::jsonb,
  currency             TEXT         NOT NULL DEFAULT 'ILS' CHECK (currency IN ('ILS', 'USD', 'EUR')),
  vat_rate             NUMERIC(5,4) NOT NULL DEFAULT 0.18,
  frequency            TEXT         NOT NULL CHECK (frequency IN ('weekly', 'monthly', 'quarterly', 'yearly')),
  frequency_day        INTEGER,
  payment_terms_days   INTEGER      NOT NULL DEFAULT 30,
  start_date           DATE         NOT NULL,
  end_date             DATE,
  auto_send            BOOLEAN      NOT NULL DEFAULT FALSE,
  auto_charge          BOOLEAN      NOT NULL DEFAULT FALSE,
  next_generation_date DATE         NOT NULL,
  last_generated_at    TIMESTAMPTZ,
  status               TEXT         NOT NULL DEFAULT 'active'
                         CHECK (status IN ('active', 'paused', 'completed', 'cancelled')),
  generated_count      INTEGER      NOT NULL DEFAULT 0,
  created_by           UUID         NOT NULL REFERENCES users(id),
  created_at           TIMESTAMPTZ  NOT NULL DEFAULT now(),
  updated_at           TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE INDEX idx_rit_tenant   ON recurring_invoice_templates(tenant_id, status);
CREATE INDEX idx_rit_next_gen ON recurring_invoice_templates(next_generation_date) WHERE status = 'active';
CREATE INDEX idx_rit_customer ON recurring_invoice_templates(tenant_id, customer_id);

ALTER TABLE invoices
  ADD COLUMN recurring_template_id UUID REFERENCES recurring_invoice_templates(id) ON DELETE SET NULL;
```
**Acceptance:**
- [ ] Migration applies cleanly on a fresh Neon branch; all three indexes and four CHECK constraints exist.
- [ ] `invoices.recurring_template_id` is nullable and FK-constrained with `ON DELETE SET NULL`.
- [ ] Drizzle schema typechecks and `recurringInvoiceTemplates` is exported from the schema barrel.

### Task 2: Types + serializer — `RecurringInvoiceTemplate`, `RecurringTemplateLineItem`, `serializeRecurringTemplate`
**Blocks:** 4, 7, 8, 9  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/recurring-invoice.ts`
- Modify: `packages/types/src/index.ts` (export new types)
- Create: `packages/db/serializers/recurring-invoice.ts` (`serializeRecurringTemplate`)
**Steps:**
- [ ] Define the wire `RecurringTemplateObject` (decimal-string monetary fields, ISO date strings) consistent with invoices-core `InvoiceLineObject`.
- [ ] Define `RecurringFrequency`, `RecurringTemplateStatus`, and the line-item shape reused for `line_items` JSONB.
- [ ] Define `CreateRecurringTemplateInput` / `UpdateRecurringTemplateInput` and a Zod schema pair (`createRecurringTemplateSchema`, `updateRecurringTemplateSchema`) for route validation.
- [ ] Implement `serializeRecurringTemplate(row, opts?)` returning `RecurringTemplateObject` plus `next_dates: string[]` (next 3 dates) when requested.
**Schema / Interfaces:**
```ts
export type RecurringFrequency = 'weekly' | 'monthly' | 'quarterly' | 'yearly';
export type RecurringTemplateStatus = 'active' | 'paused' | 'completed' | 'cancelled';

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

export interface RecurringTemplateObject {
  id: string;
  customer_id: string;
  title: string;
  description: string | null;
  line_items: RecurringTemplateLineItem[];
  currency: 'ILS' | 'USD' | 'EUR';
  vat_rate: string;                 // e.g. "0.1800"
  frequency: RecurringFrequency;
  frequency_day: number | null;
  payment_terms_days: number;
  start_date: string;               // ISO date
  end_date: string | null;          // ISO date
  auto_send: boolean;
  auto_charge: boolean;
  next_generation_date: string;     // ISO date
  last_generated_at: string | null; // ISO 8601 ts
  status: RecurringTemplateStatus;
  generated_count: number;
  next_dates?: string[];            // next 3 scheduled dates (detail view only)
  created_at: string;
  updated_at: string;
}

export interface CreateRecurringTemplateInput {
  customer_id: string;
  title: string;                    // max 100 chars
  description?: string;
  line_items: RecurringTemplateLineItem[];
  currency?: 'ILS' | 'USD' | 'EUR';
  vat_rate?: string;
  frequency: RecurringFrequency;
  frequency_day?: number;           // 1–28 month/quarter/year; 0–6 weekly
  payment_terms_days?: 7 | 14 | 30 | 45 | 60;
  start_date: string;
  end_date?: string | null;
  auto_send?: boolean;
  auto_charge?: boolean;            // accepted but ignored (v2 placeholder)
}
export type UpdateRecurringTemplateInput = Partial<CreateRecurringTemplateInput>;
```
**Acceptance:**
- [ ] Types exported from `@zync/types`; Zod schemas reject `title` > 100 chars and out-of-range `frequency`/`payment_terms_days`.
- [ ] `serializeRecurringTemplate` emits `vat_rate` as a 4-decimal string and dates as ISO strings.

### Task 3: Date logic + query/generation layer
**Blocks:** 4, 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/lib/recurring-date.ts` (pure date functions)
- Create: `packages/db/src/queries/recurring-invoices.ts` (CRUD + generation)
**Steps:**
- [ ] Implement `computeNextDate(frequency, fromDate, frequencyDay?)` per the spec switch, with month/quarter/year day-set + clamping to the target month's last valid day when `frequencyDay > daysInMonth`. Weekly advances 7 days. Returns `YYYY-MM-DD`.
- [ ] Implement `computeInitialNextGenerationDate(startDate, frequency, frequencyDay?)`: first generation on/after `start_date`; for monthly/quarterly/yearly snap to `frequency_day` (clamped); for weekly advance to the matching day-of-week ≥ start_date.
- [ ] Implement `nextNDates(template, n)` returning the next `n` projected dates from `next_generation_date` (UI preview).
- [ ] Implement CRUD: `listRecurringTemplates`, `getRecurringTemplate`, `createRecurringTemplate`, `updateRecurringTemplate`, `cancelRecurringTemplate` (sets `status='cancelled'`), `setRecurringTemplateStatus` (pause/resume), `countActiveTemplates`.
- [ ] Implement `generateInvoiceFromTemplate(db, template)`: (1) idempotency guard — exact match on `(recurring_template_id, recurring_period_start)` where `recurring_period_start = template.next_generation_date` (`SELECT count(*) FROM invoices WHERE recurring_template_id=$1 AND recurring_period_start=$2`); skip if > 0; partial UNIQUE index `invoices_recurring_template_period_uniq` on `(recurring_template_id, recurring_period_start)` WHERE both NOT NULL prevents double-cron races; (2) insert `invoices` row (`recurring_template_id`, `recurring_period_start = template.next_generation_date`, `customer_id`, `currency`, `vat_rate`, computed `due_date = generation_date + payment_terms_days`, `source='manual'`, status `DRAFT`) + `invoice_lines` from `line_items`, computing `subtotal`/`vat_amount`/`total`; (3) advance via the same UPDATE as the spec (set `next_generation_date`, `last_generated_at=now()`, `generated_count+1`, auto-complete when `next > end_date`); (4) return `{ invoiceId, completed: boolean }`. Auto-send is handled by the caller (route/cron), not here.
- [ ] Implement `runRecurringInvoiceJob(db, today)`: select active, due, not-past-end templates and call `generateInvoiceFromTemplate` per row inside a per-template transaction; collect results for auto-send + notification handling.
**Schema / Interfaces:**
```ts
export function computeNextDate(
  frequency: RecurringFrequency, fromDate: string, frequencyDay?: number | null,
): string; // YYYY-MM-DD, clamps day to month length

export function nextNDates(
  args: { frequency: RecurringFrequency; nextGenerationDate: string; frequencyDay?: number | null; endDate?: string | null },
  n: number,
): string[];

export function generateInvoiceFromTemplate(
  db: DB, template: RecurringTemplateRow,
): Promise<{ invoiceId: string | null; completed: boolean; skipped: boolean }>;

export function runRecurringInvoiceJob(
  db: DB, today: string,
): Promise<Array<{ templateId: string; invoiceId: string | null; completed: boolean; autoSend: boolean; ownerUserId: string }>>;
```
**Acceptance:**
- [ ] `computeNextDate('monthly', '2026-01-31', 31)` returns `'2026-02-28'` (clamped); stored `frequency_day` stays 31.
- [ ] Idempotency guard prevents a second invoice for the same period on cron retry.
- [ ] Advancing past `end_date` sets template `status='completed'` and the function reports `completed: true`.

### Task 4: API CRUD routes — list / create / get / patch / delete
**Blocks:** 7, 8  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/recurring-invoices.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router under `/api/recurring-invoices`)
**Steps:**
- [ ] Gate the whole router with `requireModuleEnabled('invoices')`, `requireTier('business')`, and `authMiddleware`; per-route `requirePermission('invoices:write')` for mutations and `invoices:read` for reads (read-only members may view; pause/cancel/delete additionally require OWNER/ADMIN per the permission matrix).
- [ ] `GET /api/recurring-invoices` — query params `status`, `customer_id`, `cursor`, `limit` (default 50, hard cap 100); cursor pagination via `encodeCursor`/`decodeCursor`/`buildPaginated`; scope by `tenantQuery`. Returns `PaginatedResponse<RecurringTemplateObject>`.
- [ ] `POST /api/recurring-invoices` — validate with `createRecurringTemplateSchema`; enforce active-template limit: if tier is `business` and `countActiveTemplates(tenantId) >= 20`, return `403` with upgrade-prompt body `{ error: 'template_limit_reached', upgrade: { requiredTier: 'enterprise', ... } }`; compute `next_generation_date` via `computeInitialNextGenerationDate`; if `auto_charge` truthy, store it but include `warning: 'auto_charge is not yet supported and will be ignored'` in the response; set `created_by` from session. Returns `201 RecurringTemplateObject`.
- [ ] `GET /api/recurring-invoices/:id` — return template with `generated_count` and `next_dates` (next 3) via `serializeRecurringTemplate(row, { withNextDates: true })`. 404 if not in tenant.
- [ ] `PATCH /api/recurring-invoices/:id` — validate with `updateRecurringTemplateSchema`; if `frequency`, `frequency_day`, or `start_date` changed, recompute `next_generation_date`; same `auto_charge` warning behavior. Pause/resume go through this route via `status` (OWNER/ADMIN only).
- [ ] `DELETE /api/recurring-invoices/:id` — soft-cancel: `cancelRecurringTemplate` sets `status='cancelled'`; generated invoices remain intact; OWNER/ADMIN only. Returns `200 { status: 'cancelled' }`.
**Schema / Interfaces:**
```ts
// GET /api/recurring-invoices  -> PaginatedResponse<RecurringTemplateObject>
// POST /api/recurring-invoices -> 201 RecurringTemplateObject (+ optional `warning`)
//   403 body on Business limit: { error: 'template_limit_reached', upgrade: { requiredTier: 'enterprise' } }
// GET /api/recurring-invoices/:id  -> RecurringTemplateObject (with next_dates[3])
// PATCH /api/recurring-invoices/:id -> RecurringTemplateObject
// DELETE /api/recurring-invoices/:id -> { status: 'cancelled' }
```
**Acceptance:**
- [ ] Freelancer tenant receives 402 from `requireTier('business')` on every route.
- [ ] Business tenant with 20 active templates gets 403 `template_limit_reached`; Enterprise is unlimited.
- [ ] `auto_charge: true` request succeeds, stores the value, and returns the documented `warning`.
- [ ] List never returns more than 100 rows; cursor round-trips.

### Task 5: API action routes — generate-now + generated-invoices list
**Blocks:** 7  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-api/src/routes/recurring-invoices.ts`
**Steps:**
- [ ] `POST /api/recurring-invoices/:id/generate-now` — require `invoices:write`; reject with 409 if template `status != 'active'`; apply `rateLimit` keyed `recurring-gen:{templateId}` allowing max 1 call per template per hour (429 on exceed); call `generateInvoiceFromTemplate`; if `template.auto_send`, invoke the invoices-core send flow (`POST /api/invoices/:id/send` internal helper) on the new invoice; return `{ invoiceId, completed }`.
- [ ] `GET /api/recurring-invoices/:id/invoices` — list all invoices where `recurring_template_id = :id` for this tenant, using the standard invoice list response shape (cursor pagination, `serializeInvoice`). 404 if template not in tenant.
**Schema / Interfaces:**
```ts
// POST /api/recurring-invoices/:id/generate-now
//   -> 200 { invoiceId: string; completed: boolean }
//   -> 409 if status != 'active'
//   -> 429 if called > 1x/hour for this template
// GET /api/recurring-invoices/:id/invoices -> PaginatedResponse<InvoiceObject>
```
**Acceptance:**
- [ ] Second `generate-now` within an hour for the same template returns 429.
- [ ] `generate-now` on a paused/cancelled/completed template returns 409.
- [ ] When `auto_send=true`, the generated invoice is moved out of DRAFT via the send flow (proforma number assigned).

### Task 6: Daily generation cron worker
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/cron/recurring-invoice-generate.ts`
- Modify: `apps/zync-api/src/index.ts` (mount `POST /api/cron/recurring-invoice-generate`; extend the `scheduled()` handler to dispatch `0 6 * * *` → this route)
- Modify: `apps/zync-api/wrangler.toml` (add `0 6 * * *` to `[triggers] crons`, alongside the existing subscription-trial-check schedule)
**Steps:**
- [ ] Guard the route with a `CRON_SECRET` header compared via `timingSafeEqual`; mismatch → 401 (mirror the subscription-trial-check route exactly; do not clobber its registration).
- [ ] In the `scheduled()` handler, dispatch by `event.cron`: when it matches `0 6 * * *`, invoke the recurring-generate handler; preserve the existing subscription-trial-check dispatch branch.
- [ ] Handler computes `today = new Date().toISOString().slice(0,10)` and calls `runRecurringInvoiceJob(db, today)`.
- [ ] For each generated invoice where the template `auto_send=true`, run the invoices-core send flow (PDF/HTML generation + email).
- [ ] For each template that transitioned to `completed`, call `createNotification(db, { tenantId, userId: ownerUserId, type: 'invoice', titleKey: 'recurring.completed.title', bodyKey: 'recurring.completed.body', params: { title: template.title } })` addressed to the tenant OWNER.
- [ ] Log a per-run summary (templates due, generated, skipped-by-idempotency, completed, auto-sent).
**Schema / Interfaces:**
```ts
// POST /api/cron/recurring-invoice-generate   (internal; header `X-Cron-Secret` timing-safe vs CRON_SECRET)
//   body: none ; -> 200 { due: number; generated: number; skipped: number; completed: number; autoSent: number }
// wrangler.toml:
//   [triggers]
//   crons = ["0 6 * * *", "<existing subscription-trial-check schedule>"]
// scheduled(event) dispatch: event.cron === '0 6 * * *' -> recurring-generate
```
**Acceptance:**
- [ ] Missing/incorrect cron secret → 401 (timing-safe compare).
- [ ] Adding the new schedule does not remove or alter the subscription-trial-check cron; both dispatch correctly by `event.cron`.
- [ ] A due active template produces exactly one invoice; auto_send templates are sent; completed templates notify the OWNER.

### Task 7: UI — `/invoices/recurring` templates list (Invoices tab)
**Blocks:** —  ·  **Blocked by:** 4, 5
**Files:**
- Create: `apps/zync-app/src/pages/invoices/recurring/RecurringTemplatesPage.tsx`
- Create: `apps/zync-app/src/pages/invoices/recurring/useRecurringTemplates.ts` (react-query hooks)
- Modify: invoices module nav/router config (add `Invoices | Recurring` tab + route `/invoices/recurring`)
**Steps:**
- [ ] Add the `Recurring` tab to the invoices module navigation next to `Invoices`.
- [ ] Render a `DataTable` with columns: Template name, Customer, Frequency, Next date, Status, Generated, Actions. Cursor pagination via the list hook.
- [ ] Status badge using `Badge`: Active (green / success), Paused (amber / warning), Completed (grey / neutral), Cancelled (red / destructive).
- [ ] Row actions: Edit (opens form, Task 8), Pause/Resume toggle (PATCH status), Cancel (with `Dialog` confirmation → DELETE), "Generate now" (POST generate-now; disabled when `status != 'active'`), "View generated invoices" (navigate to `/invoices?recurring_template_id={id}`).
- [ ] Gate the page/tab and write-actions: hide for tenants below Business via `useTierGate('business')`; show upgrade affordance via `useUpgradeModal`. Hide pause/cancel/delete for non-OWNER/ADMIN per the permission matrix; hide entirely for CONTRACTOR / CLIENT_PORTAL.
- [ ] "New Recurring Template" button opens the create form (Task 8).
- [ ] Respect a11y: table has aria roles, status badges have text labels (not color only); honor `prefers-reduced-motion` on toggles/dialogs; RTL via existing layout direction.
**Acceptance:**
- [ ] List renders all seven columns with correct status colors and accessible labels.
- [ ] "Generate now" is disabled for non-active templates; Cancel shows a confirmation dialog.
- [ ] Below-Business tenants see an upgrade prompt instead of the management UI.

### Task 8: UI — Template create/edit form (preview + next-3-dates)
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/pages/invoices/recurring/RecurringTemplateForm.tsx`
- Create: `apps/zync-app/src/pages/invoices/recurring/RecurringTemplatePreview.tsx`
**Steps:**
- [ ] Build the form (in a `Sheet` or page) with fields: Customer (searchable `Select`, required), Template title (`Input`, required, max 100), Description (`Textarea`, optional), Frequency (`Select`: Weekly/Monthly/Quarterly/Yearly), Day of month/week (`Input` number 1–28 for monthly/quarterly/yearly, day-of-week picker for weekly — shown conditionally), Start date (date picker, required, defaults to today), End date (date picker, optional), Line items (reuse the invoices-core line-item editor component), Payment terms (`Select` 7/14/30/45/60), Currency (`Select` ILS/USD/EUR), VAT rate (`Input` number, pre-filled from tenant default via `getVatRate`/tenant settings), Auto-send (`Switch`, Business+ only; if off, label clarifies "creates Draft").
- [ ] Validate client-side mirroring `createRecurringTemplateSchema`; submit to POST (create) or PATCH (edit).
- [ ] Preview panel: live sample invoice from current line items — compute subtotal, VAT (using the VAT rate field), total, and due date (start_date + payment_terms_days).
- [ ] "Next generation" preview: show the next 3 scheduled dates computed from current frequency/start/frequency_day (use the detail endpoint's `next_dates`, or compute client-side from the same rule).
- [ ] a11y: labeled inputs, error messages associated via `aria-describedby`; reduced-motion respected on the sheet transition; RTL-aware date pickers.
**Acceptance:**
- [ ] Day-of-month field appears only for monthly/quarterly/yearly; day-of-week picker only for weekly.
- [ ] Preview totals and due date update live as line items / VAT / terms change.
- [ ] "Next generation" panel shows 3 correct upcoming dates (clamped for end-of-month cases).
- [ ] Auto-send toggle is disabled/hidden below Business.

### Task 9: UI — generated invoices filtered view
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-app/src/pages/invoices/InvoicesListPage.tsx` (honor `recurring_template_id` query param)
**Steps:**
- [ ] When `/invoices?recurring_template_id={id}` is present, pass the filter to the invoice list query (server-side filter implemented in Task 5's `GET /api/recurring-invoices/:id/invoices`, or the existing invoices list filter param if available).
- [ ] Add a breadcrumb: "Recurring Templates > {Template Title}" (fetch the template title via the detail hook).
- [ ] Provide a clear-filter affordance returning to the full invoice list.
**Acceptance:**
- [ ] Visiting `/invoices?recurring_template_id={id}` shows only invoices generated from that template with the correct breadcrumb.
- [ ] Removing the filter restores the full list.
