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
465 lines
15 KiB
JavaScript
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;
|