# Recurring Expenses — Implementation Plan

**Spec:** docs/specs/2026-06-01-recurring-expenses.md  ·  **Slug:** recurring-expenses  ·  **Wave:** 10
**Depends on:** expenses-module, foundation-auth-rbac, settings-module

## Goal
Deliver recurring expense templates that auto-generate `expenses` rows on a configurable schedule (weekly / monthly / quarterly / yearly), removing the manual re-entry of fixed recurring costs (SaaS, rent, retainers, insurance). A daily Cloudflare Cron Trigger materializes due templates into normal expense records that flow through the standard expense + approval pipeline. Templates are manageable (create / edit / pause / resume / delete) under `/expenses/recurring`.

## Architecture
A new tenant-scoped table `recurring_expenses` holds the template definition (amount, category, VAT, schedule, approval flag, pre-computed `next_due_date`). The `expenses` table (owned by `expenses-module`) gains one new FK column `recurring_template_id` linking generated rows back to their template, and the `r2_key / file_name / file_type / file_size_bytes` NOT NULL constraints are relaxed because recurring-generated expenses have no receipt file.

Data flow:
1. Staff create a template via `POST /api/expenses/recurring` (Hono route, `expenses:write`, tenant-scoped via `tenantQuery`). `next_due_date` is computed at creation from `start_date` + schedule.
2. A daily cron (`0 6 * * *`, registered in `apps/zync-api/wrangler.toml [triggers]`) invokes `generateDueExpenses(env)` which selects active templates with `next_due_date <= today` (index `idx_recurring_exp_tenant`), inserts an `expenses` row per template, advances `next_due_date` via `computeNextDueDate`, and notifies approvers when `auto_approve = false`.
3. Generated expenses appear in the normal `/expenses` list; staff can edit or reject them without touching the template.

Upstream consumed (exact names):
- Tables: `tenants(id)`, `users(id)`, `projects(id)`, `expenses`, `notifications`, `tenant_memberships`.
- Exports: `createDb` / `Db` / `Env` (`@zync/db`, `@zync/types`), `tenantQuery` + `systemQuery` (tenant/cross-tenant scoping helpers), `authMiddleware` + `requirePermission` (`@zync/auth`), `createNotification` (`@zync/notifications`), `buildPaginated` / `clampLimit` (pagination), `ApiError`, `Button` / `Dialog` / `DataTable` / `Form` / `FormField` / `Select` / `Input` / `Switch` / `Badge` / `EmptyState` / `toast` (`@zync/ui`).

**Runtime coupling note (not in dependency chain):** the columns `expenses.approval_status` ('not_required'|'pending'|'approved'|'rejected'), `approved_by`, `approved_at` are owned by the `expense-approval-workflow` spec, which is NOT in this task's `depends_on`. The cron writes `approval_status` per the spec. The implementing agent must ensure those columns exist (they are added by `expense-approval-workflow`); if building before that spec lands, guard the write so a missing column degrades gracefully (insert without `approval_status`, default applies). The canonical approver notification type used by `expense-approval-workflow` is "Expense submitted (above threshold) → Approver"; we reuse that path.

