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:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user