···11+# Pro Tier Subscription System
22+33+**Status**: draft
44+55+## Goal
66+77+Add a paid Pro tier to Leaflet using Stripe for billing, with an entitlements-based architecture that decouples feature access from subscription state.
88+99+## Design
1010+1111+### Data Model
1212+1313+Two new tables separate Stripe subscription state from feature access:
1414+1515+**`user_subscriptions`** — Stripe sync state
1616+- `identity_id` (UUID, FK to identities, PK)
1717+- `stripe_customer_id` (text, unique)
1818+- `stripe_subscription_id` (text, unique, nullable)
1919+- `plan` (text) — Price ID from `stripe/products.ts`, e.g., `pro_monthly_v1_usd`
2020+- `status` (text) — mirrors Stripe: `trialing`, `active`, `past_due`, `canceled`, `unpaid`
2121+- `current_period_end` (timestamp)
2222+- `created_at`, `updated_at`
2323+2424+**`user_entitlements`** — Feature access grants
2525+- `identity_id` (UUID, FK to identities)
2626+- `entitlement_key` (text) — e.g., `analytics`
2727+- `granted_at` (timestamp)
2828+- `expires_at` (timestamp, nullable) — null means permanent
2929+- `source` (text) — provenance, e.g., `stripe:sub_xxx`, `manual:admin`, `promo:launch2026`
3030+- `metadata` (jsonb, nullable) — for limits or additional config
3131+- Primary key: `(identity_id, entitlement_key)` — one entitlement per key per user
3232+3333+The unique constraint on `(identity_id, entitlement_key)` means writes are upserts. The most recent write wins; `source` tracks provenance.
3434+3535+### SKU → Entitlements Mapping
3636+3737+Entitlements for each Stripe Product are stored in Stripe's product metadata, not locally. Example product metadata:
3838+3939+```json
4040+{
4141+ "entitlements": "{\"analytics\": true}"
4242+}
4343+```
4444+4545+This keeps Stripe as the source of truth for what each SKU grants.
4646+4747+### Stripe Product Sync
4848+4949+Products and prices are defined in code and synced to Stripe via a GitHub Action. This provides version control, reproducibility, and enforces immutability.
5050+5151+**Directory structure:**
5252+```
5353+stripe/
5454+├── products.ts # Product/price definitions with entitlement metadata
5555+└── sync.ts # Script that ensures Stripe matches definitions
5656+```
5757+5858+**Product definition format** (`stripe/products.ts`):
5959+```typescript
6060+export const products = [
6161+ {
6262+ id: "pro_monthly_v1",
6363+ name: "Leaflet Pro (Monthly)",
6464+ prices: [
6565+ {
6666+ id: "pro_monthly_v1_usd",
6767+ currency: "usd",
6868+ unit_amount: 900, // $9.00
6969+ recurring: { interval: "month" },
7070+ },
7171+ ],
7272+ metadata: {
7373+ entitlements: JSON.stringify({ publication_analytics: true }),
7474+ },
7575+ },
7676+];
7777+```
7878+7979+**Sync script behavior** (`stripe/sync.ts`):
8080+1. Fetch existing products and prices by `id` (`stripe.products.retrieve(id)`, `stripe.prices.retrieve(id)`)
8181+2. For each definition:
8282+ - If missing in Stripe → create it with the custom `id`
8383+ - If exists and matches → no-op
8484+ - If exists but differs → update it to match the definition
8585+3. Never deletes products/prices
8686+8787+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.
8888+8989+**GitHub Action** (`.github/workflows/stripe-sync.yml`):
9090+- Triggers on push to `stripe/` directory
9191+- Runs sync against test mode automatically
9292+- Live mode sync requires manual workflow dispatch with approval
9393+9494+**Versioning/grandfathering:**
9595+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.
9696+9797+### Stripe Sync Strategy
9898+9999+**Webhook-driven** with **optimistic updates**:
100100+101101+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.
102102+103103+2. **Durable**: Stripe webhooks confirm and reconcile state. Handles edge cases (payment failures, disputes, subscription updates from Stripe dashboard).
104104+105105+Webhooks to handle:
106106+- `checkout.session.completed` — initial subscription created
107107+- `customer.subscription.created` — backup for subscription creation
108108+- `customer.subscription.updated` — plan changes, renewals, status changes
109109+- `customer.subscription.deleted` — subscription ended
110110+- `invoice.payment_failed` — payment issues
111111+- `customer.subscription.trial_will_end` — trial ending reminder (optional, for notifications)
112112+113113+### Entitlement Lifecycle
114114+115115+**Grant flow** (subscription activates or trial starts):
116116+1. Webhook receives event with subscription and product data
117117+2. Parse `entitlements` from product metadata
118118+3. Upsert `user_entitlements` rows with `expires_at` = `current_period_end`, `source` = `stripe:{subscription_id}`
119119+120120+**Renewal flow** (subscription renews):
121121+1. `customer.subscription.updated` webhook fires
122122+2. Update `expires_at` on all entitlements with matching `source`
123123+124124+**Cancellation flow** (user cancels but period remains):
125125+1. `customer.subscription.updated` with `cancel_at_period_end: true`
126126+2. Update `user_subscriptions.status`
127127+3. Entitlements remain valid until `expires_at` (already set to period end)
128128+129129+**Expiration flow** (subscription ends):
130130+1. `customer.subscription.deleted` webhook fires
131131+2. No action needed on `user_entitlements` — they naturally expire via `expires_at`
132132+3. Update `user_subscriptions.status` to `canceled`
133133+134134+Soft expiration via `expires_at` preserves audit history and handles the canceled-but-paid-through-period case without additional webhooks.
135135+136136+### Trials
137137+138138+Stripe-managed trials:
139139+1. Trial starts → subscription created with `status: trialing`
140140+2. Entitlements granted with `expires_at` = trial end date
141141+3. Trial converts → `expires_at` updated to subscription period end
142142+4. Trial lapses → entitlements expire naturally, no action needed
143143+144144+### Entitlement Checks
145145+146146+Extend `getIdentityData()` to join against `user_entitlements` and return active entitlements:
147147+148148+```typescript
149149+type IdentityData = {
150150+ id: string;
151151+ email: string;
152152+ atp_did: string;
153153+ // ... existing fields
154154+ entitlements: Record<string, {
155155+ granted_at: string;
156156+ expires_at: string | null;
157157+ source: string;
158158+ metadata: Record<string, unknown> | null;
159159+ }>;
160160+};
161161+```
162162+163163+Query filters to `expires_at IS NULL OR expires_at > NOW()`. The `useIdentityData()` hook exposes the same shape client-side.
164164+165165+Feature gating in server actions:
166166+167167+```typescript
168168+export async function getAnalyticsData() {
169169+ const identity = await getIdentityData();
170170+ if (!identity?.entitlements.analytics) {
171171+ return err("Pro subscription required");
172172+ }
173173+ // ... fetch analytics
174174+}
175175+```
176176+177177+### Initial Pro Features
178178+179179+Single entitlement at launch:
180180+- `publication_analytics` (boolean) — access to analytics features for
181181+ publications
182182+183183+### Webhook Endpoint
184184+185185+New API route at `app/api/webhooks/stripe/route.ts`:
186186+- Verify Stripe signature using `STRIPE_WEBHOOK_SECRET`
187187+- Dispatch to Inngest functions for processing (keeps webhook response fast, enables retries)
188188+189189+### Inngest Functions
190190+191191+New functions in `app/api/inngest/functions/`:
192192+- `stripe/handle-checkout-completed` — process successful checkout
193193+- `stripe/handle-subscription-updated` — sync subscription changes to entitlements
194194+- `stripe/handle-subscription-deleted` — update subscription status
195195+196196+Events to add to Inngest client:
197197+```typescript
198198+"stripe/checkout.session.completed": { data: { sessionId: string } }
199199+"stripe/customer.subscription.updated": { data: { subscriptionId: string } }
200200+"stripe/customer.subscription.deleted": { data: { subscriptionId: string } }
201201+```
202202+203203+### Environment Variables
204204+205205+```
206206+STRIPE_SECRET_KEY=sk_...
207207+STRIPE_WEBHOOK_SECRET=whsec_...
208208+```
209209+210210+Price IDs are defined in `stripe/products.ts` and imported directly — no env var needed.
211211+212212+## Open Questions
213213+214214+- **UI**: Where upgrade prompts appear, pricing page design, checkout flow UX — to be designed separately
215215+- **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
216216+217217+## Implementation
218218+219219+1. **Add Stripe packages**: Install `stripe` npm package
220220+221221+2. **Database migration**: Create `user_subscriptions` and `user_entitlements` tables with indexes on `identity_id` and `expires_at`
222222+223223+3. **Drizzle schema**: Add table definitions to `drizzle/schema.ts`
224224+225225+4. **Environment setup**: Add Stripe env vars to `.env.local` and deployment config
226226+227227+5. **Stripe webhook endpoint**: Create `app/api/webhooks/stripe/route.ts` with signature verification, dispatch events to Inngest
228228+229229+6. **Inngest events and functions**: Add event types to `app/api/inngest/client.ts`, create handler functions for checkout completed, subscription updated, subscription deleted
230230+231231+7. **Extend getIdentityData**: Join against `user_entitlements`, filter expired, return entitlements object
232232+233233+8. **Extend useIdentityData**: Ensure client-side hook receives entitlements from server
234234+235235+9. **Optimistic update action**: Create `actions/subscriptions/activateSubscription.ts` for immediate access after checkout
236236+237237+10. **Stripe sync infrastructure**:
238238+ - Create `stripe/products.ts` with Pro product definitions and entitlement metadata
239239+ - Create `stripe/sync.ts` script using Stripe Node SDK to reconcile definitions with Stripe
240240+ - Add `.github/workflows/stripe-sync.yml` for automated test mode sync on push, manual live mode sync
241241+242242+11. **Initial product sync**: Run sync script against both test and live mode to create Pro products
243243+244244+12. **Gate analytics**: Add entitlement check to analytics server actions