# Zync Subscription Management

**Date:** 2026-05-31
**Status:** Draft
**Depends on:** `foundation-auth-rbac`, `system-communications-notifications`
**Referenced by:** 2026-05-30-admin-dashboard (manual provisioning)

---

## Overview

Self-service subscription management for Zync tenants. Covers how a tenant signs up for a paid plan, upgrades or downgrades, manages payment, and cancels. This is **not** the billing module — that spec handles a tenant's billing of their own customers. This spec covers the commercial relationship between Zync and its tenants.

Scope:
- Tenant-facing UI at `/settings/plan`
- Payment abstraction layer (adapter pattern, no concrete provider yet)
- Trial period logic
- System-admin manual tier override
- `zync_subscriptions` table as the authoritative tier source

Out of scope: entitlement enforcement (handled by `useTierGate` / `requireTier` in foundation-auth-rbac), tenant billing of end-customers (billing-module), invoice generation for Zync's own revenue (internal ops).

---

## Tiers

Zync has four tiers, aligned with `TenantTier` in `foundation-auth-rbac`:

| Tier | Display Name | Price | Self-Service |
|------|-------------|-------|--------------|
| `freelancer` | Freelancer | Free forever | Yes — default on signup |
| `business` | Business | 89 ILS/mo (or annual equivalent) | Yes — upgrade/downgrade |
| `enterprise` | Enterprise | 159 ILS/mo list; custom negotiated pricing | No — manually provisioned |
| `white_label` | White Label | 250 ILS/mo list; custom negotiated pricing | No — manually provisioned |

`ENTERPRISE` and `WHITE_LABEL` are not purchasable through the self-service checkout flow. Tenants on these tiers are provisioned by a system admin using the manual override. The upgrade modal shows "Contact us" for these tiers.

The plan comparison table on `/settings/plan` shows 3 columns: Freelancer, Business, Enterprise (White Label is omitted from the self-service table — it is a separate commercial arrangement surfaced only in admin tooling).

---

## Payment Abstraction Layer

No concrete payment provider is committed at this stage. All payment operations pass through a `ZyncPaymentAdapter` interface. The system ships with `NullPaymentAdapter` (no-op, logs all calls). Real provider adapters (Stripe, PayPal, etc.) are plugged in as packages later — the application code never imports a provider directly.

### Types

