# Spec 36 — Upgrade / Upsell Modal — Implementation Plan

**Spec:** docs/specs/2026-05-31-upgrade-upsell-modal.md  ·  **Slug:** upgrade-upsell-modal  ·  **Wave:** 4
**Depends on:** app-shell, foundation-auth-rbac, foundation-design-system, zync-subscription

## Goal
Deliver a globally-mounted, context-aware upgrade modal that is the single self-service entry point for tier upgrades anywhere in the app. It renders tier cards (Freelancer / Business / Enterprise) with a monthly/annual toggle, hands off to the existing checkout endpoint, falls back to a "Contact us" mailto for Enterprise/White-Label and when the payment provider is the NullAdapter, and shows an "Ask your admin" screen to non-OWNER/ADMIN members. It is a pure UI layer: no new DB tables and no new API endpoints — it consumes infrastructure already built by `zync-subscription` and `foundation-auth-rbac`.

## Architecture
- The modal is a `<Dialog>` (from `@zync/ui`, rendered in a Portal) mounted exactly once by `UpgradeModalProvider`, which wraps the app root in `apps/zync-app/src/main.tsx` (inside the existing Tenant/Auth providers so role + tier are readable).
- State lives in `UpgradeModalContext`: `{ isOpen, opts }`. `open(opts?)` / `close()` toggle it. The exported hook `useUpgradeModal()` exposes `{ isOpen, open, close }` and is already named in the locked interface sheet.
- **Tier source of truth:** the session (`SessionPayload.tier: TenantTier`, `SessionPayload.role: string`) from `foundation-auth-rbac`. The current subscription record (for `adapter` → NullAdapter detection) comes from the upstream `useSubscription()` hook backed by `GET /api/zync-subscription` (returns a `ZyncSubscriptionRow` whose `adapter` field is one of `'null'`, `'stripe'`, `'paypal'`, `'manual'`).
  - NOTE: the spec's prose quotes a `useCurrentMember()` hook returning `{ role: TenantRole }`. That hook/type is **not** an upstream export. Use the session's `role`/`tier` (exposed by the app's existing auth/session hook) and `useSubscription()` instead. Do not import or invent `useCurrentMember`/`TenantRole`.
- **Checkout handoff:** `POST /api/zync-subscription/checkout` with `{ tier, period }`, expecting `{ checkoutUrl }`. On a non-null URL the browser navigates via `window.location.href`. NullAdapter / Enterprise / White-Label never call checkout — they resolve to a `mailto:sales@zync.is` link client-side.
- **Post-checkout success** is handled on the `/settings/plan` page (it is the redirect target `?upgrade=success`), not inside the modal — the modal is unmounted after the redirect.
- **`useTierGate` wiring:** `foundation-auth-rbac` exports `useTierGate(minimum): { allowed, upgrade }`. This plan makes an additive change so `upgrade(featureName?)` calls `useUpgradeModal().open({ featureName, targetTier: minimum })`.

Consumed upstream tables: `zync_subscriptions` (read-only, via API). Consumed upstream exports: `useTierGate`, `useUpgradeModal`, `useSubscription`, `TenantTier`, `TIER_DISPLAY`, `ZyncSubscriptionRow`, `Dialog`, `Button`, `toast`, `Toaster`, `useDirection`, `SessionPayload`. Consumed routes: `POST /api/zync-subscription/checkout`, `GET /api/zync-subscription`.

## Tech Stack
- **Package `@zync/ui`** (`packages/ui/src/upgrade-modal/`) — React 18 + TypeScript. Uses the existing `Dialog`, `Button` primitives and design tokens from `foundation-design-system` (OKLCH tokens, `--accent`, `--surface`, `--ink`, `--radius`, 8px grid).
- **Package `@zync/auth`** (`packages/auth/src/tier-gate.ts`) — additive wiring only.
- **Package `@zync/i18n`** (`packages/i18n/locales/{he,en}/upgrade-modal.json`) — `he` is primary (RTL); `en` fallback.
- **App `apps/zync-app`** — provider mount in `src/main.tsx`; success-toast effect in `src/routes/settings/plan.tsx`; trial-banner button already in `src/root-layout.tsx` (app-shell) calls `open()`.
- React Query (`@tanstack/react-query`) for `useSubscription` reads / cache invalidation (`['zync-subscription']`).
- No new Cloudflare bindings, no DB migration, no Worker route.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A | 1, 2 | `tier-card-data.ts`, `cta-logic.ts`, i18n JSON | Yes (pure data/logic + strings) |
| B | 3, 4, 5 | `PeriodToggle.tsx`, `TierCard.tsx`, `UpgradeModalProvider.tsx` | After A; 3 & 4 parallel, 5 needs context shape |
| C | 6 | `UpgradeModal.tsx`, `index.ts` | After B |
| D | 7, 8, 9 | `tier-gate.ts`, `main.tsx`, `settings/plan.tsx` | After C; all three parallel |

## Tasks

### Task 1: Tier card data + tier-rank constants
**Blocks:** 4, 6  ·  **Blocked by:** —
**Files:**
- Create: `packages/ui/src/upgrade-modal/tier-card-data.ts`
**Steps:**
- [ ] Define `TierCardData`, `TierFeatureLine` interfaces verbatim from the spec.
- [ ] Export the `TIER_CARDS` array for `freelancer`, `business`, `enterprise` with the exact prices and Hebrew feature lines from the spec (White Label is intentionally NOT a card).
- [ ] Export `TIER_RANK` and `tierRank(t)` helper.
- [ ] Make all human-readable feature labels reference i18n keys at render time; keep `TIER_CARDS` as structural data (tier, price, availability, highlight) and resolve `displayName`/labels through the `upgrade-modal` i18n namespace in `TierCard`. (Prices are numeric, not translated.)
**Schema / Interfaces:**
```ts
import type { TenantTier } from '@zync/types' // 'freelancer' | 'business' | 'enterprise' | 'white_label'

export interface TierFeatureLine {
  /** i18n key under upgrade-modal.features.* resolving to a Hebrew label */
  labelKey: string
  available: boolean      // true = ✓, false = —
  highlight?: boolean     // visual differentiator vs lower tier
}

export interface TierCardData {
  tier: TenantTier
  displayName: string                 // 'Freelancer' | 'Business' | 'Enterprise' (brand names, not translated)
  price: {
    monthly: number | null            // ILS; null = contact / free
    annual: number | null             // ILS per month under annual billing; null = contact/free
    annualBilledAs: number | null      // total charged across 10 payments; null if N/A
  }
  recommended: boolean
  features: TierFeatureLine[]
}

export const TIER_CARDS: TierCardData[] = [
  {
    tier: 'freelancer',
    displayName: 'Freelancer',
    price: { monthly: 0, annual: 0, annualBilledAs: null },
    recommended: false,
    features: [
      { labelKey: 'features.team_1',        available: true },
      { labelKey: 'features.modules_basic', available: true },
      { labelKey: 'features.storage_1gb',   available: true },
      { labelKey: 'features.ai',            available: false },
      { labelKey: 'features.white_label',   available: false },
      { labelKey: 'features.custom_domain', available: false },
      { labelKey: 'features.api',           available: false },
      { labelKey: 'features.support_community', available: true, highlight: false },
    ],
  },
  {
    tier: 'business',
    displayName: 'Business',
    price: { monthly: 89, annual: 74, annualBilledAs: 740 },
    recommended: true,
    features: [
      { labelKey: 'features.team_8',         available: true, highlight: true },
      { labelKey: 'features.modules_all',    available: true, highlight: true },
      { labelKey: 'features.storage_20gb',   available: true, highlight: true },
      { labelKey: 'features.ai',             available: true, highlight: true },
      { labelKey: 'features.white_label',    available: false },
      { labelKey: 'features.custom_domain',  available: false },
      { labelKey: 'features.api',            available: false },
      { labelKey: 'features.support_email48', available: true, highlight: true },
    ],
  },
  {
    tier: 'enterprise',
    displayName: 'Enterprise',
    price: { monthly: null, annual: null, annualBilledAs: null }, // contact only
    recommended: false,
    features: [
      { labelKey: 'features.team_15',         available: true, highlight: true },
      { labelKey: 'features.modules_all',     available: true },
      { labelKey: 'features.storage_100gb',   available: true, highlight: true },
      { labelKey: 'features.ai',              available: true },
      { labelKey: 'features.white_label',     available: true, highlight: true },
      { labelKey: 'features.custom_domain',   available: true, highlight: true },
      { labelKey: 'features.api',             available: true, highlight: true },
      { labelKey: 'features.support_priority4', available: true, highlight: true },
    ],
  },
]

export const TIER_RANK: Record<TenantTier, number> = {
  freelancer: 0, business: 1, enterprise: 2, white_label: 3,
}
export function tierRank(t: TenantTier): number { return TIER_RANK[t] }

/** Next purchasable tier above the current one (for default card selection). */
export function nextTierAbove(current: TenantTier): TenantTier {
  if (current === 'freelancer') return 'business'
  if (current === 'business') return 'enterprise'
  return 'enterprise' // already enterprise/white_label → keep enterprise selected
}
```
**Acceptance:**
- [ ] `TIER_CARDS` has exactly 3 entries; `white_label` is absent.
- [ ] Business `price` is `{ monthly: 89, annual: 74, annualBilledAs: 740 }`; Enterprise prices are all `null`.
- [ ] `tierRank('white_label') === 3` and `nextTierAbove('freelancer') === 'business'`.

### Task 2: i18n strings (he + en)
**Blocks:** 4, 6  ·  **Blocked by:** —
**Files:**
- Create: `packages/i18n/locales/he/upgrade-modal.json`
- Create: `packages/i18n/locales/en/upgrade-modal.json`
**Steps:**
- [ ] Write the `he` namespace with every key the spec lists, plus the `features.*` keys referenced by Task 1.
- [ ] Write an English fallback `en` namespace mirroring the same keys.
- [ ] Register the `upgrade-modal` namespace with the i18n loader (follow the existing per-namespace registration pattern in `@zync/i18n`).
**Schema / Interfaces:**
```jsonc
// packages/i18n/locales/he/upgrade-modal.json
{
  "title": "שדרג את התוכנית שלך",
  "context_line": "תצטרך {{tier}} כדי להשתמש ב-{{feature}}",
  "period_monthly": "חודשי",
  "period_annual": "שנתי — חסוך 17%",
  "annual_billed_as": "חיוב שנתי: {{amount}} ₪ (10 תשלומים)",
  "cta_current": "תוכנית נוכחית",
  "cta_upgrade": "שדרג ל-{{tier}}",
  "cta_contact": "צור קשר עם מכירות →",
  "cta_null_adapter": "צור קשר כדי לשדרג →",
  "ask_admin_title": "רק מנהל יכול לשדרג",
  "ask_admin_body": "רק בעל חשבון או מנהל יכולים לשדרג את התוכנית. פנה למנהל המערכת שלך.",
  "close": "סגור",
  "see_full_plan": "ראה פרטי תוכנית מלאים",
  "recommended_badge": "★ מומלץ",
  "current_plan_badge": "תוכנית נוכחית",
  "price_free": "חינם לתמיד",
  "price_contact": "צור קשר",
  "price_per_month": "{{amount}} ₪ / חודש",
  "error_checkout": "שגיאה ביצירת הזמנה — נסה שוב",
  "error_no_provider": "ספק התשלום אינו מוגדר — צור קשר עם התמיכה",
  "success_upgraded": "התוכנית שודרגה בהצלחה! התוכנית החדשה שלך פעילה.",
  "features": {
    "team_1": "חבר צוות 1",
    "team_8": "עד 8 חברי צוות",
    "team_15": "עד 15 חברי צוות",
    "modules_basic": "מודולים בסיסיים",
    "modules_all": "כל המודולים",
    "storage_1gb": "1 GB אחסון",
    "storage_20gb": "20 GB אחסון",
    "storage_100gb": "100 GB אחסון",
    "ai": "AI אסיסטנט",
    "white_label": "ברנד לבן",
    "custom_domain": "דומיין מותאם",
    "api": "גישת API",
    "support_community": "תמיכה",
    "support_email48": "תמיכה: אימייל, 48 ש׳",
    "support_priority4": "תמיכה: עדיפות, 4 ש׳"
  }
}
```
`en/upgrade-modal.json` mirrors the same keys with English values (e.g. `"title": "Upgrade your plan"`, `"period_annual": "Annual — save 17%"`, `"features.team_8": "Up to 8 team members"`, etc.).
**Acceptance:**
- [ ] Both files parse as JSON and contain identical key sets (including nested `features.*`).
- [ ] No key referenced by `TIER_CARDS` (`features.*`) or by the components is missing.

### Task 3: PeriodToggle component
**Blocks:** 6  ·  **Blocked by:** —
**Files:**
- Create: `packages/ui/src/upgrade-modal/PeriodToggle.tsx`
**Steps:**
- [ ] Render two segmented buttons: `period_monthly` and `period_annual` (the annual label already carries "— חסוך 17%").
- [ ] Active segment: `--accent` background, white text. Inactive: `--surface` background, `--ink-soft` text.
- [ ] Render the annual "save 17%" badge in success green `oklch(50% 0.18 145)`.
- [ ] Expose `value: 'monthly' | 'annual'` and `onChange(next)`.
- [ ] Implement as a radiogroup: `role="radiogroup"`, each segment `role="radio"` with `aria-checked`; arrow keys move selection; the group is labelled by visually-hidden text or `aria-label`.
- [ ] Pull all spacing from the 8px grid; no hardcoded colors (token vars only).
**Schema / Interfaces:**
```tsx
export interface PeriodToggleProps {
  value: 'monthly' | 'annual'
  onChange(next: 'monthly' | 'annual'): void
}
export function PeriodToggle(props: PeriodToggleProps): JSX.Element
```
**Acceptance:**
- [ ] Clicking a segment calls `onChange` with the other value; active styling tracks `value`.
- [ ] Keyboard arrow navigation switches the active radio; `aria-checked` reflects state.

### Task 4: TierCard component
**Blocks:** 6  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/ui/src/upgrade-modal/TierCard.tsx`
**Steps:**
- [ ] Render one card from a `TierCardData`: title (`displayName`), optional `★ מומלץ` badge (recommended), price block, feature rows, and a CTA slot.
- [ ] Price block resolves by `period` + tier: Freelancer → `price_free`; Enterprise → `price_contact`; Business monthly → `price_per_month {89}`; Business annual → `price_per_month {74}` plus a sub-line `annual_billed_as {740}`.
- [ ] Crossfade the price text (150ms opacity) when `period` changes — **gate behind `prefers-reduced-motion`**: if `(prefers-reduced-motion: reduce)` matches, switch price text instantly with no transition.
- [ ] Feature rows: `available === true` → ✓ in success green `oklch(50% 0.18 145)`; `false` → — in `--ink-soft` at 40% opacity. `highlight` rows get a 4px `--accent` inline-start/inline-end border highlight on `--accent-soft`.
- [ ] Card states: current tier → `--ink-soft` border + `current_plan_badge` + disabled CTA; selected (non-current) → 2px `--accent` ring + elevated shadow; hover non-selected darkens `--surface` by `l - 0.03`.
- [ ] Receive `ctaVariant`, `ctaLabel`, `onCheckout`, `contactHref`, `selected`, `onSelect`, `readOnly` as props; render the CTA per Task 5 logic (button for `current`/`checkout`, `<a href={mailto}>` for `contact`/`null_adapter`, hidden for `downgrade`). When `readOnly` (ask-admin), render no CTA button and no selected ring.
- [ ] Tokens only; 8px-grid spacing; the whole card is a labelled region (`role="group"` with `aria-label` = tier display name).
**Schema / Interfaces:**
```tsx
import type { TierCardData } from './tier-card-data'
import type { CtaVariant } from './cta-logic'

export interface TierCardProps {
  data: TierCardData
  period: 'monthly' | 'annual'
  selected: boolean
  isCurrent: boolean
  ctaVariant: CtaVariant
  ctaLabel: string | null          // null when downgrade (CTA hidden)
  contactHref?: string             // mailto for contact / null_adapter
  loading?: boolean                // disable checkout button while POST in flight
  readOnly?: boolean               // ask-admin view: no CTAs, no selection ring
  onSelect(tier: TierCardData['tier']): void
  onCheckout(tier: TierCardData['tier']): void
}
export function TierCard(props: TierCardProps): JSX.Element
```
**Acceptance:**
- [ ] Business card shows `★ מומלץ`; Freelancer (when current) shows `current_plan_badge` and a disabled CTA.
- [ ] With reduced-motion enabled the price text changes with no fade; otherwise a ~150ms crossfade plays.
- [ ] Highlighted feature rows render the 4px accent border; unavailable rows render — at 40% opacity.

### Task 5: CTA logic + contact mailto + UpgradeModalProvider/context
**Blocks:** 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/ui/src/upgrade-modal/cta-logic.ts`
- Create: `packages/ui/src/upgrade-modal/UpgradeModalProvider.tsx`
**Steps:**
- [ ] In `cta-logic.ts` implement `resolveCtaVariant(cardTier, currentTier, isNullAdapter)` exactly per the spec state table and `buildContactMailto(featureName?, targetTier?)`.
- [ ] In `UpgradeModalProvider.tsx` implement `UpgradeModalContext`, `UpgradeModalProvider`, and `useUpgradeModal()` per the spec, rendering `<UpgradeModal />` as the provider's last child so it is always mounted.
- [ ] `open(opts?)` sets `{ isOpen: true, opts: opts ?? null }`; `close()` resets to `{ isOpen: false, opts: null }`. Memoize `open`/`close` with `useCallback`.
- [ ] `useUpgradeModal()` throws if used outside the provider; returns `{ isOpen, open, close }`.
**Schema / Interfaces:**
```ts
// cta-logic.ts
import type { TenantTier } from '@zync/types'
import { tierRank } from './tier-card-data'

export type CtaVariant =
  | 'current'       // disabled — already on this tier
  | 'downgrade'     // hidden — lower than current
  | 'checkout'      // self-service upgrade → checkout
  | 'contact'       // enterprise/white_label
  | 'null_adapter'  // NullAdapter fallback (provider not configured)

export function resolveCtaVariant(
  cardTier: TenantTier,
  currentTier: TenantTier,
  isNullAdapter: boolean,
): CtaVariant {
  if (cardTier === currentTier) return 'current'
  if (tierRank(cardTier) < tierRank(currentTier)) return 'downgrade'
  if (cardTier === 'enterprise' || cardTier === 'white_label') return 'contact'
  if (isNullAdapter) return 'null_adapter'
  return 'checkout'
}

export function buildContactMailto(featureName?: string, targetTier?: TenantTier): string {
  const subject = encodeURIComponent(
    targetTier === 'enterprise'
      ? `בקשת שדרוג ל-Enterprise${featureName ? ` — ${featureName}` : ''}`
      : `בקשת שדרוג לתוכנית${featureName ? ` — ${featureName}` : ''}`,
  )
  return `mailto:sales@zync.is?subject=${subject}`
}
```
```tsx
// UpgradeModalProvider.tsx
import type { TenantTier } from '@zync/types'

export interface UpgradeModalOpts {
  featureName?: string        // shown in context line; omit → no context line
  targetTier?: TenantTier     // pre-selected card; defaults to nextTierAbove(currentTier)
}
export interface UpgradeModalState { isOpen: boolean; opts: UpgradeModalOpts | null }
export interface UpgradeModalContextValue {
  state: UpgradeModalState
  open(opts?: UpgradeModalOpts): void
  close(): void
}
export function UpgradeModalProvider(props: { children: React.ReactNode }): JSX.Element
export function useUpgradeModal(): { isOpen: boolean; open(opts?: UpgradeModalOpts): void; close(): void }
```
**Acceptance:**
- [ ] `resolveCtaVariant('enterprise','freelancer',false) === 'contact'`; `resolveCtaVariant('business','freelancer',true) === 'null_adapter'`; `resolveCtaVariant('business','freelancer',false) === 'checkout'`; `resolveCtaVariant('freelancer','business',false) === 'downgrade'`.
- [ ] `buildContactMailto('AI Assistant','enterprise')` URL-encodes the Enterprise subject including the feature.
- [ ] `useUpgradeModal()` throws when called outside `UpgradeModalProvider`.

### Task 6: UpgradeModal main component + barrel export
**Blocks:** 7, 8, 9  ·  **Blocked by:** 1, 2, 3, 4, 5
**Files:**
- Create: `packages/ui/src/upgrade-modal/UpgradeModal.tsx`
- Create: `packages/ui/src/upgrade-modal/index.ts`
- Modify: `packages/ui/src/index.ts` (re-export the upgrade-modal barrel)
**Steps:**
- [ ] Read `{ state, close }` from context; render `null` when `!state.isOpen`.
- [ ] Read current tier + role from the session (the app's existing auth/session hook exposing `SessionPayload.tier`/`SessionPayload.role`); read the subscription record via `useSubscription()` to derive `isNullAdapter = subscription?.adapter === 'null'`.
- [ ] Local state: `selectedTier = opts?.targetTier ?? nextTierAbove(currentTier)`, `period: 'monthly'` (default), `loading: false`.
- [ ] Compute `canUpgrade = role === 'OWNER' || role === 'ADMIN'`.
- [ ] Render `<Dialog>` with `dir="rtl"`, width 640px desktop / full-width bottom sheet ≤640px, backdrop `oklch(0% 0 0 / 40%)`, surface `--surface`, radius `--radius`.
- [ ] Header: ✕ close button (inline-end), title (`title`, h2, `--ink`), context line (only if `opts?.featureName`) styled `--accent-soft` bg / `--accent` text rendering `context_line` interpolated with `TIER_DISPLAY[selectedTier]` and the feature name.
- [ ] If `canUpgrade === false`: render ASK_ADMIN_VIEW — context line + `ask_admin_title`/`ask_admin_body` + a single `close` button; cards rendered read-only (no selection, no CTAs); keep the "see full plan" link. No checkout possible.
- [ ] If `canUpgrade === true`: render `<PeriodToggle>` then the three `<TierCard>`s. For each card compute `ctaVariant = resolveCtaVariant(card.tier, currentTier, isNullAdapter)`, build `ctaLabel` from i18n (`cta_current` / `cta_upgrade {TIER_DISPLAY}` / `cta_contact` / `cta_null_adapter`; downgrade → null), and `contactHref = buildContactMailto(opts?.featureName, card.tier)` for contact/null_adapter.
- [ ] `handleCheckout(tier)`: POST `/api/zync-subscription/checkout` `{ tier, period }`; on `!res.ok` → `toast.error(error_checkout)`; on `{ checkoutUrl }` null → `toast.error(error_no_provider)`; on non-null → `window.location.href = checkoutUrl`. Toggle `loading` around the call; never throw to the user.
- [ ] Footer: `see_full_plan` link → `/settings/plan` (always shown, even in ask-admin view).
- [ ] a11y: `Dialog` must trap focus, restore focus to the trigger on close, set `aria-modal="true"` and `aria-labelledby` pointing at the title element, and close on Escape / backdrop / ✕. Verify the `@zync/ui` `Dialog` primitive provides trap+restore; if a behaviour is missing, wire it here explicitly.
- [ ] Contact-link click does NOT auto-close the modal (user may copy the address).
- [ ] `index.ts` re-exports `UpgradeModal`, `UpgradeModalProvider`, `useUpgradeModal`, `TIER_CARDS`, `resolveCtaVariant`, `buildContactMailto`, and the public types.
**Schema / Interfaces:**
```tsx
// packages/ui/src/upgrade-modal/UpgradeModal.tsx
export function UpgradeModal(): JSX.Element | null
```
```ts
// packages/ui/src/upgrade-modal/index.ts
export { UpgradeModal } from './UpgradeModal'
export { UpgradeModalProvider, useUpgradeModal } from './UpgradeModalProvider'
export type { UpgradeModalOpts, UpgradeModalContextValue, UpgradeModalState } from './UpgradeModalProvider'
export { TIER_CARDS, TIER_RANK, tierRank, nextTierAbove } from './tier-card-data'
export type { TierCardData, TierFeatureLine } from './tier-card-data'
export { resolveCtaVariant, buildContactMailto } from './cta-logic'
export type { CtaVariant } from './cta-logic'
export { TierCard } from './TierCard'
export { PeriodToggle } from './PeriodToggle'
```
**Acceptance:**
- [ ] Opening with `{ featureName: 'AI Assistant', targetTier: 'business' }` shows the context line "תצטרך Business כדי להשתמש ב-AI Assistant" and pre-selects the Business card.
- [ ] Non-OWNER/ADMIN role renders the ask-admin view with no CTA buttons but keeps the context line + see-full-plan link.
- [ ] When `subscription.adapter === 'null'`, the Business card CTA is `cta_null_adapter` (mailto), and clicking it never POSTs to checkout.
- [ ] Escape, backdrop click, and ✕ all close the modal and restore focus; `aria-modal`/`aria-labelledby` are set.
- [ ] A failed checkout POST shows `error_checkout` toast and leaves the modal open.

### Task 7: Wire `useTierGate.upgrade(featureName?)` to the modal
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: `packages/auth/src/tier-gate.ts`
**Steps:**
- [ ] First grep for the actual `useTierGate` definition — the modal spec places it at `packages/auth/src/tier-gate.ts` while foundation-auth-rbac's prose references `apps/zync-app/src/hooks/use-tier-gate.ts`; edit whichever file actually defines it. (If it lives in the app, the `app → @zync/ui` import is trivially safe and the cycle concern below does not apply.)
- [ ] Additively widen the `upgrade` signature to accept an optional `featureName?: string` (existing zero-arg call sites stay valid).
- [ ] Inside the hook, obtain `open` from `useUpgradeModal()` and implement `upgrade(featureName?) => open({ featureName, targetTier: minimum })`.
- [ ] Keep `allowed` derived from the session tier via `meetsMinimumTier(session.tier, minimum)` (unchanged).
- [ ] Do not introduce a hard import cycle: `@zync/auth` should import `useUpgradeModal` from `@zync/ui` (ui already depends on types, not auth) — confirm the dependency direction is auth→ui and not the reverse; if a cycle would form, accept the hook via context only (it already is) so the import is type-light.
**Schema / Interfaces:**
```ts
// packages/auth/src/tier-gate.ts (additive)
export function useTierGate(minimum: TenantTier): {
  allowed: boolean
  upgrade: (featureName?: string) => void
}
```
**Acceptance:**
- [ ] Existing `upgrade()` call sites compile unchanged.
- [ ] `upgrade('AI Assistant')` opens the modal with that feature name and `targetTier === minimum`.

### Task 8: Mount UpgradeModalProvider at app root
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/src/main.tsx`
**Steps:**
- [ ] Import `UpgradeModalProvider` from `@zync/ui`.
- [ ] Wrap the app tree with `<UpgradeModalProvider>` placed **inside** the existing Tenant and Auth providers (so role/tier are readable) and inside the React Query provider (so `useSubscription` works), but it may sit above the router.
- [ ] Confirm a single `<Toaster>` is mounted (from `@zync/ui`) so modal/page toasts render; do not add a second.
**Acceptance:**
- [ ] `useUpgradeModal()` resolves anywhere in the app without throwing.
- [ ] The modal renders above all routes (Portal) regardless of current page.

### Task 9: Post-checkout success toast on `/settings/plan`
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/src/routes/settings/plan.tsx`
**Steps:**
- [ ] On mount, parse `location.search`; if `upgrade === 'success'`, fire `toast.success(success_upgraded)`, `queryClient.invalidateQueries(['zync-subscription'])`, then `history.replaceState({}, '', location.pathname)` to strip the param without navigation.
- [ ] If `upgrade === 'cancel'`, silently strip the param (no error toast — user aborted intentionally).
- [ ] This is an additive effect on the existing plan page (owned by `zync-subscription`/settings); do not alter other page behaviour.
**Schema / Interfaces:**
```tsx
useEffect(() => {
  const params = new URLSearchParams(location.search)
  const flag = params.get('upgrade')
  if (flag === 'success') {
    toast.success(t('upgrade-modal:success_upgraded'))
    queryClient.invalidateQueries({ queryKey: ['zync-subscription'] })
  }
  if (flag === 'success' || flag === 'cancel') {
    history.replaceState({}, '', location.pathname)
  }
}, [])
```
**Acceptance:**
- [ ] Landing on `/settings/plan?upgrade=success` shows the success toast, refetches subscription, and removes the query param.
- [ ] `?upgrade=cancel` removes the param with no toast.
