Story 3.6: Stripe Subscription Tiers - Verified all pre-existing billing implementation (API routes, webhook, sync, UI) - Added Enterprise plan card with contact sales CTA (mailto:sales@momento.app) - Fixed lib/stripe.ts build error (lazy getStripe() + placeholder default) - Added enterpriseFeature1-5 i18n keys to all 15 locales - 22/22 unit tests pass, build succeeds - Story status: ready-for-dev → review
22 KiB
Story 3.6: Stripe Subscription Tiers
Status: review
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
- [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_urlis an acceptable fallback if embedded is blocked. - [AC2] Instant unlock: On successful payment (
checkout.session.completedand/orcustomer.subscription.created/updatedwebhook), the user'sSubscriptionrow is upserted with correcttier,status: ACTIVE,stripeCustomerId,stripeSubscriptionId,stripePriceId,currentPeriodStart,currentPeriodEnd.getEffectiveTier(userId)immediately returns the paid tier (no app restart). - [AC3] Entitlements wired: After tier change,
getUserQuotas()reflects newTIER_LIMITS(e.g. PRO chat/reformulate unlocked). SidebarUsageMeterrefreshes within one client poll cycle (invalidate React Query['usage','current']on success). - [AC4] Customer portal: Paid users can open Stripe Customer Portal (manage card, cancel, switch plan) from Settings → Billing without custom cancel UI in v1.
- [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_DUEretains tier untilcurrentPeriodEnd(matches existinggetEffectiveTierlogic). - [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).
- [AC7] Billing settings page: New
/settings/billing(nav item + i18n). Shows current plan, period end, upgrade cards (Pro/Business), portal button. Fixes broken link fromUsageMeter(/settings?tab=billing→/settings/billing). - [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.
- [AC9] Security & config: Stripe secrets only server-side; webhook raw body preserved for signature verification;
.env.exampledocuments allSTRIPE_*price IDs. Feature flagNEXT_PUBLIC_FEATURE_BILLING_ENABLEDgates checkout UI when false. - [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
- Task 1: Dependencies & Stripe client (AC: #9)
- Subtask 1.1: Add
stripenpm package (+@stripe/stripe-jsif embedded checkout client-side) - Subtask 1.2: Create
lib/stripe.ts— singletonStripeserver client,getStripe(), price ID map from env - Subtask 1.3: Extend
memento-note/.env.examplewithSTRIPE_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
- Subtask 1.1: Add
- Task 2: Billing API routes (AC: #1, #4, #9)
- Subtask 2.1:
POST /api/billing/create-checkout— body{ tier: 'PRO'|'BUSINESS', interval: 'month'|'year' }; create/find Stripe Customer; create Checkout Session (mode: 'subscription', metadatauserId,tier) - Subtask 2.2: Return
{ clientSecret }for embedded checkout OR{ url }for redirect mode - Subtask 2.3:
POST /api/billing/portal— Stripe Billing Portal session forstripeCustomerId - Subtask 2.4:
GET /api/billing/status— current tier, status, period end, cancelAtPeriodEnd (for billing page)
- Subtask 2.1:
- Task 3: Webhook & subscription sync (AC: #2, #5, #6)
- Subtask 3.1:
POST /api/billing/webhook— raw body route config (export const runtime = 'nodejs', disable JSON parse middleware for this route only) - Subtask 3.2: Implement
lib/billing/sync-subscription-from-stripe.ts— map Stripe subscription → PrismaSubscriptionupsert - Subtask 3.3: Map
stripePriceId→SubscriptionTiervia env price ID table (single functionpriceIdToTier(priceId)) - Subtask 3.4: Idempotency: store processed event ids in DB or skip if
updatedAton subscription already newer (minimal: trust Stripe retry + upsert bystripeSubscriptionId)
- Subtask 3.1:
- Task 4: Billing UI (AC: #1, #3, #7, #8)
- Subtask 4.1:
app/(main)/settings/billing/page.tsx+ client component — plan cards, interval toggle, embedded checkout mount - Subtask 4.2: Add Billing to
SettingsNav(CreditCard icon), i18n all 15 locale files - Subtask 4.3: Update
UsageMeterupgrade CTA:href="/settings/billing"; optional: open checkout modal directly from exhausted state (stretch) - Subtask 4.4: On checkout success callback: toast +
queryClient.invalidateQueries(['usage','current']) - Subtask 4.5: Enterprise card → contact sales (no checkout)
- Subtask 4.1:
- Task 5: Tests & local dev (AC: all)
- Subtask 5.1:
tests/unit/billing-price-map.test.ts— priceId → tier mapping - Subtask 5.2:
tests/unit/billing-sync.test.ts— mock Stripe subscription object → Prisma upsert payload - Subtask 5.3: Document
stripe listen --forward-to localhost:3000/api/billing/webhookin Dev Agent Record - Subtask 5.4:
npm run buildinmemento-note/
- Subtask 5.1:
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
Subscriptionmodel withstripeCustomerId,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
stripepackage inpackage.json. - No
lib/stripe.ts, no/api/billing/*routes. - No
/settings/billingpage;SettingsNavhas no billing section. - No webhook handler.
- New users have no
Subscriptionrow —getUserInforeturns{ tier: 'BASIC', status: 'INACTIVE' }(correct).
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.
// 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
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:
// POST /api/billing/create-checkout
// Response for embedded:
{ clientSecret: string, sessionId: string }
// Or redirect fallback:
{ url: string }
Metadata on Checkout Session / Subscription:
{ "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).
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
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.tsmemento-note/lib/billing/stripe-prices.tsmemento-note/lib/billing/sync-subscription-from-stripe.tsmemento-note/app/api/billing/create-checkout/route.tsmemento-note/app/api/billing/portal/route.tsmemento-note/app/api/billing/webhook/route.tsmemento-note/app/api/billing/status/route.tsmemento-note/app/(main)/settings/billing/page.tsxmemento-note/components/settings/billing-plans.tsx(or similar)memento-note/tests/unit/billing-price-map.test.tsmemento-note/tests/unit/billing-sync.test.ts
UPDATE
memento-note/package.json— stripe depsmemento-note/.env.examplememento-note/components/settings/SettingsNav.tsx— billing nav itemmemento-note/components/usage-meter.tsx— fix billing href; optional embedded checkout triggermemento-note/locales/*.json(15 files) —billing.*keysmemento-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
UsageLoganalytics pipeline / MRR dashboard — defer- Workspace-level billing (team pays once) — defer; subscription remains per User as schema defines today
- Changing
TIER_LIMITSnumbers — 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/currentshows PRO limits (e.g.chatlimit 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
canUseFeaturehot 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
stripeNode SDK (server). @stripe/stripe-js+@stripe/react-stripe-jsonly 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/(notapp/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+getEffectiveTieralready 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; returnsclient_secretfor front-end mount — fits UX "overlay" requirement. - Stripe webhooks: Use
constructEventon 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:
upsertonuserIdorstripeSubscriptionIdfor 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_KEYcheck at module evaluation time inlib/stripe.ts. Fixed by using placeholder defaultsk_test_placeholderwith lazygetStripe()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.tsbuild 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.tsalready existed — added lazygetStripe()function for runtime safety..env.examplealready documented allSTRIPE_*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.tsmaps Stripe statuses to Prisma enums correctly. Idempotent viaupsertonstripeSubscriptionId. - 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 withmailto:sales@momento.appCTA (no checkout). AddedenterpriseFeature1-5i18n 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:
- Set
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET, and price IDs in.env - Run
stripe listen --forward-to localhost:3000/api/billing/webhook - Use test card
4242 4242 4242 4242for checkout - Set
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=trueto 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 lazygetStripe()+ placeholder default for build safetymemento-note/components/settings/billing-plans.tsx— added Enterprise plan card (contact sales CTA), grid → 4 colsmemento-note/locales/*.json(15 files) — addedenterpriseFeature1-5keys
PRE-EXISTING (verified working):
memento-note/app/api/billing/create-checkout/route.tsmemento-note/app/api/billing/portal/route.tsmemento-note/app/api/billing/status/route.tsmemento-note/app/api/billing/webhook/route.tsmemento-note/lib/billing/stripe-prices.tsmemento-note/lib/billing/sync-subscription-from-stripe.tsmemento-note/app/(main)/settings/billing/page.tsxmemento-note/components/settings/billing-history.tsxmemento-note/components/settings/SettingsNav.tsxmemento-note/components/usage-meter.tsxmemento-note/tests/unit/billing-price-map.test.tsmemento-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.