```ts
type ZyncSubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled'

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

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

### Interface

```ts
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>

  /**
   * Returns the provider's billing portal URL for the tenant to self-manage
   * payment methods, download invoices, etc. Returns null if the adapter
   * does not support a billing portal (NullAdapter always returns null).
   */
  getBillingPortalUrl(tenantId: string): Promise<string | null>
}
```

### NullPaymentAdapter

Ships with the platform. Used when no payment provider is configured.

Behaviour:
- `createCheckoutSession` — throws `PaymentProviderNotConfiguredError` (caught by upgrade modal, shows toast: "Payment provider not yet configured — contact support")
- `getSubscriptionStatus` — reads `zync_subscriptions.status` directly from DB (no remote call)
- `updateSubscription` / `cancelSubscription` — updates `zync_subscriptions` directly and logs action to console
- `getInvoiceHistory` — returns `[]`
- `handleWebhook` — throws; webhook endpoint returns 501

The active adapter is resolved at startup from env var `ZYNC_PAYMENT_ADAPTER` (unset or blank defaults to `'null'`). Unknown non-blank ids fail closed. Adapter registry in `packages/payments/src/registry.ts`. Rationale: Cloudflare permits empty string vars, which must behave like an omitted optional selector instead of crashing subscription reads.

---

## Route: `/settings/plan`

Lives within the Settings Module (`2026-05-30-settings-module`). No new shell required — uses the existing settings layout and nav. A "Plan & Billing" nav item is added to the settings sidebar (visible to `OWNER` and `ADMIN` roles only).

---

## UI Sections

### Current Plan Card

Displayed at top of `/settings/plan`.

Fields:
- Plan name (e.g. "Business")
- Billing period: "Monthly" / "Annual" / "—" (Freelancer)
- Status badge: `active` → green; `trialing` → blue; `past_due` → amber; `canceled` → red
- Next renewal date (formatted locale date), or "Cancels on [date]" if `canceled_at` is set
- "Upgrade plan" button — hidden if Enterprise or White Label
- "Manage billing" link:
  - Self-service tiers: calls adapter's billing portal URL (adapter must expose `getBillingPortalUrl(tenantId)` — added to interface; NullAdapter returns null → link hidden)
  - Enterprise / White Label: mailto link to `billing@zync.is`
- "Cancel subscription" link — visible only if status is `active` or `trialing` and tier is not `freelancer`

**Cancel confirmation dialog:**
- Shows effective cancellation date (end of current period, from adapter response)
- "I understand, cancel my subscription" confirm button
- Calls `DELETE /api/zync-subscription`
- On success: status → `canceled`, `canceled_at` set, banner updated

### Trial Banner

Shown in the app shell (above main content) when `status = 'trialing'`:

> "X days left in your Business trial — [Add payment method →]"

"Add payment method" → `/settings/plan` (scrolls to current plan card). Banner is dismissed when payment is confirmed or trial expires.

X = `Math.ceil((trial_ends_at - now) / 86400000)`. Banner not shown when ≤ 0.

### Usage Stats (Freelancer tier only)

Shown below current plan card when `tier = 'freelancer'`.

Pulls from `usage_counters` via existing `checkCounterLimit` infrastructure:

| Resource | Usage | Limit | Notes |
|----------|-------|-------|-------|
| Team members | X / 1 | Freelancer: 1, Business: 8, Enterprise: 15 | From `getMaxTeamMembers(tier)` |
| Active modules | X / N | Based on tier entitlement matrix | — |
| AI assistant | Locked | Business+ | Upgrade prompt |
| White-label | Locked | Enterprise+ | Upgrade prompt |
| Custom domain | Locked | Enterprise+ | Upgrade prompt |
| API access | Locked | Enterprise+ | Upgrade prompt |

Each locked row shows a pill badge: "Business" or "Enterprise" — clicking it opens the Upgrade Modal.

### Plan Comparison Table

Three columns: Freelancer · Business · Enterprise

| Feature | Freelancer | Business | Enterprise |
|---------|-----------|----------|-----------|
| Team members | 1 | Up to 8 | Up to 15 |
| Modules | Core only | All | All |
| Storage | 1 GB | 20 GB | 100 GB |
| AI assistant | — | ✓ | ✓ |
| White-label | — | — | ✓ |
| Custom domain | — | — | ✓ |
| API access | — | — | ✓ |
| Support SLA | Community | Email, 48 h | Priority, 4 h |
| Price | Free | 89 ILS/mo | Custom |

"Get Business" / "Contact us" CTAs at bottom of each paid column.

### Invoice History

Table of past Zync subscription invoices. Populated by `getInvoiceHistory(tenantId)` from the active adapter.

Columns: Date · Period · Amount · Status · PDF

Empty-state message when `tier = 'freelancer'` or adapter returns `[]`: "No invoices yet."

---

## Upgrade Modal

Trigger points:
1. "Upgrade plan" button on `/settings/plan`
2. Upgrade badge on any tier-gated feature (via `useTierGate`)

The modal is a global component mounted in the app shell, toggled by a `useUpgradeModal()` context hook. Callers pass an optional `targetTier` to pre-select the target column.

Content:
- Current tier highlighted
- Target tier features highlighted (checkmarks vs current)
- Monthly / Annual toggle — annual shows discount label (e.g. "Save 2 months")
- Price display updates on toggle
- "Upgrade to Business" CTA

On CTA click:
1. `POST /api/zync-subscription/checkout` with `{ tier, period }`
2. Adapter `createCheckoutSession` returns `{ checkoutUrl, sessionId }`
3. Redirect to `checkoutUrl`
4. On return (success callback URL `/settings/plan?upgrade=success`): show success toast, refetch subscription status

NullAdapter: `createCheckoutSession` throws `PaymentProviderNotConfiguredError` → toast: "Payment provider not yet configured — contact support."

Enterprise / White Label target: CTA replaced with "Contact sales →" (mailto).

---

## Trial Period

All tenants self-register on the `freelancer` tier (`status = 'active'`). A 14-day Business trial begins when a tenant **selects Business** during onboarding or via the upgrade flow but does not immediately complete payment:

Trial entry conditions:
- Tenant chooses Business tier but exits checkout without completing payment, **or**
- System admin grants a trial via admin override (sets `status = 'trialing'`, `tier = 'business'`)

On trial entry:
- `tier = 'business'`
- `status = 'trialing'`
- `trial_ends_at = now() + 14 days`
- `adapter = 'null'` (until payment added)

At trial end, if no payment method confirmed:
- Background job (`/api/cron/subscription-trial-check`) runs daily
- Checks all rows where `status = 'trialing' AND trial_ends_at <= now()`
- On expiry: `tier → 'freelancer'`, `status → 'active'`, `trial_ends_at` left as-is (historical record)
- Notification sent via `system-communications-notifications` spec channel

Trial converts to paid by completing checkout flow — adapter webhook sets `status = 'active'` and populates `adapter_subscription_id`, `adapter_customer_id`, `current_period_start`, `current_period_end`.

---

## Admin Override

System admins can manually set a tenant's tier without payment via `/admin/tenants/:slug`.

A new "Subscription" tab is added to the tenant detail page:
- Shows current `zync_subscriptions` record
- "Set tier" form: tier dropdown (all 4 values), status dropdown, optional note
- Submit → `PATCH /api/admin/tenants/:slug/subscription`
- Creates or updates `zync_subscriptions` with `adapter = 'manual'`, `adapter_subscription_id = null`
- Audit-logged to `audit_log` with actor = admin user id

This is the provisioning path for Enterprise and White Label tenants.

---

## Data Model

```sql
-- Tier stored as the TenantTier enum string values (freelancer | business | enterprise | white_label)
-- matching foundation-auth-rbac's runtime enum; 'FREE' is NOT used.
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,
  -- 'monthly' | 'annual' | NULL
  -- NULL is intentional for freelancer (no billing period); task SQL had DEFAULT 'monthly'
  -- but default-freelancer onboarding needs NULL — documented deviation.
  adapter                  TEXT NOT NULL DEFAULT 'null',
  -- 'null' | 'stripe' | 'paypal' | 'manual' | ...
  adapter_subscription_id  TEXT,
  adapter_customer_id      TEXT,
  current_period_start     TIMESTAMPTZ,
  current_period_end       TIMESTAMPTZ,
  trial_ends_at            TIMESTAMPTZ,
  canceled_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);
