# Subscription Cancellation Flow — Implementation Plan

**Spec:** docs/specs/2026-05-31-subscription-cancellation-flow.md  ·  **Slug:** subscription-cancellation-flow  ·  **Wave:** 10
**Depends on:** foundation-auth-rbac, settings-module, system-communications-notifications, zync-subscription

## Goal
Deliver the complete cancellation UX for paid Business subscriptions, embedded in the existing `/settings/plan` page. This fills in what `zync-subscription` (spec 33) only sketched: a two-step impact-summary + confirmation flow with an optional offboarding survey, a post-cancellation "Cancels on {date}" state, a one-click reactivation path (before period end), and a confirmation email to the tenant OWNER. Cancellation is effective at period end (not immediate); the tenant retains full Business access until then.

## Architecture
The flow is layered on top of the upstream `zync_subscriptions` table (owned by `zync-subscription`) and the existing `DELETE /api/zync-subscription` route. This plan:
- Adds three columns to `zync_subscriptions` via a Drizzle migration: `cancellation_reason`, `cancellation_reason_freetext` (both this spec), surfacing them through the existing `zyncSubscriptions` Drizzle table export.
- Extends the existing `DELETE /api/zync-subscription` handler to accept an optional request body `{ cancellationReason, cancellationReasonFreetext }`, persist it, set `canceled_at = now()` and `status = 'canceled'`, and trigger the cancellation email + in-app notification.
- Adds a NEW route `POST /api/zync-subscription/reactivate` that clears `canceled_at` and sets `status = 'active'` (guarded: OWNER only, requires `status = 'canceled'` and period not yet ended).
- Adds React UI inside the existing `/settings/plan` page (host provided by `settings-module`): a `CancelSubscriptionFlow` component (impact sheet → confirm dialog) using `Sheet`/`Dialog` from `@zync/ui`, a `PostCancellationCard` showing the "Cancels on {date}" state with a `ReactivateButton`, and a `ReactivateDialog`.
- Consumes upstream exports: `Sheet`, `Dialog`, `Button`, `Radio`, `Textarea`, `Badge`, `toast` (`@zync/ui`); `useSubscription` (data hook from `zync-subscription`); `authMiddleware`, `requirePermission`/role guard for OWNER (`@zync/auth`); `sendEmail` + `SendEmailOptions` and `createNotification` (`@zync/notifications`); `tenantQuery`/`zyncSubscriptions` (`@zync/db`); `ZyncSubscriptionStatus`, `TenantTier`, `ZyncSubscriptionRow` (`@zync/types`).

**Period-end downgrade ownership:** this flow sets `status = 'canceled'` but intentionally leaves `tier` and `current_period_end` intact so access continues until period end. The actual `canceled → freelancer` downgrade when `current_period_end` passes is NOT performed by this spec — it is owned upstream by `zync-subscription`'s downgrade path (`downgradeToFreelancer` / cron). Tasks here that assume "after period end, tier is already freelancer" rely on that upstream transition; do not add a cron for it in this plan.

Data flow: OWNER clicks "Cancel subscription" → impact sheet collects optional survey → confirm dialog → `DELETE /api/zync-subscription` with survey body → server persists reason + sets `canceled_at`/`status='canceled'` → fires email (OWNER) + in-app notification → client refetches subscription → `/settings/plan` renders `PostCancellationCard`. Reactivation reverses it through `POST /api/zync-subscription/reactivate`.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers). Extends existing `apps/zync-api/src/routes/zync-subscription.ts`. DB via Drizzle over Neon Postgres through Cloudflare Hyperdrive (`DB` binding).
- **App UI:** `apps/zync-app` (Vite + React). New components under `apps/zync-app/src/features/settings/plan/cancellation/`.
- **Packages:** `@zync/db` (Drizzle schema/migration for the `zync_subscriptions` deltas), `@zync/notifications` (`sendEmail`, `createNotification`), `@zync/ui` (Sheet/Dialog/Radio/Textarea/Button/Badge/toast), `@zync/types`, `@zync/auth`.
- **Cloudflare bindings:** `DB` (Hyperdrive→Neon). Email send goes through the configured `TenantEmailAdapter` behind `sendEmail`.
- **Validation:** Zod schema for the cancellation/reactivation request bodies (per `require-zod-validation-in-routes`).
- **i18n/RTL:** all UI strings via translation keys; survey radio group and dialogs honor `useDirection` (RTL/Hebrew). Confirmation email rendered in tenant locale via `sendEmail`'s `locale` param. Animations honor `prefers-reduced-motion`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema | 1 | `packages/db/src/schema/zync-subscriptions.ts`, migration SQL | No (blocks all) |
| B — server | 2, 3, 4 | `apps/zync-api/src/routes/zync-subscription.ts`, validation, email/notification helpers | After A; 3 & 4 parallel after 2 |
| C — client UI | 5, 6, 7, 8 | `apps/zync-app/src/features/settings/plan/cancellation/*`, i18n keys | After B; 5–7 parallel, 8 after 5–7 |

