# Invoice Payment Reminders — Implementation Plan

**Spec:** docs/specs/2026-05-31-invoice-payment-reminders.md  ·  **Slug:** invoice-payment-reminders  ·  **Wave:** 12
**Depends on:** email-template-editor, foundation-auth-rbac, invoices-core, system-communications-notifications

## Goal
Deliver automated and manual payment-reminder emails for unpaid invoices. Tenants configure a per-stage reminder schedule (3 days pre-due, on due date, and +7/+14/+30 overdue); a daily cron at 07:00 UTC selects eligible invoices via a single indexed query and emails customers using the `invoice_reminder` email template. Staff can also fire a one-off reminder from the invoice detail view and opt individual invoices out. A dedup guard hands the entire post-due window to the dunning engine (spec 93) whenever the tenant has dunning configured, so Business+ invoices are never double-emailed while Freelancer tenants still get the full overdue series.

## Architecture
- **DB deltas** live on two upstream-owned tables. `invoices` (owned by `invoices-core`) gains reminder state columns (`reminder_last_sent_at`, `reminder_count`, `reminders_disabled`, `next_reminder_at`, `next_reminder_offset`). `tenant_settings` (the canonical tenant KV-config table owned by `invoices-core` — note: it is NOT one of the 30 locked foundation tables, it is introduced by the `invoices-core` dependency) gains `invoice_reminders_enabled` and `invoice_reminder_schedule`.
- **Send path** reuses `sendEmail` / `SendEmailOptions` from `@zync/notifications` (system-communications-notifications) with an explicit `locale`, and resolves body/subject through the `tenant_email_templates` table + `interpolateTemplate` HTML-value-escaping from `email-template-editor` (template key `invoice_reminder`). The payment link variable is read from the invoice's payment link (owned by `invoice-payment-link-generation`); when absent it falls back to empty string.
- **Cron** follows the established `POST /api/cron/*` route pattern (mirrors the locked `POST /api/cron/subscription-trial-check`). A Cloudflare cron trigger `0 7 * * *` invokes `POST /api/cron/invoice-reminders`; the handler runs the indexed selection query, sends each reminder, and advances per-invoice schedule state.
- **Initialization:** `next_reminder_at` / `next_reminder_offset` are computed when an invoice first becomes payable (transition into `SENT`/`APPROVED`/`TAX_ISSUED`) and recomputed after every send. This closes the gap where a freshly-sent invoice would otherwise have `next_reminder_at = NULL` and never be picked up.
- **Dedup guard** reads `dunning_schedules` (owned by `payment-retry-dunning`, spec 93 — cross-spec read, not a build dependency; the table is created by that spec which builds in the same/earlier wave). The cron query's `NOT EXISTS (SELECT 1 FROM dunning_schedules ...)` clause suppresses every post-due stage (`offset_days > 0`) when the tenant has any dunning schedule row.
- **UI** extends the existing `/settings/invoicing` page (Payment Reminders section) and the existing `/invoices/:id` detail view (Reminders panel), both in the React app, using `@zync/ui` primitives.

Upstream exports consumed: `invoices`, `InvoiceStatus`, `InvoiceObject`, `serializeInvoice`, `tenant_email_templates`, `sendEmail`, `SendEmailOptions`, `createDb`/`createDb`'s `DB`, `tenantQuery`, `authMiddleware`, `requirePermission`, `Button`, `Switch`, `Checkbox`, `Card`, `Input`, `Form`, `toast`.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers). New route modules under `apps/zync-api/src/routes/`, cron route under `apps/zync-api/src/routes/cron/`.
- **Worker service:** reminder send service in `packages/invoices` (or `apps/zync-api/src/services/`) — invoice reminder domain logic.
- **DB/ORM:** Drizzle against Neon Postgres via Cloudflare Hyperdrive. Migrations in `packages/db/migrations`, schema in `packages/db/src/schema`.
- **Email:** `@zync/notifications` (`sendEmail`, `SendEmailOptions`), `tenant_email_templates` + `interpolateTemplate` from the email-template layer.
- **App UI:** `apps/zync-app` (Vite + React), `@zync/ui` components, `react-i18next` for he-IL/en-US.
- **Bindings:** Hyperdrive (Postgres), the email/queue bindings already wired by system-communications-notifications. Cron trigger declared in `apps/zync-api/wrangler.toml`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema | 1 | `packages/db/migrations`, `packages/db/src/schema/invoices.ts`, `packages/db/src/schema/tenant-settings.ts` | No (foundation for all) |
| B — domain logic | 2, 3 | `packages/invoices/src/reminders/*` | 2 then 3 (3 uses 2) |
| C — API | 4, 5, 6 | `apps/zync-api/src/routes/invoice-reminders.ts`, `.../settings-invoicing-reminders.ts`, `.../cron/invoice-reminders.ts`, `apps/zync-api/wrangler.toml` | Yes (after B) |
| D — UI | 7, 8 | `apps/zync-app/src/features/settings/...`, `apps/zync-app/src/features/invoices/...` | Yes (after C) |
| E — i18n + template seed | 9 | locale json, default template fallback | Yes (after B) |

