Files
firefrost-services/services/arbiter-3.0/src/routes/stripe.js
Claude 0acea3b95f fix: Skip OAuth if already logged in, go straight to checkout
If user has existing session from Trinity Console login,
/stripe/auth now redirects directly to /stripe/checkout
instead of re-triggering Discord OAuth.

Chronicler #75
2026-04-10 15:06:34 +00:00

465 lines
15 KiB
JavaScript

/**
* Stripe Integration Routes
* Handles checkout sessions, webhooks, and customer portal
* Date: April 3, 2026
* Updated: April 6, 2026 - Added Discord role sync (Task #87)
* Updated: April 10, 2026 - Added /auth and /checkout routes for Discord OAuth flow
*/
const express = require('express');
const router = express.Router();
const cors = require('cors');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const db = require('../database');
const { syncRole, removeAllRoles } = require('../services/discordRoleSync');
// CORS configuration for checkout endpoint
const corsOptions = {
origin: [
'https://firefrostgaming.com',
'https://www.firefrostgaming.com',
'https://firefrost-website.pages.dev' // Cloudflare Pages preview domain
],
methods: ['POST', 'OPTIONS'],
optionsSuccessStatus: 200
};
// 👇 THE MAGIC LINE - Handle CORS preflight OPTIONS request
router.options('/create-checkout-session', cors(corsOptions));
/**
* STRIPE AUTH - Entry point from website
* GET /stripe/auth?tier=X
* If already logged in, goes straight to checkout
* If not, stores tier in session and redirects to Discord OAuth
*/
router.get('/auth', (req, res) => {
const tierLevel = req.query.tier;
if (!tierLevel || isNaN(parseInt(tierLevel))) {
return res.status(400).send('Invalid tier level. Please return to the subscribe page and try again.');
}
// If user is already authenticated, skip OAuth and go straight to checkout
if (req.user && req.user.id) {
return res.redirect(`/stripe/checkout?tier=${tierLevel}`);
}
// Store tier in session for after OAuth callback
req.session.pendingCheckoutTier = parseInt(tierLevel);
// Redirect to Discord OAuth
res.redirect('/auth/discord');
});
/**
* CHECKOUT - Creates Stripe session after Discord OAuth
* GET /stripe/checkout?tier=X
* Called from auth callback, user is now logged in
*/
router.get('/checkout', async (req, res) => {
try {
// User must be authenticated
if (!req.user || !req.user.id) {
return res.redirect('/stripe/auth?tier=' + (req.query.tier || '1'));
}
const tierLevel = parseInt(req.query.tier);
const discordId = req.user.id;
if (!tierLevel || isNaN(tierLevel)) {
return res.status(400).send('Invalid tier level');
}
// Get Stripe Price ID from database
const productResult = await db.query(
'SELECT stripe_price_id, tier_name, billing_type FROM stripe_products WHERE tier_level = $1',
[tierLevel]
);
if (productResult.rows.length === 0) {
return res.status(404).send('Invalid tier level - product not found');
}
const product = productResult.rows[0];
const priceId = product.stripe_price_id;
const billingMode = product.billing_type === 'one-time' ? 'payment' : 'subscription';
console.log('🔍 Creating checkout session (OAuth flow):', {
discord_id: discordId,
tier_level: tierLevel,
tier_name: product.tier_name,
priceId,
billingMode
});
// Create Stripe Checkout Session WITH client_reference_id
const sessionConfig = {
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
mode: billingMode,
client_reference_id: discordId, // 🔑 THE KEY - Discord ID for webhook
success_url: 'https://firefrostgaming.com/success',
cancel_url: 'https://firefrostgaming.com/subscribe',
metadata: {
tier_level: tierLevel.toString(),
tier_name: product.tier_name,
discord_id: discordId
}
};
const session = await stripe.checkout.sessions.create(sessionConfig);
// Redirect directly to Stripe Checkout
res.redirect(session.url);
} catch (error) {
console.error('Checkout creation error:', error);
res.status(500).send('Failed to create checkout session. Please try again.');
}
});
/**
* CREATE CHECKOUT SESSION (Legacy API - kept for compatibility)
* POST /stripe/create-checkout-session
* Body: { tier_level }
* NOTE: This doesn't have Discord ID - use /stripe/auth flow instead
*/
router.post('/create-checkout-session', cors(corsOptions), async (req, res) => {
try {
const { tier_level } = req.body;
if (!tier_level) {
return res.status(400).json({ error: 'Missing tier_level' });
}
// Get Stripe Price ID from database based on tier level
const productResult = await db.query(
'SELECT stripe_price_id, tier_name, billing_type FROM stripe_products WHERE tier_level = $1',
[tier_level]
);
if (productResult.rows.length === 0) {
return res.status(404).json({ error: 'Invalid tier level' });
}
const product = productResult.rows[0];
const priceId = product.stripe_price_id;
const billingMode = product.billing_type === 'one-time' ? 'payment' : 'subscription';
console.log('🔍 Creating checkout session (legacy, no Discord ID):', {
tier_level,
tier_name: product.tier_name,
priceId,
billingMode
});
// Create Stripe Checkout Session
const sessionConfig = {
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
mode: billingMode,
success_url: 'https://firefrostgaming.com/success',
cancel_url: 'https://firefrostgaming.com/subscribe',
metadata: {
tier_level: tier_level.toString(),
tier_name: product.tier_name
}
};
const session = await stripe.checkout.sessions.create(sessionConfig);
res.json({ checkout_url: session.url });
} catch (error) {
console.error('Checkout session error:', error);
res.status(500).json({ error: 'Failed to create checkout session' });
}
});
/**
* STRIPE WEBHOOK HANDLER
* POST /stripe/webhook
* Receives events from Stripe (signature verified)
*/
router.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Check for duplicate events (idempotency)
const existingEvent = await db.query(
'SELECT stripe_event_id FROM webhook_events_processed WHERE stripe_event_id = $1',
[event.id]
);
if (existingEvent.rows.length > 0) {
console.log(`Duplicate event ${event.id}, skipping`);
return res.json({ received: true, duplicate: true });
}
// Process event with transaction safety
const client = await db.pool.connect();
try {
await client.query('BEGIN');
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
const discordId = session.client_reference_id;
const customerId = session.customer;
let tierLevel = null;
if (session.mode === 'subscription') {
// RECURRING SUBSCRIPTION (Tiers 2-9)
const subId = session.subscription;
const subscription = await stripe.subscriptions.retrieve(subId);
const priceId = subscription.items.data[0].price.id;
const productRes = await client.query(
'SELECT tier_level, price_monthly FROM stripe_products WHERE stripe_price_id = $1',
[priceId]
);
if (productRes.rows.length === 0) {
throw new Error(`Unknown price ID: ${priceId}`);
}
const tierData = productRes.rows[0];
tierLevel = tierData.tier_level;
await client.query(`
INSERT INTO subscriptions (discord_id, tier_level, status, stripe_subscription_id, stripe_customer_id, mrr_value, is_lifetime)
VALUES ($1, $2, 'active', $3, $4, $5, FALSE)
ON CONFLICT (stripe_subscription_id) DO UPDATE SET status = 'active', updated_at = CURRENT_TIMESTAMP
`, [discordId, tierData.tier_level, subId, customerId, tierData.price_monthly]);
} else if (session.mode === 'payment') {
// ONE-TIME PAYMENT (Awakened or Sovereign)
const paymentIntentId = session.payment_intent;
const lineItems = await stripe.checkout.sessions.listLineItems(session.id);
const priceId = lineItems.data[0].price.id;
const productRes = await client.query(
'SELECT tier_level, price_monthly FROM stripe_products WHERE stripe_price_id = $1',
[priceId]
);
if (productRes.rows.length === 0) {
throw new Error(`Unknown price ID: ${priceId}`);
}
const tierData = productRes.rows[0];
tierLevel = tierData.tier_level;
await client.query(`
INSERT INTO subscriptions (discord_id, tier_level, status, stripe_payment_intent_id, stripe_customer_id, mrr_value, is_lifetime)
VALUES ($1, $2, 'lifetime', $3, $4, 0, TRUE)
ON CONFLICT (stripe_payment_intent_id) DO UPDATE SET status = 'lifetime', updated_at = CURRENT_TIMESTAMP
`, [discordId, tierData.tier_level, paymentIntentId, customerId]);
}
// Log successful checkout
await client.query(`
INSERT INTO admin_audit_log (action_type, target_identifier, details)
VALUES ('CHECKOUT_COMPLETED', $1, $2)
`, [discordId, JSON.stringify({ mode: session.mode, customer: customerId })]);
// Sync Discord role
if (discordId && tierLevel) {
const roleResult = await syncRole(discordId, tierLevel);
console.log(`🎭 Role sync for ${discordId}: ${roleResult.message}`);
}
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
const subId = subscription.id;
const status = subscription.status; // active, past_due, canceled, etc.
await client.query(`
UPDATE subscriptions
SET status = $1, updated_at = CURRENT_TIMESTAMP
WHERE stripe_subscription_id = $2
`, [status, subId]);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
const subId = subscription.id;
// Start grace period (don't remove immediately)
const gracePeriodEnds = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); // 3 days
await client.query(`
UPDATE subscriptions
SET status = 'grace_period',
grace_period_started_at = CURRENT_TIMESTAMP,
grace_period_ends_at = $1,
updated_at = CURRENT_TIMESTAMP
WHERE stripe_subscription_id = $2 AND is_lifetime = FALSE
`, [gracePeriodEnds, subId]);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
const subId = invoice.subscription;
if (subId) {
await client.query(`
UPDATE subscriptions
SET status = 'past_due',
payment_failure_reason = $1,
last_payment_attempt = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE stripe_subscription_id = $2
`, [invoice.failure_message || 'Payment failed', subId]);
}
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object;
const subId = invoice.subscription;
if (subId) {
// Clear grace period if payment succeeded
await client.query(`
UPDATE subscriptions
SET status = 'active',
grace_period_started_at = NULL,
grace_period_ends_at = NULL,
payment_failure_reason = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE stripe_subscription_id = $1
`, [subId]);
}
break;
}
case 'charge.dispute.created': {
const dispute = event.data.object;
const paymentIntentId = dispute.payment_intent;
const customerId = dispute.customer;
// Find the subscription by customer ID (more reliable)
const subResult = await client.query(`
SELECT discord_id FROM subscriptions
WHERE stripe_customer_id = $1
LIMIT 1
`, [customerId]);
let discordId = null;
if (subResult.rows.length > 0) {
discordId = subResult.rows[0].discord_id;
}
// Mark as banned
await client.query(`
UPDATE subscriptions
SET status = 'chargeback_ban',
updated_at = CURRENT_TIMESTAMP
WHERE stripe_customer_id = $1
`, [customerId]);
// Add to banned_users table
if (discordId) {
await client.query(`
INSERT INTO banned_users (discord_id, ban_reason, notes)
VALUES ($1, 'chargeback', $2)
ON CONFLICT (discord_id) DO UPDATE SET
ban_reason = 'chargeback',
notes = $2,
banned_at = CURRENT_TIMESTAMP
`, [discordId, JSON.stringify({
payment_intent: paymentIntentId,
dispute_id: dispute.id
})]);
// Remove all Discord roles
const roleResult = await removeAllRoles(discordId);
console.log(`🚫 Chargeback role removal for ${discordId}: ${roleResult.message}`);
}
await client.query(`
INSERT INTO admin_audit_log (action_type, target_identifier, details)
VALUES ('CHARGEBACK_BAN', $1, $2)
`, [discordId || 'unknown', JSON.stringify({
payment_intent: paymentIntentId,
customer_id: customerId,
reason: 'Chargeback dispute created'
})]);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Mark event as processed
await client.query(
'INSERT INTO webhook_events_processed (stripe_event_id, event_type) VALUES ($1, $2)',
[event.id, event.type]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
console.error('Webhook processing error:', error);
return res.status(500).json({ error: 'Webhook processing failed' });
} finally {
client.release();
}
res.json({ received: true });
});
/**
* CREATE CUSTOMER PORTAL SESSION
* POST /stripe/create-portal-session
* Body: { customerId }
*/
router.post('/create-portal-session', async (req, res) => {
try {
const { customerId } = req.body;
if (!customerId) {
return res.status(400).json({ error: 'Missing customerId' });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.BASE_URL || 'https://discord-bot.firefrostgaming.com'}/admin`
});
res.json({ url: portalSession.url });
} catch (error) {
console.error('Portal session error:', error);
res.status(500).json({ error: 'Failed to create portal session' });
}
});
module.exports = router;