Files
Momento/docs/3-6-stripe-subscription-tiers.md
Antigravity e2672cd2c2
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped
feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 14:27:29 +00:00

433 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Story 3.6: Stripe Subscription Tiers
Status: done
<!-- Ultimate context engine analysis completed - comprehensive developer guide created -->
## 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.13.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.23.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<SubscriptionTier> {
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": "<cuid>", "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.