Files
vyndr/specs/feature-3-4-stripe.md
T

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:

  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):

{
  "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:

  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.