## Tasks

### Task 1: Schema delta — add cancellation columns to `zync_subscriptions`
**Blocks:** 2, 3, 5, 6  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/zync-subscriptions.ts` (the existing `zyncSubscriptions` Drizzle table)
- Create: `packages/db/drizzle/20260602000000_subscription_cancellation_columns.sql`
**Steps:**
- [ ] Add `cancellationReason` and `cancellationReasonFreetext` text columns to the existing `zyncSubscriptions` Drizzle table definition (do NOT redefine the table — extend it).
- [ ] Author the forward migration SQL with the two `ALTER TABLE ADD COLUMN IF NOT EXISTS` statements shown below (idempotent).
- [ ] Run `pnpm --filter @zync/db drizzle:generate` to confirm the snapshot matches, then verify the migration applies cleanly against a Neon branch.
- [ ] Confirm `ZyncSubscriptionRow` type in `@zync/types` reflects the two new nullable fields (extend if it is a hand-written interface).
**Schema / Interfaces:**
```sql
-- packages/db/drizzle/20260602000000_subscription_cancellation_columns.sql
ALTER TABLE zync_subscriptions
  ADD COLUMN IF NOT EXISTS cancellation_reason TEXT;
ALTER TABLE zync_subscriptions
  ADD COLUMN IF NOT EXISTS cancellation_reason_freetext TEXT;
-- cancellation_reason holds one of the survey option codes:
--   'too_expensive' | 'missing_feature' | 'switching_tool' | 'temporary' | 'other'
-- enforced at the application layer (Zod), not a DB CHECK, because the survey is optional/nullable.
-- cancellation_reason_freetext holds the "Other" textarea content (nullable).
```
```ts
// packages/db/src/schema/zync-subscriptions.ts — additions to the existing zyncSubscriptions table
cancellationReason:          text('cancellation_reason'),            // nullable
cancellationReasonFreetext:  text('cancellation_reason_freetext'),   // nullable
```
**Acceptance:**
- [ ] `zync_subscriptions` has both new nullable TEXT columns after migration; existing rows unaffected (values NULL).
- [ ] `zyncSubscriptions` Drizzle export exposes `cancellationReason` and `cancellationReasonFreetext`.
- [ ] Migration is reversible/idempotent (re-running is a no-op).

### Task 2: Validation schemas for cancellation + reactivation request bodies
**Blocks:** 3, 4  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/zync-subscription.validation.ts` (or extend the existing validation module for this route group)
**Steps:**
- [ ] Define `cancelSubscriptionSchema` validating the optional survey body; reason must be one of the five option codes or omitted; freetext optional, max 2000 chars, only meaningful when reason is `'other'` (do not reject if present otherwise — just store).
- [ ] Define `reactivateSubscriptionSchema` as an empty object schema (no body fields) so the route still runs Zod validation per `require-zod-validation-in-routes`.
- [ ] Export both for use in Task 3 and Task 4.
**Schema / Interfaces:**
```ts
import { z } from 'zod'

export const CANCELLATION_REASONS = [
  'too_expensive',
  'missing_feature',
  'switching_tool',
  'temporary',
  'other',
] as const

export const cancelSubscriptionSchema = z.object({
  cancellationReason: z.enum(CANCELLATION_REASONS).optional(),
  cancellationReasonFreetext: z.string().trim().max(2000).optional(),
}).strict()

export const reactivateSubscriptionSchema = z.object({}).strict()

export type CancelSubscriptionBody = z.infer<typeof cancelSubscriptionSchema>
```
**Acceptance:**
- [ ] Invalid reason codes are rejected with 400.
- [ ] Empty body (`{}`) is accepted for cancellation (survey skippable).
- [ ] Freetext over 2000 chars is rejected.

