# Scheduled Report Delivery — Implementation Plan

**Spec:** docs/specs/2026-06-01-scheduled-reports.md  ·  **Slug:** scheduled-reports  ·  **Wave:** 15
**Depends on:** admin-reports-analytics, financial-statements, israeli-tax-reports, system-communications-notifications, foundation-auth-rbac

## Goal
Deliver any tenant report (P&L, cash flow, VAT/PCN874, annual summary, revenue, expenses, time, profitability, bad debt, leads, proposals) automatically on a daily/weekly/monthly/quarterly schedule, rendered to Excel or PDF and emailed as an attachment to staff members and/or external email addresses. A cron poll every 15 minutes generates due reports under the **creator's permission scope** (never raw tenant scope) and delivers them; the schedule auto-disables if the creator loses report-export access. This is an explicitly gated financial-data-exfiltration surface: creating schedules requires `reports:export`, and adding external recipients requires the new `reports:export_external` permission.

## Architecture
- **New table `report_schedules`** (tenant-scoped) stores schedule config, recipients (JSONB), pre-computed `next_run_at` (TIMESTAMPTZ UTC), and run bookkeeping. Single indexed `WHERE is_active AND next_run_at <= NOW()` query finds due rows — O(1) via `idx_report_schedules_tenant`.
- **API** in `apps/zync-api`: a Hono router at `/api/reports/scheduled` (list/create/detail/edit/delete/pause/resume/run). All writes wrapped with `tenantQuery`; permission checks via `requirePermission`/`hasScope` from `@zync/auth`; tier gate Business+ via `requireTier`/`meetsMinimumTier`. Every create/edit/recipient change emits `logAuditEvent` (from `tenant-audit-log`, enqueued to `audit-log-queue`) with before/after recipients.
- **Permission context** built from foundation-auth-rbac primitives we already have (`tenant_memberships` → `roles` → `role_permissions` → `permissions`). spec 121 (field-level permissions) is referenced by the spec but is NOT a dependency and does not exist yet — this plan *defines* `buildPermissionContext` from existing scope primitives and `canRunReport(report_type)` as a report_type→scope+module map, with a documented seam where spec-121 field filtering plugs in later.
- **Report dispatch contract:** `generateReport(report_type, period, params, ctx)` delegates to per-type report services. The four generators our dependencies provide concretely are wired: `pl` and `cashflow` → financial-statements (`GET /api/reports/pl`, `/api/reports/cashflow` data builders); `vat` → israeli-tax-reports PCN874 builder; `annual_summary` → israeli-tax-reports annual-summary builder. The remaining enum values (`revenue, expenses, time, profitability, bad_debt, leads, proposals`) live in specs we do not depend on — the plan defines the `ReportGenerator` interface they must satisfy and a registry; unimplemented types throw a typed `ReportTypeUnavailableError` (schedule skipped + owner/admin alerted), never reinvented SQL.
- **Rendering:** Excel goes through the **shared financial export writer from financial-statements spec 170** (`worksheet.views[0].rightToLeft = true`, Hebrew-capable font, formula-injection guard prefixing any cell starting `= + - @ \t \r` with `'`). spec 170 lists spec 175 (this) as a consumer — we reuse, not rebuild. PDF has no shared path in 170 (which only shares xlsx/CSV), so this plan **owns** a `renderReportPdf` utility.
- **Cron** `apps/zync-api/src/cron/scheduled-reports.ts`, registered as a wrangler `[triggers]` cron (`*/15 * * * *`) and dispatched from the Worker `scheduled()` handler, mirroring the existing `apps/zync-api/src/cron/subscription-trial-check.ts` convention.
- **Email** uses the tenant's configured `EmailNotificationAdapter` (Resend/custom SMTP). Because report mail needs a custom subject/body **plus a file attachment**, and `SendEmailOptions` is template-only (no attachments), delivery routes through the adapter's underlying `CommsAdapter.send(OutboundMessage)` path where `OutboundMessage.attachments?: Attachment[]` is supported.
- **"Run now"** returns `202 Accepted` and performs delivery asynchronously via the existing `QUEUE` binding (enqueue a `{ scheduleId, oneOff: true }` job consumed by the same generation routine), so the request never blocks on report generation.

