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

22 KiB
Raw Permalink Blame History

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.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

  • Task 1: Dependencies & Stripe client (AC: #9)
    • Subtask 1.1: Add stripe npm package (+ @stripe/stripe-js if embedded checkout client-side)
    • Subtask 1.2: Create lib/stripe.ts — singleton Stripe server client, getStripe(), price ID map from env
    • 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
  • 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', metadata userId, 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 for stripeCustomerId
    • Subtask 2.4: GET /api/billing/status — current tier, status, period end, cancelAtPeriodEnd (for billing page)
  • Task 3: Webhook & subscription sync (AC: #2, #5, #6)
    • Subtask 3.1: POST /api/billing/webhookraw 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 → Prisma Subscription upsert
    • Subtask 3.3: Map stripePriceIdSubscriptionTier via env price ID table (single function priceIdToTier(priceId))
    • 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)
  • 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 UsageMeter upgrade 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)
  • 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/webhook in Dev Agent Record
    • 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/currenttier 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.tsgetUserInfo, 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).
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.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.