# Story 3.6: Stripe Subscription Tiers Status: done ## Story As a user, I want to upgrade to a paid tier (Pro, Business, Enterprise) via Stripe, so that I can unlock higher quotas and features. **Epic:** Epic 3 — The SaaS Commercial Engine (Monetization & API Cost Protection) **FR coverage:** FR16 (paid subscription tiers via payment gateway), FR13 (usage meter reflects new limits after upgrade) **NFR coverage:** NFR-SC2 (post-checkout entitlement reads remain Redis-fast; tier comes from PG, not Stripe per request) --- ## Acceptance Criteria 1. [AC1] **Checkout Pro & Business:** Authenticated users on BASIC (or expired paid) can start checkout for **Pro** or **Business**, **monthly or annual**, via Stripe. Checkout uses Stripe **Embedded Checkout** (iframe/modal overlay) when possible so the user does not leave the editor context; hosted redirect with `success_url` / `cancel_url` is an acceptable fallback if embedded is blocked. 2. [AC2] **Instant unlock:** On successful payment (`checkout.session.completed` and/or `customer.subscription.created/updated` webhook), the user's `Subscription` row is upserted with correct `tier`, `status: ACTIVE`, `stripeCustomerId`, `stripeSubscriptionId`, `stripePriceId`, `currentPeriodStart`, `currentPeriodEnd`. `getEffectiveTier(userId)` immediately returns the paid tier (no app restart). 3. [AC3] **Entitlements wired:** After tier change, `getUserQuotas()` reflects new `TIER_LIMITS` (e.g. PRO chat/reformulate unlocked). Sidebar `UsageMeter` refreshes within one client poll cycle (invalidate React Query `['usage','current']` on success). 4. [AC4] **Customer portal:** Paid users can open Stripe Customer Portal (manage card, cancel, switch plan) from **Settings → Billing** without custom cancel UI in v1. 5. [AC5] **Lifecycle webhooks:** Webhook handler verifies `Stripe-Signature`, is idempotent, and handles at minimum: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`. `PAST_DUE` retains tier until `currentPeriodEnd` (matches existing `getEffectiveTier` logic). 6. [AC6] **Downgrade / cancel:** When subscription ends or is canceled at period end, tier reverts to BASIC after period end; Redis usage keys are **not** deleted (monthly counters stay; limits change via tier only). 7. [AC7] **Billing settings page:** New `/settings/billing` (nav item + i18n). Shows current plan, period end, upgrade cards (Pro/Business), portal button. Fixes broken link from `UsageMeter` (`/settings?tab=billing` → `/settings/billing`). 8. [AC8] **Enterprise:** No self-serve Stripe checkout in this story — show **Contact sales** CTA (mailto or static link). ENTERPRISE tier assignment remains manual/admin for now. 9. [AC9] **Security & config:** Stripe secrets only server-side; webhook raw body preserved for signature verification; `.env.example` documents all `STRIPE_*` price IDs. Feature flag `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` gates checkout UI when false. 10. [AC10] **Regression:** Stories 3.1–3.5 unchanged for users without paid subscription. BYOK tier gating (3.5) respects post-Stripe tier. Host-pays (3.4) bills host tier limits. No destructive DB commands. --- ## Tasks / Subtasks - [x] Task 1: Dependencies & Stripe client (AC: #9) - [x] Subtask 1.1: Add `stripe` npm package (+ `@stripe/stripe-js` if embedded checkout client-side) - [x] Subtask 1.2: Create `lib/stripe.ts` — singleton `Stripe` server client, `getStripe()`, price ID map from env - [x] Subtask 1.3: Extend `memento-note/.env.example` with `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_PRO_MONTHLY`, `STRIPE_PRICE_PRO_ANNUAL`, `STRIPE_PRICE_BUSINESS_MONTHLY`, `STRIPE_PRICE_BUSINESS_ANNUAL`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`, `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` - [x] Task 2: Billing API routes (AC: #1, #4, #9) - [x] Subtask 2.1: `POST /api/billing/create-checkout` — body `{ tier: 'PRO'|'BUSINESS', interval: 'month'|'year' }`; create/find Stripe Customer; create Checkout Session (`mode: 'subscription'`, metadata `userId`, `tier`) - [x] Subtask 2.2: Return `{ clientSecret }` for embedded checkout OR `{ url }` for redirect mode - [x] Subtask 2.3: `POST /api/billing/portal` — Stripe Billing Portal session for `stripeCustomerId` - [x] Subtask 2.4: `GET /api/billing/status` — current tier, status, period end, cancelAtPeriodEnd (for billing page) - [x] Task 3: Webhook & subscription sync (AC: #2, #5, #6) - [x] Subtask 3.1: `POST /api/billing/webhook` — **raw body** route config (`export const runtime = 'nodejs'`, disable JSON parse middleware for this route only) - [x] Subtask 3.2: Implement `lib/billing/sync-subscription-from-stripe.ts` — map Stripe subscription → Prisma `Subscription` upsert - [x] Subtask 3.3: Map `stripePriceId` → `SubscriptionTier` via env price ID table (single function `priceIdToTier(priceId)`) - [x] Subtask 3.4: Idempotency: store processed event ids in DB or skip if `updatedAt` on subscription already newer (minimal: trust Stripe retry + upsert by `stripeSubscriptionId`) - [x] Task 4: Billing UI (AC: #1, #3, #7, #8) - [x] Subtask 4.1: `app/(main)/settings/billing/page.tsx` + client component — plan cards, interval toggle, embedded checkout mount - [x] Subtask 4.2: Add Billing to `SettingsNav` (CreditCard icon), i18n **all 15** locale files - [x] Subtask 4.3: Update `UsageMeter` upgrade CTA: `href="/settings/billing"`; optional: open checkout modal directly from exhausted state (stretch) - [x] Subtask 4.4: On checkout success callback: toast + `queryClient.invalidateQueries(['usage','current'])` - [x] Subtask 4.5: Enterprise card → contact sales (no checkout) - [x] Task 5: Tests & local dev (AC: all) - [x] Subtask 5.1: `tests/unit/billing-price-map.test.ts` — priceId → tier mapping - [x] Subtask 5.2: `tests/unit/billing-sync.test.ts` — mock Stripe subscription object → Prisma upsert payload - [x] Subtask 5.3: Document `stripe listen --forward-to localhost:3000/api/billing/webhook` in Dev Agent Record - [x] Subtask 5.4: `npm run build` in `memento-note/` --- ## Dev Notes ### Epic context | Story | Relevance to 3.6 | |-------|------------------| | 3.1 | `Subscription` model, `getEffectiveTier`, `TIER_LIMITS`, `UsageMeter`, `/api/usage/current` — **tier source of truth after Stripe** | | 3.2–3.3 | Unchanged; paid tier unlocks more features in `TIER_LIMITS` | | 3.4 | Host quota uses host's `getEffectiveTier(billingOwnerId)` — upgrades host account unlocks guest AI | | 3.5 | BYOK provider lists tier-gated — **on downgrade** deactivate disallowed BYOK keys (minimal: reject saves; optional `isActive=false`) | | 4.x | GDPR/SSO separate epics | ### Critical brownfield reality **Already implemented (do NOT rebuild):** - Prisma `Subscription` model with `stripeCustomerId`, `stripeSubscriptionId`, `stripePriceId`, period fields, statuses (`ACTIVE`, `PAST_DUE`, `CANCELED`, `TRIALING`, `INACTIVE`). - `lib/entitlements.ts` — `getUserInfo`, `getEffectiveTier`, `TIER_LIMITS`, `getUserQuotas` (Redis + tier). - `GET /api/usage/current` — powers sidebar meter. - `components/usage-meter.tsx` — paywall modal; links to **broken** `/settings?tab=billing`. **Not implemented (this story):** - No `stripe` package in `package.json`. - No `lib/stripe.ts`, no `/api/billing/*` routes. - No `/settings/billing` page; `SettingsNav` has no billing section. - No webhook handler. - New users have **no** `Subscription` row — `getUserInfo` returns `{ tier: 'BASIC', status: 'INACTIVE' }` (correct). ```653:674:memento-note/prisma/schema.prisma model Subscription { id String @id @default(cuid()) userId String @unique tier SubscriptionTier @default(BASIC) status SubscriptionStatus @default(ACTIVE) stripeCustomerId String? @unique stripeSubscriptionId String? @unique stripePriceId String? // ... currentPeriodStart DateTime currentPeriodEnd DateTime user User @relation(...) } ``` **Important:** First webhook upsert must set valid `currentPeriodStart` / `currentPeriodEnd` from Stripe subscription object (`current_period_start`, `current_period_end`). ### Tier ↔ Stripe price mapping Align with `docs/gtm-pricing-strategy.md`: | Tier | Monthly (EUR) | Annual (EUR) | Self-serve in 3.6 | |------|-----------------|--------------|-------------------| | BASIC | Free | — | N/A (default) | | PRO | €9.90 | €99 | Yes | | BUSINESS | €29.90 | €299 | Yes | | ENTERPRISE | Custom | Custom | **No** (contact sales) | Create products/prices in Stripe Dashboard (test mode first). Map via env vars — **never hardcode** `price_xxx` in source. ```typescript // lib/billing/stripe-prices.ts (recommended) export function resolvePriceId(tier: 'PRO' | 'BUSINESS', interval: 'month' | 'year'): string { const map = { PRO: { month: process.env.STRIPE_PRICE_PRO_MONTHLY!, year: process.env.STRIPE_PRICE_PRO_ANNUAL! }, BUSINESS: { month: process.env.STRIPE_PRICE_BUSINESS_MONTHLY!, year: process.env.STRIPE_PRICE_BUSINESS_ANNUAL! }, } return map[tier][interval] } export function priceIdToTier(priceId: string): SubscriptionTier | null { // compare against all env price ids } ``` ### `getEffectiveTier` — preserve existing behavior ```163:175:memento-note/lib/entitlements.ts export async function getEffectiveTier(userId: string): Promise { const { tier, status, currentPeriodEnd } = await getUserInfo(userId); if (status === 'ACTIVE' || status === 'TRIALING') return tier; if ((status === 'PAST_DUE' || status === 'CANCELED') && currentPeriodEnd) { if (new Date() < new Date(currentPeriodEnd)) return tier; } return 'BASIC'; } ``` Webhook mapping: | Stripe status | Prisma `SubscriptionStatus` | |---------------|------------------------------| | active | ACTIVE | | trialing | TRIALING | | past_due | PAST_DUE | | canceled, unpaid | CANCELED (keep period end) | | incomplete_expired | INACTIVE → effective BASIC | ### Checkout flow (UX-aligned) Per `docs/ux-design-specification.md`: - **Zero-redirect preferred:** Stripe Embedded Checkout in modal over editor/settings. - **Success:** Close modal → invalidate usage query → meter shows higher limits / "Unlimited" where applicable. - **No new topbar** — billing lives under existing settings shell (`settings/layout.tsx`). Suggested API contract: ```typescript // POST /api/billing/create-checkout // Response for embedded: { clientSecret: string, sessionId: string } // Or redirect fallback: { url: string } ``` Metadata on Checkout Session / Subscription: ```json { "userId": "", "tier": "PRO" } ``` Always set `customer_email` from session user if creating new Stripe Customer. ### Webhook route (Next.js App Router) Use **one** path: `app/api/billing/webhook/route.ts` (matches `saas-deployment-prep.md` §3.3). ```typescript import { headers } from 'next/headers' import { stripe } from '@/lib/stripe' export async function POST(req: Request) { const body = await req.text() // NOT req.json() const sig = (await headers()).get('stripe-signature')! const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!) // dispatch... } ``` Register webhook events in Stripe Dashboard for test endpoint. ### Customer portal ```typescript const portal = await stripe.billingPortal.sessions.create({ customer: subscription.stripeCustomerId, return_url: `${origin}/settings/billing`, }) return { url: portal.url } ``` ### Files — expected touch list **NEW** - `memento-note/lib/stripe.ts` - `memento-note/lib/billing/stripe-prices.ts` - `memento-note/lib/billing/sync-subscription-from-stripe.ts` - `memento-note/app/api/billing/create-checkout/route.ts` - `memento-note/app/api/billing/portal/route.ts` - `memento-note/app/api/billing/webhook/route.ts` - `memento-note/app/api/billing/status/route.ts` - `memento-note/app/(main)/settings/billing/page.tsx` - `memento-note/components/settings/billing-plans.tsx` (or similar) - `memento-note/tests/unit/billing-price-map.test.ts` - `memento-note/tests/unit/billing-sync.test.ts` **UPDATE** - `memento-note/package.json` — stripe deps - `memento-note/.env.example` - `memento-note/components/settings/SettingsNav.tsx` — billing nav item - `memento-note/components/usage-meter.tsx` — fix billing href; optional embedded checkout trigger - `memento-note/locales/*.json` (15 files) — `billing.*` keys - `memento-note/components/settings/index.ts` — export if needed **READ BEFORE MODIFY** | File | Current state | What 3.6 changes | |------|---------------|------------------| | `lib/entitlements.ts` | Tier from `Subscription` table | No logic change unless adding `TRIALING` tier limits (use paid tier while TRIALING) | | `components/usage-meter.tsx` | Modal → broken `/settings?tab=billing` | Point to `/settings/billing`; refresh quotas on return | | `app/(main)/settings/layout.tsx` | Shell for settings | Host new billing page | | `prisma/schema.prisma` | Subscription model exists | **No schema change required** unless adding `StripeWebhookEvent` idempotency table (optional) | ### Scope boundaries (do NOT implement in 3.6) - Seat-based Enterprise billing / Stripe Licensing API (`saas-deployment-prep.md` §D) — defer - AI credit add-ons (+100 packs) — defer - Trial without card (14-day) — optional flag only; no custom trial logic unless Stripe Dashboard trial configured on prices - `UsageLog` analytics pipeline / MRR dashboard — defer - Workspace-level billing (team pays once) — defer; subscription remains **per User** as schema defines today - Changing `TIER_LIMITS` numbers — out of scope (product already codified in entitlements.ts) - Tax/VAT invoicing beyond Stripe defaults ### Product decisions (document in Dev Agent Record) | Decision | Recommendation | |----------|----------------| | Checkout UX | Embedded Checkout in modal; redirect fallback OK | | Upgrade path | BASIC → PRO or BUSINESS; BUSINESS upgrade from PRO via new checkout or portal | | Proration | Let Stripe handle via Dashboard settings | | Currency | EUR default prices in Stripe; multi-currency via Stripe adaptive pricing later | | Webhook idempotency | Upsert by `stripeSubscriptionId` sufficient for v1 | | 3.5 interaction | On tier downgrade in webhook, call helper to deactivate BYOK keys for disallowed providers | ### Testing standards - Unit tests with mocked Stripe objects (no live API in CI). - Manual: Stripe CLI webhook forward + test card `4242...`. - Verify: after webhook, `GET /api/usage/current` shows PRO limits (e.g. `chat` limit 100). - Verify: canceled sub past `currentPeriodEnd` → BASIC limits. ### Database safety Per `CLAUDE.md`: backup before any migration. **This story should not require schema migration** if `Subscription` model is sufficient. If adding `StripeEvent` table for idempotency, backup + `prisma migrate dev` only. --- ## Dev Agent Guardrails ### Technical requirements - **Stripe API version:** Pin in `lib/stripe.ts` (e.g. `apiVersion: '2024-11-20.acacia'` or latest stable at implementation time — check Stripe docs). - **Auth:** All billing routes except webhook require `auth()` session. - **Webhook:** No session auth; signature only. - **Performance:** Do not call Stripe API inside `canUseFeature` hot path — tier already in PG. - **402 responses:** Unchanged; upgrade CTA routes to billing page. ### Architecture compliance - Brownfield Next.js under `memento-note/`. - Billing domain in `lib/billing/*` + `app/api/billing/*` — do not scatter Stripe calls in chat/brainstorm routes. - i18n: zero hardcoded UI strings (FR/EN reference, all 15 locales). ### Library / framework requirements - Official `stripe` Node SDK (server). - `@stripe/stripe-js` + `@stripe/react-stripe-js` only if using Embedded Checkout on client. - Alternative: redirect Checkout only (fewer deps, worse UX) — document choice in Dev Agent Record. ### File structure requirements - Webhook and checkout routes under `app/api/billing/` (not `app/api/webhook/stripe/` — avoid duplicate handlers). --- ## Previous Story Intelligence **Source:** `docs/3-5-secure-byok-management.md` - Tier gating for BYOK providers: PRO vs BUSINESS lists — **webhook tier downgrade** should deactivate keys for providers no longer allowed. - `QuotaExceededError.byokConfigured` — billing page is separate upsell path from BYOK. **Source:** `docs/3-4-host-pays-session-logic.md` - Guest AI uses host tier; upgrading host via Stripe unlocks collaborative AI for guests. **Source:** `docs/3-1-freemium-quota-tracking.md` - `Subscription` + `getEffectiveTier` already built; 3.6 fills the **payment** side. - Review note: period fields on Subscription — webhook must populate from Stripe. **Source:** `docs/3-1` code review deferrals - "Subscription requires period fields" — addressed by Stripe webhook sync in 3.6. --- ## Git Intelligence Summary | Commit | Insight | |--------|---------| | `1fcea6e` | Brainstorm/AI surfaces live — paid tier unlocks `brainstorm_*` limits for hosts | | `41596c2` | Env-based provider keys pattern — mirror for `STRIPE_*` env vars | | `195e845` | Security-conscious patterns — webhook signature verification mandatory | --- ## Latest Technical Information - **Stripe Embedded Checkout (2024+):** Supports `ui_mode: 'embedded'` on Checkout Sessions; returns `client_secret` for front-end mount — fits UX "overlay" requirement. - **Stripe webhooks:** Use `constructEvent` on raw body; subscribe to subscription lifecycle events. - **Next.js App Router:** For webhook route, read raw body with `req.text()`; do not use body parser middleware that consumes JSON globally for that route. - **Prisma:** `upsert` on `userId` or `stripeSubscriptionId` for idempotent sync. --- ## Project Context Reference | Document | Use | |----------|-----| | `docs/epics.md` | Story 3.6 AC + FR16 | | `docs/gtm-pricing-strategy.md` | Prices, annual discount, Enterprise positioning | | `docs/ux-design-specification.md` | Zero-redirect, overlay checkout, meter refresh | | `memento-note/docs/saas-deployment-prep.md` | §3.3 endpoints, §4.1 Stripe products, §B env vars | | `docs/3-1-freemium-quota-tracking.md` | Entitlements + UsageMeter foundation | | `docs/3-5-secure-byok-management.md` | Tier downgrade + BYOK interaction | | `docs/3-4-host-pays-session-logic.md` | Host tier drives guest billing | | `CLAUDE.md` | Database safety | | `AGENTS.md` | i18n 15 locales, French communication | --- ## Dev Agent Record ### Agent Model Used glm-5.1 (via opencode) ### Debug Log References - Build failed initially due to `STRIPE_SECRET_KEY` check at module evaluation time in `lib/stripe.ts`. Fixed by using placeholder default `sk_test_placeholder` with lazy `getStripe()` for runtime validation. - Most of the story was **pre-implemented** — Tasks 1-3 backend, Task 4 UI, and Task 5 tests were already in place. Only Task 4.5 (Enterprise card) and the `lib/stripe.ts` build fix were needed. ### Completion Notes List - **Task 1:** `stripe` (^22.1.1), `@stripe/stripe-js` (^9.5.0), `@stripe/react-stripe-js` (^6.3.0) already installed. `lib/stripe.ts` already existed — added lazy `getStripe()` function for runtime safety. `.env.example` already documented all `STRIPE_*` vars. - **Task 2:** All 4 billing API routes (`create-checkout`, `portal`, `status`, `webhook`) already implemented with auth, embedded checkout support, and proper error handling. - **Task 3:** Webhook handler handles `checkout.session.completed`, `customer.subscription.created/updated/deleted`, `invoice.payment_failed`. `sync-subscription-from-stripe.ts` maps Stripe statuses to Prisma enums correctly. Idempotent via `upsert` on `stripeSubscriptionId`. - **Task 4:** Billing page with plan cards, interval toggle, embedded checkout modal, usage overview grid, billing info panel. SettingsNav includes Billing with CreditCard icon. UsageMeter links to `/settings/billing`. **Added Enterprise card** with `mailto:sales@momento.app` CTA (no checkout). Added `enterpriseFeature1-5` i18n keys to all 15 locales. - **Task 5:** 22 unit tests pass (billing-price-map: 10, billing-sync: 12). Build succeeds. ### Local Development To test Stripe integration locally: 1. Set `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, and price IDs in `.env` 2. Run `stripe listen --forward-to localhost:3000/api/billing/webhook` 3. Use test card `4242 4242 4242 4242` for checkout 4. Set `NEXT_PUBLIC_FEATURE_BILLING_ENABLED=true` to show billing UI ### File List **NEW (added in this session):** - (none — Enterprise card added inline to existing file) **MODIFIED:** - `memento-note/lib/stripe.ts` — added lazy `getStripe()` + placeholder default for build safety - `memento-note/components/settings/billing-plans.tsx` — added Enterprise plan card (contact sales CTA), grid → 4 cols - `memento-note/locales/*.json` (15 files) — added `enterpriseFeature1-5` keys **PRE-EXISTING (verified working):** - `memento-note/app/api/billing/create-checkout/route.ts` - `memento-note/app/api/billing/portal/route.ts` - `memento-note/app/api/billing/status/route.ts` - `memento-note/app/api/billing/webhook/route.ts` - `memento-note/lib/billing/stripe-prices.ts` - `memento-note/lib/billing/sync-subscription-from-stripe.ts` - `memento-note/app/(main)/settings/billing/page.tsx` - `memento-note/components/settings/billing-history.tsx` - `memento-note/components/settings/SettingsNav.tsx` - `memento-note/components/usage-meter.tsx` - `memento-note/tests/unit/billing-price-map.test.ts` - `memento-note/tests/unit/billing-sync.test.ts` ### Change Log - 2026-05-16: Story 3.6 completed — verified all pre-existing billing implementation, added Enterprise card with contact sales CTA, fixed lib/stripe.ts build issue, added Enterprise i18n keys to all 15 locales. 22/22 unit tests pass, build succeeds. --- ## Story Completion Status - Story ID: 3.6 - Story Key: `3-6-stripe-subscription-tiers` - File: `docs/3-6-stripe-subscription-tiers.md` - Status: **review** - Completion Note: Ultimate context engine analysis completed — comprehensive developer guide created.