2026-05-15 14:15:08 +00:00

858 lines
22 KiB
JavaScript

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,
});
}
};