# 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, cloud
wipe, 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 before it's removed. **Local data always
survives** — only the Convex (server) copy is wiped; each device keeps its own tasks.

**Sandbox exception (Glide Beta):** in free-sync mode
([environments.md](../release/environments.md#sandbox-free-sync-no-payments)) signing in *is*
the entitlement — `EXPO_PUBLIC_FREE_SYNC=1` on the client, `SANDBOX_GRANT_ALL=1` on the
sandbox Convex deployment. Only **Free** and **Subscribed** exist there; everything below this
line (billing, lifecycle, grace, wipe) applies to production only and is never exercised in
sandbox — test it via TestFlight builds of the production app.

## 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 cloud rows are wiped in
  bounded batches. Device-local data is never deleted.
- 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:** the client talks to a RevenueCat **Test Store** — a built-in sandbox keyed by a
`test_…` API key in `.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. This proves the client integration;
it 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 environment:** EAS production uses the public `appl_…` client key;
   preview/development can use the `test_…` Test Store key. 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.** (Whether wipe is pure TTL vs. also session-aware
  is still TBD; pure TTL is the simpler default.)
- **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.
- **On wipe (open):** un-adopt local rows (`ownerId → null`) vs. keep `ownerId` but inert — local
  data survives either way, so this is bookkeeping for a future re-subscribe.