**Schema reconciliation (spec illustrative SQL is corrected here — do NOT transcribe the spec's cron INSERT verbatim):**
- `category` (template column) maps to `expenses.expense_category` on insert — the expenses spec forbids a bare `category` column on `expenses`.
- `expenses` has no `vat_rate` column; it has `vat_amount`. The template carries `vat_rate`; on generation derive `vat_amount = round(amount * vat_rate / (1 + vat_rate), 2)` because `amount` is GROSS (incl VAT) per the canonical expenses notes. When `vat_deductible = false`, set `vat_amount = 0`.
- OCR `status` must be `'COMPLETED'` (with `processed_at = now()`), NOT `'PENDING'` — a fileless expense set to PENDING would stall in the OCR queue. `status` (OCR lifecycle) and `approval_status` (approval lifecycle) are orthogonal.
- `source` already exists on `expenses` (DEFAULT `'upload'`). Do NOT `ADD COLUMN source` — only document `'recurring'` as a newly-allowed value and set it on insert.
- `created_by` on the template is nullable with `ON DELETE SET NULL` (matching the codebase `_by` pattern; a NOT NULL + SET NULL combination is self-contradictory).

## Tech Stack
- **App:** `apps/zync-api` (Hono on Cloudflare Workers) — routes + cron consumer.
- **App:** `apps/zync-app` (Vite + React) — `/expenses/recurring` page + template modal.
- **Packages:** `@zync/db` (Drizzle schema + migration), `@zync/types` (template types, frequency enum, list response), `@zync/ui` (existing primitives), `@zync/auth` (`authMiddleware`, `requirePermission`), `@zync/notifications` (`createNotification`).
- **Cloudflare bindings:** Hyperdrive → Neon Postgres (via `createDb`), Cron Trigger `0 6 * * *`.
- **Validation:** Zod schemas in routes (per `require-zod-validation-in-routes`).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 10a (schema) | 1, 2 | `packages/db/src/schema/recurring-expenses.ts`, migration SQL, `packages/db/src/schema/expenses.ts` | No — schema first |
| 10b (types + logic) | 3, 4 | `packages/types/src/recurring-expenses.ts`, `apps/zync-api/src/lib/recurring-expenses/compute-next-due.ts` | Yes (after 10a) |
| 10c (API) | 5, 6 | `apps/zync-api/src/routes/expenses/recurring.ts`, `apps/zync-api/src/cron/recurring-expenses.ts`, `apps/zync-api/wrangler.toml`, `apps/zync-api/src/index.ts` | Routes ∥ cron after 3,4 |
| 10d (UI) | 7, 8 | `apps/zync-app/src/routes/expenses/recurring/*`, `apps/zync-app/src/hooks/use-recurring-expenses.ts` | Yes (after 5) |
| 10e (verify) | 9 | test files | No — last |

## Tasks

### Task 1: `recurring_expenses` table + Drizzle schema
**Blocks:** 2, 3, 4, 5, 6  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/recurring-expenses.ts`
- Create: `packages/db/migrations/0XXX_recurring_expenses.sql` (next sequential migration number)
- Modify: `packages/db/src/schema/index.ts` (export the new table)
**Steps:**
- [ ] Write the `recurring_expenses` DDL in canonical Postgres (UUID PKs/FKs, TIMESTAMPTZ, BOOLEAN, CHECK enums) exactly as below.
- [ ] Add the composite index `idx_recurring_exp_tenant`.
- [ ] Mirror the table in a Drizzle `pgTable` definition with matching column types, defaults, and the index.
- [ ] Export `recurringExpenses` from `packages/db/src/schema/index.ts`.
**Schema / Interfaces:**
```sql
CREATE TABLE recurring_expenses (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id           UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  created_by          UUID REFERENCES users(id) ON DELETE SET NULL,
  name                TEXT NOT NULL,
  category            TEXT NOT NULL,                       -- maps to expenses.expense_category on generation
  amount              NUMERIC(12,2) NOT NULL,              -- default GROSS amount (incl VAT)
  currency            TEXT NOT NULL DEFAULT 'ILS',
  vat_deductible      BOOLEAN NOT NULL DEFAULT true,
  vat_rate            NUMERIC(5,4) DEFAULT 0.18,
  project_id          UUID REFERENCES projects(id) ON DELETE SET NULL,
  vendor_name         TEXT,
  notes               TEXT,
  -- Schedule
  frequency           TEXT NOT NULL CHECK (frequency IN ('weekly', 'monthly', 'quarterly', 'yearly')),
  day_of_month        INTEGER CHECK (day_of_month BETWEEN 1 AND 31),  -- monthly/quarterly/yearly
  day_of_week         INTEGER CHECK (day_of_week BETWEEN 0 AND 6),    -- weekly (0=Sunday)
  start_date          DATE NOT NULL,
  end_date            DATE,                                -- NULL = no end
  -- State
  is_active           BOOLEAN NOT NULL DEFAULT true,
  last_generated_date DATE,
  next_due_date       DATE NOT NULL,                       -- pre-computed; advanced after each generation
  -- Approval
  auto_approve        BOOLEAN NOT NULL DEFAULT false,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_recurring_exp_tenant ON recurring_expenses(tenant_id, is_active, next_due_date);
```
**Acceptance:**
- [ ] Migration applies cleanly against Neon (`drizzle-kit`/manual) with no SQLite-isms.
- [ ] `recurringExpenses` Drizzle object compiles and is exported from the schema barrel.

### Task 2: `expenses` schema delta (link column + relax file constraints)
**Blocks:** 6  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/db/src/schema/expenses.ts`
- Create/append: `packages/db/migrations/0XXX_expenses_recurring_link.sql`
**Steps:**
- [ ] Add `recurring_template_id UUID REFERENCES recurring_expenses(id) ON DELETE SET NULL` to `expenses` (Drizzle + SQL).
- [ ] Relax the receipt-file NOT NULL constraints so recurring-generated expenses (no file) can be inserted.
- [ ] Do NOT add a `source` column — it already exists (`DEFAULT 'upload'`). Document `'recurring'` as a newly-allowed value in a code comment only.
- [ ] Add an index on `recurring_template_id` to back template-detail history queries.
**Schema / Interfaces:**
```sql
ALTER TABLE expenses
  ADD COLUMN recurring_template_id UUID REFERENCES recurring_expenses(id) ON DELETE SET NULL;

-- Recurring-generated expenses have no receipt file; relax file NOT NULLs.
ALTER TABLE expenses ALTER COLUMN r2_key          DROP NOT NULL;
ALTER TABLE expenses ALTER COLUMN file_name       DROP NOT NULL;
ALTER TABLE expenses ALTER COLUMN file_type       DROP NOT NULL;
ALTER TABLE expenses ALTER COLUMN file_size_bytes DROP NOT NULL;

CREATE INDEX idx_expenses_recurring_template ON expenses(recurring_template_id);
-- NOTE: 'recurring' is a newly-allowed value for the existing expenses.source column (no DDL change).
```
**Acceptance:**
- [ ] Inserting an `expenses` row with NULL `r2_key`/`file_name`/`file_type`/`file_size_bytes` and `source='recurring'` succeeds.
- [ ] `recurring_template_id` FK enforces referential integrity and SET NULLs on template delete.

### Task 3: Types — template, frequency enum, list response, Zod schemas
**Blocks:** 5, 7  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/recurring-expenses.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define the `RecurringFrequency` union and `RecurringExpense` row type matching the table columns.
- [ ] Define `RecurringExpenseListResponse` (paginated) and the create/update Zod schemas.
- [ ] Export all from the package index.
**Schema / Interfaces:**
```ts
export type RecurringFrequency = 'weekly' | 'monthly' | 'quarterly' | 'yearly'

export interface RecurringExpense {
  id: string
  tenantId: string
  createdBy: string | null
  name: string
  category: string
  amount: string            // NUMERIC serialized as string
  currency: string
  vatDeductible: boolean
  vatRate: string | null
  projectId: string | null
  vendorName: string | null
  notes: string | null
  frequency: RecurringFrequency
  dayOfMonth: number | null
  dayOfWeek: number | null
  startDate: string         // YYYY-MM-DD
  endDate: string | null
  isActive: boolean
  lastGeneratedDate: string | null
  nextDueDate: string
  autoApprove: boolean
  createdAt: string
  updatedAt: string
}

export interface RecurringExpenseListResponse {
  items: RecurringExpense[]
  nextCursor: string | null
  total: number
}

import { z } from 'zod'

export const createRecurringExpenseSchema = z.object({
  name: z.string().min(1).max(200),
  category: z.string().min(1),
  amount: z.number().positive(),
  currency: z.string().default('ILS'),
  vatDeductible: z.boolean().default(true),
  vatRate: z.number().min(0).max(1).nullable().default(0.18),
  projectId: z.string().uuid().nullable().optional(),
  vendorName: z.string().max(200).nullable().optional(),
  notes: z.string().nullable().optional(),
  frequency: z.enum(['weekly', 'monthly', 'quarterly', 'yearly']),
  dayOfMonth: z.number().int().min(1).max(31).nullable().optional(),
  dayOfWeek: z.number().int().min(0).max(6).nullable().optional(),
  startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
  autoApprove: z.boolean().default(false),
}).refine(
  (v) => v.frequency === 'weekly' ? v.dayOfWeek != null : v.dayOfMonth != null,
  { message: 'weekly requires dayOfWeek; other frequencies require dayOfMonth' },
)

export const updateRecurringExpenseSchema = createRecurringExpenseSchema.partial()
```
**Acceptance:**
- [ ] Types compile and are re-exported; Zod `refine` rejects a weekly template missing `dayOfWeek`.

### Task 4: `computeNextDueDate` schedule logic
**Blocks:** 5, 6  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/lib/recurring-expenses/compute-next-due.ts`
**Steps:**
- [ ] Implement `computeNextDueDate` advancing from a base date per the spec rules, clamping `day_of_month` to the month's last day.
- [ ] Implement `computeFirstDueDate(startDate, template)` used at creation to seed `next_due_date` (first occurrence on/after `start_date`).
- [ ] Operate purely on `YYYY-MM-DD` strings in UTC to avoid timezone drift; no external date lib required.
**Schema / Interfaces:**
```ts
interface ScheduleInput {
  frequency: RecurringFrequency
  dayOfMonth: number | null   // 1..31
  dayOfWeek: number | null    // 0..6, 0=Sunday
}

/** Advance one period from `fromDate` (YYYY-MM-DD). */
export function computeNextDueDate(fromDate: string, s: ScheduleInput): string
//  weekly:    fromDate + 7 days (lands on s.dayOfWeek by construction)
//  monthly:   fromDate + 1 month, day := clampToMonthEnd(s.dayOfMonth)
//  quarterly: fromDate + 3 months, day := clampToMonthEnd(s.dayOfMonth)
//  yearly:    fromDate + 12 months, day := clampToMonthEnd(s.dayOfMonth)

/** First occurrence on/after startDate for a new template. */
export function computeFirstDueDate(startDate: string, s: ScheduleInput): string

function clampToMonthEnd(year: number, monthIndex0: number, day: number): number
```
**Acceptance:**
- [ ] `computeNextDueDate('2026-01-31', {frequency:'monthly', dayOfMonth:31})` → `'2026-02-28'` (clamped).
- [ ] `computeNextDueDate('2026-07-01', {frequency:'quarterly', dayOfMonth:1})` → `'2026-10-01'`.
- [ ] `computeNextDueDate('2026-06-01', {frequency:'weekly', dayOfWeek:1})` → `'2026-06-08'`.

### Task 5: REST API routes for templates
**Blocks:** 7  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/expenses/recurring.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router under `/api/expenses/recurring`)
**Steps:**
- [ ] Mount all routes behind `authMiddleware` and `requirePermission('expenses:write')`.
- [ ] All DB access via `tenantQuery(db, tenantId)` — never raw Drizzle from routes (`no-raw-drizzle-from-routes`).
- [ ] Validate bodies with the Zod schemas from Task 3 (`require-zod-validation-in-routes`).
- [ ] On create: compute `next_due_date := computeFirstDueDate(start_date, schedule)`; set `created_by = session.userId`.
- [ ] List supports `?status=active|paused|all` filter and cursor pagination via `clampLimit` + `buildPaginated`.
- [ ] Detail returns the template plus its generated-expense history (`expenses WHERE recurring_template_id = :id ORDER BY expense_date DESC`).
- [ ] Delete returns the count of generated expenses that will be unlinked (for the confirm dialog) and relies on the FK `ON DELETE SET NULL`.
- [ ] Pause sets `is_active=false`; Resume sets `is_active=true` and recomputes `next_due_date := computeFirstDueDate(today, schedule)`.
- [ ] Use `ApiError` for 404 / validation failures.
**Schema / Interfaces:**
```
GET    /api/expenses/recurring            -> RecurringExpenseListResponse   (auth: expenses:write; ?status=active|paused|all)
POST   /api/expenses/recurring            -> { id }                          (body: createRecurringExpenseSchema)
GET    /api/expenses/recurring/:id        -> { template: RecurringExpense, history: ExpenseSummary[] }
PATCH  /api/expenses/recurring/:id        -> { template: RecurringExpense }   (body: updateRecurringExpenseSchema)
DELETE /api/expenses/recurring/:id        -> { deleted: true, unlinked: number }
POST   /api/expenses/recurring/:id/pause  -> { template: RecurringExpense }   (is_active=false)
POST   /api/expenses/recurring/:id/resume -> { template: RecurringExpense }   (is_active=true; next_due_date recalculated from today)
```
**Acceptance:**
- [ ] A request lacking `expenses:write` receives 403.
- [ ] Creating a monthly template with `dayOfMonth=1, startDate=2026-07-01` persists `next_due_date='2026-07-01'`.
- [ ] Tenant A cannot read or mutate Tenant B's templates (tenant scoping enforced).
- [ ] DELETE response reports the correct `unlinked` count and leaves generated expenses with `recurring_template_id = NULL`.

### Task 6: Daily generation cron
**Blocks:** —  ·  **Blocked by:** 1, 2, 4
**Files:**
- Create: `apps/zync-api/src/cron/recurring-expenses.ts`
- Modify: `apps/zync-api/wrangler.toml` (add `[triggers] crons = ["0 6 * * *"]`)
- Modify: `apps/zync-api/src/index.ts` (`scheduled` handler dispatches `0 6 * * *` → `generateDueExpenses`)
**Steps:**
- [ ] Register the cron trigger in `wrangler.toml`; route it from the Worker `scheduled(event, env, ctx)` handler by cron expression.
- [ ] Select due templates across all tenants with a single index scan via `systemQuery` (cross-tenant): `is_active = true AND next_due_date <= today AND (end_date IS NULL OR end_date >= today)`.
- [ ] For each template, insert an `expenses` row with the corrected, canonical column mapping (below) inside a transaction with the `next_due_date` update; wrap per-template work so one failure doesn't abort the batch.
- [ ] When `auto_approve = false`, look up approvers for the tenant and emit an approver notification via `createNotification`; when `true`, skip notification.
- [ ] Advance `next_due_date := computeNextDueDate(template.next_due_date, schedule)` and set `last_generated_date := template.next_due_date`.
- [ ] Approver lookup: select `user_id` from `tenant_memberships WHERE tenant_id = :t AND role = 'OWNER'` (robust fallback; if `expense-approval-workflow`'s configured approver column exists in tenant settings, prefer it). Send one notification per approver.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/cron/recurring-expenses.ts
export async function generateDueExpenses(env: Env): Promise<{ generated: number }> {
  const db = createDb(env)
  const today = new Date().toISOString().slice(0, 10)

  const due = await systemQuery(db).recurringExpensesDueOn(today) // is_active, next_due_date<=today, end_date null/>=today

  let generated = 0
  for (const t of due) {
    await db.transaction(async (tx) => {
      // Derive VAT from GROSS amount; expenses has vat_amount (NOT vat_rate).
      const vatRate = t.vatDeductible ? Number(t.vatRate ?? 0.18) : 0
      const vatAmount = t.vatDeductible
        ? Number((Number(t.amount) * vatRate / (1 + vatRate)).toFixed(2))
        : 0

      const [expense] = await tx.insert(expenses).values({
        tenantId:            t.tenantId,
        expenseCategory:     t.category,        // template.category -> expenses.expense_category
        amount:              t.amount,          // GROSS, ILS-normalized
        currency:            t.currency,
        vatAmount:           vatAmount.toFixed(2),
        vatDeductible:       t.vatDeductible,
        vendorName:          t.vendorName,
        expenseDate:         t.nextDueDate,      // expense dated to due date
        notes:               t.notes ?? `Recurring: ${t.name}`,
        projectId:           t.projectId,
        source:              'recurring',        // existing column, newly-allowed value
        status:              'COMPLETED',        // no OCR; do NOT use 'PENDING'
        processedAt:         new Date(),
        approvalStatus:      t.autoApprove ? 'approved' : 'pending', // owned by expense-approval-workflow
        createdBy:           t.createdBy,
        recurringTemplateId: t.id,
      }).returning({ id: expenses.id })

      const next = computeNextDueDate(t.nextDueDate, {
        frequency: t.frequency, dayOfMonth: t.dayOfMonth, dayOfWeek: t.dayOfWeek,
      })
      await tx.update(recurringExpenses)
        .set({ lastGeneratedDate: t.nextDueDate, nextDueDate: next, updatedAt: new Date() })
        .where(eq(recurringExpenses.id, t.id))

      if (!t.autoApprove) {
        const approvers = await tx.select({ userId: tenantMemberships.userId })
          .from(tenantMemberships)
          .where(and(eq(tenantMemberships.tenantId, t.tenantId), eq(tenantMemberships.role, 'OWNER')))
        for (const a of approvers) {
          await createNotification(tx, {
            tenantId:   t.tenantId,
            userId:     a.userId,
            type:       'system',          // routed via delivery layer; "expense submitted" path
            titleKey:   'notifications.expense_submitted.title',
            bodyKey:    'notifications.expense_submitted.body',
            params:     { amount: t.amount, currency: t.currency, name: t.name },
            entityType: 'expense',
            entityId:   expense.id,
          })
        }
      }
      generated++
    })
  }
  return { generated }
}
```
**Acceptance:**
- [ ] Running the cron on a day where a monthly template is due inserts exactly one `expenses` row with `source='recurring'`, `status='COMPLETED'`, NULL file fields, and `recurring_template_id` set.
- [ ] `vat_amount` is derived from the GROSS `amount` (e.g. amount 1180, vatRate 0.18 → vat_amount 180.00) and is 0 when `vat_deductible=false`.
- [ ] After generation `next_due_date` advances one period and `last_generated_date` equals the prior due date.
- [ ] `auto_approve=true` template yields `approval_status='approved'` and no approver notification; `auto_approve=false` yields `approval_status='pending'` and one notification per OWNER.
- [ ] A template whose `end_date < today` is skipped; an inactive template is skipped.

### Task 7: Recurring list page + create/edit modal (UI)
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/routes/expenses/recurring/RecurringExpensesPage.tsx`
- Create: `apps/zync-app/src/routes/expenses/recurring/RecurringExpenseModal.tsx`
- Create: `apps/zync-app/src/hooks/use-recurring-expenses.ts`
- Modify: expenses page header/nav to add "Recurring templates (N active)" link to `/expenses/recurring`.
**Steps:**
- [ ] Build the list (`DataTable`) with columns Name, Frequency, Amount, Next due, Status, Actions (Edit / Pause-Resume / Delete). Paused rows render grayed with a "Paused" `Badge`.
- [ ] Footer line: "{N} active templates · Next due: {date} ({name})".
- [ ] Build the create/edit modal (`Dialog` + `Form`/`FormField`) with Name, Category (`Select`), Amount, Currency, VAT deductible (`Switch`) + VAT rate, Vendor, Project, Frequency (`Select`), Day-of-month / Day-of-week (conditional on frequency), Start date, End date, Auto-approve (`Switch`). Validate client-side with the Zod schema from Task 3.
- [ ] Delete uses a confirm `Dialog` showing "{count} generated expenses will be unlinked" from the DELETE response.
- [ ] All currency/date strings use existing i18n + RTL-aware formatting helpers; the page renders correctly under `dir="rtl"` (Hebrew) — no hardcoded left/right; use logical properties (per `rtl-hebrew-ui`). Frequency label "Annually"/"שנתי" maps to the `'yearly'` enum value.
- [ ] No hardcoded colors/spacing (`no-hardcoded-colors`, `no-hardcoded-spacing`); use design tokens only. Empty list renders `EmptyState`. Action results use `toast`.
- [ ] Respect `prefers-reduced-motion` for modal/transition animations.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/hooks/use-recurring-expenses.ts
export function useRecurringExpenses(status?: 'active' | 'paused' | 'all'): {
  data: RecurringExpenseListResponse | undefined; isLoading: boolean
}
export function useCreateRecurringExpense(): UseMutationResult<{ id: string }, Error, CreateRecurringExpenseInput>
export function useUpdateRecurringExpense(): UseMutationResult<{ template: RecurringExpense }, Error, { id: string; patch: Partial<CreateRecurringExpenseInput> }>
export function usePauseRecurringExpense(): UseMutationResult<{ template: RecurringExpense }, Error, string>
export function useResumeRecurringExpense(): UseMutationResult<{ template: RecurringExpense }, Error, string>
export function useDeleteRecurringExpense(): UseMutationResult<{ deleted: true; unlinked: number }, Error, string>
```
**Acceptance:**
- [ ] `/expenses/recurring` lists templates with correct frequency labels and next-due dates; the `/expenses` header shows the active count and links here.
- [ ] Creating a template via the modal calls `POST` and the new row appears without a full reload.
- [ ] Pausing toggles the badge and graying; resuming restores it and updates next-due.
- [ ] Delete confirm shows the unlink count; the page renders correctly in Hebrew RTL with no hardcoded colors/spacing.

### Task 8: Wire i18n keys for the approver notification + UI labels
**Blocks:** —  ·  **Blocked by:** 6, 7
**Files:**
- Modify: `packages/i18n` (or app locale files) — add `notifications.expense_submitted.title/body` and recurring-expenses UI strings (en + he).
**Steps:**
- [ ] Add English + Hebrew translations for the approver notification `titleKey`/`bodyKey` used by the cron, interpolating `{amount} {currency} {name}`.
- [ ] Add recurring-expenses page/modal labels (frequency options including "Annually"→`yearly`, column headers, actions) in en + he.
**Acceptance:**
- [ ] The approver notification renders a human-readable title/body in both locales; no missing-key warnings.

### Task 9: Tests (logic + cron + route guards)
**Blocks:** —  ·  **Blocked by:** 5, 6
**Files:**
- Create: `apps/zync-api/src/lib/recurring-expenses/compute-next-due.test.ts`
- Create: `apps/zync-api/src/cron/recurring-expenses.test.ts`
- Create: `apps/zync-api/src/routes/expenses/recurring.test.ts`
**Steps:**
- [ ] Unit-test `computeNextDueDate` for all four frequencies plus month-end clamping (Jan-31 → Feb-28/29, leap year).
- [ ] Cron test: seed an active due template (auto_approve false and true variants), run `generateDueExpenses`, assert the inserted `expenses` row's canonical columns (`expense_category`, `amount`, `vat_amount`, `source='recurring'`, `status='COMPLETED'`, `approval_status`, `recurring_template_id`, NULL file fields), the advanced `next_due_date`, and notification emission.
- [ ] Cron test: assert end-dated and inactive templates are skipped.
- [ ] Route test: assert `expenses:write` enforcement, tenant isolation, create→next_due seeding, delete unlink count, pause/resume next_due recomputation.
**Acceptance:**
- [ ] All tests pass; coverage includes the schedule clamp edge cases and the auto-approve branch split.