## Tech Stack
- App: `apps/zync-api` (Hono on Cloudflare Workers) — routes, cron, queue consumer.
- App: `apps/zync-app` (Vite + React) — `/reports/scheduled` list page + create/edit modal.
- Packages: `@zync/db` (Drizzle schema + migration), `@zync/auth` (`requirePermission`, `hasScope`, `requireTier`, `meetsMinimumTier`, `tenantQuery`, `seedPermissions`), `@zync/types` (`Attachment`, `OutboundMessage`), `@zync/ui` (Dialog, Form, Select, Checkbox, Input, Button, DataTable, Badge, Toast), `@zync/notifications` (`EmailNotificationAdapter`, `createNotification`).
- Cloudflare bindings: `DB`/Hyperdrive (Neon Postgres), `QUEUE` (one-off run + report job dispatch), `audit-log-queue` (audit writes), wrangler `[triggers]` cron.
- Libraries: `exceljs` (via shared writer), existing PDF renderer dependency used by financial-statements exports, `zod` (route validation).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema & permissions | 1, 2 | `packages/db/src/schema/report-schedules.ts`, migration, `packages/auth` seed | Task 1 & 2 parallel |
| B — domain core | 3, 4, 5, 6 | permission-context, schedule-math, report-dispatch, render libs | 3,4 parallel; 5 after 3; 6 after none |
| C — delivery & cron | 7, 8 | email delivery, cron + queue consumer | 8 after 5,6,7 |
| D — API | 9 | `apps/zync-api/src/routes/scheduled-reports.ts` | after 1,2,3,4,5 |
| E — UI | 10, 11 | list page, create/edit modal | after 9 |
| F — i18n & verification | 12 | locale JSON, acceptance checks | last |

## Tasks

