const Stripe = require('stripe'); const db = require('../db/models'); const config = require('../config'); const ForbiddenError = require('./notifications/errors/forbidden'); const ACTIVE_STATUSES = ['trialing', 'active', 'past_due']; const Op = db.Sequelize.Op; const STRIPE_WEBHOOK_EVENTS = [ 'checkout.session.completed', 'customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted', ]; function parseFeatures(featuresJson) { if (!featuresJson) { return []; } const parsed = JSON.parse(featuresJson); if (Array.isArray(parsed)) { return parsed; } return []; } function createHttpError(message, code) { const error = new Error(message); error.code = code; return error; } function maskSecret(value) { if (!value) { return ''; } if (value.length <= 8) { return value; } return `${value.slice(0, 4)}••••${value.slice(-4)}`; } async function findStripeSettingsRecord() { return db.billing_settings.findOne({ where: { key: 'default', }, }); } async function findOrCreateStripeSettingsRecord(currentUserId) { const existingRecord = await findStripeSettingsRecord(); if (existingRecord) { return existingRecord; } return db.billing_settings.create({ createdById: currentUserId || null, key: 'default', updatedById: currentUserId || null, }); } async function getStripeSecretKeyValue() { const settings = await findStripeSettingsRecord(); if (settings && settings.stripe_secret_key) { return settings.stripe_secret_key; } return process.env.STRIPE_SECRET_KEY || ''; } async function getStripeWebhookSecretValue() { const settings = await findStripeSettingsRecord(); if (settings && settings.stripe_webhook_secret) { return settings.stripe_webhook_secret; } return process.env.STRIPE_WEBHOOK_SECRET || ''; } async function getStripeClient() { const secretKey = await getStripeSecretKeyValue(); if (!secretKey) { throw createHttpError('Stripe is not configured. Set STRIPE_SECRET_KEY.', 500); } return new Stripe(secretKey); } function getFrontendBaseUrl() { if (process.env.FRONTEND_APP_URL) { return process.env.FRONTEND_APP_URL.replace(/\/$/, ''); } if (process.env.NEXT_PUBLIC_BACK_API) { return process.env.NEXT_PUBLIC_BACK_API.replace(/\/api\/?$/, ''); } if (process.env.FULL_DOMAIN) { return `https://${process.env.FULL_DOMAIN.replace(/\/$/, '')}`; } if (process.env.HOST_FQDN) { return `https://${process.env.HOST_FQDN.replace(/\/$/, '')}`; } if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'dev_stage') { throw createHttpError( 'Stripe checkout is missing FRONTEND_APP_URL or NEXT_PUBLIC_BACK_API.', 500, ); } return 'http://localhost:3000'; } function getStripeWebhookUrl() { return `${getFrontendBaseUrl()}/api/billing/webhook`; } function isMockStripeId(value) { if (!value) { return false; } return String(value).includes('_mock_'); } function normalizeStripeSubscriptionStatus(status) { if (status === 'trialing') { return 'trialing'; } if (status === 'active') { return 'active'; } if (status === 'canceled') { return 'canceled'; } if (status === 'past_due') { return 'past_due'; } if (status === 'unpaid') { return 'past_due'; } if (status === 'incomplete') { return 'past_due'; } if (status === 'incomplete_expired') { return 'canceled'; } if (status === 'paused') { return 'past_due'; } return 'past_due'; } function serializePlan(plan) { if (!plan) { return null; } return { id: plan.id, name: plan.name, slug: plan.slug, description: plan.description, priceMonthly: plan.price_monthly, currency: plan.currency, stripeProductId: plan.stripe_product_id, stripePriceId: plan.stripe_price_id, messageLimit: plan.message_limit, agentLimit: plan.agent_limit, isFeatured: plan.is_featured, isActive: plan.is_active, features: parseFeatures(plan.features_json), }; } function serializeSubscription(subscription) { if (!subscription) { return null; } return { id: subscription.id, status: subscription.status, stripeCustomerId: subscription.stripe_customer_id, stripeSubscriptionId: subscription.stripe_subscription_id, currentPeriodStart: subscription.current_period_start, currentPeriodEnd: subscription.current_period_end, cancelAtPeriodEnd: subscription.cancel_at_period_end, plan: serializePlan(subscription.plan), }; } function getStripeWebhookUrlForDisplay(requestHost) { if (requestHost) { const normalizedHost = String(requestHost) .replace(/^https?:\/\//, '') .replace(/\/$/, ''); return `https://${normalizedHost}/api/billing/webhook`; } try { return getStripeWebhookUrl(); } catch (error) { return '/api/billing/webhook'; } } function serializeStripeSettings(settings, plans, requestHost) { const savedSecretKey = settings ? settings.stripe_secret_key || '' : ''; const savedWebhookSecret = settings ? settings.stripe_webhook_secret || '' : ''; return { plans: plans.map(serializePlan), stripeSecretKey: savedSecretKey, stripeSecretKeyPreview: maskSecret(savedSecretKey || process.env.STRIPE_SECRET_KEY || ''), stripeSecretKeySource: savedSecretKey ? 'saved' : process.env.STRIPE_SECRET_KEY ? 'env' : 'missing', stripeWebhookSecret: savedWebhookSecret, stripeWebhookSecretPreview: maskSecret(savedWebhookSecret || process.env.STRIPE_WEBHOOK_SECRET || ''), stripeWebhookSecretSource: savedWebhookSecret ? 'saved' : process.env.STRIPE_WEBHOOK_SECRET ? 'env' : 'missing', webhookEndpoint: getStripeWebhookUrlForDisplay(requestHost), webhookEvents: STRIPE_WEBHOOK_EVENTS, }; } function getStripePriceIdFromSubscription(stripeSubscription) { if (!stripeSubscription || !stripeSubscription.items || !Array.isArray(stripeSubscription.items.data)) { return ''; } for (const item of stripeSubscription.items.data) { if (item && item.price && item.price.id) { return item.price.id; } } return ''; } function getCustomerEmail(checkoutSession) { if (checkoutSession.customer_details && checkoutSession.customer_details.email) { return checkoutSession.customer_details.email; } if (checkoutSession.customer_email) { return checkoutSession.customer_email; } return ''; } module.exports = class BillingService { static ensureCurrentUser(currentUser) { if (!currentUser || !currentUser.id) { throw new ForbiddenError(); } } static ensureAdministrator(currentUser) { this.ensureCurrentUser(currentUser); if (currentUser.app_role?.name !== config.roles.admin) { throw new ForbiddenError(); } } static async listPlans() { const plans = await db.subscription_plans.findAll({ where: { is_active: true, }, order: [ ['price_monthly', 'ASC'], ['createdAt', 'ASC'], ], }); return plans.map(serializePlan); } static async getCurrentSubscription(currentUser) { this.ensureCurrentUser(currentUser); const subscription = await this.findVisibleSubscription(currentUser.id); return serializeSubscription(subscription); } static async getStripeSettings(currentUser, requestHost) { this.ensureAdministrator(currentUser); const settings = await findOrCreateStripeSettingsRecord(currentUser.id); const plans = await db.subscription_plans.findAll({ order: [ ['price_monthly', 'ASC'], ['createdAt', 'ASC'], ], }); return serializeStripeSettings(settings, plans, requestHost); } static async updateStripeSettings(data, currentUser, requestHost) { this.ensureAdministrator(currentUser); if (!data || typeof data !== 'object') { throw createHttpError('Stripe settings payload is required.', 400); } const settings = await findOrCreateStripeSettingsRecord(currentUser.id); if (typeof data.stripeSecretKey === 'string') { settings.stripe_secret_key = data.stripeSecretKey.trim(); } if (typeof data.stripeWebhookSecret === 'string') { settings.stripe_webhook_secret = data.stripeWebhookSecret.trim(); } settings.updatedById = currentUser.id; if (!settings.createdById) { settings.createdById = currentUser.id; } await settings.save(); if (Array.isArray(data.plans)) { for (const planData of data.plans) { if (!planData || !planData.id) { throw createHttpError('Each Stripe plan mapping must include an id.', 400); } const plan = await db.subscription_plans.findOne({ where: { id: planData.id, }, }); if (!plan) { throw createHttpError(`Subscription plan ${planData.id} was not found.`, 404); } if (typeof planData.stripeProductId === 'string') { plan.stripe_product_id = planData.stripeProductId.trim(); } if (typeof planData.stripePriceId === 'string') { plan.stripe_price_id = planData.stripePriceId.trim(); } plan.updatedById = currentUser.id; await plan.save(); } } return this.getStripeSettings(currentUser, requestHost); } static async subscribe(planId, currentUser) { this.ensureCurrentUser(currentUser); if (!planId) { throw createHttpError('Subscription plan is required.', 400); } const plan = await db.subscription_plans.findOne({ where: { id: planId, is_active: true, }, }); if (!plan) { throw createHttpError('Subscription plan not found.', 404); } if (!plan.stripe_price_id) { throw createHttpError('This plan does not have a Stripe price yet.', 400); } if (isMockStripeId(plan.stripe_price_id)) { throw createHttpError( 'This plan is still using a mock Stripe price. Replace it with a real Stripe price ID first.', 400, ); } const stripe = await getStripeClient(); const customerId = await this.findOrCreateStripeCustomer(currentUser); const frontendBaseUrl = getFrontendBaseUrl(); const successUrl = `${frontendBaseUrl}/billing?checkout=success&session_id={CHECKOUT_SESSION_ID}`; const cancelUrl = `${frontendBaseUrl}/billing?checkout=canceled`; const checkoutSession = await stripe.checkout.sessions.create({ allow_promotion_codes: true, cancel_url: cancelUrl, client_reference_id: currentUser.id, customer: customerId, line_items: [ { price: plan.stripe_price_id, quantity: 1, }, ], metadata: { planId: plan.id, userId: currentUser.id, }, mode: 'subscription', subscription_data: { metadata: { planId: plan.id, userId: currentUser.id, }, }, success_url: successUrl, }); if (!checkoutSession.url) { throw createHttpError('Stripe did not return a checkout URL.', 500); } return { checkoutSessionId: checkoutSession.id, checkoutUrl: checkoutSession.url, }; } static async confirmCheckoutSession(sessionId, currentUser) { this.ensureCurrentUser(currentUser); if (!sessionId) { throw createHttpError('Checkout session ID is required.', 400); } const stripe = await getStripeClient(); const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId); const sessionUserId = checkoutSession.metadata ? checkoutSession.metadata.userId : null; const sessionEmail = getCustomerEmail(checkoutSession); if ( checkoutSession.client_reference_id !== currentUser.id && sessionUserId !== currentUser.id && sessionEmail !== currentUser.email ) { throw new ForbiddenError(); } if (checkoutSession.mode !== 'subscription') { throw createHttpError('This checkout session does not contain a subscription.', 400); } if (!checkoutSession.subscription) { return serializeSubscription(await this.findVisibleSubscription(currentUser.id)); } const stripeSubscription = await stripe.subscriptions.retrieve(checkoutSession.subscription); const subscription = await this.syncStripeSubscription(stripeSubscription); return serializeSubscription(subscription); } static async cancel(currentUser) { this.ensureCurrentUser(currentUser); const currentSubscription = await this.findActiveSubscription(currentUser.id); if (!currentSubscription) { throw createHttpError('No active subscription to cancel.', 404); } if ( !currentSubscription.stripe_subscription_id || isMockStripeId(currentSubscription.stripe_subscription_id) ) { currentSubscription.status = 'canceled'; currentSubscription.cancel_at_period_end = false; currentSubscription.current_period_end = new Date(); currentSubscription.updatedById = currentUser.id; await currentSubscription.save(); const subscription = await this.findVisibleSubscription(currentUser.id); return serializeSubscription(subscription); } const stripe = await getStripeClient(); const stripeSubscription = await stripe.subscriptions.update( currentSubscription.stripe_subscription_id, { cancel_at_period_end: true, }, ); const subscription = await this.syncStripeSubscription(stripeSubscription); return serializeSubscription(subscription); } static async handleWebhook(rawBody, signature) { const webhookSecret = await getStripeWebhookSecretValue(); if (!webhookSecret) { throw createHttpError('Stripe webhook is not configured. Set STRIPE_WEBHOOK_SECRET.', 500); } if (!signature) { throw createHttpError('Stripe-Signature header is required.', 400); } const stripe = await getStripeClient(); const event = stripe.webhooks.constructEvent( rawBody, signature, webhookSecret, ); if (event.type === 'checkout.session.completed') { const checkoutSession = event.data.object; if (checkoutSession.mode === 'subscription' && checkoutSession.subscription) { const stripeSubscription = await stripe.subscriptions.retrieve(checkoutSession.subscription); await this.syncStripeSubscription(stripeSubscription); } } if ( event.type === 'customer.subscription.created' || event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.deleted' ) { await this.syncStripeSubscription(event.data.object); } return { received: true, type: event.type, }; } static async syncStripeSubscription(stripeSubscription) { const userId = await this.resolveUserId(stripeSubscription); const plan = await this.resolvePlan(stripeSubscription); const transaction = await db.sequelize.transaction(); try { let subscription = await this.findSubscriptionByStripeSubscriptionId( stripeSubscription.id, transaction, ); if (!subscription && stripeSubscription.customer) { subscription = await this.findSubscriptionByStripeCustomerId( String(stripeSubscription.customer), userId, transaction, ); } if (!subscription) { subscription = db.subscriptions.build({ createdById: userId, stripe_customer_id: stripeSubscription.customer ? String(stripeSubscription.customer) : null, stripe_subscription_id: stripeSubscription.id, userId, }); } subscription.userId = userId; subscription.planId = plan.id; subscription.status = normalizeStripeSubscriptionStatus(stripeSubscription.status); subscription.stripe_customer_id = stripeSubscription.customer ? String(stripeSubscription.customer) : null; subscription.stripe_subscription_id = stripeSubscription.id; subscription.current_period_start = stripeSubscription.current_period_start ? new Date(stripeSubscription.current_period_start * 1000) : null; subscription.current_period_end = stripeSubscription.current_period_end ? new Date(stripeSubscription.current_period_end * 1000) : null; subscription.cancel_at_period_end = Boolean(stripeSubscription.cancel_at_period_end); subscription.updatedById = userId; await subscription.save({ transaction }); await db.subscriptions.update( { status: 'canceled', updatedById: userId, }, { transaction, where: { id: { [Op.ne]: subscription.id, }, status: { [Op.in]: ACTIVE_STATUSES, }, userId, }, }, ); const visibleSubscription = await this.findSubscriptionById(subscription.id, transaction); await transaction.commit(); return visibleSubscription; } catch (error) { await transaction.rollback(); throw error; } } static async resolveUserId(stripeSubscription) { if ( stripeSubscription.metadata && stripeSubscription.metadata.userId && typeof stripeSubscription.metadata.userId === 'string' ) { return stripeSubscription.metadata.userId; } const existingSubscription = await this.findSubscriptionByStripeSubscriptionId( stripeSubscription.id, ); if (existingSubscription && existingSubscription.userId) { return existingSubscription.userId; } if (stripeSubscription.customer) { const existingCustomerSubscription = await this.findSubscriptionByStripeCustomerId( String(stripeSubscription.customer), ); if (existingCustomerSubscription && existingCustomerSubscription.userId) { return existingCustomerSubscription.userId; } } throw createHttpError( `Stripe subscription ${stripeSubscription.id} is missing a user mapping.`, 500, ); } static async resolvePlan(stripeSubscription) { if ( stripeSubscription.metadata && stripeSubscription.metadata.planId && typeof stripeSubscription.metadata.planId === 'string' ) { const planById = await db.subscription_plans.findOne({ where: { id: stripeSubscription.metadata.planId, }, }); if (planById) { return planById; } } const stripePriceId = getStripePriceIdFromSubscription(stripeSubscription); if (stripePriceId) { const planByPrice = await db.subscription_plans.findOne({ where: { stripe_price_id: stripePriceId, }, }); if (planByPrice) { return planByPrice; } } const existingSubscription = await this.findSubscriptionByStripeSubscriptionId( stripeSubscription.id, ); if (existingSubscription && existingSubscription.planId) { const existingPlan = await db.subscription_plans.findOne({ where: { id: existingSubscription.planId, }, }); if (existingPlan) { return existingPlan; } } throw createHttpError( `Stripe subscription ${stripeSubscription.id} could not be matched to a plan.`, 500, ); } static async findOrCreateStripeCustomer(currentUser) { const existingSubscription = await db.subscriptions.findOne({ order: [['createdAt', 'DESC']], where: { stripe_customer_id: { [Op.ne]: null, }, userId: currentUser.id, }, }); if ( existingSubscription && existingSubscription.stripe_customer_id && !isMockStripeId(existingSubscription.stripe_customer_id) ) { return existingSubscription.stripe_customer_id; } const stripe = await getStripeClient(); const fullName = [currentUser.firstName, currentUser.lastName].filter(Boolean).join(' ').trim(); const customer = await stripe.customers.create({ email: currentUser.email || undefined, metadata: { userId: currentUser.id, }, name: fullName || undefined, }); return customer.id; } static async findActiveSubscription(userId, transaction) { return db.subscriptions.findOne({ where: { status: { [Op.in]: ACTIVE_STATUSES, }, stripe_subscription_id: { [Op.notLike]: '%_mock_%', }, userId, }, include: [ { model: db.subscription_plans, as: 'plan', }, ], order: [['createdAt', 'DESC']], transaction, }); } static async findVisibleSubscription(userId, transaction) { const activeSubscription = await this.findActiveSubscription(userId, transaction); if (activeSubscription) { return activeSubscription; } return db.subscriptions.findOne({ where: { stripe_subscription_id: { [Op.notLike]: '%_mock_%', }, userId, }, include: [ { model: db.subscription_plans, as: 'plan', }, ], order: [['createdAt', 'DESC']], transaction, }); } static async findSubscriptionById(id, transaction) { return db.subscriptions.findOne({ where: { id, }, include: [ { model: db.subscription_plans, as: 'plan', }, ], transaction, }); } static async findSubscriptionByStripeSubscriptionId(stripeSubscriptionId, transaction) { if (!stripeSubscriptionId) { return null; } return db.subscriptions.findOne({ where: { stripe_subscription_id: stripeSubscriptionId, }, include: [ { model: db.subscription_plans, as: 'plan', }, ], order: [['createdAt', 'DESC']], transaction, }); } static async findSubscriptionByStripeCustomerId(stripeCustomerId, userId, transaction) { if (!stripeCustomerId) { return null; } const where = { stripe_customer_id: stripeCustomerId, }; if (userId) { where.userId = userId; } return db.subscriptions.findOne({ where, include: [ { model: db.subscription_plans, as: 'plan', }, ], order: [['createdAt', 'DESC']], transaction, }); } };