### Task 3: Extend `DELETE /api/zync-subscription` — persist survey, set canceled state, fire email + notification
**Blocks:** 5, 8  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `apps/zync-api/src/routes/zync-subscription.ts` (existing `DELETE /api/zync-subscription` handler)
**Steps:**
- [ ] Apply `authMiddleware`; enforce OWNER-only (reject ADMIN/MEMBER/VIEWER with 403) — cancellation is OWNER only per this spec, narrower than the route's general OWNER/ADMIN guard.
- [ ] Parse + validate the optional body with `cancelSubscriptionSchema` (Task 2).
- [ ] Load the session tenant's `zync_subscriptions` row via `tenantQuery`. Reject with 409 if `status` is not `'active'` or `'trialing'`, or if `tier = 'freelancer'` (nothing to cancel).
- [ ] In a single transaction, update the row: `status = 'canceled'`, `canceled_at = now()`, `cancellation_reason = body.cancellationReason ?? NULL`, `cancellation_reason_freetext = body.cancellationReasonFreetext ?? NULL`. Do NOT change `tier`, `current_period_end`, or `period` — access continues until period end.
- [ ] Call the active adapter's `cancelSubscription(tenantId)` (resolved via `getPaymentAdapter`); on `NullPaymentAdapter` this is the DB-only path already. Use the returned `effectiveDate` (period end) for the email/response; fall back to `current_period_end` if adapter returns nothing.
- [ ] After commit, send the cancellation email (Task 4) and create the in-app notification (Task 4) for the OWNER. Email/notification failures must be logged but must NOT fail the cancellation response.
- [ ] Return `{ status: 'canceled', canceledAt, periodEnd }`.
**Schema / Interfaces:**
```ts
// DELETE /api/zync-subscription  (OWNER only)
// Request body (optional): CancelSubscriptionBody
// Response 200:
interface CancelSubscriptionResponse {
  status: 'canceled'
  canceledAt: string   // ISO timestamp
  periodEnd: string | null  // ISO; current_period_end / adapter effectiveDate
}
// 403 if not OWNER · 409 if status not active|trialing or tier=freelancer · 400 on invalid body
```
**Acceptance:**
- [ ] OWNER cancellation sets `status='canceled'` + `canceled_at=now()` and persists the survey fields when supplied.
- [ ] `tier`, `current_period_end`, `period` are unchanged (access continues until period end).
- [ ] Non-OWNER receives 403; cancelling a freelancer/already-canceled row receives 409.
- [ ] A confirmation email and in-app notification are dispatched to the OWNER; their failure does not roll back the cancellation.

### Task 4: Cancellation email template + in-app notification
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Create: `packages/notifications/src/templates/subscription-cancelled.he.mjml`
- Create: `packages/notifications/src/templates/subscription-cancelled.en.mjml`
- Modify: `packages/notifications/src/templates/index.ts` (register `subscription_cancelled_he` / `subscription_cancelled_en` template keys)
- Modify: `apps/zync-api/src/routes/zync-subscription.ts` (wire send from Task 3)
**Steps:**
- [ ] Author both MJML templates: subject "Your Zync Business subscription has been cancelled"; body confirms cancellation date + period end date, a "Reactivate" link to `/settings/plan`, and a data-retention assurance ("Your data stays safe. You can reactivate anytime."). Set `<html lang>`/`dir` per locale (Hebrew = RTL).
- [ ] Resolve the OWNER's email and the tenant locale; call `sendEmail` with `SendEmailOptions` using `templateKey` `subscription_cancelled_he`/`subscription_cancelled_en` and `locale` from tenant settings (never default to en-US — Hebrew-first).
- [ ] Create an in-app notification via `createNotification` using i18n keys (no pre-rendered text): `title_key = 'notification.subscription_cancelled.title'`, `body_key = 'notification.subscription_cancelled.body'`, `params = { periodEnd }`, targeted at the OWNER user.
- [ ] Add the two new i18n keys to the `he-IL` and `en-US` translation bundles consumed by `translations`.
**Schema / Interfaces:**
```ts
await sendEmail({
  to: ownerEmail,
  templateKey: locale === 'he-IL' ? 'subscription_cancelled_he' : 'subscription_cancelled_en',
  vars: { periodEnd: formattedPeriodEnd, reactivateUrl: '/settings/plan' },
  locale, // 'he-IL' | 'en-US' — from tenant settings, never hardcoded en-US
})

await createNotification({
  tenantId,
  userId: ownerUserId,
  titleKey: 'notification.subscription_cancelled.title',
  bodyKey:  'notification.subscription_cancelled.body',
  params:   { periodEnd: formattedPeriodEnd },
})
```
**Acceptance:**
- [ ] Email is sent in the tenant's locale (Hebrew template for he-IL) with correct cancellation + period-end dates and a reactivate link.
- [ ] In-app notification row carries `title_key`/`body_key`/`params` only (no pre-rendered strings).
- [ ] Both Hebrew and English templates/keys exist and resolve.

