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