```

A `zync_subscriptions` row is created for every tenant on signup with `tier = 'freelancer'`, `status = 'active'`, `period = NULL`. The 14-day Business trial begins only if the tenant elects Business tier without completing payment (see Trial Period section).

### Tier as Source of Truth

`zync_subscriptions.tier` is the authoritative tier for a tenant. The `tenants` table in foundation-auth-rbac holds a denormalized `tier` column (already present) that is synced whenever `zync_subscriptions.tier` changes:

```ts
// packages/payments/src/sync-tier.ts
export async function syncTierToTenant(tenantId: string, tier: TenantTier) {
  await db.update(tenants).set({ tier }).where(eq(tenants.id, tenantId))
}
```

All entitlement checks (`requireTier`, `useTierGate`, `meetsMinimumTier`) continue to read from the session/tenant record — they are unaware of `zync_subscriptions`. The sync is the bridge. If the two diverge (e.g. failed sync), `tenants.tier` is used at runtime; an admin can re-sync manually via the admin subscription tab.

---

## API Endpoints

All routes under `/api/zync-subscription` require an authenticated session and `OWNER` or `ADMIN` role.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/zync-subscription` | Fetch current subscription record for session tenant |
| `POST` | `/api/zync-subscription/checkout` | Create checkout session; body: `{ tier, period }` |
| `DELETE` | `/api/zync-subscription` | Cancel subscription (calls adapter, sets canceled_at) |
| `GET` | `/api/zync-subscription/invoices` | Fetch invoice history via adapter |
| `POST` | `/api/zync-subscription/webhook` | Inbound webhook from payment provider (no auth — verified by signature) |

Admin routes (require system admin session):

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/tenants/:slug/subscription` | Fetch tenant's subscription record |
| `PATCH` | `/api/admin/tenants/:slug/subscription` | Manually set tier/status; `adapter = 'manual'` |

Cron routes (internal, secret header required):

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/cron/subscription-trial-check` | Daily: expire trials past `trial_ends_at` |

---

## Storage Quota Enforcement

Storage quotas are enforced at upload time using the `usage_counters` infrastructure from spec 5 (foundation-auth-rbac). Storage is tracked in **bytes** (not MB) for precision.

