5.6 KiB
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:
{
"tier": "analyst",
"founder_code": "FOUNDER2026"
}
Response (200):
{
"checkout_url": "https://checkout.stripe.com/c/pay/cs_...",
"session_id": "cs_..."
}
Logic:
- Look up user from auth token
- If user already has a Stripe customer ID, use it. Otherwise create one.
- Determine price ID:
- If
founder_codeis valid → use founder price - Otherwise → use standard price
- If
- Create Stripe Checkout Session with:
customer: Stripe customer IDline_items: [{ price: price_id, quantity: 1 }]mode: 'subscription'success_url:{BASE_URL}/scan?upgraded=truecancel_url:{BASE_URL}/pricingmetadata: { user_id, tier, is_founder }
- 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):
{
"portal_url": "https://billing.stripe.com/p/session/..."
}
GET /api/stripe/status
Returns current subscription status for the authenticated user.
Response (200):
{
"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:
- Checkout uses the founder price ID
users.founder_statusset totrue- 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-signatureheader - 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
POST /api/stripe/checkoutcreates a Stripe Checkout session and returns URL- Founder code applies founder pricing when valid
- Invalid/expired founder code falls back to standard pricing
- Webhook
checkout.session.completedupdates user tier and stripe_customer_id - Webhook
customer.subscription.deletedreverts user to free tier - Webhook signature verification rejects invalid signatures
POST /api/stripe/portalreturns Stripe Customer Portal URLGET /api/stripe/statusreturns current subscription info- Founder subscribers keep their rate permanently (locked price in Stripe)
- 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.