# Zync Subscription Management — Implementation Plan

**Spec:** docs/specs/2026-05-31-zync-subscription.md  ·  **Slug:** zync-subscription  ·  **Wave:** 2
**Depends on:** foundation-auth-rbac

## Goal
Self-service subscription management for the commercial relationship between Zync and its tenants: signup onto a paid plan, upgrade/downgrade, manage payment, cancel, and trial handling. Introduces `zync_subscriptions` as the authoritative tier source (synced down to the denormalized `tenants.tier`), a provider-agnostic payment abstraction layer (`ZyncPaymentAdapter` + `NullPaymentAdapter`), storage-quota enforcement, the `/settings/plan` UI, the upgrade modal context, a system-admin manual tier override, and a daily trial-expiry/grace-period cron. This is **not** the billing module (which bills a tenant's own customers).

## Architecture
- **Source of truth:** `zync_subscriptions.tier` is authoritative. Foundation's `tenants` table already holds a denormalized `tier` column read by every entitlement check (`requireTier`, `useTierGate`, `meetsMinimumTier`). `syncTierToTenant(tenantId, tier)` is the bridge — called on every tier mutation. Entitlement enforcement is unchanged and owned by `foundation-auth-rbac`; this spec is the management plane only.
- **Payment abstraction:** All payment operations pass through the `ZyncPaymentAdapter` interface. The platform ships `NullPaymentAdapter` (no-op / DB-direct / logging). The active adapter is resolved at startup from env `ZYNC_PAYMENT_ADAPTER` (unset or blank defaults to `'null'`) via a registry in `packages/payments/src/registry.ts`. Unknown non-blank ids fail closed. Application code never imports a provider directly.
- **Consumes from `foundation-auth-rbac`:**
  - `TenantTier` enum (`packages/auth`) — values `freelancer | business | enterprise | white_label`.
  - `tenants` table (`tenants.id` UUID PK, `tenants.tier`) — sync target.
  - `usage_counters` table (PK `(tenant_id, counter_key, period)`, `count INTEGER`) — storage quota counter `storage_bytes` / period `all_time`.
  - `packages/db/src/queries/usage.ts` — existing `incrementCounter`, `checkCounterLimit`. This plan ADDS `getCounterValue` and `decrementCounter` to the same module (both absent upstream; the spec relies on them).
  - `requireTier`, `requirePermission`, `requireAdminSession` middleware (`packages/auth/src/middleware.ts`); session carries `role`, `tier`, `type`.
  - `audit_log` table (admin override is audit-logged with actor = admin user id).
  - `QuotaExceededError` (re-used; storage quota throws it).
- **Consumes from `system-communications-notifications`:** `notifyTenant(tenantId, eventType, payload)` for trial/grace notifications (`trial_expiring`).
- **Plugs into `settings-module`:** `/settings/plan` route mounts inside the existing settings layout/nav; a "Plan & Billing" settings-sidebar item is added (visible to `OWNER`/`ADMIN` only).
- **Plugs into `app-shell`:** Trial banner, grace-period banner, and past-due banner mount above main content; the global Upgrade Modal is mounted in the shell and toggled by `useUpgradeModal()`.
- **Plugs into `admin-dashboard`:** A "Subscription" tab on `/admin/tenants/:slug` for manual provisioning (Enterprise / White Label).

## Tech Stack
- **Packages:** `packages/payments` (adapter interface, NullAdapter, registry, sync-tier, error types), `packages/storage` (quota helpers), `packages/db/src/queries/usage.ts` (extend), `packages/db/src/schema` (new `zync_subscriptions` Drizzle table).
- **API:** `apps/zync-api` (Hono on Cloudflare Workers) — `/api/zync-subscription/*`, admin subscription routes, `/api/cron/subscription-trial-check`.
- **App:** `apps/zync-app` (Vite + React) — `/settings/plan` page, current-plan card, usage stats, plan comparison, invoice history, upgrade modal + `useUpgradeModal` context, app-shell banners.
- **Bindings:** Neon Postgres via Cloudflare Hyperdrive (Drizzle); R2 (storage quota call sites); env `ZYNC_PAYMENT_ADAPTER`, `CRON_SECRET`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 2a (data + helpers) | 1, 2, 3 | `packages/db/src/schema/zync-subscriptions.ts`, `packages/db/src/queries/usage.ts`, `packages/payments/src/errors.ts` | Tasks 2 & 3 parallel after 1 |
| 2b (payment layer) | 4, 5, 6, 7 | `packages/payments/src/*`, `packages/storage/src/quota.ts` | 4→5→6 serial; 7 parallel to 5/6 |
| 2c (API) | 8, 9, 10, 11 | `apps/zync-api/src/routes/zync-subscription.ts`, `.../admin-subscription.ts`, `.../cron/subscription-trial-check.ts` | 8→9; 10, 11 parallel after 4–7 |
| 2d (UI) | 12, 13, 14, 15, 16 | `apps/zync-app/src/pages/settings/plan.tsx`, components, `app-shell` banners, upgrade modal | 12 first; 13–16 parallel after 12 |
| 2e (wiring) | 17, 18 | signup hook, settings nav, R2 call sites | after 2c |

## Tasks

### Task 1: `zync_subscriptions` schema + Drizzle model
**Blocks:** 2, 4, 8, 9, 10, 11, 12  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/zync-subscriptions.ts`
- Modify: `packages/db/src/schema/index.ts` (export new table)
- Create: `packages/db/migrations/<ts>_zync_subscriptions.sql`
**Steps:**
- [ ] Author the canonical Postgres DDL (single CREATE TABLE — fold the `grace_period_started_at` delta in; this is greenfield, no ALTER).
- [ ] Transcribe the two enum constraints that the spec wrote as SQL comments into LIVE inline `CHECK` constraints (`tier`, `status`).
- [ ] Set `period` to `DEFAULT NULL` (per Architecture Decision #12 — NOT `'monthly'`).
- [ ] Mirror the table in Drizzle (`pgTable`) with matching column types, the UNIQUE on `tenant_id`, and both indexes.
- [ ] Export the Drizzle table as `zyncSubscriptions` and a row type `ZyncSubscriptionRow`.
**Schema / Interfaces:**
```sql
CREATE TABLE zync_subscriptions (
  id                       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id                UUID UNIQUE NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  tier                     TEXT NOT NULL DEFAULT 'freelancer'
                             CHECK (tier IN ('freelancer','business','enterprise','white_label')),
  status                   TEXT NOT NULL DEFAULT 'active'
                             CHECK (status IN ('active','trialing','past_due','canceled')),
  period                   TEXT DEFAULT NULL
                             CHECK (period IS NULL OR period IN ('monthly','annual')),
  adapter                  TEXT NOT NULL DEFAULT 'null',
  adapter_subscription_id  TEXT,
  adapter_customer_id      TEXT,
  current_period_start     TIMESTAMPTZ,
  current_period_end       TIMESTAMPTZ,
  trial_ends_at            TIMESTAMPTZ,
  canceled_at              TIMESTAMPTZ,
  grace_period_started_at  TIMESTAMPTZ,
  created_at               TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_zync_subscriptions_tenant ON zync_subscriptions(tenant_id);
CREATE INDEX idx_zync_subscriptions_status ON zync_subscriptions(status);
```
```ts
// Drizzle export
export const zyncSubscriptions = pgTable('zync_subscriptions', { /* columns above */ })
export type ZyncSubscriptionRow = typeof zyncSubscriptions.$inferSelect
```
**Acceptance:**
- [ ] Migration applies cleanly on Neon; both CHECK constraints reject out-of-enum values.
- [ ] `period` accepts NULL, `'monthly'`, `'annual'`; rejects others.
- [ ] `tenant_id` UNIQUE enforced (one subscription per tenant); cascade delete with tenant verified.

### Task 2: Extend usage queries with `getCounterValue` and `decrementCounter`
**Blocks:** 6, 8  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/db/src/queries/usage.ts`
**Steps:**
- [ ] Add `getCounterValue(db, tenantId, key, period)` — returns current `count` (0 if no row).
- [ ] Add `decrementCounter(tenantId, key, period, delta)` — atomic negative delta, clamped at 0 (never goes negative), returns new count. Same pattern as existing `incrementCounter`.
- [ ] Re-export both from the package index so `packages/storage` and the API can import them.
**Schema / Interfaces:**
```ts
export async function getCounterValue(
  db: DB, tenantId: string, key: string, period: string
): Promise<number>

export async function decrementCounter(
  db: DB, tenantId: string, key: string, period: string, delta: number
): Promise<number>  // clamped >= 0
```
**Acceptance:**
- [ ] `getCounterValue` returns 0 for an absent counter and the stored count otherwise.
- [ ] `decrementCounter` never produces a negative count; concurrent calls are atomic (single SQL UPDATE with `GREATEST(count - delta, 0)`).

### Task 3: Payment error types
**Blocks:** 4, 6, 10  ·  **Blocked by:** —
**Files:**
- Create: `packages/payments/src/errors.ts`
**Steps:**
- [ ] Define `PaymentProviderNotConfiguredError` (thrown by NullAdapter `createCheckoutSession`).
- [ ] Re-use foundation's `QuotaExceededError` for storage (do NOT redefine it); storage error type lives in `packages/storage` (Task 6) but extends the same `QuotaExceededError`.
**Schema / Interfaces:**
```ts
export class PaymentProviderNotConfiguredError extends Error {
  constructor(message = 'Payment provider not configured') {
    super(message); this.name = 'PaymentProviderNotConfiguredError'
  }
}
```
**Acceptance:**
- [ ] Error is instanceof `Error`; `name` is stable for catch-site discrimination.

### Task 4: `ZyncPaymentAdapter` interface + shared types
**Blocks:** 5, 10  ·  **Blocked by:** 1, 3
**Files:**
- Create: `packages/payments/src/types.ts`
- Create: `packages/payments/src/adapter.ts`
**Steps:**
- [ ] Define `ZyncSubscriptionStatus`, `ZyncInvoice`, `WebhookEvent` types verbatim from spec.
- [ ] Define the `ZyncPaymentAdapter` interface with all SEVEN methods (including `getBillingPortalUrl` — the 7th method per Architecture Decision #11).
- [ ] `handleWebhook(payload, signature)` signature documents that implementations MUST verify the signature using timing-safe equality (security cross-cutting requirement).
**Schema / Interfaces:**
```ts
export type ZyncSubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled'

export interface ZyncInvoice {
  id: string
  tenantId: string
  issuedAt: Date
  periodStart: Date
  periodEnd: Date
  amountIls: number
  currency: 'ILS' | string
  status: 'paid' | 'open' | 'void'
  pdfUrl: string | null
}

export interface WebhookEvent {
  type: string            // e.g. 'subscription.updated', 'invoice.paid'
  tenantId: string
  payload: unknown
  receivedAt: Date
}

export interface ZyncPaymentAdapter {
  createCheckoutSession(
    tenantId: string, tier: TenantTier, period: 'monthly' | 'annual'
  ): Promise<{ checkoutUrl: string; sessionId: string }>
  getSubscriptionStatus(tenantId: string): Promise<ZyncSubscriptionStatus>
  updateSubscription(tenantId: string, newTier: TenantTier): Promise<void>
  cancelSubscription(tenantId: string): Promise<{ effectiveDate: Date }>
  getInvoiceHistory(tenantId: string): Promise<ZyncInvoice[]>
  handleWebhook(payload: unknown, signature: string): Promise<WebhookEvent>  // MUST timing-safe verify
  getBillingPortalUrl(tenantId: string): Promise<string | null>
}
```
**Acceptance:**
- [ ] Interface compiles against imported `TenantTier`; all 7 methods present.

### Task 5: `NullPaymentAdapter` + adapter registry + `syncTierToTenant`
**Blocks:** 10, 11  ·  **Blocked by:** 4
**Files:**
- Create: `packages/payments/src/null-adapter.ts`
- Create: `packages/payments/src/registry.ts`
- Create: `packages/payments/src/sync-tier.ts`
- Create: `packages/payments/src/index.ts`
**Steps:**
- [ ] Implement `NullPaymentAdapter`:
  - `createCheckoutSession` → throws `PaymentProviderNotConfiguredError`.
  - `getSubscriptionStatus` → reads `zync_subscriptions.status` directly from DB.
  - `updateSubscription` / `cancelSubscription` → update `zync_subscriptions` directly, log to console; `cancelSubscription` sets `canceled_at` and returns `{ effectiveDate }` = end of current period (`current_period_end` or now).
  - `getInvoiceHistory` → returns `[]`.
  - `handleWebhook` → throws (webhook endpoint maps to 501).
  - `getBillingPortalUrl` → returns `null`.
- [ ] Implement registry: `getPaymentAdapter(env)` resolves from `env.ZYNC_PAYMENT_ADAPTER` (unset or blank defaults to `'null'`); registered map `{ null: NullPaymentAdapter }`. Unknown non-blank id throws.
- [ ] Implement `syncTierToTenant(tenantId, tier)` updating `tenants.tier`.
- [ ] Barrel-export the public surface from `index.ts`.
**Schema / Interfaces:**
```ts
export function getPaymentAdapter(env: Env): ZyncPaymentAdapter
export async function syncTierToTenant(tenantId: string, tier: TenantTier): Promise<void>
export class NullPaymentAdapter implements ZyncPaymentAdapter { /* ... */ }
```
**Acceptance:**
- [ ] Default env resolves to `NullPaymentAdapter`.
- [ ] `createCheckoutSession` and `handleWebhook` throw; `getInvoiceHistory` returns `[]`; `getBillingPortalUrl` returns `null`.
- [ ] `syncTierToTenant` writes `tenants.tier` for the given tenant only.

### Task 6: Storage quota helpers
**Blocks:** 8, 18  ·  **Blocked by:** 2, 3
**Files:**
- Create: `packages/storage/src/quota.ts`
- Modify: `packages/storage/src/index.ts`
**Steps:**
- [ ] Implement `getStorageQuotaBytes(tier)` with exact byte limits.
- [ ] Implement `checkStorageQuota(tenantId, uploadBytes, tier, db)` — reads `getCounterValue(db, tenantId, 'storage_bytes', 'all_time')`; throws `QuotaExceededError('storage', { usedBytes, limitBytes, requestedBytes })` if `current + uploadBytes > limit`.
- [ ] Document the post-write contract: callers do `incrementCounter(tenantId, 'storage_bytes', 'all_time', fileBytes)` after successful upload and `decrementCounter(...)` after deletion.
**Schema / Interfaces:**
```ts
export function getStorageQuotaBytes(tier: TenantTier): number {
  return {
    freelancer:  1_073_741_824,    // 1 GB
    business:   21_474_836_480,    // 20 GB
    enterprise: 107_374_182_400,   // 100 GB
    white_label: 107_374_182_400,  // 100 GB
  }[tier]
}

export async function checkStorageQuota(
  tenantId: string, uploadBytes: number, tier: TenantTier, db: DB
): Promise<void>   // throws QuotaExceededError('storage', {...}) on overflow
```
**Acceptance:**
- [ ] Returns the four exact byte limits; `white_label` equals `enterprise`.
- [ ] Throws when `current + uploadBytes > limit`; passes at exactly the limit.

### Task 7: Tier pricing / display config
**Blocks:** 13, 14, 15  ·  **Blocked by:** —
**Files:**
- Create: `packages/payments/src/tiers-config.ts`
**Steps:**
- [ ] Encode tier display metadata: display names, prices (Freelancer free, Business 89 ILS/mo, Enterprise 159 ILS/mo list, White Label 250 ILS/mo list), self-service flag, annual discount label ("Save 2 months").
- [ ] Encode the 3-column comparison matrix data (Freelancer · Business · Enterprise) used by the comparison table — White Label omitted from self-service config.
- [ ] Mark `enterprise` and `white_label` as `selfService: false` (checkout disabled; "Contact us" / "Contact sales").
**Schema / Interfaces:**
```ts
export interface TierDisplay {
  tier: TenantTier
  displayName: string
  monthlyIls: number | null   // null = free
  selfService: boolean
}
export const TIER_DISPLAY: Record<TenantTier, TierDisplay>
export const ANNUAL_DISCOUNT_LABEL = 'Save 2 months'
```
**Acceptance:**
- [ ] `freelancer.monthlyIls === null`; `business === 89`; `enterprise === 159`; `white_label === 250`.
- [ ] `enterprise.selfService === false` and `white_label.selfService === false`.

### Task 8: Tenant subscription API routes (GET / checkout / DELETE / invoices)
**Blocks:** 12, 13  ·  **Blocked by:** 1, 2, 5, 6
**Files:**
- Create: `apps/zync-api/src/routes/zync-subscription.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] Mount under `/api/zync-subscription`; guard all routes with auth session + `requireRole('OWNER','ADMIN')` (OWNER or ADMIN only).
- [ ] `GET /api/zync-subscription` — return the session tenant's `zync_subscriptions` row, extended with `storage: { usedBytes, limitBytes, pct }` (computed from `getCounterValue` + `getStorageQuotaBytes`).
- [ ] `POST /api/zync-subscription/checkout` — body `{ tier, period }`; reject self-service-disabled tiers; call adapter `createCheckoutSession`; on `PaymentProviderNotConfiguredError` return a structured error the modal maps to the "not yet configured" toast; else return `{ checkoutUrl, sessionId }`.
- [ ] `DELETE /api/zync-subscription` — call adapter `cancelSubscription`; set `status='canceled'`, `canceled_at=now()`; return `{ effectiveDate }`.
- [ ] `GET /api/zync-subscription/invoices` — return adapter `getInvoiceHistory(tenantId)`.
- [ ] Every state-mutating route validates `Origin` header per foundation auth middleware (already in the middleware chain).
**Schema / Interfaces:**
```
GET    /api/zync-subscription            -> { ...subscription, storage: { usedBytes, limitBytes, pct } }
POST   /api/zync-subscription/checkout   body { tier, period } -> { checkoutUrl, sessionId }
DELETE /api/zync-subscription            -> { effectiveDate }
GET    /api/zync-subscription/invoices   -> ZyncInvoice[]
```
**Acceptance:**
- [ ] Non-OWNER/ADMIN roles receive 403.
- [ ] Checkout for `enterprise`/`white_label` is rejected (not a self-service tier).
- [ ] GET response includes `storage.pct` rounded from `usedBytes/limitBytes`.
- [ ] DELETE sets `canceled_at` and `status='canceled'`.

### Task 9: Webhook endpoint
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-api/src/routes/zync-subscription.ts`
**Steps:**
- [ ] `POST /api/zync-subscription/webhook` — NO session auth; verification is by signature only.
- [ ] Read raw body + provider signature header; call adapter `handleWebhook(payload, signature)`. The adapter performs timing-safe signature comparison (security cross-cutting).
- [ ] On a `subscription.updated` / `invoice.paid` event: set `status='active'`, clear `grace_period_started_at`, and (when present in payload) populate `adapter_subscription_id`, `adapter_customer_id`, `current_period_start`, `current_period_end`; call `syncTierToTenant`.
- [ ] NullAdapter `handleWebhook` throws → return HTTP 501.
- [ ] Reject unverified signatures with 400.
**Schema / Interfaces:**
```
POST /api/zync-subscription/webhook   (no session; signature-verified) -> 200 | 400 | 501
```
**Acceptance:**
- [ ] With NullAdapter the endpoint returns 501.
- [ ] Invalid signature returns 400; a valid `invoice.paid` flips `status` to `active`, clears `grace_period_started_at`, and re-syncs tier.

### Task 10: Admin subscription routes
**Blocks:** —  ·  **Blocked by:** 1, 3, 4, 5
**Files:**
- Create: `apps/zync-api/src/routes/admin-subscription.ts`
- Modify: `apps/zync-api/src/index.ts` (mount under admin router)
**Steps:**
- [ ] Guard with `requireAdminSession()` (system admin only).
- [ ] `GET /api/admin/tenants/:slug/subscription` — resolve tenant by slug, return its `zync_subscriptions` row.
- [ ] `PATCH /api/admin/tenants/:slug/subscription` — body `{ tier, status, note? }`; upsert `zync_subscriptions` with `adapter='manual'`, `adapter_subscription_id=null`; call `syncTierToTenant`; write an `audit_log` entry with actor = admin user id and the note.
- [ ] This is the provisioning path for Enterprise / White Label.
**Schema / Interfaces:**
```
GET   /api/admin/tenants/:slug/subscription -> zync_subscriptions row
PATCH /api/admin/tenants/:slug/subscription  body { tier, status, note? } -> updated row
```
**Acceptance:**
- [ ] Non-admin sessions receive 403/redirect.
- [ ] PATCH creates the row if absent (upsert), sets `adapter='manual'`, syncs `tenants.tier`, and writes one `audit_log` entry with the admin actor id.

### Task 11: Trial-check cron with grace period
**Blocks:** —  ·  **Blocked by:** 1, 5
**Files:**
- Create: `apps/zync-api/src/cron/subscription-trial-check.ts`
- Modify: `apps/zync-api/src/index.ts` (mount `POST /api/cron/subscription-trial-check`)
- Modify: `apps/zync-api/wrangler.toml` (daily trigger)
**Steps:**
- [ ] Guard the route by a secret header (`CRON_SECRET`) compared with timing-safe equality; reject mismatches with 401.
- [ ] Query all rows where `status='trialing' AND trial_ends_at <= now()`.
- [ ] For each, compute `daysSinceExpiry = floor((now - trial_ends_at)/86_400_000)`.
- [ ] If `daysSinceExpiry < 7` (grace period): if `grace_period_started_at` is null, set it to now and `notifyTenant(tenantId, 'trial_expiring', { daysRemaining: 7 - daysSinceExpiry })`. Tenant retains `business` access.
- [ ] If `daysSinceExpiry >= 7`: `downgradeToFreelancer(tenantId)` (`tier='freelancer'`, `status='active'`, leave `trial_ends_at` as historical, call `syncTierToTenant`), then `notifyTenant(tenantId, 'trial_expiring', { daysRemaining: 0 })`.
- [ ] Register as a daily Cloudflare cron trigger.
**Schema / Interfaces:**
```ts
async function downgradeToFreelancer(tenantId: string): Promise<void>
// sets tier='freelancer', status='active'; calls syncTierToTenant; trial_ends_at preserved
```
**Acceptance:**
- [ ] Missing/incorrect cron secret → 401 (timing-safe compare).
- [ ] A trial expired <7 days ago sets `grace_period_started_at` once and notifies; retains business tier.
- [ ] A trial expired ≥7 days ago downgrades to freelancer, re-syncs `tenants.tier`, and notifies with `daysRemaining: 0`.

### Task 12: `/settings/plan` page shell + data hooks + settings nav
**Blocks:** 13, 14, 15, 16  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/pages/settings/plan.tsx`
- Create: `apps/zync-app/src/hooks/use-subscription.ts`
- Modify: settings router + settings sidebar nav config
**Steps:**
- [ ] Add a "Plan & Billing" item to the settings sidebar, visible to `OWNER`/`ADMIN` roles only.
- [ ] Implement `useSubscription()` (react-query) calling `GET /api/zync-subscription`.
- [ ] Compose the page: Current Plan Card, Storage usage, Usage Stats (freelancer only), Plan Comparison, Invoice History.
- [ ] Handle the `?upgrade=success` return param: success toast + refetch subscription.
- [ ] All dates rendered via the locale date formatter (RTL/Hebrew aware).
**Acceptance:**
- [ ] Route reachable only for OWNER/ADMIN; sidebar item hidden otherwise.
- [ ] `?upgrade=success` shows a success toast and refetches.

### Task 13: Current Plan Card + Cancel dialog + Storage usage
**Blocks:** —  ·  **Blocked by:** 7, 12
**Files:**
- Create: `apps/zync-app/src/components/plan/current-plan-card.tsx`
- Create: `apps/zync-app/src/components/plan/cancel-dialog.tsx`
**Steps:**
- [ ] Render plan name, billing period ("Monthly"/"Annual"/"—" for Freelancer), status badge (active→green, trialing→blue, past_due→amber, canceled→red), next renewal date or "Cancels on [date]" when `canceled_at` set.
- [ ] "Upgrade plan" button — hidden for Enterprise/White Label; opens upgrade modal.
- [ ] "Manage billing" link — self-service tiers call adapter billing-portal URL (hidden when null); Enterprise/White Label → mailto `billing@zync.is`.
- [ ] "Cancel subscription" link — visible only when status is `active`/`trialing` and tier is not `freelancer`; opens Cancel dialog.
- [ ] Cancel dialog: show effective date (from `DELETE` response), confirm button "I understand, cancel my subscription", call `DELETE /api/zync-subscription`, on success update banner/status.
- [ ] Storage row for ALL tiers: `Storage: 0.8 GB used of 1 GB [bar] 80% [Upgrade for 20 GB]` from `storage.{usedBytes,limitBytes,pct}`; progress bar has accessible label.
**Acceptance:**
- [ ] Status badge colors map exactly per spec.
- [ ] Cancel link hidden for freelancer and for canceled/past_due statuses.
- [ ] Storage bar shows correct pct and an upgrade CTA.

### Task 14: Usage Stats + Plan Comparison Table
**Blocks:** —  ·  **Blocked by:** 7, 12
**Files:**
- Create: `apps/zync-app/src/components/plan/usage-stats.tsx`
- Create: `apps/zync-app/src/components/plan/plan-comparison.tsx`
**Steps:**
- [ ] Usage Stats (freelancer tier only): Team members `X / getMaxTeamMembers(tier)`, Active modules `X / N`, and locked rows (AI assistant→Business+, White-label/Custom domain/API access→Enterprise+) each with a pill badge ("Business"/"Enterprise") that opens the Upgrade Modal.
- [ ] Plan Comparison Table: 3 columns (Freelancer · Business · Enterprise) from `tiers-config`, rows per spec matrix (team members, modules, storage, AI assistant, white-label, custom domain, API access, support SLA, price); bottom CTAs "Get Business" / "Contact us".
- [ ] White Label omitted from the comparison table.
**Acceptance:**
- [ ] Usage Stats renders only for `tier='freelancer'`.
- [ ] Comparison shows exactly 3 columns; locked-row pills open the modal.

### Task 15: Invoice History table
**Blocks:** —  ·  **Blocked by:** 12
**Files:**
- Create: `apps/zync-app/src/components/plan/invoice-history.tsx`
- Create: `apps/zync-app/src/hooks/use-subscription-invoices.ts`
**Steps:**
- [ ] `useSubscriptionInvoices()` calls `GET /api/zync-subscription/invoices`.
- [ ] Columns: Date · Period · Amount · Status · PDF (PDF link when `pdfUrl` present).
- [ ] Empty state ("No invoices yet.") when `tier='freelancer'` or the adapter returns `[]`.
- [ ] Dates/amounts locale-formatted.
**Acceptance:**
- [ ] Empty-state message shown for freelancer or `[]`.
- [ ] PDF column links only when `pdfUrl` is non-null.

### Task 16: Upgrade Modal + `useUpgradeModal` context
**Blocks:** —  ·  **Blocked by:** 7, 12
**Files:**
- Create: `apps/zync-app/src/components/upgrade/upgrade-modal.tsx`
- Create: `apps/zync-app/src/context/upgrade-modal-context.tsx`
- Modify: app-shell root (mount the modal once)
**Steps:**
- [ ] Implement `UpgradeModalProvider` + `useUpgradeModal()` returning `{ open(targetTier?), close, isOpen }`; mount the modal globally in the app shell.
- [ ] Modal content: current tier highlighted, target tier feature checkmarks, Monthly/Annual toggle (annual shows `ANNUAL_DISCOUNT_LABEL`), price updates on toggle, "Upgrade to Business" CTA.
- [ ] On CTA: `POST /api/zync-subscription/checkout` `{ tier, period }` → redirect to `checkoutUrl`; success return URL is `/settings/plan?upgrade=success`.
- [ ] On `PaymentProviderNotConfiguredError` response: toast "Payment provider not yet configured — contact support."
- [ ] Enterprise/White Label target: CTA replaced with "Contact sales →" (mailto).
- [ ] Modal uses `role="dialog"`, focus trap, and respects `prefers-reduced-motion` for transitions.
- [ ] NOTE: this task owns the checkout/trigger contract and the `useUpgradeModal` export. The richer upsell presentation is elaborated by P031 `upgrade-upsell-modal`, which consumes this context.
**Schema / Interfaces:**
```ts
export function useUpgradeModal(): {
  open: (targetTier?: TenantTier) => void
  close: () => void
  isOpen: boolean
}
```
**Acceptance:**
- [ ] `useTierGate` upgrade badges and the "Upgrade plan" button both open the modal.
- [ ] NullAdapter checkout surfaces the "not yet configured" toast.
- [ ] Enterprise/White Label shows "Contact sales" mailto, not a checkout CTA.

### Task 17: Signup-time subscription provisioning + admin Subscription tab
**Blocks:** —  ·  **Blocked by:** 1, 10
**Files:**
- Modify: signup/verify-email handler in `apps/zync-api` (foundation auth flow)
- Create: `apps/zync-app` admin tenant-detail "Subscription" tab (admin dashboard surface)
**Steps:**
- [ ] On tenant creation (verify-email → first tenant), insert a `zync_subscriptions` row: `tier='freelancer'`, `status='active'`, `period=NULL`, `adapter='null'`.
- [ ] Add a "Subscription" tab to `/admin/tenants/:slug`: show the current record; "Set tier" form (tier dropdown all 4 values, status dropdown, optional note) → `PATCH /api/admin/tenants/:slug/subscription`.
**Acceptance:**
- [ ] Every newly created tenant has exactly one freelancer/active/`period=NULL` subscription row.
- [ ] Admin tab submits the PATCH and reflects the updated record.

### Task 18: App-shell banners (trial / grace / past-due) + R2 quota call-site wiring
**Blocks:** —  ·  **Blocked by:** 6, 12
**Files:**
- Create: `apps/zync-app/src/components/shell/subscription-banners.tsx`
- Modify: app-shell layout (mount banners above main content)
- Modify: R2 upload call sites (invoke `checkStorageQuota`)
**Steps:**
- [ ] Trial banner (status `trialing`, `grace_period_started_at` null): "X days left in your Business trial — [Add payment method →]" where `X = ceil((trial_ends_at - now)/86400000)`; not shown when ≤0; dismiss on payment confirmed/expiry. "Add payment method" → `/settings/plan`.
- [ ] Grace banner (`grace_period_started_at IS NOT NULL`): "⚠ Your trial has ended — N days remaining before downgrade to Freelancer. [Add payment method →]" where `N = 7 - floor((now - grace_period_started_at)/86400000)`; non-dismissible; disappears when status→active or downgraded.
- [ ] Past-due banner (status `past_due`): role-specific copy — OWNER gets "[Update payment method →]" CTA; ADMIN/MEMBER/VIEWER get the "ask your account owner" message. Amber background `oklch(0.85 0.12 80)`, persistent / non-dismissible, on every page.
- [ ] Banners use `role="alert"` (or `role="status"`) for screen readers; dates locale-formatted.
- [ ] Past-due restrictions (API, return 402): block new team-member invitations, file uploads ≥1 MB (allow <1 MB), and new API-key creation; core features (invoicing, time, CRM, tasks) stay accessible. Implement these checks in the relevant route guards reading `status='past_due'`.
- [ ] Wire `checkStorageQuota(tenantId, uploadBytes, tier, db)` into every R2 write path; on overflow return HTTP 402 with `{ error: 'storage_quota_exceeded', used_bytes, limit_bytes, requested_bytes, upgrade_url: '/settings/plan' }`; after success `incrementCounter('storage_bytes','all_time', fileBytes)`, after delete `decrementCounter(...)`.
**Schema / Interfaces:**
```json
HTTP 402
{ "error": "storage_quota_exceeded", "used_bytes": 1073741824, "limit_bytes": 1073741824,
  "requested_bytes": 204800, "upgrade_url": "/settings/plan" }
```
**Acceptance:**
- [ ] Trial banner shows correct day count and hides at ≤0.
- [ ] Grace banner is non-dismissible and shows correct N.
- [ ] Past-due banner copy differs by role; amber `oklch(0.85 0.12 80)`; non-dismissible.
- [ ] Uploads over quota return the exact 402 JSON; counter increments after upload and decrements after delete.
- [ ] During `past_due`: invites/API-keys/uploads≥1MB return 402; core features unaffected.