## Tasks

### Task 1: Schema deltas on `invoices` and `tenant_settings`
**Blocks:** 2, 3, 4, 5, 6  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/<timestamp>_invoice_payment_reminders.sql`
- Modify: `packages/db/src/schema/invoices.ts`
- Modify: `packages/db/src/schema/tenant-settings.ts` (create this schema module if `tenant_settings` is not yet modeled in Drizzle by the invoices-core dependency)
**Steps:**
- [ ] Add the five reminder columns to `invoices` and the partial index that backs the daily cron selection.
- [ ] Add the two reminder columns to `tenant_settings` via `ALTER ... ADD COLUMN`. The base table is owned by `foundation-auth-rbac` (in every tenant's closure), so no guard CREATE is needed.
- [ ] Mirror all new columns in the Drizzle schema modules with matching types/defaults.
- [ ] Backfill `next_reminder_at` / `next_reminder_offset` for already-payable invoices (see Task 2 computation) in the migration so existing invoices enter the cron.
**Schema / Interfaces:**
```sql
-- invoices deltas (table owned by invoices-core)
ALTER TABLE invoices ADD COLUMN reminder_last_sent_at TIMESTAMPTZ;
ALTER TABLE invoices ADD COLUMN reminder_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE invoices ADD COLUMN reminders_disabled BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE invoices ADD COLUMN next_reminder_at TIMESTAMPTZ;
-- next_reminder_at computed on each send; NULL when all stages sent or reminders_disabled
ALTER TABLE invoices ADD COLUMN next_reminder_offset INTEGER;
-- offset_days of the stage next_reminder_at points to; used by the dunning dedup guard
-- (<= 0 = pre/at-due, always sent; > 0 = post-due, suppressed when dunning is active)

-- Partial index: cron selects on (next_reminder_at <= now()) among payable, non-disabled invoices
CREATE INDEX idx_invoices_next_reminder
  ON invoices (next_reminder_at)
  WHERE reminders_disabled = false AND next_reminder_at IS NOT NULL;

-- tenant_settings deltas (base table owned by foundation-auth-rbac)
ALTER TABLE tenant_settings ADD COLUMN invoice_reminders_enabled BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE tenant_settings ADD COLUMN invoice_reminder_schedule JSONB NOT NULL DEFAULT
  '[{"offset_days":-3,"enabled":true},{"offset_days":0,"enabled":true},{"offset_days":7,"enabled":true},{"offset_days":14,"enabled":true},{"offset_days":30,"enabled":true}]'::jsonb;
```
**Acceptance:**
- [ ] Migration applies cleanly on a fresh Neon branch and is idempotent re-runnable where guarded.
- [ ] `idx_invoices_next_reminder` exists and is used by the cron query (verify with `EXPLAIN`).
- [ ] Drizzle schema compiles and `select` over the new columns type-checks.

### Task 2: Schedule computation helper `computeNextReminder`
**Blocks:** 3, 4, 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/invoices/src/reminders/schedule.ts`
- Create: `packages/invoices/src/reminders/types.ts`
**Steps:**
- [ ] Define the schedule stage type and parse `tenant_settings.invoice_reminder_schedule` JSONB into typed stages.
- [ ] Implement `computeNextReminder(dueDate, schedule, lastSentOffset?)`: returns the next enabled stage strictly after `lastSentOffset` (or the earliest enabled stage when none sent yet), as `{ nextReminderAt: Date, nextReminderOffset: number } | null`. `nextReminderAt = dueDate + offset_days`. Returns `null` when no enabled stage remains.
- [ ] Disabled stages (`enabled: false`) are skipped entirely.
**Schema / Interfaces:**
```ts
export interface ReminderStage { offset_days: number; enabled: boolean }
export const DEFAULT_REMINDER_SCHEDULE: ReminderStage[] = [
  { offset_days: -3, enabled: true },
  { offset_days: 0, enabled: true },
  { offset_days: 7, enabled: true },
  { offset_days: 14, enabled: true },
  { offset_days: 30, enabled: true },
]
export interface NextReminder { nextReminderAt: Date; nextReminderOffset: number }
export function parseReminderSchedule(raw: unknown): ReminderStage[]
export function computeNextReminder(
  dueDate: Date,
  schedule: ReminderStage[],
  lastSentOffset?: number | null,
): NextReminder | null
```
**Acceptance:**
- [ ] Fresh payable invoice with default schedule → first call returns offset `-3` at `dueDate - 3d`.
- [ ] After sending offset `0`, next call returns offset `7`; after `30`, returns `null`.
- [ ] A schedule with `enabled:false` on offset `7` skips straight from `0` to `14`.

