feat: Feature 3.1 — Landing page + blog + Phase 3 specs
Next.js 14+ web app in web/ directory: - Landing page: Hero, How It Works, Features, 3-tier Pricing with founder badges, Footer with email capture - Blog system: MDX-powered, /blog index + /blog/[slug] pages, reading time, Open Graph tags, JSON-LD structured data - Auth pages: /login + /signup (Supabase Auth ready) - Design system: dark theme, grade colors (A/B/C/D), BetonBLK voice - 1 seed blog post: "How to Read Line Movement Like a Sharp" - Specs for 3.2 (Scan UI), 3.3 (Bet Tracker), 3.4 (Stripe) Build passes clean: 7 static pages generated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
# 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,BETONBLK,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,BETONBLK,EARLYBIRD
|
||||
FOUNDER_CODE_EXPIRY=2026-06-30
|
||||
BASE_URL=https://betonblk.com
|
||||
```
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user