a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

wip pro plan spec

+244
+244
specs/2026-02-03-pro-tier.md
··· 1 + # Pro Tier Subscription System 2 + 3 + **Status**: draft 4 + 5 + ## Goal 6 + 7 + Add a paid Pro tier to Leaflet using Stripe for billing, with an entitlements-based architecture that decouples feature access from subscription state. 8 + 9 + ## Design 10 + 11 + ### Data Model 12 + 13 + Two new tables separate Stripe subscription state from feature access: 14 + 15 + **`user_subscriptions`** — Stripe sync state 16 + - `identity_id` (UUID, FK to identities, PK) 17 + - `stripe_customer_id` (text, unique) 18 + - `stripe_subscription_id` (text, unique, nullable) 19 + - `plan` (text) — Price ID from `stripe/products.ts`, e.g., `pro_monthly_v1_usd` 20 + - `status` (text) — mirrors Stripe: `trialing`, `active`, `past_due`, `canceled`, `unpaid` 21 + - `current_period_end` (timestamp) 22 + - `created_at`, `updated_at` 23 + 24 + **`user_entitlements`** — Feature access grants 25 + - `identity_id` (UUID, FK to identities) 26 + - `entitlement_key` (text) — e.g., `analytics` 27 + - `granted_at` (timestamp) 28 + - `expires_at` (timestamp, nullable) — null means permanent 29 + - `source` (text) — provenance, e.g., `stripe:sub_xxx`, `manual:admin`, `promo:launch2026` 30 + - `metadata` (jsonb, nullable) — for limits or additional config 31 + - Primary key: `(identity_id, entitlement_key)` — one entitlement per key per user 32 + 33 + The unique constraint on `(identity_id, entitlement_key)` means writes are upserts. The most recent write wins; `source` tracks provenance. 34 + 35 + ### SKU → Entitlements Mapping 36 + 37 + Entitlements for each Stripe Product are stored in Stripe's product metadata, not locally. Example product metadata: 38 + 39 + ```json 40 + { 41 + "entitlements": "{\"analytics\": true}" 42 + } 43 + ``` 44 + 45 + This keeps Stripe as the source of truth for what each SKU grants. 46 + 47 + ### Stripe Product Sync 48 + 49 + Products and prices are defined in code and synced to Stripe via a GitHub Action. This provides version control, reproducibility, and enforces immutability. 50 + 51 + **Directory structure:** 52 + ``` 53 + stripe/ 54 + ├── products.ts # Product/price definitions with entitlement metadata 55 + └── sync.ts # Script that ensures Stripe matches definitions 56 + ``` 57 + 58 + **Product definition format** (`stripe/products.ts`): 59 + ```typescript 60 + export const products = [ 61 + { 62 + id: "pro_monthly_v1", 63 + name: "Leaflet Pro (Monthly)", 64 + prices: [ 65 + { 66 + id: "pro_monthly_v1_usd", 67 + currency: "usd", 68 + unit_amount: 900, // $9.00 69 + recurring: { interval: "month" }, 70 + }, 71 + ], 72 + metadata: { 73 + entitlements: JSON.stringify({ publication_analytics: true }), 74 + }, 75 + }, 76 + ]; 77 + ``` 78 + 79 + **Sync script behavior** (`stripe/sync.ts`): 80 + 1. Fetch existing products and prices by `id` (`stripe.products.retrieve(id)`, `stripe.prices.retrieve(id)`) 81 + 2. For each definition: 82 + - If missing in Stripe → create it with the custom `id` 83 + - If exists and matches → no-op 84 + - If exists but differs → update it to match the definition 85 + 3. Never deletes products/prices 86 + 87 + Both products and prices use custom `id` for idempotent matching (set at creation). Same definitions apply to both test and live mode—the script targets whichever mode the API key belongs to. 88 + 89 + **GitHub Action** (`.github/workflows/stripe-sync.yml`): 90 + - Triggers on push to `stripe/` directory 91 + - Runs sync against test mode automatically 92 + - Live mode sync requires manual workflow dispatch with approval 93 + 94 + **Versioning/grandfathering:** 95 + To grandfather existing subscribers on different terms, create a new product with a new `id` (e.g., `pro_monthly_v2`). Existing subscribers stay on v1. New subscribers get v2. For additive changes (new entitlements, metadata updates), just update the existing product definition. 96 + 97 + ### Stripe Sync Strategy 98 + 99 + **Webhook-driven** with **optimistic updates**: 100 + 101 + 1. **Optimistic**: After successful Checkout Session completion on the client, immediately call a server action to write `user_subscriptions` and `user_entitlements` based on the session data. User gets instant access. 102 + 103 + 2. **Durable**: Stripe webhooks confirm and reconcile state. Handles edge cases (payment failures, disputes, subscription updates from Stripe dashboard). 104 + 105 + Webhooks to handle: 106 + - `checkout.session.completed` — initial subscription created 107 + - `customer.subscription.created` — backup for subscription creation 108 + - `customer.subscription.updated` — plan changes, renewals, status changes 109 + - `customer.subscription.deleted` — subscription ended 110 + - `invoice.payment_failed` — payment issues 111 + - `customer.subscription.trial_will_end` — trial ending reminder (optional, for notifications) 112 + 113 + ### Entitlement Lifecycle 114 + 115 + **Grant flow** (subscription activates or trial starts): 116 + 1. Webhook receives event with subscription and product data 117 + 2. Parse `entitlements` from product metadata 118 + 3. Upsert `user_entitlements` rows with `expires_at` = `current_period_end`, `source` = `stripe:{subscription_id}` 119 + 120 + **Renewal flow** (subscription renews): 121 + 1. `customer.subscription.updated` webhook fires 122 + 2. Update `expires_at` on all entitlements with matching `source` 123 + 124 + **Cancellation flow** (user cancels but period remains): 125 + 1. `customer.subscription.updated` with `cancel_at_period_end: true` 126 + 2. Update `user_subscriptions.status` 127 + 3. Entitlements remain valid until `expires_at` (already set to period end) 128 + 129 + **Expiration flow** (subscription ends): 130 + 1. `customer.subscription.deleted` webhook fires 131 + 2. No action needed on `user_entitlements` — they naturally expire via `expires_at` 132 + 3. Update `user_subscriptions.status` to `canceled` 133 + 134 + Soft expiration via `expires_at` preserves audit history and handles the canceled-but-paid-through-period case without additional webhooks. 135 + 136 + ### Trials 137 + 138 + Stripe-managed trials: 139 + 1. Trial starts → subscription created with `status: trialing` 140 + 2. Entitlements granted with `expires_at` = trial end date 141 + 3. Trial converts → `expires_at` updated to subscription period end 142 + 4. Trial lapses → entitlements expire naturally, no action needed 143 + 144 + ### Entitlement Checks 145 + 146 + Extend `getIdentityData()` to join against `user_entitlements` and return active entitlements: 147 + 148 + ```typescript 149 + type IdentityData = { 150 + id: string; 151 + email: string; 152 + atp_did: string; 153 + // ... existing fields 154 + entitlements: Record<string, { 155 + granted_at: string; 156 + expires_at: string | null; 157 + source: string; 158 + metadata: Record<string, unknown> | null; 159 + }>; 160 + }; 161 + ``` 162 + 163 + Query filters to `expires_at IS NULL OR expires_at > NOW()`. The `useIdentityData()` hook exposes the same shape client-side. 164 + 165 + Feature gating in server actions: 166 + 167 + ```typescript 168 + export async function getAnalyticsData() { 169 + const identity = await getIdentityData(); 170 + if (!identity?.entitlements.analytics) { 171 + return err("Pro subscription required"); 172 + } 173 + // ... fetch analytics 174 + } 175 + ``` 176 + 177 + ### Initial Pro Features 178 + 179 + Single entitlement at launch: 180 + - `publication_analytics` (boolean) — access to analytics features for 181 + publications 182 + 183 + ### Webhook Endpoint 184 + 185 + New API route at `app/api/webhooks/stripe/route.ts`: 186 + - Verify Stripe signature using `STRIPE_WEBHOOK_SECRET` 187 + - Dispatch to Inngest functions for processing (keeps webhook response fast, enables retries) 188 + 189 + ### Inngest Functions 190 + 191 + New functions in `app/api/inngest/functions/`: 192 + - `stripe/handle-checkout-completed` — process successful checkout 193 + - `stripe/handle-subscription-updated` — sync subscription changes to entitlements 194 + - `stripe/handle-subscription-deleted` — update subscription status 195 + 196 + Events to add to Inngest client: 197 + ```typescript 198 + "stripe/checkout.session.completed": { data: { sessionId: string } } 199 + "stripe/customer.subscription.updated": { data: { subscriptionId: string } } 200 + "stripe/customer.subscription.deleted": { data: { subscriptionId: string } } 201 + ``` 202 + 203 + ### Environment Variables 204 + 205 + ``` 206 + STRIPE_SECRET_KEY=sk_... 207 + STRIPE_WEBHOOK_SECRET=whsec_... 208 + ``` 209 + 210 + Price IDs are defined in `stripe/products.ts` and imported directly — no env var needed. 211 + 212 + ## Open Questions 213 + 214 + - **UI**: Where upgrade prompts appear, pricing page design, checkout flow UX — to be designed separately 215 + - **Multiple plans**: Current design supports one subscription per user. If we add team plans or multiple concurrent subscriptions, `user_subscriptions` would need to become one-to-many 216 + 217 + ## Implementation 218 + 219 + 1. **Add Stripe packages**: Install `stripe` npm package 220 + 221 + 2. **Database migration**: Create `user_subscriptions` and `user_entitlements` tables with indexes on `identity_id` and `expires_at` 222 + 223 + 3. **Drizzle schema**: Add table definitions to `drizzle/schema.ts` 224 + 225 + 4. **Environment setup**: Add Stripe env vars to `.env.local` and deployment config 226 + 227 + 5. **Stripe webhook endpoint**: Create `app/api/webhooks/stripe/route.ts` with signature verification, dispatch events to Inngest 228 + 229 + 6. **Inngest events and functions**: Add event types to `app/api/inngest/client.ts`, create handler functions for checkout completed, subscription updated, subscription deleted 230 + 231 + 7. **Extend getIdentityData**: Join against `user_entitlements`, filter expired, return entitlements object 232 + 233 + 8. **Extend useIdentityData**: Ensure client-side hook receives entitlements from server 234 + 235 + 9. **Optimistic update action**: Create `actions/subscriptions/activateSubscription.ts` for immediate access after checkout 236 + 237 + 10. **Stripe sync infrastructure**: 238 + - Create `stripe/products.ts` with Pro product definitions and entitlement metadata 239 + - Create `stripe/sync.ts` script using Stripe Node SDK to reconcile definitions with Stripe 240 + - Add `.github/workflows/stripe-sync.yml` for automated test mode sync on push, manual live mode sync 241 + 242 + 11. **Initial product sync**: Run sync script against both test and live mode to create Pro products 243 + 244 + 12. **Gate analytics**: Add entitlement check to analytics server actions