### Quota limits per tier

| Tier | Quota | Counter key |
|------|-------|-------------|
| `freelancer` | 1 GB (1,073,741,824 bytes) | `storage_bytes` |
| `business` | 20 GB (21,474,836,480 bytes) | `storage_bytes` |
| `enterprise` | 100 GB (107,374,182,400 bytes) | `storage_bytes` |
| `white_label` | 100 GB (same as Enterprise) | `storage_bytes` |

Stored as `counter_key = 'storage_bytes'`, `period = 'all_time'` in `usage_counters`.

### Upload enforcement

Any route that writes to R2 (file uploads, invoice HTML snapshots, attachment storage) must call `checkStorageQuota` before writing:

```ts
// packages/storage/src/quota.ts
export async function checkStorageQuota(
  tenantId: string,
  uploadBytes: number,
  tier: TenantTier,
  db: DB
): Promise<void> {
  const limit = getStorageQuotaBytes(tier)
  const current = await getCounterValue(db, tenantId, 'storage_bytes', 'all_time')
  if (current + uploadBytes > limit) {
    throw new QuotaExceededError('storage', {
      usedBytes: current,
      limitBytes: limit,
      requestedBytes: uploadBytes,
    })
  }
}

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]
}
```

After successful upload: `incrementCounter(tenantId, 'storage_bytes', 'all_time', fileBytes)`.

After file deletion: `decrementCounter(tenantId, 'storage_bytes', 'all_time', fileBytes)` (new helper, same pattern as increment but negative delta).

### Quota enforcement API response

When storage quota exceeded, upload endpoints return:

```json
HTTP 402
{
  "error": "storage_quota_exceeded",
  "used_bytes": 1073741824,
  "limit_bytes": 1073741824,
  "requested_bytes": 204800,
  "upgrade_url": "/settings/plan"
}
```

### Usage display on `/settings/plan`

Storage usage shown on the Current Plan Card for all tiers:

```
Storage: 0.8 GB used of 1 GB [███████░░░] 80%   [Upgrade for 20 GB]
```

`GET /api/zync-subscription` response extended: `{ ..., storage: { usedBytes, limitBytes, pct } }`.

---

## Trial Grace Period

When `status = 'trialing'` and `trial_ends_at` passes, the tenant enters a **7-day grace period** before being downgraded to `freelancer`.

### Rationale

A hard downgrade on trial expiry is disruptive — the tenant may be in the middle of work, or may simply have forgotten. A grace period allows them to add payment without losing access.

### Implementation

The trial-check cron (already defined above) is modified:

```ts
// apps/zync-api/src/cron/subscription-trial-check.ts
for (const sub of expiredTrials) {
  const daysSinceExpiry = Math.floor((now - sub.trial_ends_at) / 86_400_000)

  if (daysSinceExpiry < 7) {
    // Grace period — tenant retains business tier access; show grace banner
    if (!sub.grace_period_started_at) {
      await db.update(zyncSubscriptions)
        .set({ grace_period_started_at: now })
        .where(eq(zyncSubscriptions.tenantId, sub.tenant_id))
      // Send notification: 'trial_expiring' with daysRemaining = 7 - daysSinceExpiry
      await notifyTenant(sub.tenant_id, 'trial_expiring', { daysRemaining: 7 - daysSinceExpiry })
    }
  } else {
    // Grace period over — downgrade
    await downgradeToFreelancer(sub.tenant_id)
    await notifyTenant(sub.tenant_id, 'trial_expiring', { daysRemaining: 0 })
  }
}
```

### Schema delta

```sql
ALTER TABLE zync_subscriptions ADD COLUMN grace_period_started_at TIMESTAMPTZ;
-- NULL = not in grace period. Set when trial expires; cleared on upgrade.
```

### Grace period banner (app shell)

Shown above main content when `grace_period_started_at IS NOT NULL`:

```
⚠ Your trial has ended — 5 days remaining before downgrade to Freelancer.
[Add payment method →]
```

Days remaining = `7 - floor((now - grace_period_started_at) / 86400000)`. Banner not dismissible. Disappears when payment confirmed (status → `active`) or grace period ends (downgraded).

---

## Past-Due UX

When `status = 'past_due'` (payment failed and retry schedule active — see spec 93), the tenant retains access but sees a persistent payment warning.