### Task 5: `POST /api/zync-subscription/reactivate` route
**Blocks:** 7, 8  ·  **Blocked by:** 1, 2, 3
**Files:**
- Modify: `apps/zync-api/src/routes/zync-subscription.ts` (add new route)
**Steps:**
- [ ] Register `POST /api/zync-subscription/reactivate` under `authMiddleware`, OWNER only (403 otherwise).
- [ ] Validate body with `reactivateSubscriptionSchema` (empty object).
- [ ] Load the tenant's `zync_subscriptions` row via `tenantQuery`. Require `status = 'canceled'` AND `tier != 'freelancer'` AND the period not yet ended (`current_period_end IS NULL OR current_period_end > now()`); otherwise 409 with a hint that a full upgrade flow is required.
- [ ] Update the row: `canceled_at = NULL`, `status = 'active'`. Leave `cancellation_reason`/`cancellation_reason_freetext` as historical record (do not clear). Do not touch `tier`/`period`.
- [ ] Return `{ status: 'active', canceledAt: null }`.
**Schema / Interfaces:**
```ts
// POST /api/zync-subscription/reactivate  (OWNER only)
// Preconditions: status='canceled', tier != 'freelancer', current_period_end > now() (or NULL)
// Effect: canceled_at = NULL, status = 'active'
// Response 200:
interface ReactivateSubscriptionResponse {
  status: 'active'
  canceledAt: null
}
// 403 if not OWNER · 409 if not eligible (period ended / not canceled / freelancer)
```
**Acceptance:**
- [ ] Reactivating a canceled, not-yet-ended Business subscription sets `status='active'`, `canceled_at=NULL`.
- [ ] Reactivation after period end (tier already freelancer) returns 409 (full upgrade flow needed, owned by zync-subscription).
- [ ] Non-OWNER receives 403.

### Task 6: `CancelSubscriptionFlow` UI — impact sheet + survey + confirm dialog
**Blocks:** 8  ·  **Blocked by:** 1, 3
**Files:**
- Create: `apps/zync-app/src/features/settings/plan/cancellation/CancelSubscriptionFlow.tsx`
- Create: `apps/zync-app/src/features/settings/plan/cancellation/useCancelSubscription.ts`
- Modify: `apps/zync-app/src/features/settings/plan/PlanPage.tsx` (render the "Cancel subscription" link)
**Steps:**
- [ ] Render a "Cancel subscription" link below the Current Plan Card, shown only when `status ∈ {'active','trialing'}`, `tier != 'freelancer'`, and the current user is OWNER.
- [ ] Step 1 — Impact Summary in a `Sheet` (bottom sheet / overlay, NOT a full-page redirect). Heading "Cancel Business subscription?"; copy: plan continues until `{periodEndDate}`, then moves to Freelancer (free); the lost-access bullet list (Lead forms & inbound webhooks; Contracts and e-signing; Expense approval workflow; Custom email domain and SMTP; API keys and outbound webhooks; Multi-currency invoicing); data-safe/reactivate assurance.
- [ ] Render the optional offboarding survey as a `Radio` group (one option selectable): Too expensive · Missing a feature I need · Switching to another tool · Only needed it temporarily · Other (with a `Textarea` shown when "Other" is selected). Survey is skippable.
- [ ] Footer buttons: "Never mind, keep my plan" (closes sheet, no-op) and "Continue to cancel →" (advances to Step 2).
- [ ] Step 2 — Confirmation in a `Dialog`: "Confirm cancellation"; copy: "Your Business plan will end on `{periodEndDate}`. You won't be charged again after that date."; buttons "Go back" (returns to Step 1) and "Cancel my subscription" (calls the mutation).
- [ ] `useCancelSubscription` posts `DELETE /api/zync-subscription` with `{ cancellationReason, cancellationReasonFreetext }` mapped from the survey (omit when skipped). On success: close flow, refetch subscription (invalidate the `useSubscription` query), toast success. On error: toast the server error.
- [ ] All strings via i18n keys; layout honors `useDirection` (RTL); sheet/dialog transitions honor `prefers-reduced-motion`; survey radio group has an accessible `aria` group label and the freetext textarea has an associated label.
**Schema / Interfaces:**
```ts
// useCancelSubscription mutation payload
interface CancelSubscriptionInput {
  cancellationReason?: 'too_expensive' | 'missing_feature' | 'switching_tool' | 'temporary' | 'other'
  cancellationReasonFreetext?: string
}
// reason option -> radio value mapping:
//   'Too expensive'            -> 'too_expensive'
//   'Missing a feature I need' -> 'missing_feature'
//   'Switching to another tool'-> 'switching_tool'
//   'Only needed it temporarily'-> 'temporary'
//   'Other'                    -> 'other' (+ freetext)
```
**Acceptance:**
- [ ] "Cancel subscription" link appears only for OWNER on an active/trialing non-freelancer plan.
- [ ] Two-step flow works: impact sheet → confirm dialog → cancellation; "Never mind" and "Go back" abort without mutating.
- [ ] Survey is optional; selecting "Other" reveals the freetext field; the chosen reason is sent in the request body.
- [ ] Radio group and dialogs are keyboard-navigable, have aria group/labels, render correctly in RTL, and respect reduced-motion.