### Task 1: `report_schedules` table + Drizzle schema + migration
**Blocks:** 3, 4, 5, 9  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/report-schedules.ts`
- Modify: `packages/db/src/schema/index.ts` (export new table)
- Create: `packages/db/migrations/<timestamp>_report_schedules.sql`
**Steps:**
- [ ] Write the canonical Postgres DDL (below) into the migration file.
- [ ] Model the table in Drizzle (`pgTable`) with matching columns, CHECK constraints, defaults, and the composite index.
- [ ] Export `reportSchedules` and inferred `ReportSchedule` / `NewReportSchedule` types from the db package index.
- [ ] Add Drizzle `relations` to `tenants` (tenant_id) and `users` (created_by).
**Schema / Interfaces:**
```sql
CREATE TABLE report_schedules (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  created_by      UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL,
  name            TEXT NOT NULL,
  report_type     TEXT NOT NULL
    CHECK (report_type IN (
      'revenue','expenses','pl','cashflow','vat','time','profitability',
      'annual_summary','bad_debt','leads','proposals'
    )),
  format          TEXT NOT NULL DEFAULT 'xlsx'
    CHECK (format IN ('xlsx','pdf')),
  frequency       TEXT NOT NULL
    CHECK (frequency IN ('daily','weekly','monthly','quarterly')),
  day_of_week     INTEGER CHECK (day_of_week BETWEEN 0 AND 6),
  day_of_month    INTEGER CHECK (day_of_month BETWEEN 1 AND 28),
  time_of_day     TIME NOT NULL DEFAULT '08:00',
  period_type     TEXT NOT NULL DEFAULT 'previous'
    CHECK (period_type IN ('previous','current','ytd')),
  report_params   JSONB NOT NULL DEFAULT '{}',
  recipients      JSONB NOT NULL DEFAULT '[]',
  is_active       BOOLEAN NOT NULL DEFAULT true,
  last_run_at     TIMESTAMPTZ,
  next_run_at     TIMESTAMPTZ NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_report_schedules_tenant
  ON report_schedules(tenant_id, is_active, next_run_at);
```
TypeScript recipient shape (stored in `recipients` JSONB):
```ts
type ReportRecipient =
  | { type: 'user'; user_id: string }
  | { type: 'email'; email: string };
```
**Acceptance:**
- [ ] `pnpm db:migrate` applies cleanly against a Neon branch; `report_schedules` exists with all CHECKs and the composite index.
- [ ] Every FK is UUID→UUID; `created_at`/`next_run_at`/`last_run_at` are TIMESTAMPTZ; `is_active` BOOLEAN; `report_params`/`recipients` JSONB.

### Task 2: Seed `reports:export_external` permission
**Blocks:** 9  ·  **Blocked by:** —
**Files:**
- Modify: `packages/auth/src/seed/permissions.ts` (the `seedPermissions` source list)
- Modify: `packages/auth/src/seed/role-permissions.ts` (the `seedSystemRoles` grant map)
**Steps:**
- [ ] Add permission `reports:export_external` (label "Send reports to external email recipients") to the seeded `permissions` set.
- [ ] Grant `reports:export_external` to OWNER and ADMIN system roles by default; leave it ungranted for MEMBER/CONTRACTOR/CLIENT_PORTAL (grantable per custom role).
- [ ] Confirm `reports:read` and `reports:export` already exist in the seed (introduced by reporting specs); if `reports:export` is absent in seed, add it and grant to OWNER/ADMIN — do not invent new names.
**Schema / Interfaces:** Permission rows are `permissions(id UUID, key TEXT UNIQUE, label TEXT)`; grants are `role_permissions(role_id UUID, permission_id UUID)` — use existing `seedPermissions`/`seedSystemRoles` helpers, no new tables.
**Acceptance:**
- [ ] After seed, `permissions` contains `reports:export_external`; `role_permissions` links it to OWNER and ADMIN only.

### Task 3: Permission context + `canRunReport` map
**Blocks:** 5, 9  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/lib/report-permission-context.ts`
**Steps:**
- [ ] Implement `buildPermissionContext(tenantId, userId)`: load the user's active `tenant_memberships` row for the tenant, resolve `roles` → `role_permissions` → `permissions` into a `Set<string>` of scope keys. Return `null` if no active membership (creator removed).
- [ ] Expose `ctx.can(scope: string): boolean` using `hasScope`-equivalent set membership.
- [ ] Implement `ctx.canRunReport(reportType)` via the `REPORT_TYPE_REQUIREMENTS` map: every report_type requires `reports:export`; plus the owning module gate (e.g. `pl`/`cashflow`/`vat`/`annual_summary`/`revenue`/`expenses`/`profitability`/`bad_debt` → module `invoices`/`expenses` enabled; `leads`/`proposals` → module `marketing`; `time` → module `time`). Module-enabled check via `getEnabledModuleIds(tenantId)`.
- [ ] Document the spec-121 seam: a `ctx.fieldFilter(reportType)` no-op hook returning `null` now, to be replaced by field-level permission filtering when spec 121 lands.
**Schema / Interfaces:**
```ts
interface ReportPermissionContext {
  tenantId: string;
  userId: string;
  scopes: Set<string>;
  can(scope: string): boolean;
  canRunReport(reportType: ReportType): boolean;
  fieldFilter(reportType: ReportType): null; // spec-121 seam
}
type ReportType =
  | 'revenue' | 'expenses' | 'pl' | 'cashflow' | 'vat' | 'time'
  | 'profitability' | 'annual_summary' | 'bad_debt' | 'leads' | 'proposals';
export async function buildPermissionContext(
  tenantId: string, userId: string
): Promise<ReportPermissionContext | null>;
```
**Acceptance:**
- [ ] A creator with `reports:export` + the report's module enabled passes `canRunReport`; missing either fails.
- [ ] Removed/inactive member yields `null` context.

### Task 4: Schedule math — `computeReportPeriod`, `computeNextRun`
**Blocks:** 5  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/lib/schedule-math.ts`
**Steps:**
- [ ] `computeReportPeriod(schedule, now)`: derive `{ from, to }` (YYYY-MM-DD) from `period_type` + `frequency`. `previous` = the previous complete period (e.g. last month for monthly, last full week Sun–Sat for weekly, prior quarter for quarterly, prior day for daily); `current` = period-to-date; `ytd` = Jan 1 of current year → now. Compute in the tenant's local timezone (`tenants.default_timezone`), then format date boundaries as local calendar dates.
- [ ] `computeNextRun(schedule, fromInstant)`: compute the next fire instant from `frequency` + `day_of_week`/`day_of_month` + `time_of_day` in the tenant's `default_timezone`, then convert to UTC for storage in `next_run_at` (TIMESTAMPTZ). Weekly uses `day_of_week`; monthly/quarterly use `day_of_month` (clamped to 1–28); daily uses `time_of_day` only; quarterly advances 3 months.
- [ ] Use a timezone-aware date library available in the workspace (Temporal polyfill or `date-fns-tz` already used by `hebrew-locale-dates`) — do not do naive UTC arithmetic.
**Schema / Interfaces:**
```ts
export interface ReportPeriod { from: string; to: string; label: string } // label e.g. "June 2026"
export function computeReportPeriod(schedule: ReportSchedule, now: Date, tz: string): ReportPeriod;
export function computeNextRun(schedule: ReportSchedule, fromInstant: Date, tz: string): Date; // UTC instant
```
**Acceptance:**
- [ ] Monthly `previous` schedule run on Jul 1 yields period 2026-06-01..2026-06-30 and `next_run_at` = Aug 1 08:00 tenant-local expressed as UTC.
- [ ] DST/timezone offset is applied via `default_timezone`, not hardcoded.

### Task 5: Report dispatch registry — `generateReport`
**Blocks:** 8, 9  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/lib/report-generators/index.ts`
- Create: `apps/zync-api/src/lib/report-generators/financial.ts` (pl, cashflow)
- Create: `apps/zync-api/src/lib/report-generators/tax.ts` (vat, annual_summary)
**Steps:**
- [ ] Define the `ReportGenerator` interface and a `REPORT_GENERATORS: Record<ReportType, ReportGenerator>` registry.
- [ ] Implement `pl` and `cashflow` by calling the financial-statements data builders (the same query logic backing `GET /api/reports/pl` and `GET /api/reports/cashflow`) scoped by `ctx`.
- [ ] Implement `vat` (PCN874 output/input VAT aggregation) and `annual_summary` by calling the israeli-tax-reports data builders backing `GET /api/reports/vat` and `GET /api/reports/annual-summary`.
- [ ] For `revenue, expenses, time, profitability, bad_debt, leads, proposals`, register entries that throw `ReportTypeUnavailableError` (typed) — these generators are owned by specs outside this dependency set; downstream wiring fills them in. Never reimplement their SQL here.
- [ ] `generateReport(reportType, period, params, ctx)` looks up the registry, verifies `ctx.canRunReport(reportType)` again (defense in depth), and returns a normalized `ReportData`.
**Schema / Interfaces:**
```ts
export interface ReportData {
  reportType: ReportType;
  period: ReportPeriod;
  title: string;                 // localized report title
  sections: ReportSection[];     // tabular sections for the renderer
}
export interface ReportSection { heading: string; columns: string[]; rows: (string | number)[][] }
export interface ReportGenerator {
  build(period: ReportPeriod, params: Record<string, unknown>, ctx: ReportPermissionContext): Promise<ReportData>;
}
export class ReportTypeUnavailableError extends Error {}
export async function generateReport(
  reportType: ReportType, period: ReportPeriod,
  params: Record<string, unknown>, ctx: ReportPermissionContext
): Promise<ReportData>;
```
**Acceptance:**
- [ ] `generateReport('pl', ...)` returns populated sections matching financial-statements P&L lines.
- [ ] `generateReport('leads', ...)` throws `ReportTypeUnavailableError` (no fabricated query).

### Task 6: Renderers — `renderToFile` (xlsx via shared writer, pdf owned here)
**Blocks:** 8  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/src/lib/report-renderers.ts`
- Modify: (reference only) shared financial xlsx writer from financial-statements `apps/zync-api/src/lib/financial-export.ts`
**Steps:**
- [ ] `renderToFile(data, format, reportType, period)` returns `{ filename, contentType, body: ArrayBuffer }`.
- [ ] For `xlsx`, call the **shared financial export writer** (spec 170): set `worksheet.views[0].rightToLeft = true`, apply a Hebrew-capable font (Arial/David) to header + data cells, and run every string cell through the formula-injection guard (prefix `'` when the value starts with `=`, `+`, `-`, `@`, `\t`, or `\r`). Do not write a second xlsx writer.
- [ ] For `pdf`, implement `renderReportPdf(data)` (no shared PDF path exists in spec 170) producing an RTL-aware PDF with the same formula-safe text handling for displayed strings; embed a Hebrew-capable font.
- [ ] Filename `{report_type}-{period}.{ext}` (e.g. `pl-2026-06.xlsx`); contentType `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` or `application/pdf`.
**Acceptance:**
- [ ] xlsx output sets `rightToLeft` and quotes a cell whose value starts with `=`.
- [ ] pdf output renders Hebrew labels RTL and opens in a standard viewer.

### Task 7: Email delivery — `sendReportEmail`
**Blocks:** 8  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/src/lib/send-report-email.ts`
**Steps:**
- [ ] Resolve each recipient: `{type:'user'}` → load the user's email + display name + locale (must be an active tenant member; skip + log if not); `{type:'email'}` → use the raw address.
- [ ] Obtain the tenant's configured email adapter (`EmailNotificationAdapter` backed by Resend/custom SMTP from `system-communications-notifications`); send via the underlying `CommsAdapter.send(OutboundMessage)` so the attachment is carried.
- [ ] Build `OutboundMessage`: `subject = "[Zync] {report_name} — {period.label}"`; plain `body` and `html` per the spec template ("Hi {name}, Your scheduled {report_type} report for {period} is attached…", manage link `https://app.zync.is/reports/scheduled`); `attachments: [{ filename, contentType, content }]` from the rendered file.
- [ ] Localize subject/body to the recipient's locale (default tenant `settings.locale`, never default to en-US per comms spec).
**Schema / Interfaces:**
```ts
export async function sendReportEmail(
  recipient: ReportRecipient,
  file: { filename: string; contentType: string; body: ArrayBuffer },
  schedule: ReportSchedule, period: ReportPeriod, env: Env
): Promise<void>;
// OutboundMessage.attachments?: Attachment[]  (from @zync/types — already defined upstream)
```
**Acceptance:**
- [ ] A delivered email has subject `[Zync] {name} — {label}` and exactly one attachment named `{report_type}-{period}.{ext}`.
- [ ] An internal recipient who is no longer a tenant member is skipped, not emailed.

### Task 8: Cron `runScheduledReports` + one-off queue consumer
**Blocks:** —  ·  **Blocked by:** 5, 6, 7
**Files:**
- Create: `apps/zync-api/src/cron/scheduled-reports.ts`
- Modify: `apps/zync-api/src/index.ts` (Worker `scheduled()` dispatch + queue consumer)
- Modify: `apps/zync-api/wrangler.toml` (`[triggers] crons` add `*/15 * * * *`)
**Steps:**
- [ ] `runScheduledReports(env)`: `SELECT * FROM report_schedules WHERE is_active = true AND next_run_at <= NOW() LIMIT 50`.
- [ ] For each: `period = computeReportPeriod(schedule, now, tz)`; `ctx = buildPermissionContext(tenant_id, created_by)`; if `!ctx || !ctx.can('reports:export') || !ctx.canRunReport(report_type)` → call `disableScheduleAndAlert(schedule, 'creator_lost_access', env)` (set `is_active=false`, `createNotification` to OWNER/ADMIN, `logAuditEvent`) and continue.
- [ ] `data = generateReport(...)`; on `ReportTypeUnavailableError` → `disableScheduleAndAlert(schedule, 'report_type_unavailable', env)` and continue.
- [ ] `file = renderToFile(...)`; loop recipients → `sendReportEmail(...)`; then `UPDATE report_schedules SET last_run_at = NOW(), next_run_at = $1 WHERE id = $2` with `computeNextRun(schedule, now, tz)`.
- [ ] Register the cron in `wrangler.toml` `[triggers] crons = ["*/15 * * * *"]` and dispatch from `scheduled()` matching the `subscription-trial-check` convention.
- [ ] Add a `QUEUE` consumer branch handling `{ scheduleId, oneOff: true }` that runs the identical generate→render→send flow for one schedule **without** touching `next_run_at` (one-off does not reschedule).
**Schema / Interfaces:**
```ts
export async function runScheduledReports(env: Env): Promise<void>;
async function disableScheduleAndAlert(
  schedule: ReportSchedule,
  reason: 'creator_lost_access' | 'report_type_unavailable',
  env: Env
): Promise<void>;
```
**Acceptance:**
- [ ] A due active schedule produces a delivered email and an advanced `next_run_at`; a schedule whose creator lost `reports:export` is set `is_active=false` and OWNER/ADMIN are notified.
- [ ] Cron is registered at `*/15 * * * *` and reached via `scheduled()`.

### Task 9: API routes `/api/reports/scheduled`
**Blocks:** 10, 11  ·  **Blocked by:** 1, 2, 3, 4, 5
**Files:**
- Create: `apps/zync-api/src/routes/scheduled-reports.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] Apply `authMiddleware` + Business+ tier gate (`requireTier('business')` / `meetsMinimumTier`) to the whole router.
- [ ] `GET /api/reports/scheduled` → list tenant schedules (requires `reports:read`); serialize recipients with resolved user display names.
- [ ] `POST /api/reports/scheduled` → create. Require `reports:export`. Validate body with zod (`createScheduleSchema`). Enforce: creator's `ctx.canRunReport(report_type)` must pass; every `{type:'user'}` recipient must be an active tenant member; if any `{type:'email'}` recipient present, require `reports:export_external`. Compute initial `next_run_at` via `computeNextRun`. `logAuditEvent` with `metadata.recipients` (after).
- [ ] `GET /api/reports/scheduled/:id` → detail (`reports:read`, tenant-scoped).
- [ ] `PATCH /api/reports/scheduled/:id` → edit (`reports:export`; same recipient/external/canRunReport rules; recompute `next_run_at` if frequency/day/time changed). `logAuditEvent` with before/after recipients.
- [ ] `DELETE /api/reports/scheduled/:id` → delete (`reports:export`). `logAuditEvent`.
- [ ] `POST /api/reports/scheduled/:id/pause` → `is_active=false` (`reports:export`); `POST .../resume` → `is_active=true` + recompute `next_run_at` (`reports:export`).
- [ ] `POST /api/reports/scheduled/:id/run` → require `reports:export`; enqueue `{ scheduleId, oneOff: true }` to `QUEUE`; respond `202 Accepted`.
- [ ] All queries via `tenantQuery`; never raw drizzle from routes; all bodies zod-validated.
**Schema / Interfaces:**
```ts
const recipientSchema = z.union([
  z.object({ type: z.literal('user'), user_id: z.string().uuid() }),
  z.object({ type: z.literal('email'), email: z.string().email() }),
]);
const createScheduleSchema = z.object({
  name: z.string().min(1),
  report_type: z.enum(['revenue','expenses','pl','cashflow','vat','time','profitability','annual_summary','bad_debt','leads','proposals']),
  format: z.enum(['xlsx','pdf']).default('xlsx'),
  frequency: z.enum(['daily','weekly','monthly','quarterly']),
  day_of_week: z.number().int().min(0).max(6).optional(),
  day_of_month: z.number().int().min(1).max(28).optional(),
  time_of_day: z.string().regex(/^\d{2}:\d{2}$/).default('08:00'),
  period_type: z.enum(['previous','current','ytd']).default('previous'),
  report_params: z.record(z.unknown()).default({}),
  recipients: z.array(recipientSchema).min(1),
});
```
Routes exported: `GET /api/reports/scheduled`, `POST /api/reports/scheduled`, `GET /api/reports/scheduled/:id`, `PATCH /api/reports/scheduled/:id`, `DELETE /api/reports/scheduled/:id`, `POST /api/reports/scheduled/:id/pause`, `POST /api/reports/scheduled/:id/resume`, `POST /api/reports/scheduled/:id/run`.
**Acceptance:**
- [ ] Creating a schedule with an external email recipient without `reports:export_external` → 403; with it → 201.
- [ ] A user without `reports:export` cannot create/edit/delete/run; can list (with `reports:read`).
- [ ] `POST .../run` returns 202 and enqueues a one-off job; Freelancer-tier tenant gets a tier gate rejection on all routes.

### Task 10: Scheduled Reports list page (`/reports/scheduled`)
**Blocks:** 12  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/pages/reports/ScheduledReportsPage.tsx`
- Create: `apps/zync-app/src/hooks/useScheduledReports.ts`
- Modify: `apps/zync-app/src/router.tsx` (route + Business+ guard)
**Steps:**
- [ ] `useScheduledReports` react-query hook hitting the API (list/create/edit/delete/pause/resume/run) with cache invalidation.
- [ ] Render a `DataTable` (from `@zync/ui`) with columns Name, Frequency, Format, Next run (formatted in tenant locale via `hebrew-locale-dates` formatter). Row actions: Edit, Pause/Resume (toggle by `is_active`), Run now, Delete.
- [ ] "+ New schedule" button opens the create modal (Task 11). "Run now" shows a `Toast` confirming 202 (queued).
- [ ] Use an `EmptyState` when no schedules; gate the whole page behind the Business+ tier (redirect/upsell for lower tiers via `useTierGate`).
- [ ] Accessibility: table has proper `aria` roles, action buttons have accessible labels; honor `prefers-reduced-motion` on any modal/transition.
**Acceptance:**
- [ ] List shows schedules with localized next-run times; Pause flips to Resume; Run now fires a toast.
- [ ] Lower-tier users see the upsell, not the table.

### Task 11: Create / Edit schedule modal
**Blocks:** 12  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/pages/reports/ScheduleFormModal.tsx`
**Steps:**
- [ ] Build a `Dialog` + `Form` with fields: Name (Input), Report type (Select of the 11 enum values, labels localized; hide types the user cannot export per `canRunReport` surfaced from API), Format (Radio Excel/PDF), Frequency (Select), conditional "Send on" (day_of_week for weekly, day_of_month 1–28 for monthly/quarterly) + time_of_day, Period (Select previous/current/ytd).
- [ ] Recipients: checkbox list of tenant members (resolved names + role) + "Add external email" input. The external-email row is disabled with an explanatory tooltip when the current user lacks `reports:export_external`.
- [ ] Client-side zod mirroring `createScheduleSchema`; submit to create/edit mutation; show server validation errors (`FormError`).
- [ ] RTL/Hebrew: layout flips under `dir="rtl"`; all labels via i18n keys; respect `prefers-reduced-motion` for dialog animation.
**Acceptance:**
- [ ] Selecting Weekly reveals day_of_week; Monthly reveals day_of_month (1–28); time defaults to 08:00.
- [ ] External-email input is disabled without `reports:export_external` and submitting one is blocked client- and server-side.

### Task 12: i18n keys + cross-cutting verification
**Blocks:** —  ·  **Blocked by:** 10, 11
**Files:**
- Modify: `packages/ui/src/locales/en.json`, `packages/ui/src/locales/he.json`
- Create: `packages/notifications/src/templates/scheduled-report.he.mjml`, `scheduled-report.en.mjml` (email body shell, if template route is used for plain body)
**Steps:**
- [ ] Add all UI strings (page title, column headers, modal labels, report-type labels, action buttons, empty state, tier upsell copy) to both `en.json` and `he.json`.
- [ ] Add email subject/body i18n strings for both locales matching the spec template.
- [ ] Verify Hebrew strings render RTL in the modal and the rendered xlsx/pdf (`rightToLeft`, Hebrew font).
- [ ] Verify the formula-injection guard, the `reports:export`/`reports:export_external` gating, audit logging on every create/edit/recipient change, and creator-scope generation end-to-end.
**Acceptance:**
- [ ] No hardcoded user-facing strings; both locales complete.
- [ ] Hebrew report renders RTL; injection guard quotes formula-leading cells; external-recipient gate enforced; audit entries written with before/after recipients.
