mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 11:55:03 +03:00
e3ef01932f
no-issue This refactors the members-api module so that it is easier to test going forward, as well as easier to understand & navigate. The Stripe API no longer contains storage code, this is all handled via the member repository. And we have dedicated services for webhooks, and stripe plans initialisation.
543 lines
17 KiB
JavaScript
543 lines
17 KiB
JavaScript
const debug = require('ghost-ignition').debug('services/stripe');
|
|
const Stripe = require('stripe');
|
|
const LeakyBucket = require('leaky-bucket');
|
|
const EXPECTED_API_EFFICIENCY = 0.95;
|
|
|
|
/** @type {(data: string) => string} */
|
|
const hash = data => require('crypto').createHash('sha256').update(data).digest('hex');
|
|
|
|
const STRIPE_API_VERSION = '2019-09-09';
|
|
|
|
/**
|
|
* @typedef {import('stripe').IDataOptions} IDataOptions
|
|
* @typedef {import('stripe').customers.ICustomer} ICustomer
|
|
* @typedef {import('stripe').products.IProduct} IProduct
|
|
* @typedef {import('stripe').plans.IPlan} IPlan
|
|
* @typedef {import('stripe').webhookEndpoints.IWebhookEndpoint} IWebhookEndpoint
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} ILogger
|
|
* @prop {(x: any) => void} error
|
|
* @prop {(x: any) => void} info
|
|
* @prop {(x: any) => void} warn
|
|
*/
|
|
|
|
/**
|
|
* @typedef {'customers'|'subscriptions'|'plans'} StripeResource
|
|
*/
|
|
|
|
module.exports = class StripeAPIService {
|
|
/**
|
|
* StripeService
|
|
*
|
|
* @param {object} params
|
|
*
|
|
* @param {ILogger} params.logger
|
|
*
|
|
* @param {object} params.config
|
|
* @param {string} params.config.secretKey
|
|
* @param {string} params.config.publicKey
|
|
* @param {object} params.config.appInfo
|
|
* @param {string} params.config.appInfo.name
|
|
* @param {string} params.config.appInfo.version
|
|
* @param {string} params.config.appInfo.partner_id
|
|
* @param {string} params.config.appInfo.url
|
|
* @param {boolean} params.config.enablePromoCodes
|
|
*/
|
|
constructor({config, logger}) {
|
|
this.logging = logger;
|
|
if (config.secretKey) {
|
|
this.configure(config);
|
|
}
|
|
}
|
|
|
|
configure(config) {
|
|
this._stripe = new Stripe(config.secretKey);
|
|
this._config = config;
|
|
this._testMode = config.secretKey && config.secretKey.startsWith('sk_test_');
|
|
if (this._testMode) {
|
|
this._rateLimitBucket = new LeakyBucket(EXPECTED_API_EFFICIENCY * 25, 1);
|
|
} else {
|
|
this._rateLimitBucket = new LeakyBucket(EXPECTED_API_EFFICIENCY * 100, 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ensureProduct.
|
|
*
|
|
* @param {string} name
|
|
*
|
|
* @returns {Promise<IProduct>}
|
|
*/
|
|
async ensureProduct(name) {
|
|
const idSeed = 'Ghost Subscription';
|
|
|
|
/** @type {(x: string) => string} */
|
|
const prefixHashSeed = seed => (this._testMode ? `test_${seed}` : `prod_${seed}`);
|
|
|
|
/** @type {(idSeed: string) => Promise<IProduct>} */
|
|
const getOrCreateActiveProduct = async (idSeed) => {
|
|
const id = hash(prefixHashSeed(idSeed));
|
|
try {
|
|
await this._rateLimitBucket.throttle();
|
|
const product = await this._stripe.products.retrieve(id);
|
|
|
|
if (product.active) {
|
|
return product;
|
|
}
|
|
|
|
return getOrCreateActiveProduct(id);
|
|
} catch (err) {
|
|
if (err.code !== 'resource_missing') {
|
|
throw err;
|
|
}
|
|
await this._rateLimitBucket.throttle();
|
|
return this._stripe.products.create({
|
|
id,
|
|
name
|
|
});
|
|
}
|
|
};
|
|
|
|
return getOrCreateActiveProduct(idSeed);
|
|
}
|
|
|
|
/**
|
|
* ensurePlan.
|
|
*
|
|
* @param {object} plan
|
|
* @param {object} product
|
|
*
|
|
* @returns {Promise<IPlan>}
|
|
*/
|
|
async ensurePlan(plan, product) {
|
|
const idSeed = product.id + plan.interval + plan.currency + plan.amount;
|
|
|
|
/** @type {(x: string) => string} */
|
|
const prefixHashSeed = seed => (this._testMode ? `test_${seed}` : `prod_${seed}`);
|
|
|
|
/** @type {(idSeed: string) => Promise<IPlan>} */
|
|
const getOrCreateActivePlan = async (idSeed) => {
|
|
const id = hash(prefixHashSeed(idSeed));
|
|
try {
|
|
await this._rateLimitBucket.throttle();
|
|
const plan = await this._stripe.plans.retrieve(id);
|
|
|
|
if (plan.active) {
|
|
return plan;
|
|
}
|
|
|
|
return getOrCreateActivePlan(id);
|
|
} catch (err) {
|
|
if (err.code !== 'resource_missing') {
|
|
throw err;
|
|
}
|
|
await this._rateLimitBucket.throttle();
|
|
return this._stripe.plans.create({
|
|
id,
|
|
nickname: plan.name,
|
|
amount: plan.amount,
|
|
interval: plan.interval,
|
|
currency: plan.currency,
|
|
product: product.id,
|
|
billing_scheme: 'per_unit'
|
|
});
|
|
}
|
|
};
|
|
|
|
return getOrCreateActivePlan(idSeed);
|
|
}
|
|
|
|
/**
|
|
* @param {string} id
|
|
* @param {IDataOptions} options
|
|
*
|
|
* @returns {Promise<ICustomer>}
|
|
*/
|
|
async getCustomer(id, options = {}) {
|
|
debug(`getCustomer(${id}, ${JSON.stringify(options)})`);
|
|
try {
|
|
await this._rateLimitBucket.throttle();
|
|
const customer = await this._stripe.customers.retrieve(id, options);
|
|
debug(`getCustomer(${id}, ${JSON.stringify(options)}) -> Success`);
|
|
return customer;
|
|
} catch (err) {
|
|
debug(`getCustomer(${id}, ${JSON.stringify(options)}) -> ${err.type}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {any} member
|
|
*
|
|
* @returns {Promise<ICustomer>}
|
|
*/
|
|
async getCustomerForMemberCheckoutSession(member) {
|
|
await member.related('stripeCustomers').fetch();
|
|
const customers = member.related('stripeCustomers');
|
|
|
|
for (const data of customers) {
|
|
try {
|
|
const customer = await this.getCustomer(data.customer_id);
|
|
if (!customer.deleted) {
|
|
return customer;
|
|
}
|
|
} catch (err) {
|
|
debug(`Ignoring Error getting customer for member ${err.message}`);
|
|
}
|
|
}
|
|
|
|
debug(`Creating customer for member ${member.get('email')}`);
|
|
const customer = await this.createCustomer({
|
|
email: member.get('email')
|
|
});
|
|
|
|
return customer;
|
|
}
|
|
|
|
/**
|
|
* @param {IDataOptions} options
|
|
*
|
|
* @returns {Promise<ICustomer>}
|
|
*/
|
|
async createCustomer(options = {}) {
|
|
debug(`createCustomer(${JSON.stringify(options)})`);
|
|
try {
|
|
await this._rateLimitBucket.throttle();
|
|
const customer = await this._stripe.customers.create(options);
|
|
debug(`createCustomer(${JSON.stringify(options)}) -> Success`);
|
|
return customer;
|
|
} catch (err) {
|
|
debug(`createCustomer(${JSON.stringify(options)}) -> ${err.type}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} id
|
|
* @param {string} email
|
|
*
|
|
* @returns {Promise<ICustomer>}
|
|
*/
|
|
async updateCustomerEmail(id, email) {
|
|
debug(`updateCustomerEmail(${id}, ${email})`);
|
|
try {
|
|
await this._rateLimitBucket.throttle();
|
|
const customer = await this._stripe.customers.update(id, {email});
|
|
debug(`updateCustomerEmail(${id}, ${email}) -> Success`);
|
|
return customer;
|
|
} catch (err) {
|
|
debug(`updateCustomerEmail(${id}, ${email}) -> ${err.type}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* createWebhook.
|
|
*
|
|
* @param {string} url
|
|
* @param {import('stripe').events.EventType[]} events
|
|
*
|
|
* @returns {Promise<IWebhookEndpoint>}
|
|
*/
|
|
async createWebhookEndpoint(url, events) {
|
|
debug(`createWebhook(${url})`);
|
|
try {
|
|
await this._rateLimitBucket.throttle();
|
|
const webhook = await this._stripe.webhookEndpoints.create({
|
|
url,
|
|
enabled_events: events,
|
|
api_version: STRIPE_API_VERSION
|
|
});
|
|
debug(`createWebhook(${url}) -> Success`);
|
|
return webhook;
|
|
} catch (err) {
|
|
debug(`createWebhook(${url}) -> ${err.type}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} id
|
|
*
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async deleteWebhookEndpoint(id) {
|
|
debug(`deleteWebhook(${id})`);
|
|
try {
|
|
await this._rateLimitBucket.throttle();
|
|
await this._stripe.webhookEndpoints.del(id);
|
|
debug(`deleteWebhook(${id}) -> Success`);
|
|
return;
|
|
} catch (err) {
|
|
debug(`deleteWebhook(${id}) -> ${err.type}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} id
|
|
* @param {string} url
|
|
* @param {import('stripe').events.EventType[]} events
|
|
*
|
|
* @returns {Promise<IWebhookEndpoint>}
|
|
*/
|
|
async updateWebhookEndpoint(id, url, events) {
|
|
debug(`updateWebhook(${id}, ${url})`);
|
|
try {
|
|
await this._rateLimitBucket.throttle();
|
|
const webhook = await this._stripe.webhookEndpoints.update(id, {
|
|
url,
|
|
enabled_events: events
|
|
});
|
|
if (webhook.api_version !== STRIPE_API_VERSION) {
|
|
throw new Error('Webhook has incorrect api_version');
|
|
}
|
|
debug(`updateWebhook(${id}, ${url}) -> Success`);
|
|
return webhook;
|
|
} catch (err) {
|
|
debug(`updateWebhook(${id}, ${url}) -> ${err.type}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* parseWebhook.
|
|
*
|
|
* @param {string} body
|
|
* @param {string} signature
|
|
* @param {string} secret
|
|
*
|
|
* @returns {import('stripe').events.IEvent}
|
|
*/
|
|
parseWebhook(body, signature, secret) {
|
|
debug(`parseWebhook(${body}, ${signature}, ${secret})`);
|
|
try {
|
|
const event = this._stripe.webhooks.constructEvent(body, signature, secret);
|
|
debug(`parseWebhook(${body}, ${signature}, ${secret}) -> Success ${event.type}`);
|
|
return event;
|
|
} catch (err) {
|
|
debug(`parseWebhook(${body}, ${signature}, ${secret}) -> ${err.type}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {IPlan} plan
|
|
* @param {ICustomer} customer
|
|
* @param {object} options
|
|
*
|
|
* @returns {Promise<import('stripe').checkouts.sessions.ICheckoutSession>}
|
|
*/
|
|
async createCheckoutSession(plan, customer, options) {
|
|
const metadata = options.metadata || undefined;
|
|
const customerEmail = customer ? undefined : options.customerEmail;
|
|
await this._rateLimitBucket.throttle();
|
|
const session = await this._stripe.checkout.sessions.create({
|
|
payment_method_types: ['card'],
|
|
success_url: options.successUrl,
|
|
cancel_url: options.cancelUrl,
|
|
customer: customer ? customer.id : undefined,
|
|
customer_email: customerEmail,
|
|
// @ts-ignore - we need to update to latest stripe library to correctly use newer features
|
|
allow_promotion_codes: this._config.enablePromoCodes,
|
|
metadata,
|
|
subscription_data: {
|
|
trial_from_plan: true,
|
|
items: [{
|
|
plan: plan.id
|
|
}]
|
|
}
|
|
});
|
|
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* @param {ICustomer} customer
|
|
* @param {object} options
|
|
*
|
|
* @returns {Promise<import('stripe').checkouts.sessions.ICheckoutSession>}
|
|
*/
|
|
async createCheckoutSetupSession(customer, options) {
|
|
await this._rateLimitBucket.throttle();
|
|
const session = await this._stripe.checkout.sessions.create({
|
|
mode: 'setup',
|
|
payment_method_types: ['card'],
|
|
success_url: options.successUrl,
|
|
cancel_url: options.cancelUrl,
|
|
customer_email: customer.email,
|
|
setup_intent_data: {
|
|
metadata: {
|
|
customer_id: customer.id
|
|
}
|
|
}
|
|
});
|
|
|
|
return session;
|
|
}
|
|
|
|
getPublicKey() {
|
|
return this._config.publicKey;
|
|
}
|
|
|
|
/**
|
|
* getSubscription.
|
|
*
|
|
* @param {string} id
|
|
* @param {IDataOptions} options
|
|
*
|
|
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
|
|
*/
|
|
async getSubscription(id, options = {}) {
|
|
debug(`getSubscription(${id}, ${JSON.stringify(options)})`);
|
|
try {
|
|
await this._rateLimitBucket.throttle();
|
|
const subscription = await this._stripe.subscriptions.retrieve(id, options);
|
|
debug(`getSubscription(${id}, ${JSON.stringify(options)}) -> Success`);
|
|
return subscription;
|
|
} catch (err) {
|
|
debug(`getSubscription(${id}, ${JSON.stringify(options)}) -> ${err.type}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* cancelSubscription.
|
|
*
|
|
* @param {string} id
|
|
*
|
|
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
|
|
*/
|
|
async cancelSubscription(id) {
|
|
debug(`cancelSubscription(${id})`);
|
|
try {
|
|
await this._rateLimitBucket.throttle();
|
|
const subscription = await this._stripe.subscriptions.del(id);
|
|
debug(`cancelSubscription(${id}) -> Success`);
|
|
return subscription;
|
|
} catch (err) {
|
|
debug(`cancelSubscription(${id}) -> ${err.type}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} id - The ID of the Subscription to modify
|
|
* @param {string} [reason=''] - The user defined cancellation reason
|
|
*
|
|
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
|
|
*/
|
|
async cancelSubscriptionAtPeriodEnd(id, reason = '') {
|
|
await this._rateLimitBucket.throttle();
|
|
const subscription = await this._stripe.subscriptions.update(id, {
|
|
cancel_at_period_end: true,
|
|
metadata: {
|
|
cancellation_reason: reason
|
|
}
|
|
});
|
|
return subscription;
|
|
}
|
|
|
|
/**
|
|
* @param {string} id - The ID of the Subscription to modify
|
|
*
|
|
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
|
|
*/
|
|
async continueSubscriptionAtPeriodEnd(id) {
|
|
await this._rateLimitBucket.throttle();
|
|
const subscription = await this._stripe.subscriptions.update(id, {
|
|
cancel_at_period_end: false,
|
|
metadata: {
|
|
cancellation_reason: null
|
|
}
|
|
});
|
|
return subscription;
|
|
}
|
|
|
|
/**
|
|
* @param {string} id - The ID of the Subscription to modify
|
|
* @param {string} plan - The ID of the new Plan
|
|
*
|
|
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
|
|
*/
|
|
async changeSubscriptionPlan(id, plan) {
|
|
await this._rateLimitBucket.throttle();
|
|
const subscription = await this._stripe.subscriptions.update(id, {
|
|
plan,
|
|
cancel_at_period_end: false,
|
|
metadata: {
|
|
cancellation_reason: null
|
|
}
|
|
});
|
|
return subscription;
|
|
}
|
|
|
|
/**
|
|
* @param {string} customer - The ID of the Customer to create the subscription for
|
|
* @param {string} plan - The ID of the new Plan
|
|
*
|
|
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
|
|
*/
|
|
async createSubscription(customer, plan) {
|
|
await this._rateLimitBucket.throttle();
|
|
const subscription = await this._stripe.subscriptions.create({
|
|
customer,
|
|
items: [{plan}]
|
|
});
|
|
return subscription;
|
|
}
|
|
|
|
/**
|
|
* @param {string} id
|
|
* @param {IDataOptions} options
|
|
*
|
|
* @returns {Promise<import('stripe').setupIntents.ISetupIntent>}
|
|
*/
|
|
async getSetupIntent(id, options = {}) {
|
|
await this._rateLimitBucket.throttle();
|
|
return await this._stripe.setupIntents.retrieve(id, options);
|
|
}
|
|
|
|
/**
|
|
* @param {string} customer
|
|
* @param {string} paymentMethod
|
|
*
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async attachPaymentMethodToCustomer(customer, paymentMethod) {
|
|
await this._rateLimitBucket.throttle();
|
|
await this._stripe.paymentMethods.attach(paymentMethod, {customer});
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* @param {string} id
|
|
*
|
|
* @returns {Promise<import('stripe').paymentMethods.ICardPaymentMethod|null>}
|
|
*/
|
|
async getCardPaymentMethod(id) {
|
|
await this._rateLimitBucket.throttle();
|
|
const paymentMethod = await this._stripe.paymentMethods.retrieve(id);
|
|
if (paymentMethod.type !== 'card') {
|
|
return null;
|
|
}
|
|
/** @type {import('stripe').paymentMethods.ICardPaymentMethod} */
|
|
return paymentMethod;
|
|
}
|
|
|
|
/**
|
|
* @param {string} subscription
|
|
* @param {string} paymentMethod
|
|
*
|
|
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
|
|
*/
|
|
async updateSubscriptionDefaultPaymentMethod(subscription, paymentMethod) {
|
|
await this._rateLimitBucket.throttle();
|
|
return await this._stripe.subscriptions.update(subscription, {
|
|
default_payment_method: paymentMethod
|
|
});
|
|
}
|
|
};
|