### Task 7: `PostCancellationCard` + `ReactivateDialog` UI
**Blocks:** 8  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/features/settings/plan/cancellation/PostCancellationCard.tsx`
- Create: `apps/zync-app/src/features/settings/plan/cancellation/ReactivateDialog.tsx`
- Create: `apps/zync-app/src/features/settings/plan/cancellation/useReactivateSubscription.ts`
- Modify: `apps/zync-app/src/features/settings/plan/PlanPage.tsx` (render `PostCancellationCard` when canceled)
**Steps:**
- [ ] When `status = 'canceled'` and `tier != 'freelancer'` (period not ended), render `PostCancellationCard` in place of / above the standard plan card: heading "Business Plan", a `Badge` "Cancels on `{periodEndDate}`", body "You have full Business access until `{periodEndDate}`. After that date, your account moves to Freelancer.", and a "Reactivate" button (OWNER only).
- [ ] "Reactivate" opens `ReactivateDialog`: "Reactivate Business subscription?"; copy: "Your subscription will continue as normal. You'll be billed on `{nextRenewalDate}`."; buttons "Cancel" (close) and "Reactivate".
- [ ] `useReactivateSubscription` posts `POST /api/zync-subscription/reactivate`. On success: refetch subscription, toast "Your subscription has been reactivated.". On error: toast the server message.
- [ ] If the period has already ended (tier now `freelancer`), do NOT show this card/reactivate path — defer to the upgrade flow (`useUpgradeModal`) owned by zync-subscription.
- [ ] All strings via i18n keys; RTL-aware; dialog respects `prefers-reduced-motion`; reactivate button has an accessible label.
**Schema / Interfaces:**
```ts
// useReactivateSubscription — no payload; POST /api/zync-subscription/reactivate
// Success toast key: 'settings.plan.reactivated_toast' -> "Your subscription has been reactivated."
// nextRenewalDate displayed = current_period_end (formatted in tenant locale)
```
**Acceptance:**
- [ ] After cancellation (before period end), `/settings/plan` shows the "Cancels on {date}" card with a Reactivate action for OWNER.
- [ ] Reactivation flips the UI back to the normal active plan state and shows the success toast.
- [ ] After period end (freelancer), the reactivate path is hidden and the upgrade flow is used instead.

### Task 8: Wire flow into `/settings/plan` + i18n keys (he/en)
**Blocks:** —  ·  **Blocked by:** 3, 5, 6, 7
**Files:**
- Modify: `apps/zync-app/src/features/settings/plan/PlanPage.tsx`
- Modify: `packages/i18n/src/locales/he-IL/settings.json` (or the bundle `translations` consumes)
- Modify: `packages/i18n/src/locales/en-US/settings.json`
**Steps:**
- [ ] Conditionally render: standard plan card + `CancelSubscriptionFlow` link when active/trialing non-freelancer; `PostCancellationCard` when canceled non-freelancer; neither cancel nor reactivate for freelancer.
- [ ] Ensure the page reads subscription state from `useSubscription` and re-renders after cancel/reactivate mutations (shared query invalidation).
- [ ] Add all new i18n keys for both locales: impact-sheet copy, lost-access bullets, survey option labels, confirm-dialog copy, post-cancellation card copy, reactivate-dialog copy, the reactivated toast, and the notification `title`/`body` keys (`notification.subscription_cancelled.*`).
- [ ] Verify Hebrew strings render RTL within the settings layout and dates are locale-formatted.
**Acceptance:**
- [ ] `/settings/plan` correctly switches between active, canceled, and freelancer presentations based on `zync_subscriptions` state.
- [ ] Every visible string (and the notification keys) has both `he-IL` and `en-US` entries; no hardcoded literals in components.
- [ ] End-to-end: OWNER cancels (with survey) → card shows "Cancels on {date}" + email/notification sent → OWNER reactivates → plan returns to active.
