# Feature 3.4 — Stripe Integration ## Overview Subscription billing for Analyst ($19.99/mo) and Desk ($49.99/mo) tiers. Founder codes lock a permanent discounted rate. Handles checkout, subscription management, webhooks for tier changes, and cancellation. ## Dependencies - Feature 1.4 — Database Schema (`users.tier`, `users.stripe_customer_id`, `users.founder_status`) - Feature 3.1 — Landing Page (pricing cards link to checkout) ## Stripe Products | Product | Price ID (create in Stripe) | Monthly | Founder Monthly | |---|---|---|---| | Analyst | price_analyst_monthly | $19.99 | $14.99 | | Analyst Founder | price_analyst_founder | $14.99 | — | | Desk | price_desk_monthly | $49.99 | $34.99 | | Desk Founder | price_desk_founder | $34.99 | — | Founder pricing: separate Stripe Price objects with `metadata.is_founder: true`. Once subscribed to a founder price, it's locked — no price increase even if founder pricing ends. ## Endpoints ### POST /api/stripe/checkout Creates a Stripe Checkout session. Redirects user to Stripe's hosted checkout. **Request body:** ```json { "tier": "analyst", "founder_code": "FOUNDER2026" } ``` **Response (200):** ```json { "checkout_url": "https://checkout.stripe.com/c/pay/cs_...", "session_id": "cs_..." } ``` **Logic:** 1. Look up user from auth token 2. If user already has a Stripe customer ID, use it. Otherwise create one. 3. Determine price ID: - If `founder_code` is valid → use founder price - Otherwise → use standard price 4. Create Stripe Checkout Session with: - `customer`: Stripe customer ID - `line_items`: [{ price: price_id, quantity: 1 }] - `mode`: 'subscription' - `success_url`: `{BASE_URL}/scan?upgraded=true` - `cancel_url`: `{BASE_URL}/pricing` - `metadata`: { user_id, tier, is_founder } 5. Return checkout URL ### POST /api/stripe/webhook Stripe webhook endpoint. Handles subscription lifecycle events. **Events handled:** | Event | Action | |---|---| | `checkout.session.completed` | Set user tier + stripe_customer_id + founder_status | | `customer.subscription.updated` | Update tier if plan changed | | `customer.subscription.deleted` | Revert user to free tier | | `invoice.payment_failed` | Log warning, Stripe handles retry | **Webhook verification:** Validate `stripe-signature` header using webhook signing secret. ### POST /api/stripe/portal Creates a Stripe Customer Portal session for subscription management (cancel, update payment, view invoices). **Response (200):** ```json { "portal_url": "https://billing.stripe.com/p/session/..." } ``` ### GET /api/stripe/status Returns current subscription status for the authenticated user. **Response (200):** ```json { "tier": "analyst", "is_founder": true, "subscription_status": "active", "current_period_end": "2026-04-21T00:00:00Z", "cancel_at_period_end": false } ``` ## Founder Code System Simple validation — not a full promo code engine: ``` VALID_FOUNDER_CODES = ['FOUNDER2026', 'BETONBLK', 'EARLYBIRD'] ``` Stored in environment variable: `FOUNDER_CODES=FOUNDER2026,VYNDR,EARLYBIRD` When a valid founder code is used: 1. Checkout uses the founder price ID 2. `users.founder_status` set to `true` 3. Founder badge displayed in UI Founder codes have an expiry date (env var `FOUNDER_CODE_EXPIRY`). After expiry, new subscriptions use standard pricing, but existing founder subscribers keep their rate. ## Webhook Security - Stripe sends signature in `stripe-signature` header - Verify using `stripe.webhooks.constructEvent(body, sig, webhook_secret)` - Raw body required (not parsed JSON) — needs `express.raw()` middleware on webhook route - Webhook secret stored in env: `STRIPE_WEBHOOK_SECRET` ## Service Architecture ``` src/ ├── services/ │ └── stripeService.js # Checkout, portal, webhook handling, tier updates ├── routes/ │ └── stripe.js # POST /checkout, POST /webhook, POST /portal, GET /status ``` ## Environment Variables ``` STRIPE_SECRET_KEY=sk_... STRIPE_WEBHOOK_SECRET=whsec_... FOUNDER_CODES=FOUNDER2026,VYNDR,EARLYBIRD FOUNDER_CODE_EXPIRY=2026-06-30 BASE_URL=https://vyndr.app ``` ## Acceptance Criteria 1. `POST /api/stripe/checkout` creates a Stripe Checkout session and returns URL 2. Founder code applies founder pricing when valid 3. Invalid/expired founder code falls back to standard pricing 4. Webhook `checkout.session.completed` updates user tier and stripe_customer_id 5. Webhook `customer.subscription.deleted` reverts user to free tier 6. Webhook signature verification rejects invalid signatures 7. `POST /api/stripe/portal` returns Stripe Customer Portal URL 8. `GET /api/stripe/status` returns current subscription info 9. Founder subscribers keep their rate permanently (locked price in Stripe) 10. All Stripe operations use the service role Supabase client (bypasses RLS) ## Test Plan ### Unit Tests (stripeService.js) - Correct price ID selected for analyst + founder code - Correct price ID selected for desk without founder code - Expired founder code falls back to standard - Tier mapping: checkout metadata → correct tier string - Webhook event parsing: checkout.session.completed → tier update ### Integration Tests - Checkout flow: request → returns valid URL - Webhook: mock checkout.session.completed → user tier updated in DB - Webhook: mock subscription.deleted → user reverted to free - Webhook: invalid signature → 400 - Portal: returns URL for existing customer - Status: returns correct tier and subscription info - Auth required on checkout/portal/status (not on webhook) ## Open Questions - **Stripe test mode:** Build and test entirely in Stripe test mode. Switch to live keys at launch. No code changes needed — just env var swap.