feat: Feature 3.4 — Stripe integration with founder code system

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) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-03-22 10:30:42 -04:00
parent 850fe60e8f
commit eb443232fb
8 changed files with 525 additions and 40 deletions
+6
View File
@@ -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;
+90
View File
@@ -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;
+166
View File
@@ -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,
};