858 lines
22 KiB
JavaScript
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,
|
|
});
|
|
}
|
|
};
|