223 lines
5.5 KiB
JavaScript
223 lines
5.5 KiB
JavaScript
const Stripe = require('stripe');
|
|
const config = require('../config');
|
|
|
|
const PLAN_PRICE_ENV = {
|
|
starter: 'STRIPE_STARTER_PRICE_ID',
|
|
pro: 'STRIPE_PRO_PRICE_ID',
|
|
};
|
|
|
|
let cachedStripeClient = null;
|
|
let cachedSecretKey = null;
|
|
|
|
function httpError(message, code = 400) {
|
|
const error = new Error(message);
|
|
error.code = code;
|
|
return error;
|
|
}
|
|
|
|
function compactMissing(items) {
|
|
return items.filter(Boolean);
|
|
}
|
|
|
|
function getPriceIdForPlan(planId) {
|
|
if (planId === 'pro') {
|
|
return config.stripe.proPriceId;
|
|
}
|
|
|
|
if (planId === 'starter') {
|
|
return config.stripe.starterPriceId;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function getPlanIdForPriceId(priceId) {
|
|
if (!priceId) {
|
|
return null;
|
|
}
|
|
|
|
if (priceId === config.stripe.proPriceId) {
|
|
return 'pro';
|
|
}
|
|
|
|
if (priceId === config.stripe.starterPriceId) {
|
|
return 'starter';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getStripeClient() {
|
|
if (!config.stripe.secretKey) {
|
|
throw httpError('Stripe billing is not configured yet. Add STRIPE_SECRET_KEY in the backend environment.', 400);
|
|
}
|
|
|
|
if (!cachedStripeClient || cachedSecretKey !== config.stripe.secretKey) {
|
|
cachedStripeClient = Stripe(config.stripe.secretKey);
|
|
cachedSecretKey = config.stripe.secretKey;
|
|
}
|
|
|
|
return cachedStripeClient;
|
|
}
|
|
|
|
function formatMissingConfigurationMessage(missing) {
|
|
return `Stripe billing is not configured yet. Add ${missing.join(', ')} in the backend environment, then reload the backend service.`;
|
|
}
|
|
|
|
module.exports = class StripeBillingService {
|
|
static getPriceIdForPlan(planId) {
|
|
return getPriceIdForPlan(planId);
|
|
}
|
|
|
|
static getPlanIdForPriceId(priceId) {
|
|
return getPlanIdForPriceId(priceId);
|
|
}
|
|
|
|
static getMissingCheckoutConfiguration(planId) {
|
|
const priceEnvName = PLAN_PRICE_ENV[planId] || PLAN_PRICE_ENV.starter;
|
|
|
|
return compactMissing([
|
|
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
|
|
getPriceIdForPlan(planId) ? null : priceEnvName,
|
|
]);
|
|
}
|
|
|
|
static getMissingPortalConfiguration() {
|
|
return compactMissing([
|
|
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
|
|
]);
|
|
}
|
|
|
|
static getMissingWebhookConfiguration() {
|
|
return compactMissing([
|
|
config.stripe.secretKey ? null : 'STRIPE_SECRET_KEY',
|
|
config.stripe.webhookSecret ? null : 'STRIPE_WEBHOOK_SECRET',
|
|
]);
|
|
}
|
|
|
|
static getSetupStatus(planId = 'starter') {
|
|
const allMissing = new Set([
|
|
...this.getMissingCheckoutConfiguration('starter'),
|
|
...this.getMissingCheckoutConfiguration('pro'),
|
|
...this.getMissingWebhookConfiguration(),
|
|
]);
|
|
|
|
return {
|
|
checkoutReady: this.getMissingCheckoutConfiguration(planId).length === 0,
|
|
portalReady: this.getMissingPortalConfiguration().length === 0,
|
|
webhookReady: this.getMissingWebhookConfiguration().length === 0,
|
|
missingConfiguration: Array.from(allMissing),
|
|
};
|
|
}
|
|
|
|
static assertCheckoutConfigured(planId) {
|
|
const missing = this.getMissingCheckoutConfiguration(planId);
|
|
|
|
if (missing.length) {
|
|
throw httpError(formatMissingConfigurationMessage(missing), 400);
|
|
}
|
|
}
|
|
|
|
static assertPortalConfigured() {
|
|
const missing = this.getMissingPortalConfiguration();
|
|
|
|
if (missing.length) {
|
|
throw httpError(formatMissingConfigurationMessage(missing), 400);
|
|
}
|
|
}
|
|
|
|
static assertWebhookConfigured() {
|
|
const missing = this.getMissingWebhookConfiguration();
|
|
|
|
if (missing.length) {
|
|
throw httpError(formatMissingConfigurationMessage(missing), 400);
|
|
}
|
|
}
|
|
|
|
static async createCheckoutSession(params) {
|
|
const {
|
|
user,
|
|
plan,
|
|
baseUrl,
|
|
trialPeriodDays,
|
|
} = params;
|
|
const priceId = getPriceIdForPlan(plan.id);
|
|
const subscriptionData = {
|
|
metadata: {
|
|
userId: user.id,
|
|
planId: plan.id,
|
|
},
|
|
};
|
|
|
|
this.assertCheckoutConfigured(plan.id);
|
|
|
|
const stripe = getStripeClient();
|
|
|
|
if (trialPeriodDays && trialPeriodDays > 0) {
|
|
subscriptionData.trial_period_days = trialPeriodDays;
|
|
}
|
|
|
|
return stripe.checkout.sessions.create({
|
|
mode: 'subscription',
|
|
customer: user.stripeCustomerId || undefined,
|
|
customer_email: user.stripeCustomerId ? undefined : user.email,
|
|
client_reference_id: user.id,
|
|
line_items: [
|
|
{
|
|
price: priceId,
|
|
quantity: 1,
|
|
},
|
|
],
|
|
allow_promotion_codes: true,
|
|
billing_address_collection: 'auto',
|
|
success_url: `${baseUrl}/subscription?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
|
|
cancel_url: `${baseUrl}/subscription?checkout=cancelled`,
|
|
metadata: {
|
|
userId: user.id,
|
|
planId: plan.id,
|
|
},
|
|
subscription_data: subscriptionData,
|
|
});
|
|
}
|
|
|
|
static async createPortalSession(params) {
|
|
const { customerId, baseUrl } = params;
|
|
this.assertPortalConfigured();
|
|
|
|
const stripe = getStripeClient();
|
|
|
|
return stripe.billingPortal.sessions.create({
|
|
customer: customerId,
|
|
return_url: `${baseUrl}/subscription`,
|
|
});
|
|
}
|
|
|
|
static constructWebhookEvent(rawBody, signature) {
|
|
this.assertWebhookConfigured();
|
|
|
|
if (!signature) {
|
|
throw httpError('Missing Stripe webhook signature.', 400);
|
|
}
|
|
|
|
const stripe = getStripeClient();
|
|
|
|
return stripe.webhooks.constructEvent(
|
|
rawBody,
|
|
signature,
|
|
config.stripe.webhookSecret,
|
|
);
|
|
}
|
|
|
|
static async retrieveSubscription(subscriptionId) {
|
|
if (!subscriptionId || typeof subscriptionId !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
const stripe = getStripeClient();
|
|
|
|
return stripe.subscriptions.retrieve(subscriptionId, {
|
|
expand: ['items.data.price'],
|
|
});
|
|
}
|
|
};
|