### Past-due banner (app shell)

Shown above main content for all users when `status = 'past_due'`. The message is different per role:

**For OWNER:**
```
⚠ Your subscription payment failed. Update your payment method to avoid service interruption.
[Update payment method →]
```

**For ADMIN/MEMBER/VIEWER:**
```
⚠ Your workspace subscription payment is overdue. Please ask your account owner to update billing.
```

Banner: amber background (`oklch(0.85 0.12 80)`), persistent (non-dismissible). Shown on every page.

### Past-due restrictions

While `past_due`, the following actions are blocked (402 response):
- Creating new team member invitations
- Uploading new files (except < 1 MB — grace for small attachments)
- Creating new API keys

Core features (invoicing, time tracking, CRM, tasks) remain fully accessible during `past_due` to avoid disrupting active business operations.

### Past-due resolution

When payment succeeds (adapter webhook `subscription.updated` or `invoice.paid`): `status → 'active'`. Banner cleared. Blocked actions re-enabled.

### Schema extension (read from existing columns)

No new columns needed. `zync_subscriptions.status = 'past_due'` is already in the status CHECK constraint. The dunning flow that sets `past_due` is owned by spec 93 (`payment-retry-dunning`).

---

## Architecture Decisions

| # | Decision | Rationale |
|---|----------|-----------|
| 1 | Adapter pattern — no provider committed | Provider choice depends on IL payment regulations, future tax compliance requirements, and commercial terms. Deferring to a plugin avoids lock-in and lets the platform launch with NullAdapter. |
| 2 | `NullPaymentAdapter` as default | Enables the self-service UI and tier management logic to be shipped and tested end-to-end before any real payment integration exists. Ops manually provisioned via admin override. |
| 3 | Tier stored as foundation enum strings (`freelancer`, not `FREE`) | `meetsMinimumTier` and `getMaxTeamMembers` in `packages/auth` switch on lowercase string values. Storing `'FREE'` would silently break entitlement checks at runtime. Display labels ("Freelancer", "Free forever") are UI concerns only. |
| 4 | `zync_subscriptions` is source of truth; `tenants.tier` is denormalized | Entitlement checks read `tenants.tier` from the session (zero extra queries per request). The subscription service syncs to `tenants.tier` on every change. Divergence is recoverable via admin override. |
| 5 | 4-tier schema; 3-column comparison table | Foundation defines 4 tiers including `white_label`. The comparison table omits White Label (manual/partner deal, not publicly advertised). The DB and type system accept all 4 values. |
| 6 | Enterprise = 159 ILS list price; custom for negotiated contracts | Foundation seeds 159 ILS as Enterprise price. Enterprise tenants with custom pricing are provisioned manually (adapter = `manual`) — no checkout flow runs, so the list price is never charged via automation. Custom amounts are tracked outside Zync. |
| 7 | Trial applies to `business` tier only | Freelancer needs no trial (free). Enterprise/White Label have sales-assisted onboarding. Business is the self-service growth tier where trial reduces friction. |
| 8 | `useTierGate` and `requireTier` unchanged | This spec adds the management plane; enforcement is owned by foundation-auth-rbac. Decoupling means tier gate logic needs no changes when payment providers are swapped. |
| 9 | `/settings/plan` within settings module | Consistent user mental model — all account/workspace settings in one place. No separate billing subdomain needed at this scale. |
| 10 | Webhook endpoint auth via signature, not session | Standard provider pattern. `handleWebhook(payload, signature)` is part of the adapter interface — each provider implements its own signature verification (HMAC, Stripe-Signature header, etc.). |
| 11 | `getBillingPortalUrl` added to interface (extends task's 6-method list) | The "Manage billing" link in the Current Plan Card requires a way to get the provider's portal URL. Rather than hardcode a URL pattern per-provider, it is cleanest for the adapter to own this. NullAdapter returns `null`; the link is hidden. This is a 7th method beyond the task's verbatim interface, documented here for traceability. |
| 12 | `period DEFAULT NULL` (deviates from task's `DEFAULT 'monthly'`) | Default onboarding is `freelancer`, which has no billing period. Defaulting to `'monthly'` would require every new row to immediately override a nonsensical value. Task's default assumed Business-first onboarding; this spec adopts Freelancer-first (aligned with foundation signup flow). |