### Task 3: Reminder send service `sendInvoiceReminder`
**Blocks:** 4, 6  ·  **Blocked by:** 2
**Files:**
- Create: `packages/invoices/src/reminders/send.ts`
- Create: `packages/invoices/src/reminders/index.ts` (barrel exports)
**Steps:**
- [ ] Load the invoice, its customer recipient email, tenant locale, and resolved payment link.
- [ ] Resolve subject/body: look up `tenant_email_templates` for `(tenant_id, 'invoice_reminder')`; if present use it, else use the default fallback subject/body for the current stage.
- [ ] Build the variable map (`invoice_number`, `invoice_total`, `due_date`, `days_overdue`, `payment_link`) and render via `interpolateTemplate` so variable values are HTML-escaped at substitution time.
- [ ] Call `sendEmail({ to, templateKey: 'invoice_reminder', vars, locale })` with the tenant/customer locale (he-IL default, never default to en-US).
- [ ] In a single transaction: increment `reminder_count`, set `reminder_last_sent_at = now()`, compute next stage via `computeNextReminder(dueDate, schedule, currentOffset)` and write `next_reminder_at` + `next_reminder_offset` (both NULL when exhausted).
- [ ] Return `{ sentAt, recipientEmail, subject }`.
- [ ] `days_overdue` = `floor((now - dueDate) / 1d)`; the default-body branch shows "{days_overdue} days overdue" when `> 0`, else "on {due_date}".
**Schema / Interfaces:**
```ts
export interface SendReminderResult { sentAt: string; recipientEmail: string; subject: string }
export async function sendInvoiceReminder(
  db: DB,
  tenantId: string,
  invoiceId: string,
  opts?: { manual?: boolean },
): Promise<SendReminderResult>
// Default fallback body (when no tenant_email_templates row):
//   subject per-stage from spec table; body:
//   "This is a reminder that invoice {invoice_number} for {invoice_total} is due
//    {days_overdue>0 ? '{days_overdue} days overdue' : 'on {due_date}'}. Pay now: {payment_link}"
```
**Acceptance:**
- [ ] Sending advances `reminder_count` and recomputes `next_reminder_at`/`next_reminder_offset`.
- [ ] Variable values containing `<`/`&`/`"` are HTML-escaped in the rendered output.
- [ ] `sendEmail` is invoked with an explicit `locale`, never falling back to en-US.
- [ ] Custom `tenant_email_templates` row for `invoice_reminder` overrides the default body.

### Task 4: Initialize `next_reminder_at` on payable-status transition
**Blocks:** —  ·  **Blocked by:** 2, 1
**Files:**
- Modify: invoices-core status-transition service (e.g. `packages/invoices/src/status.ts` / wherever `invoices.status` is mutated to `SENT`/`APPROVED`/`TAX_ISSUED`)
**Steps:**
- [ ] On any transition into `SENT`, `APPROVED`, `TAX_ISSUED`, or `PARTIALLY_PAID` where `due_date IS NOT NULL` and `reminders_disabled = false`: call `computeNextReminder(dueDate, tenantSchedule, null)` and persist `next_reminder_at` + `next_reminder_offset`.
- [ ] On transition out of payable states (`PAID`, `VOID`, `REJECTED`, `WRITTEN_OFF`, `BAD_DEBT`): clear `next_reminder_at` and `next_reminder_offset` to NULL so the invoice exits the cron.
- [ ] Respect tenant `invoice_reminders_enabled = false`: leave `next_reminder_at` NULL.
**Acceptance:**
- [ ] A newly `SENT` invoice with a future due date has a non-NULL `next_reminder_at` equal to the first enabled stage.
- [ ] Marking an invoice `PAID` sets `next_reminder_at = NULL`.

