d63484e99a
refs https://github.com/TryGhost/Ghost/issues/11557 If a subscription failed to delete, we would error and bailout of the process, this updates it to log the error so that site owners have a record of the error in the logs, but also to continue through the rest of the subscriptions.
521 lines
20 KiB
JavaScript
521 lines
20 KiB
JavaScript
const debug = require('ghost-ignition').debug('stripe');
|
|
const _ = require('lodash');
|
|
const {retrieve, list, create, update, del} = require('./api/stripeRequests');
|
|
const api = require('./api');
|
|
|
|
const STRIPE_API_VERSION = '2019-09-09';
|
|
|
|
const CURRENCY_SYMBOLS = {
|
|
usd: '$',
|
|
aud: '$',
|
|
cad: '$',
|
|
gbp: '£',
|
|
eur: '€',
|
|
inr: '₹'
|
|
};
|
|
|
|
module.exports = class StripePaymentProcessor {
|
|
constructor(config, storage, logging) {
|
|
this.logging = logging;
|
|
this.storage = storage;
|
|
this._ready = new Promise((resolve, reject) => {
|
|
this._resolveReady = resolve;
|
|
this._rejectReady = reject;
|
|
});
|
|
this._configure(config);
|
|
}
|
|
|
|
async ready() {
|
|
return this._ready;
|
|
}
|
|
|
|
async _configure(config) {
|
|
this._stripe = require('stripe')(config.secretKey);
|
|
this._stripe.setAppInfo(config.appInfo);
|
|
this._stripe.setApiVersion(STRIPE_API_VERSION);
|
|
this._stripe.__TEST_MODE__ = config.secretKey.startsWith('sk_test_');
|
|
this._public_token = config.publicKey;
|
|
this._checkoutSuccessUrl = config.checkoutSuccessUrl;
|
|
this._checkoutCancelUrl = config.checkoutCancelUrl;
|
|
this._billingSuccessUrl = config.billingSuccessUrl;
|
|
this._billingCancelUrl = config.billingCancelUrl;
|
|
|
|
try {
|
|
this._product = await api.products.ensure(this._stripe, config.product);
|
|
} catch (err) {
|
|
this.logging.error('There was an error creating the Stripe Product');
|
|
this.logging.error(err);
|
|
return this._rejectReady(err);
|
|
}
|
|
|
|
/**
|
|
* @type Array<import('stripe').plans.IPlan>
|
|
*/
|
|
this._plans = [];
|
|
for (const planSpec of config.plans) {
|
|
try {
|
|
const plan = await api.plans.ensure(this._stripe, planSpec, this._product);
|
|
this._plans.push(plan);
|
|
} catch (err) {
|
|
this.logging.error('There was an error creating the Stripe Plan');
|
|
this.logging.error(err);
|
|
return this._rejectReady(err);
|
|
}
|
|
}
|
|
|
|
if (process.env.WEBHOOK_SECRET) {
|
|
this.logging.warn(`Skipping Stripe webhook creation and validation, using WEBHOOK_SECRET environment variable`);
|
|
this._webhookSecret = process.env.WEBHOOK_SECRET;
|
|
return this._resolveReady({
|
|
product: this._product,
|
|
plans: this._plans
|
|
});
|
|
}
|
|
|
|
const webhookConfig = {
|
|
url: config.webhookHandlerUrl,
|
|
enabled_events: [
|
|
'checkout.session.completed',
|
|
'customer.subscription.deleted',
|
|
'customer.subscription.updated',
|
|
'invoice.payment_succeeded',
|
|
'invoice.payment_failed'
|
|
]
|
|
};
|
|
|
|
// @TODO Delete this next time you're here
|
|
// This is a fix for the previous release of Ghost (3.25.0)
|
|
try {
|
|
const webhooks = await list(this._stripe, 'webhookEndpoints', {
|
|
limit: 100
|
|
});
|
|
|
|
const webhooksToCleanup = webhooks.data.filter((webhook) => {
|
|
return webhook.url === config.webhookHandlerUrl.slice(0, -1) || webhook.url === config.webhookHandlerUrl;
|
|
});
|
|
|
|
for (const webhookToCleanup of webhooksToCleanup) {
|
|
await del(this._stripe, 'webhookEndpoints', webhookToCleanup.id);
|
|
}
|
|
} catch (err) {
|
|
this.logging.warn(`There was an error cleaning up the old webhooks`);
|
|
}
|
|
|
|
const setupWebhook = async (id, secret, opts = {}) => {
|
|
if (!id || !secret || opts.forceCreate) {
|
|
if (id && !opts.skipDelete) {
|
|
try {
|
|
this.logging.info(`Deleting Stripe webhook ${id}`);
|
|
await del(this._stripe, 'webhookEndpoints', id);
|
|
} catch (err) {
|
|
this.logging.error(`Unable to delete Stripe webhook with id: ${id}`);
|
|
this.logging.error(err);
|
|
}
|
|
}
|
|
try {
|
|
this.logging.info(`Creating Stripe webhook with url: ${webhookConfig.url}, version: ${STRIPE_API_VERSION}, events: ${webhookConfig.enabled_events.join(', ')}`);
|
|
const webhook = await create(this._stripe, 'webhookEndpoints', Object.assign({}, webhookConfig, {
|
|
api_version: STRIPE_API_VERSION
|
|
}));
|
|
return {
|
|
id: webhook.id,
|
|
secret: webhook.secret
|
|
};
|
|
} catch (err) {
|
|
this.logging.error('Failed to create Stripe webhook. For local development please see https://ghost.org/docs/members/webhooks/#stripe-webhooks');
|
|
this.logging.error(err);
|
|
throw err;
|
|
}
|
|
} else {
|
|
try {
|
|
this.logging.info(`Updating Stripe webhook ${id} with url: ${webhookConfig.url}, events: ${webhookConfig.enabled_events.join(', ')}`);
|
|
const updatedWebhook = await update(this._stripe, 'webhookEndpoints', id, webhookConfig);
|
|
|
|
if (updatedWebhook.api_version !== STRIPE_API_VERSION) {
|
|
throw new Error(`Webhook ${id} has api_version ${updatedWebhook.api_version}, expected ${STRIPE_API_VERSION}`);
|
|
}
|
|
|
|
return {
|
|
id,
|
|
secret
|
|
};
|
|
} catch (err) {
|
|
this.logging.error(`Unable to update Stripe webhook ${id}`);
|
|
this.logging.error(err);
|
|
if (err.code === 'resource_missing') {
|
|
return setupWebhook(id, secret, {skipDelete: true, forceCreate: true});
|
|
}
|
|
return setupWebhook(id, secret, {skipDelete: false, forceCreate: true});
|
|
}
|
|
}
|
|
};
|
|
|
|
try {
|
|
const webhook = await setupWebhook(config.webhook.id, config.webhook.secret);
|
|
await this.storage.set({
|
|
webhook: {
|
|
webhook_id: webhook.id,
|
|
secret: webhook.secret
|
|
}
|
|
});
|
|
this._webhookSecret = webhook.secret;
|
|
} catch (err) {
|
|
return this._rejectReady(err);
|
|
}
|
|
|
|
return this._resolveReady({
|
|
product: this._product,
|
|
plans: this._plans
|
|
});
|
|
}
|
|
|
|
async parseWebhook(body, signature) {
|
|
try {
|
|
const event = await this._stripe.webhooks.constructEvent(body, signature, this._webhookSecret);
|
|
debug(`Parsed webhook event: ${event.type}`);
|
|
return event;
|
|
} catch (err) {
|
|
this.logging.error(`Error verifying webhook signature, using secret ${this._webhookSecret}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async createCheckoutSession(member, planName, options) {
|
|
let customer;
|
|
if (member) {
|
|
try {
|
|
customer = await this._customerForMemberCheckoutSession(member);
|
|
} catch (err) {
|
|
debug(`Ignoring Error getting customer for checkout ${err.message}`);
|
|
customer = null;
|
|
}
|
|
} else {
|
|
customer = null;
|
|
}
|
|
const plan = this._plans.find(plan => plan.nickname === planName);
|
|
const customerEmail = (!customer && options.customerEmail) ? options.customerEmail : undefined;
|
|
const metadata = options.metadata || undefined;
|
|
const session = await this._stripe.checkout.sessions.create({
|
|
payment_method_types: ['card'],
|
|
success_url: options.successUrl || this._checkoutSuccessUrl,
|
|
cancel_url: options.cancelUrl || this._checkoutCancelUrl,
|
|
customer: customer ? customer.id : undefined,
|
|
customer_email: customerEmail,
|
|
metadata,
|
|
subscription_data: {
|
|
trial_from_plan: true,
|
|
items: [{
|
|
plan: plan.id
|
|
}]
|
|
}
|
|
});
|
|
|
|
return {
|
|
sessionId: session.id,
|
|
publicKey: this._public_token
|
|
};
|
|
}
|
|
|
|
async linkStripeCustomer(id, member) {
|
|
const customer = await retrieve(this._stripe, 'customers', id);
|
|
|
|
await this._updateCustomer(member, customer);
|
|
|
|
debug(`Linking customer:${id} subscriptions`, JSON.stringify(customer.subscriptions));
|
|
|
|
if (customer.subscriptions && customer.subscriptions.data) {
|
|
for (const subscription of customer.subscriptions.data) {
|
|
await this._updateSubscription(subscription);
|
|
}
|
|
}
|
|
|
|
return customer;
|
|
}
|
|
|
|
async getStripeCustomer(id) {
|
|
return await retrieve(this._stripe, 'customers', id);
|
|
}
|
|
|
|
async createCheckoutSetupSession(member, options) {
|
|
const customer = await this._customerForMemberCheckoutSession(member);
|
|
|
|
const session = await this._stripe.checkout.sessions.create({
|
|
mode: 'setup',
|
|
payment_method_types: ['card'],
|
|
success_url: options.successUrl || this._billingSuccessUrl,
|
|
cancel_url: options.cancelUrl || this._billingCancelUrl,
|
|
customer_email: member.email,
|
|
setup_intent_data: {
|
|
metadata: {
|
|
customer_id: customer.id
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
sessionId: session.id,
|
|
publicKey: this._public_token
|
|
};
|
|
}
|
|
|
|
async cancelAllSubscriptions(member) {
|
|
const subscriptions = await this.getSubscriptions(member);
|
|
|
|
const activeSubscriptions = subscriptions.filter((subscription) => {
|
|
return subscription.status !== 'canceled';
|
|
});
|
|
|
|
for (const subscription of activeSubscriptions) {
|
|
try {
|
|
const updatedSubscription = await del(this._stripe, 'subscriptions', subscription.id);
|
|
await this._updateSubscription(updatedSubscription);
|
|
} catch (err) {
|
|
this.logging.error(`There was an error cancelling subscription ${subscription.id}`);
|
|
this.logging.error(err);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async updateSubscriptionFromClient(subscription) {
|
|
const updatedSubscription = await update(
|
|
this._stripe, 'subscriptions',
|
|
subscription.id,
|
|
_.pick(subscription, ['plan', 'cancel_at_period_end'])
|
|
);
|
|
await this._updateSubscription(updatedSubscription);
|
|
|
|
return updatedSubscription;
|
|
}
|
|
|
|
findPlanByNickname(nickname) {
|
|
return this._plans.find(plan => plan.nickname === nickname);
|
|
}
|
|
|
|
async getSubscriptions(member) {
|
|
const metadata = await this.storage.get(member);
|
|
|
|
const customers = metadata.customers.reduce((customers, customer) => {
|
|
return Object.assign(customers, {
|
|
[customer.customer_id]: {
|
|
id: customer.customer_id,
|
|
name: customer.name,
|
|
email: customer.email
|
|
}
|
|
});
|
|
}, {});
|
|
|
|
return metadata.subscriptions.map((subscription) => {
|
|
return {
|
|
id: subscription.subscription_id,
|
|
customer: customers[subscription.customer_id],
|
|
plan: {
|
|
id: subscription.plan_id,
|
|
nickname: subscription.plan_nickname,
|
|
interval: subscription.plan_interval,
|
|
amount: subscription.plan_amount,
|
|
currency: String.prototype.toUpperCase.call(subscription.plan_currency),
|
|
currency_symbol: CURRENCY_SYMBOLS[subscription.plan_currency]
|
|
},
|
|
status: subscription.status,
|
|
start_date: subscription.start_date,
|
|
default_payment_card_last4: subscription.default_payment_card_last4,
|
|
cancel_at_period_end: subscription.cancel_at_period_end,
|
|
current_period_end: subscription.current_period_end
|
|
};
|
|
});
|
|
}
|
|
|
|
async setComplimentarySubscription(member) {
|
|
const subscriptions = await this.getActiveSubscriptions(member);
|
|
|
|
// NOTE: Because we allow for multiple Complimentary plans, need to take into account currently availalbe
|
|
// plan currencies so that we don't end up giving a member complimentary subscription in wrong currency.
|
|
// Giving member a subscription in different currency would prevent them from resubscribing with a regular
|
|
// plan if Complimentary is cancelled (ref. https://stripe.com/docs/billing/customer#currency)
|
|
let complimentaryCurrency = this._plans.find(plan => plan.interval === 'month').currency.toLowerCase();
|
|
|
|
if (subscriptions.length) {
|
|
complimentaryCurrency = subscriptions[0].plan.currency.toLowerCase();
|
|
}
|
|
|
|
const complimentaryFilter = plan => (plan.nickname === 'Complimentary' && plan.currency === complimentaryCurrency);
|
|
const complimentaryPlan = this._plans.find(complimentaryFilter);
|
|
|
|
const customer = await this._customerForMemberCheckoutSession(member);
|
|
|
|
if (!subscriptions.length) {
|
|
const subscription = await create(this._stripe, 'subscriptions', {
|
|
customer: customer.id,
|
|
items: [{
|
|
plan: complimentaryPlan.id
|
|
}]
|
|
});
|
|
|
|
await this._updateSubscription(subscription);
|
|
} else {
|
|
// NOTE: we should only ever have 1 active subscription, but just in case there is more update is done on all of them
|
|
for (const subscription of subscriptions) {
|
|
const updatedSubscription = await update(this._stripe, 'subscriptions', subscription.id, {
|
|
proration_behavior: 'none',
|
|
plan: complimentaryPlan.id
|
|
});
|
|
|
|
await this._updateSubscription(updatedSubscription);
|
|
}
|
|
}
|
|
}
|
|
|
|
async cancelComplimentarySubscription(member) {
|
|
// NOTE: a more explicit way would be cancelling just the "Complimentary" subscription, but doing it
|
|
// through existing method achieves the same as there should be only one subscription at a time
|
|
await this.cancelAllSubscriptions(member);
|
|
}
|
|
|
|
async getActiveSubscriptions(member) {
|
|
const subscriptions = await this.getSubscriptions(member);
|
|
|
|
return subscriptions.filter((subscription) => {
|
|
return subscription.status === 'active' || subscription.status === 'trialing';
|
|
});
|
|
}
|
|
|
|
async handleCheckoutSessionCompletedWebhook(member, customer) {
|
|
await this._updateCustomer(member, customer);
|
|
if (!customer.subscriptions || !customer.subscriptions.data) {
|
|
return;
|
|
}
|
|
for (const subscription of customer.subscriptions.data) {
|
|
await this._updateSubscription(subscription);
|
|
}
|
|
}
|
|
|
|
async handleCheckoutSetupSessionCompletedWebhook(setupIntent, member) {
|
|
const customerId = setupIntent.metadata.customer_id;
|
|
const paymentMethod = setupIntent.payment_method;
|
|
|
|
// NOTE: has to attach payment method before being able to use it as default in the future
|
|
await this._stripe.paymentMethods.attach(paymentMethod, {
|
|
customer: customerId
|
|
});
|
|
|
|
const customer = await this.getCustomer(customerId);
|
|
await this._updateCustomer(member, customer);
|
|
|
|
if (!customer.subscriptions || !customer.subscriptions.data) {
|
|
return;
|
|
}
|
|
|
|
for (const subscription of customer.subscriptions.data) {
|
|
const updatedSubscription = await update(this._stripe, 'subscriptions', subscription.id, {
|
|
default_payment_method: paymentMethod
|
|
});
|
|
await this._updateSubscription(updatedSubscription);
|
|
}
|
|
}
|
|
|
|
async handleCustomerSubscriptionDeletedWebhook(subscription) {
|
|
await this._updateSubscription(subscription);
|
|
}
|
|
|
|
async handleCustomerSubscriptionUpdatedWebhook(subscription) {
|
|
await this._updateSubscription(subscription);
|
|
}
|
|
|
|
async handleInvoicePaymentSucceededWebhook(invoice) {
|
|
const subscription = await retrieve(this._stripe, 'subscriptions', invoice.subscription, {
|
|
expand: ['default_payment_method']
|
|
});
|
|
await this._updateSubscription(subscription);
|
|
}
|
|
|
|
async handleInvoicePaymentFailedWebhook(invoice) {
|
|
const subscription = await retrieve(this._stripe, 'subscriptions', invoice.subscription, {
|
|
expand: ['default_payment_method']
|
|
});
|
|
await this._updateSubscription(subscription);
|
|
}
|
|
|
|
async _updateCustomer(member, customer) {
|
|
debug(`Attaching customer to member ${member.email} ${customer.id}`);
|
|
await this.storage.set({
|
|
customer: {
|
|
customer_id: customer.id,
|
|
member_id: member.id,
|
|
name: customer.name,
|
|
email: customer.email
|
|
}
|
|
});
|
|
}
|
|
|
|
async _updateSubscription(subscription) {
|
|
const payment = subscription.default_payment_method;
|
|
if (typeof payment === 'string') {
|
|
debug(`Fetching default_payment_method for subscription ${subscription.id}`);
|
|
const subscriptionWithPayment = await retrieve(this._stripe, 'subscriptions', subscription.id, {
|
|
expand: ['default_payment_method']
|
|
});
|
|
return this._updateSubscription(subscriptionWithPayment);
|
|
}
|
|
|
|
const mappedSubscription = {
|
|
customer_id: subscription.customer,
|
|
|
|
subscription_id: subscription.id,
|
|
status: subscription.status,
|
|
cancel_at_period_end: subscription.cancel_at_period_end,
|
|
current_period_end: new Date(subscription.current_period_end * 1000),
|
|
start_date: new Date(subscription.start_date * 1000),
|
|
default_payment_card_last4: payment && payment.card && payment.card.last4 || null,
|
|
|
|
plan_id: subscription.plan.id,
|
|
// NOTE: Defaulting to interval as migration to nullable field turned out to be much bigger problem.
|
|
// Ideally, would need nickname field to be nullable on the DB level - condition can be simplified once this is done
|
|
plan_nickname: subscription.plan.nickname || subscription.plan.interval,
|
|
plan_interval: subscription.plan.interval,
|
|
plan_amount: subscription.plan.amount,
|
|
plan_currency: subscription.plan.currency
|
|
};
|
|
|
|
debug(`Attaching subscription to customer ${subscription.customer} ${subscription.id}`);
|
|
debug(`Subscription details`, JSON.stringify(mappedSubscription));
|
|
|
|
await this.storage.set({
|
|
subscription: mappedSubscription
|
|
});
|
|
}
|
|
|
|
async _customerForMemberCheckoutSession(member) {
|
|
const metadata = await this.storage.get(member);
|
|
|
|
for (const data of metadata.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.email}`);
|
|
const customer = await create(this._stripe, 'customers', {
|
|
email: member.email
|
|
});
|
|
|
|
await this._updateCustomer(member, customer);
|
|
|
|
return customer;
|
|
}
|
|
|
|
async getSetupIntent(id, options) {
|
|
return retrieve(this._stripe, 'setupIntents', id, options);
|
|
}
|
|
|
|
async getCustomer(id, options) {
|
|
return retrieve(this._stripe, 'customers', id, options);
|
|
}
|
|
};
|