From eb443232fb2354a521a9227a6fae9fab0576fa42 Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 22 Mar 2026 10:30:42 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Feature=203.4=20=E2=80=94=20Stripe=20in?= =?UTF-8?q?tegration=20with=20founder=20code=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscription billing: - POST /api/stripe/checkout — creates Stripe Checkout session - POST /api/stripe/webhook — handles lifecycle events (raw body) - POST /api/stripe/portal — customer self-service management - GET /api/stripe/status — current subscription info Founder code system: - Validates codes against env var list with expiry date - Routes to founder Stripe Price IDs (locked rate for life) - 4 price objects: analyst, analyst-founder, desk, desk-founder Webhook events handled: - checkout.session.completed → tier update + founder status - customer.subscription.deleted → revert to free tier - invoice.payment_failed → logged Lazy Stripe init (no API key required at import time). Raw body middleware for webhook signature verification. 16 new tests, 237 total (210 Node.js + 27 Python), all passing. Phase 3 Web MVP COMPLETE. All roadmap features shipped. Co-Authored-By: Claude Opus 4.6 (1M context) --- BUILD-STATE.md | 66 ++++++------ package-lock.json | 20 +++- package.json | 3 +- src/app.js | 6 ++ src/routes/stripe.js | 90 +++++++++++++++++ src/services/stripeService.js | 166 +++++++++++++++++++++++++++++++ tests/integration/stripe.test.js | 157 +++++++++++++++++++++++++++++ tests/unit/stripeService.test.js | 57 +++++++++++ 8 files changed, 525 insertions(+), 40 deletions(-) create mode 100644 src/routes/stripe.js create mode 100644 src/services/stripeService.js create mode 100644 tests/integration/stripe.test.js create mode 100644 tests/unit/stripeService.test.js diff --git a/BUILD-STATE.md b/BUILD-STATE.md index cbb277c..5872677 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -4,7 +4,7 @@ 2026-03-22 ## Current Phase -Phase 3 — Web MVP (IN PROGRESS) +Phase 3 — Web MVP (COMPLETE) ## What Has Shipped @@ -13,41 +13,35 @@ Phase 3 — Web MVP (IN PROGRESS) - Feature 1.2 — NBA_API Stats Wrapper (FastAPI microservice) - Feature 1.3 — Prop Analysis Engine (6-step grading pipeline) - Feature 1.4 — Database Schema (9 tables, RLS, triggers) -- Feature 1.5 — Bet Submission (3 methods) +- Feature 1.5 — Bet Submission (3 methods + performance tracking) ### Phase 2 — Core Product (COMPLETE) - Feature 2.1 — Parlay Scan (correlation detection, monetization) - Feature 2.2 — Line Movement + Cascade Detection -### Feature 3.1 — Landing Page + Blog (COMPLETE) -- Next.js 14+ App Router in web/ directory -- Landing page: Hero, How It Works, Features, Pricing (3 tiers + founder badges), Footer with email capture -- Blog: MDX-powered at /blog with [slug] dynamic routes, reading time, OG tags, JSON-LD -- Auth pages: /login, /signup (Supabase Auth ready) -- 1 seed blog post: "How to Read Line Movement Like a Sharp" -- Design system: dark theme, Inter + JetBrains Mono, grade colors (A=green, B=yellow, C=orange, D=red) -- BetonBLK voice throughout all copy -- Build passes: 7 static pages generated - -## Test Summary -- Node.js: 194 tests passing (backend unit + integration) -- Python: 27 tests passing -- Total: 221 tests, all green -- Both Next.js builds: clean - -## What's Next -- Feature 3.2 — Scan UI (/scan page) -- Feature 3.3 — Bet Tracker UI (/tracker page) -- Feature 3.4 — Stripe Integration +### Phase 3 — Web MVP (COMPLETE) +- Feature 3.1 — Landing Page + Blog (Next.js, MDX, BetonBLK voice, SEO) +- Feature 3.2 — Scan UI (leg builder, grade results, upgrade pitch) +- Feature 3.3 — Bet Tracker (performance dashboard, quick slip, settle flow) +- Feature 3.4 — Stripe Integration (checkout, webhooks, portal, founder codes) ## Also Shipped (Separate Repo) ### Mastermind Agency Site -- Next.js 14+ at /home/kev/mastermind/agency-site/ -- Glitch aesthetic: scan lines, CRT flicker, RGB split, noise grain -- JetBrains Mono throughout, cyan/magenta accents on dark (#050505) -- Pages: Home (hero + services + projects + process + contact), BetonBLK case study, Contact -- Respects prefers-reduced-motion -- Build passes: 5 static pages generated +- `/home/kev/mastermind/agency-site/` +- Glitch aesthetic, scan lines, CRT flicker, JetBrains Mono +- Home, BetonBLK case study, Contact pages + +## Test Summary +- Node.js: 210 tests passing (unit + integration) +- Python: 27 tests passing +- Total: 237 tests, all green +- Both Next.js projects build clean + +## All Features Complete +Every feature on the roadmap has shipped: +- 7 backend features (Phase 1 + 2 + 1.5) +- 4 frontend features (Phase 3) +- 1 separate agency portfolio site ## Active Blockers - BLOCKER-003: WSL2 DNS cannot resolve *.supabase.co @@ -60,13 +54,9 @@ Phase 3 — Web MVP (IN PROGRESS) - 221 backend tests passing ### Session 7 — 2026-03-22 -- Built Feature 3.1: BetonBLK Landing Page + Blog (web/ directory) - - Hero, HowItWorks, Features, Pricing, Footer, GradeCard components - - Blog system: MDX parsing, index page, [slug] pages, SEO tags - - Auth pages: login, signup - - 1 seed blog post -- Built Mastermind Agency Site (separate repo: agency-site/) - - GlitchText, ProjectCard components - - Glitch CSS: scan lines, CRT flicker, RGB split, noise grain - - Home, BetonBLK case study, Contact pages -- Both Next.js projects build clean +- Built Feature 3.1: Landing page + blog (Hero, Pricing, Blog/MDX, Auth pages) +- Built Mastermind Agency Site (glitch aesthetic, 5 pages) +- Built Features 3.2 + 3.3: Scan UI + Bet Tracker +- Built Feature 3.4: Stripe Integration (checkout, webhooks, portal, founder codes) +- ALL FEATURES COMPLETE +- Total: 237 tests (210 Node.js + 27 Python), all green diff --git a/package-lock.json b/package-lock.json index 9c7d0c6..1158e7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "ioredis": "^5.10.1", - "postgres": "^3.4.8" + "postgres": "^3.4.8", + "stripe": "^20.4.1" }, "devDependencies": { "jest": "^30.3.0", @@ -5065,6 +5066,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz", + "integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/superagent": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", diff --git a/package.json b/package.json index 099622d..4b410c1 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "ioredis": "^5.10.1", - "postgres": "^3.4.8" + "postgres": "^3.4.8", + "stripe": "^20.4.1" }, "devDependencies": { "jest": "^30.3.0", diff --git a/src/app.js b/src/app.js index 5d3e9ed..c7b1ab6 100644 --- a/src/app.js +++ b/src/app.js @@ -6,8 +6,13 @@ const scanRoutes = require('./routes/scan'); const movementsRoutes = require('./routes/movements'); const alertsRoutes = require('./routes/alerts'); const betsRoutes = require('./routes/bets'); +const stripeRoutes = require('./routes/stripe'); const app = express(); + +// Stripe webhook needs raw body — must be before express.json() +app.use('/api/stripe/webhook', express.raw({ type: 'application/json' })); + app.use(express.json()); app.use('/api/odds', oddsRoutes); app.use('/api/analyze', analyzeRoutes); @@ -15,5 +20,6 @@ app.use('/api/scan', scanRoutes); app.use('/api/movements', movementsRoutes); app.use('/api/alerts', alertsRoutes); app.use('/api/bets', betsRoutes); +app.use('/api/stripe', stripeRoutes); module.exports = app; diff --git a/src/routes/stripe.js b/src/routes/stripe.js new file mode 100644 index 0000000..e35ef27 --- /dev/null +++ b/src/routes/stripe.js @@ -0,0 +1,90 @@ +const express = require('express'); +const { requireAuth } = require('../middleware/auth'); +const { + createCheckoutSession, + handleWebhookEvent, + createPortalSession, + getSubscriptionStatus, + constructWebhookEvent, +} = require('../services/stripeService'); + +const router = express.Router(); + +// Checkout — creates Stripe checkout session +router.post('/checkout', requireAuth, async (req, res) => { + const { tier, founder_code } = req.body; + + if (!tier || !['analyst', 'desk'].includes(tier)) { + return res.status(400).json({ error: 'tier must be "analyst" or "desk"' }); + } + + try { + const result = await createCheckoutSession(req.user.id, req.user.email, tier, founder_code); + return res.json(result); + } catch (err) { + console.error('[BetonBLK] Checkout error:', err.message); + return res.status(503).json({ error: 'Checkout creation failed' }); + } +}); + +// Webhook — Stripe sends events here +// IMPORTANT: This route needs raw body, not parsed JSON +// Must be registered with express.raw() in app.js +router.post('/webhook', async (req, res) => { + const signature = req.headers['stripe-signature']; + if (!signature) { + return res.status(400).json({ error: 'Missing stripe-signature header' }); + } + + let event; + try { + event = constructWebhookEvent(req.body, signature); + } catch (err) { + console.error('[BetonBLK] Webhook signature failed:', err.message); + return res.status(400).json({ error: 'Invalid signature' }); + } + + try { + await handleWebhookEvent(event); + return res.json({ received: true }); + } catch (err) { + console.error('[BetonBLK] Webhook handler error:', err.message); + return res.status(500).json({ error: 'Webhook processing failed' }); + } +}); + +// Portal — subscription management +router.post('/portal', requireAuth, async (req, res) => { + if (!req.user.stripe_customer_id) { + return res.status(400).json({ error: 'No active subscription' }); + } + + try { + const result = await createPortalSession(req.user.stripe_customer_id); + return res.json(result); + } catch (err) { + console.error('[BetonBLK] Portal error:', err.message); + return res.status(503).json({ error: 'Portal creation failed' }); + } +}); + +// Status — current subscription info +router.get('/status', requireAuth, async (req, res) => { + try { + let subStatus = { subscription_status: 'none', current_period_end: null, cancel_at_period_end: false }; + if (req.user.stripe_customer_id) { + subStatus = await getSubscriptionStatus(req.user.stripe_customer_id); + } + + return res.json({ + tier: req.user.tier, + is_founder: req.user.founder_status, + ...subStatus, + }); + } catch (err) { + console.error('[BetonBLK] Status error:', err.message); + return res.status(503).json({ error: 'Status check failed' }); + } +}); + +module.exports = router; diff --git a/src/services/stripeService.js b/src/services/stripeService.js new file mode 100644 index 0000000..b9ac5c2 --- /dev/null +++ b/src/services/stripeService.js @@ -0,0 +1,166 @@ +const Stripe = require('stripe'); +const { getSupabaseServiceClient } = require('../utils/supabase'); + +let _stripe = null; +function getStripe() { + if (!_stripe) _stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + return _stripe; +} + +const PRICE_MAP = { + analyst: process.env.STRIPE_PRICE_ANALYST || 'price_analyst_monthly', + analyst_founder: process.env.STRIPE_PRICE_ANALYST_FOUNDER || 'price_analyst_founder', + desk: process.env.STRIPE_PRICE_DESK || 'price_desk_monthly', + desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || 'price_desk_founder', +}; + +const VALID_FOUNDER_CODES = (process.env.FOUNDER_CODES || 'FOUNDER2026,BETONBLK,EARLYBIRD').split(','); +const FOUNDER_EXPIRY = new Date(process.env.FOUNDER_CODE_EXPIRY || '2026-06-30'); + +function isFounderCodeValid(code) { + if (!code) return false; + if (new Date() > FOUNDER_EXPIRY) return false; + return VALID_FOUNDER_CODES.includes(code.toUpperCase()); +} + +function getPriceId(tier, founderCode) { + const isFounder = isFounderCodeValid(founderCode); + if (tier === 'analyst') return isFounder ? PRICE_MAP.analyst_founder : PRICE_MAP.analyst; + if (tier === 'desk') return isFounder ? PRICE_MAP.desk_founder : PRICE_MAP.desk; + throw new Error(`Invalid tier: ${tier}`); +} + +async function createCheckoutSession(userId, email, tier, founderCode) { + const supabase = getSupabaseServiceClient(); + const priceId = getPriceId(tier, founderCode); + const isFounder = isFounderCodeValid(founderCode); + + // Get or create Stripe customer + const { data: user } = await supabase + .from('users') + .select('stripe_customer_id') + .eq('id', userId) + .single(); + + let customerId = user?.stripe_customer_id; + if (!customerId) { + const customer = await getStripe().customers.create({ + email, + metadata: { user_id: userId }, + }); + customerId = customer.id; + await supabase + .from('users') + .update({ stripe_customer_id: customerId }) + .eq('id', userId); + } + + const baseUrl = process.env.BASE_URL || 'http://localhost:3001'; + const session = await getStripe().checkout.sessions.create({ + customer: customerId, + line_items: [{ price: priceId, quantity: 1 }], + mode: 'subscription', + success_url: `${baseUrl}/scan?upgraded=true`, + cancel_url: `${baseUrl}/#pricing`, + metadata: { user_id: userId, tier, is_founder: String(isFounder) }, + }); + + return { checkout_url: session.url, session_id: session.id }; +} + +async function handleWebhookEvent(event) { + const supabase = getSupabaseServiceClient(); + + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object; + const userId = session.metadata?.user_id; + const tier = session.metadata?.tier; + const isFounder = session.metadata?.is_founder === 'true'; + + if (userId && tier) { + await supabase + .from('users') + .update({ + tier, + stripe_customer_id: session.customer, + founder_status: isFounder, + }) + .eq('id', userId); + } + break; + } + + case 'customer.subscription.updated': { + // Handle plan changes if needed + break; + } + + case 'customer.subscription.deleted': { + const subscription = event.data.object; + const customerId = subscription.customer; + + // Find user by stripe_customer_id and revert to free + const { data: user } = await supabase + .from('users') + .select('id') + .eq('stripe_customer_id', customerId) + .single(); + + if (user) { + await supabase + .from('users') + .update({ tier: 'free' }) + .eq('id', user.id); + } + break; + } + + case 'invoice.payment_failed': { + console.warn('[BetonBLK] Payment failed for customer:', event.data.object.customer); + break; + } + } +} + +async function createPortalSession(stripeCustomerId) { + const baseUrl = process.env.BASE_URL || 'http://localhost:3001'; + const session = await getStripe().billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: `${baseUrl}/tracker`, + }); + return { portal_url: session.url }; +} + +async function getSubscriptionStatus(stripeCustomerId) { + const subscriptions = await getStripe().subscriptions.list({ + customer: stripeCustomerId, + status: 'active', + limit: 1, + }); + + if (subscriptions.data.length === 0) { + return { subscription_status: 'none', current_period_end: null, cancel_at_period_end: false }; + } + + const sub = subscriptions.data[0]; + return { + subscription_status: sub.status, + current_period_end: new Date(sub.current_period_end * 1000).toISOString(), + cancel_at_period_end: sub.cancel_at_period_end, + }; +} + +function constructWebhookEvent(body, signature) { + return getStripe().webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET); +} + +module.exports = { + createCheckoutSession, + handleWebhookEvent, + createPortalSession, + getSubscriptionStatus, + constructWebhookEvent, + isFounderCodeValid, + getPriceId, +}; diff --git a/tests/integration/stripe.test.js b/tests/integration/stripe.test.js new file mode 100644 index 0000000..248576b --- /dev/null +++ b/tests/integration/stripe.test.js @@ -0,0 +1,157 @@ +const request = require('supertest'); + +// Mock Redis +const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() }; +jest.mock('../../src/utils/redis', () => ({ getRedisClient: () => mockRedis })); + +// Mock Supabase +const mockSupabaseFrom = jest.fn(); +const mockSupabaseAuth = { getUser: jest.fn() }; +jest.mock('../../src/utils/supabase', () => ({ + getSupabaseClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }), + getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }), +})); + +// Mock Stripe +jest.mock('stripe', () => { + return jest.fn().mockImplementation(() => ({ + customers: { + create: jest.fn().mockResolvedValue({ id: 'cus_test123' }), + }, + checkout: { + sessions: { + create: jest.fn().mockResolvedValue({ url: 'https://checkout.stripe.com/test', id: 'cs_test' }), + }, + }, + billingPortal: { + sessions: { + create: jest.fn().mockResolvedValue({ url: 'https://billing.stripe.com/test' }), + }, + }, + subscriptions: { + list: jest.fn().mockResolvedValue({ data: [] }), + }, + webhooks: { + constructEvent: jest.fn(), + }, + })); +}); + +jest.mock('axios'); +process.env.ODDS_API_KEY = 'test'; +process.env.STRIPE_SECRET_KEY = 'sk_test_xxx'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test'; + +const app = require('../../src/app'); + +const MOCK_USER = { + id: 'user-1', email: 'test@test.com', tier: 'free', + scan_count: 0, stripe_customer_id: null, founder_status: false, +}; + +function setupAuthMocks(user = MOCK_USER) { + mockSupabaseAuth.getUser.mockResolvedValue({ + data: { user: { id: user.id, email: user.email } }, error: null, + }); + mockSupabaseFrom.mockImplementation((table) => { + if (table === 'users') { + return { + select: () => ({ + eq: () => ({ + single: () => Promise.resolve({ data: user, error: null }), + }), + }), + update: () => ({ + eq: () => Promise.resolve({ error: null }), + }), + }; + } + return { select: () => ({ eq: () => Promise.resolve({ data: [] }) }) }; + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockRedis.get.mockResolvedValue(null); + mockRedis.set.mockResolvedValue('OK'); + mockRedis.hset.mockResolvedValue(1); + mockRedis.hgetall.mockResolvedValue({}); + mockRedis.expire.mockResolvedValue(1); +}); + +describe('POST /api/stripe/checkout', () => { + test('creates checkout session and returns URL', async () => { + setupAuthMocks(); + const res = await request(app) + .post('/api/stripe/checkout') + .set('Authorization', 'Bearer valid-token') + .send({ tier: 'analyst' }) + .expect(200); + + expect(res.body.checkout_url).toBeDefined(); + expect(res.body.session_id).toBeDefined(); + }); + + test('returns 400 for invalid tier', async () => { + setupAuthMocks(); + const res = await request(app) + .post('/api/stripe/checkout') + .set('Authorization', 'Bearer valid-token') + .send({ tier: 'gold' }) + .expect(400); + + expect(res.body.error).toContain('tier'); + }); + + test('returns 401 without auth', async () => { + await request(app) + .post('/api/stripe/checkout') + .send({ tier: 'analyst' }) + .expect(401); + }); +}); + +describe('POST /api/stripe/portal', () => { + test('returns portal URL for existing customer', async () => { + setupAuthMocks({ ...MOCK_USER, stripe_customer_id: 'cus_existing' }); + const res = await request(app) + .post('/api/stripe/portal') + .set('Authorization', 'Bearer valid-token') + .expect(200); + + expect(res.body.portal_url).toBeDefined(); + }); + + test('returns 400 when no subscription', async () => { + setupAuthMocks(); + const res = await request(app) + .post('/api/stripe/portal') + .set('Authorization', 'Bearer valid-token') + .expect(400); + + expect(res.body.error).toContain('No active subscription'); + }); +}); + +describe('GET /api/stripe/status', () => { + test('returns tier and subscription info', async () => { + setupAuthMocks({ ...MOCK_USER, tier: 'analyst', founder_status: true }); + const res = await request(app) + .get('/api/stripe/status') + .set('Authorization', 'Bearer valid-token') + .expect(200); + + expect(res.body.tier).toBe('analyst'); + expect(res.body.is_founder).toBe(true); + }); +}); + +describe('POST /api/stripe/webhook', () => { + test('returns 400 without signature', async () => { + await request(app) + .post('/api/stripe/webhook') + .send('{}') + .set('Content-Type', 'application/json') + .expect(400); + }); +}); diff --git a/tests/unit/stripeService.test.js b/tests/unit/stripeService.test.js new file mode 100644 index 0000000..f2b028e --- /dev/null +++ b/tests/unit/stripeService.test.js @@ -0,0 +1,57 @@ +process.env.STRIPE_SECRET_KEY = 'sk_test_dummy'; + +jest.mock('../../src/utils/supabase', () => ({ + getSupabaseServiceClient: () => ({ from: jest.fn() }), +})); + +const { isFounderCodeValid, getPriceId } = require('../../src/services/stripeService'); + +describe('stripeService', () => { + describe('isFounderCodeValid', () => { + test('valid founder code returns true', () => { + expect(isFounderCodeValid('FOUNDER2026')).toBe(true); + expect(isFounderCodeValid('BETONBLK')).toBe(true); + }); + + test('case insensitive', () => { + expect(isFounderCodeValid('founder2026')).toBe(true); + }); + + test('invalid code returns false', () => { + expect(isFounderCodeValid('INVALID')).toBe(false); + }); + + test('null/empty returns false', () => { + expect(isFounderCodeValid(null)).toBe(false); + expect(isFounderCodeValid('')).toBe(false); + }); + }); + + describe('getPriceId', () => { + test('analyst without founder code returns standard price', () => { + const id = getPriceId('analyst', null); + expect(id).toContain('analyst'); + expect(id).not.toContain('founder'); + }); + + test('analyst with valid founder code returns founder price', () => { + const id = getPriceId('analyst', 'FOUNDER2026'); + expect(id).toContain('founder'); + }); + + test('desk without founder code returns standard price', () => { + const id = getPriceId('desk', null); + expect(id).toContain('desk'); + expect(id).not.toContain('founder'); + }); + + test('desk with valid founder code returns founder price', () => { + const id = getPriceId('desk', 'BETONBLK'); + expect(id).toContain('founder'); + }); + + test('invalid tier throws', () => { + expect(() => getPriceId('gold', null)).toThrow('Invalid tier'); + }); + }); +});