### Task 5: Tenant reminder settings API
**Blocks:** 7  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/settings-invoicing-reminders.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount routes)
**Steps:**
- [ ] `GET /api/settings/invoicing/reminders` → return `{ enabled, schedule }` from `tenant_settings` (`invoice_reminders_enabled`, `invoice_reminder_schedule`).
- [ ] `PATCH /api/settings/invoicing/reminders` → validate body with Zod (`enabled: boolean`, `schedule: ReminderStage[]` with integer `offset_days`, boolean `enabled`), persist, require `settings:write`.
- [ ] Apply `authMiddleware` + `requirePermission('settings:write')` on PATCH; tenant-scope all queries via `tenantQuery`.
- [ ] Optionally recompute `next_reminder_at` for the tenant's payable invoices when the schedule changes (so disabling/enabling stages takes effect without waiting for the next send).
**Schema / Interfaces:**
```ts
// Zod
const scheduleStageSchema = z.object({ offset_days: z.number().int(), enabled: z.boolean() })
const patchReminderSettingsSchema = z.object({
  enabled: z.boolean(),
  schedule: z.array(scheduleStageSchema),
})
// GET  /api/settings/invoicing/reminders -> { enabled: boolean, schedule: ReminderStage[] }
// PATCH/api/settings/invoicing/reminders  body: { enabled, schedule } -> { enabled, schedule }
```
**Acceptance:**
- [ ] PATCH without `settings:write` returns 403.
- [ ] Invalid `schedule` (non-integer offset) returns 400 from Zod.
- [ ] GET reflects persisted changes round-trip.

### Task 6: Per-invoice reminder API (manual send + opt-out)
**Blocks:** 8  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/invoice-reminders.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount routes)
**Steps:**
- [ ] `POST /api/invoices/:id/reminders/send` → call `sendInvoiceReminder(db, tenantId, id, { manual: true })`; return `{ sentAt, recipientEmail, subject }`. Require `invoices:write`. Reject (409) if invoice not in a payable status or `due_date` is NULL.
- [ ] `PATCH /api/invoices/:id/reminders` → body `{ disabled?: boolean }`; set `invoices.reminders_disabled`. When disabling, set `next_reminder_at = NULL`; when re-enabling, recompute via `computeNextReminder`. Require `invoices:write`.
- [ ] `authMiddleware` + `requirePermission('invoices:write')`; tenant-scope via `tenantQuery`; Zod-validate the PATCH body.
**Schema / Interfaces:**
```ts
const patchInvoiceReminderSchema = z.object({ disabled: z.boolean().optional() })
// POST /api/invoices/:id/reminders/send -> { sentAt: string, recipientEmail: string, subject: string }
// PATCH/api/invoices/:id/reminders      body: { disabled?: boolean } -> { reminders_disabled, next_reminder_at }
```
**Acceptance:**
- [ ] Manual send on a payable invoice emails and advances the schedule.
- [ ] `{ disabled: true }` sets `reminders_disabled = true` and nulls `next_reminder_at`; the invoice no longer appears in the cron query.
- [ ] Both routes 403 without `invoices:write`.

### Task 7: Cron route `POST /api/cron/invoice-reminders` + trigger
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/cron/invoice-reminders.ts`
- Modify: `apps/zync-api/wrangler.toml` (cron trigger `0 7 * * *`)
- Modify: `apps/zync-api/src/index.ts` / scheduled dispatcher (route cron trigger to the handler)
**Steps:**
- [ ] Implement the handler that runs the selection query below, then iterates results calling `sendInvoiceReminder` per invoice (catch/log per-invoice failures; one failure must not abort the batch).
- [ ] Register the Cloudflare cron trigger `0 7 * * *` and dispatch it to `POST /api/cron/invoice-reminders` (mirror the `POST /api/cron/subscription-trial-check` pattern). Guard the route so only the cron trigger / an internal token can invoke it.
- [ ] Return `{ processed, sent, failed }` summary.
**Schema / Interfaces:**
```sql
-- Daily 07:00 UTC (before the 08:00 invoice-dunning cron). Backed by idx_invoices_next_reminder.
SELECT i.id, i.due_date FROM invoices i
WHERE i.status IN ('SENT', 'APPROVED', 'TAX_ISSUED', 'PARTIALLY_PAID')
  AND i.reminders_disabled = false
  AND i.due_date IS NOT NULL
  AND i.next_reminder_at <= now()
  AND i.tenant_id IN (
    SELECT tenant_id FROM tenant_settings
    WHERE invoice_reminders_enabled = true
  )
  -- Dedup guard vs dunning (spec 93): only send a POST-due stage (next stage
  -- offset_days > 0) when dunning is NOT active for this tenant. Pre-due / at-due
  -- stages (offset_days <= 0) always send.
  AND (
    i.next_reminder_offset <= 0
    OR NOT EXISTS (
      SELECT 1 FROM dunning_schedules ds WHERE ds.tenant_id = i.tenant_id
    )
  )
