# Subscriptions & access tiers

**Status:** The local-first access model, StoreKit paywall/restore flow, RevenueCat client
state, server-side entitlement cache, expiry scheduler, 15-day read-only grace period, retained
cloud vault, and Convex sync enforcement are implemented. Account deletion removes Clerk identity
and synchronized task data while retaining the device-local copy. What remains is production
service/store configuration and real-store sandbox verification (see
[Going live](#going-live-revenuecat-production-checklist)).
**Extends:** [local-first.md](local-first.md), [convex-sync.md](convex-sync.md).
**Code (today):** `src/data/` (local store), `src/data/sync/` + `convex/sync.ts` (single-user
sync), `src/auth/` (Clerk).

## The access model (true today)

Glide is **local-first and fully functional with no account, no network, and no subscription.**
The on-device store (MMKV + Zustand, see [local-first.md](local-first.md)) is the single source
of truth; every feature works offline and signed-out. A signed-out user's tasks live **only on
their device** and never touch Convex.

So "**non-paying users don't exist on the backend**" is literal, not a slogan: a free user's
rows carry `ownerId` `null`/`'dogfood'` ([owner.ts](../../src/data/sync/owner.ts)), are never
adopted into an authenticated account, and are never pushed to Convex.

## What the subscription buys

**Multi-device cloud sync** — the Convex layer in [convex-sync.md](convex-sync.md). The
subscription gates **sync, not the app.** There is deliberately **no launch paywall**: gating
app start on a Convex/account dependency would break the local-first guarantee
([local-first.md](local-first.md) — "Don't reintroduce a hard Convex dependency at launch").
The app is free forever, locally; paying turns on cloud sync across a user's devices.

Discovery is **pull, not push**: a persistent "Sync" entry (the `AuthControls` slot in the
Action header) opens the paywall when tapped. No interruptive upsell — consistent with Glide's
no-nudge character.

## The three states

| State | `ownerId` | Sync engine | Backend writes | Backend reads | App |
|---|---|---|---|---|---|
| **Free** (signed-out / unsubscribed) | `null`/`dogfood` | inert | — | — | full, local-only |
| **Subscribed** | Clerk user id | active | accepted | live | full + multi-device sync |
| **Lapsed (grace)** | Clerk user id | read-only | rejected (writes stay local, exactly like Free) | allowed (other devices can pull latest) | full, local-only + notice |

**Lapsed reverts to local-only** — the same behaviour a free user has. Writes are never blocked;
they just stop syncing. A **grace window** keeps the server copy readable so the user can open
their other devices and pull the latest server state. After grace, cloud access pauses but the
encrypted server copy and recovery anchor are retained for a safe resubscription. **Local data
always survives.** Explicit account deletion is the only destructive cloud path.

## Billing mechanism

- **Native IAP, auto-renewable,** via **RevenueCat** (enrol in Apple's Small Business Program
  → ~15% store cut; Google ~10% + 5% = 15%). Apple/Google are the merchant of record, so they
  handle VAT/sales tax. *(Considered and rejected: Clerk billing — not an MoR, so we'd owe tax;
  and a web MoR like Polar — moot for in-app digital subs, which must use store IAP.)*
- **Clerk = auth only** ([src/auth/](../../src/auth)); the Clerk identity **is** the sync owner.
  Tie the subscription to it with `Purchases.logIn(clerkUserId)`.
- **Hard paywall, no trial — for *sync*.** One tier, **annual + monthly**. Declining keeps the
  free local-only experience.
- **Sign-up-first, inside the gated flow** (avoids a paid-but-no-account limbo). Flow: pick plan
  → enter email → branch (existing Clerk user → sign-in; new → sign-up) → once authenticated, run
  the StoreKit purchase. Because payment is last, there is never an anonymous purchase to alias.
  The paywall also offers **"Already subscribed? Sign in"** for a returning subscriber on a new
  device (authenticate + restore). No account-creation surface exists outside this flow.

## Entitlement & lifecycle

- **Source of truth = schedule-driven RevenueCat REST pulls, cached in Convex** — no direct
  RevenueCat webhook endpoint and no `convex-revenuecat` component. `convex/revenuecat.ts`
  reads RevenueCat's resolved `sync` entitlement and `convex/entitlements.ts` caches the
  account's status and boundaries. Every task read/write checks that cache server-side.
- **Convex scheduling is anchored at `expiresAt`.** At the boundary the action re-reads
  RevenueCat and resolves renewal, active billing-retry, lapse into a 15-day read-only grace,
  or expiry. The grace boundary re-reads RevenueCat once more before access pauses. Expiry does
  not delete cloud rows; this avoids partial-vault divergence and preserves recovery.
- The client independently maps `CustomerInfo` to subscribed/billing/grace/free for immediate
  UI and starts Convex in read-write or read-only mode, but it cannot override the server gate.
- Account deletion calls an authenticated Convex mutation first, then Clerk's user deletion;
  any synchronized tasks left after the first transaction are removed by internal batches.
- RevenueCat REST calls live in a Convex action; queries and mutations never call `fetch`.

## UI direction (built)

Implemented in [paywall-screen.tsx](../../src/design/paywall-screen.tsx),
[sync-status.tsx](../../src/design/sync-status.tsx), and route
[paywall.tsx](../../src/app/paywall.tsx); merged to `main`.

- **Sync entry:** the existing `AuthControls` slot — a "Sync" affordance when free, the
  account-avatar when subscribed.
- **Action-screen notice** (grace / billing-retry): a **distinct, flatter notice construct** —
  deliberately *not* a status pill and *not* a hard-shadow task card (the hard shadow is reserved
  for tactile, actionable task objects; account messaging sits in a calmer register). Carries an
  icon + a line or two of static copy + an inline action. Grace reads calm; billing-retry uses
  the `resistance` tone.
- **New Task notice:** a muted **inline caption** ("saved on this device — not synced"), not a
  pill, shown only when sync was had-and-lost (lapsed / billing-retry) — never for never-paid
  users, for whom local-only is simply how the app works.

## Going live (RevenueCat production checklist)

**Where we are:** native Debug builds talk to a RevenueCat **Test Store** — a built-in
sandbox keyed by a `test_…` API key in the EAS development environment or `.env.local`
(`EXPO_PUBLIC_REVENUECAT_KEY`). Test Store purchases are simulated, need no store agreements,
and grant the `sync` entitlement exactly like production — which is why sandbox transactions
appear on the RC overview. RevenueCat rejects `test_…` keys in every native Release/distribution
binary, including EAS internal-distribution and TestFlight builds; those require a store-specific
`appl_…`/`goog_…` key. Test Store proves the client integration but ships no real subscription.

**Dashboard gotcha:** the RevenueCat **Apps** page (which nags "Get started with a Test Store")
lists *real store connections only* — App Store, Play, etc. The Test Store is a project-level
sandbox, **not** an entry there, so that page stays empty/onboarding-y until a real store is
connected. Key prefixes tell you which store a build talks to: `test_` (Test Store), `appl_`
(App Store), `goog_` (Google Play).

**To go live, in order:**

1. **App Store Connect (prerequisite — the long pole):** sign the Paid Apps Agreement + banking/
   tax; create the two **auto-renewable subscriptions** in one subscription group (reuse product
   ids `glide_sync_monthly` / `glide_sync_yearly`); set pricing, localizations, and the required
   review screenshot. *(Google Play: mirror with a payments profile + subscription base plans.)*
2. **Connect a real App Store app in RevenueCat** (*Apps* → add App Store) via bundle id + an
   App Store Connect **In-App Purchase key** (RC's StoreKit 2 path; the older shared secret also
   works).
3. **Import the real products** under that App Store app and **attach them to the existing `sync`
   entitlement and `offering`.** Offerings/entitlements/packages are **project-level and
   cross-platform** — the `$rc_annual` / `$rc_monthly` packages just gain the App Store products
   alongside the Test Store ones; the SDK serves whichever matches the running store. **The
   offering structure does not need rebuilding.**
4. **App Store Server Notifications V2:** point App Store Connect's notification URL at
   RevenueCat's webhook. Purchases work without it, but it's how RC learns about renewals,
   cancellations, billing-retry, and refunds — the exact signal the grace/billing lifecycle above
   depends on, so for Glide it's effectively required, not optional.
5. **Configure keys by build type:** EAS production, TestFlight, and ad-hoc Release previews use
   the public `appl_…` client key. Only native Debug builds use the `test_…` Test Store key; Glide's
   standalone `preview-test-store` profile embeds its bundle and does not require Metro.
   Convex separately needs a secret RevenueCat REST API key as `REVENUECAT_SECRET_API_KEY`.
6. **Enable self-service account deletion in Clerk** and configure production Clerk + Convex
   JWT integration.
7. **Test on real hardware** (TestFlight / Play internal track) in each store's sandbox, then ship
   a production EAS build and `eas submit` (IAP is reviewed with the first build).

## Decisions & open questions

- **Grace-window length — RESOLVED: 15 days.** After grace, cloud reads and writes pause while
  encrypted records remain retained.
- **Pricing — RESOLVED: $2/month, $20/year** (annual = 2 months free).
- **Grace-notice colour — RESOLVED: neutral.** No new caution token — the palette has `clear`
  (green), `resistance` (red), and `accent` (purple); the grace notice stays calm by design.
- **Retention — RESOLVED:** entitlement expiry is non-destructive. Explicit account deletion
  removes cloud records; resubscription restores access to the intact vault.
