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

166 lines
5.6 KiB
Markdown

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