feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s

- Sidebar: dynamic brand-accent colors, brainstorm section restyled
- AI chat general: popup panel with expand/collapse, hides when contextual AI open
- AI chat contextual: tabs reordered (Actions first), X close button, height fix
- Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.)
- Global color cleanup: emerald/orange hardcoded → brand-accent dynamic
- Brainstorm page: orange → brand-accent throughout
- PageEntry animation component added to key pages
- Floating AI button: bg-brand-accent instead of hardcoded black
- i18n: all 15 locales updated with new AI/billing keys
- Billing: freemium quota tracking, BYOK, stripe subscription scaffolding
- Admin: integrated into new design
- AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
Antigravity
2026-05-16 12:59:30 +00:00
parent 1fcea6ed7d
commit bd495be965
2284 changed files with 395285 additions and 2327 deletions

View File

@@ -0,0 +1,389 @@
# Story 3.6: Stripe Subscription Tiers
Status: ready-for-dev
<!-- 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
- [ ] 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/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 → Prisma `Subscription` upsert
- [ ] Subtask 3.3: Map `stripePriceId``SubscriptionTier` 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/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
{{agent_model_name_version}}
### Debug Log References
### Completion Notes List
### File List
---
## Story Completion Status
- Story ID: 3.6
- Story Key: `3-6-stripe-subscription-tiers`
- File: `docs/3-6-stripe-subscription-tiers.md`
- Status: **ready-for-dev**
- Completion Note: Ultimate context engine analysis completed — comprehensive developer guide created.