```
```toml
# apps/zync-api/wrangler.toml
[triggers]
crons = ["0 7 * * *"]  # invoice-reminders (alongside existing crons)
```
**Acceptance:**
- [ ] Cron query returns only payable, non-disabled invoices with `next_reminder_at <= now()` for reminder-enabled tenants.
- [ ] A tenant with a `dunning_schedules` row receives only `offset_days <= 0` stages; a tenant with none receives the full `+7/+14/+30` series.
- [ ] A per-invoice send error logs and the batch continues; summary counts reflect it.

### Task 8: Settings UI — Payment Reminders section on `/settings/invoicing`
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-app/src/features/settings/InvoicingSettingsPage.tsx` (or the existing `/settings/invoicing` page component)
- Create: `apps/zync-app/src/features/settings/PaymentRemindersSection.tsx`
- Create: `apps/zync-app/src/features/settings/useReminderSettings.ts` (react-query hooks for GET/PATCH)
**Steps:**
- [ ] Add a "Payment Reminders" section: an Enabled/Disabled toggle (`Switch`), five stage checkboxes (`Checkbox`) bound to the schedule stages (3 days before, on due date, 7/14/30 days overdue), and a read-only Reply-to display sourced from email settings.
- [ ] Wire react-query hooks to `GET`/`PATCH /api/settings/invoicing/reminders`; show a `toast` on save.
- [ ] Use only `@zync/ui` tokens/spacing (no hardcoded colors/spacing); respect RTL (logical properties) and `prefers-reduced-motion`; checkboxes/toggle keyboard-operable with proper `aria` labels.
- [ ] Localize all strings (he-IL / en-US) via `react-i18next`.
**Acceptance:**
- [ ] Toggling stages and saving persists and reloads correctly.
- [ ] Section renders RTL-correct in Hebrew with accessible labels (keyboard + screen-reader).
- [ ] Disabled state visually and functionally disables stage editing.

### Task 9: Invoice Detail — Reminders panel
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/src/features/invoices/InvoiceDetailPage.tsx`
- Create: `apps/zync-app/src/features/invoices/RemindersPanel.tsx`
- Create: `apps/zync-app/src/features/invoices/useInvoiceReminders.ts` (react-query mutations)
**Steps:**
- [ ] Render a "Payment Reminders" panel only when the invoice is payable (`SENT`/`APPROVED`/`TAX_ISSUED`/`PARTIALLY_PAID`). Show Last sent (stage + date from `reminder_last_sent_at`), Next (`next_reminder_at` + offset label), and Sent count (`reminder_count`).
- [ ] "Send now" button → `POST /api/invoices/:id/reminders/send`; on success `toast` the recipient + subject and refetch the invoice.
- [ ] "Disable reminders for this invoice" → `PATCH /api/invoices/:id/reminders { disabled: true }`; toggle label to re-enable when disabled.
- [ ] Use `@zync/ui` primitives, tokenized styling, RTL logical layout, reduced-motion-safe, accessible button labels; localize all strings.
**Acceptance:**
- [ ] Panel hidden for non-payable invoices (DRAFT/PAID/VOID).
- [ ] "Send now" triggers a send and updates the displayed sent count + next date.
- [ ] "Disable reminders" sets `reminders_disabled` and the panel reflects the disabled state.

### Task 10: i18n strings + default `invoice_reminder` template content
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: locale catalogs `apps/zync-app/src/i18n/he-IL.json`, `apps/zync-app/src/i18n/en-US.json` (settings + invoice reminder UI keys)
- Modify: default email template registry (the system-default `invoice_reminder` subject/body fallback consumed by Task 3) — e.g. `packages/notifications/src/templates/invoice_reminder.ts`
**Steps:**
- [ ] Add he-IL/en-US keys for: Payment Reminders section labels, the five stage labels, Reply-to label, panel labels (Last sent / Next / Sent count / Send now / Disable reminders), and toast messages.
- [ ] Register the system-default `invoice_reminder` template (subject per spec stage table + default body) used when no `tenant_email_templates` row exists, with he-IL and en-US variants keyed by locale.
- [ ] Ensure default subjects match the spec stage table verbatim ("Invoice due in 3 days — {invoiceNumber}", "Invoice due today — {invoiceNumber}", "Invoice overdue — {invoiceNumber}", "Follow-up: Invoice overdue — {invoiceNumber}", "Final notice: Invoice overdue — {invoiceNumber}").
**Acceptance:**
- [ ] All new UI strings resolve in both locales with no missing-key warnings.
- [ ] With no custom template row, sends use the registered default subject/body for the correct stage and locale.
