mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-01 05:50:35 +03:00
342 lines
13 KiB
JavaScript
342 lines
13 KiB
JavaScript
const _ = require('lodash');
|
|
const logging = require('@tryghost/logging');
|
|
const errors = require('@tryghost/errors');
|
|
const {DonationPaymentEvent} = require('@tryghost/donations');
|
|
|
|
module.exports = class WebhookController {
|
|
/**
|
|
* @param {object} deps
|
|
* @param {import('./StripeAPI')} deps.api
|
|
* @param {import('./WebhookManager')} deps.webhookManager
|
|
* @param {any} deps.eventRepository
|
|
* @param {any} deps.memberRepository
|
|
* @param {any} deps.productRepository
|
|
* @param {import('@tryghost/donations').DonationRepository} deps.donationRepository
|
|
* @param {any} deps.staffServiceEmails
|
|
* @param {any} deps.sendSignupEmail
|
|
*/
|
|
constructor(deps) {
|
|
this.deps = deps;
|
|
this.webhookManager = deps.webhookManager;
|
|
this.api = deps.api;
|
|
this.sendSignupEmail = deps.sendSignupEmail;
|
|
this.handlers = {
|
|
'customer.subscription.deleted': this.subscriptionEvent,
|
|
'customer.subscription.updated': this.subscriptionEvent,
|
|
'customer.subscription.created': this.subscriptionEvent,
|
|
'invoice.payment_succeeded': this.invoiceEvent,
|
|
'checkout.session.completed': this.checkoutSessionEvent
|
|
};
|
|
}
|
|
|
|
async handle(req, res) {
|
|
// if (!apiService.configured) {
|
|
// logging.error(`Stripe not configured, not handling webhook`);
|
|
// res.writeHead(400);
|
|
// return res.end();
|
|
// }
|
|
|
|
if (!req.body || !req.headers['stripe-signature']) {
|
|
res.writeHead(400);
|
|
return res.end();
|
|
}
|
|
let event;
|
|
try {
|
|
event = this.webhookManager.parseWebhook(req.body, req.headers['stripe-signature']);
|
|
} catch (err) {
|
|
logging.error(err);
|
|
res.writeHead(401);
|
|
return res.end();
|
|
}
|
|
|
|
logging.info(`Handling webhook ${event.type}`);
|
|
try {
|
|
await this.handleEvent(event);
|
|
res.writeHead(200);
|
|
res.end();
|
|
} catch (err) {
|
|
logging.error(`Error handling webhook ${event.type}`, err);
|
|
res.writeHead(err.statusCode || 500);
|
|
res.end();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
async handleEvent(event) {
|
|
if (!this.handlers[event.type]) {
|
|
return;
|
|
}
|
|
|
|
await this.handlers[event.type].call(this, event.data.object);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
async subscriptionEvent(subscription) {
|
|
const subscriptionPriceData = _.get(subscription, 'items.data');
|
|
if (!subscriptionPriceData || subscriptionPriceData.length !== 1) {
|
|
throw new errors.BadRequestError({
|
|
message: 'Subscription should have exactly 1 price item'
|
|
});
|
|
}
|
|
|
|
const member = await this.deps.memberRepository.get({
|
|
customer_id: subscription.customer
|
|
});
|
|
|
|
if (member) {
|
|
try {
|
|
await this.deps.memberRepository.linkSubscription({
|
|
id: member.id,
|
|
subscription
|
|
});
|
|
} catch (err) {
|
|
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
|
|
throw err;
|
|
}
|
|
throw new errors.ConflictError({
|
|
err
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('stripe').Stripe.Invoice} invoice
|
|
* @private
|
|
*/
|
|
async invoiceEvent(invoice) {
|
|
if (!invoice.subscription) {
|
|
// Check if this is a one time payment, related to a donation
|
|
if (invoice.metadata.ghost_donation && invoice.paid) {
|
|
// Track a one time payment event
|
|
const amount = invoice.amount_paid;
|
|
|
|
const member = invoice.customer ? (await this.deps.memberRepository.get({
|
|
customer_id: invoice.customer
|
|
})) : null;
|
|
|
|
const data = DonationPaymentEvent.create({
|
|
name: member?.get('name') ?? invoice.customer_name,
|
|
email: member?.get('email') ?? invoice.customer_email,
|
|
memberId: member?.id ?? null,
|
|
amount,
|
|
currency: invoice.currency,
|
|
|
|
// Attribution data
|
|
attributionId: invoice.metadata.attribution_id ?? null,
|
|
attributionUrl: invoice.metadata.attribution_url ?? null,
|
|
attributionType: invoice.metadata.attribution_type ?? null,
|
|
referrerSource: invoice.metadata.referrer_source ?? null,
|
|
referrerMedium: invoice.metadata.referrer_medium ?? null,
|
|
referrerUrl: invoice.metadata.referrer_url ?? null
|
|
});
|
|
|
|
await this.deps.donationRepository.save(data);
|
|
await this.deps.staffServiceEmails.notifyDonationReceived({
|
|
donationPaymentEvent: data
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
const subscription = await this.api.getSubscription(invoice.subscription, {
|
|
expand: ['default_payment_method']
|
|
});
|
|
|
|
const member = await this.deps.memberRepository.get({
|
|
customer_id: subscription.customer
|
|
});
|
|
|
|
if (member) {
|
|
if (invoice.paid && invoice.amount_paid !== 0) {
|
|
await this.deps.eventRepository.registerPayment({
|
|
member_id: member.id,
|
|
currency: invoice.currency,
|
|
amount: invoice.amount_paid
|
|
});
|
|
}
|
|
} else {
|
|
// Subscription has more than one plan - meaning it is not one created by us - ignore.
|
|
if (!subscription.plan) {
|
|
return;
|
|
}
|
|
// Subscription is for a different product - ignore.
|
|
const product = await this.deps.productRepository.get({
|
|
stripe_product_id: subscription.plan.product
|
|
});
|
|
if (!product) {
|
|
return;
|
|
}
|
|
|
|
// Could not find the member, which we need in order to insert an payment event.
|
|
throw new errors.NotFoundError({
|
|
message: `No member found for customer ${subscription.customer}`
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
async checkoutSessionEvent(session) {
|
|
if (session.mode === 'setup') {
|
|
const setupIntent = await this.api.getSetupIntent(session.setup_intent);
|
|
const member = await this.deps.memberRepository.get({
|
|
customer_id: setupIntent.metadata.customer_id
|
|
});
|
|
|
|
if (!member) {
|
|
return;
|
|
}
|
|
|
|
await this.api.attachPaymentMethodToCustomer(
|
|
setupIntent.metadata.customer_id,
|
|
setupIntent.payment_method
|
|
);
|
|
|
|
if (setupIntent.metadata.subscription_id) {
|
|
const updatedSubscription = await this.api.updateSubscriptionDefaultPaymentMethod(
|
|
setupIntent.metadata.subscription_id,
|
|
setupIntent.payment_method
|
|
);
|
|
try {
|
|
await this.deps.memberRepository.linkSubscription({
|
|
id: member.id,
|
|
subscription: updatedSubscription
|
|
});
|
|
} catch (err) {
|
|
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
|
|
throw err;
|
|
}
|
|
throw new errors.ConflictError({
|
|
err
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const subscriptions = await member.related('stripeSubscriptions').fetch();
|
|
|
|
const activeSubscriptions = subscriptions.models.filter((subscription) => {
|
|
return ['active', 'trialing', 'unpaid', 'past_due'].includes(subscription.get('status'));
|
|
});
|
|
|
|
for (const subscription of activeSubscriptions) {
|
|
if (subscription.get('customer_id') === setupIntent.metadata.customer_id) {
|
|
const updatedSubscription = await this.api.updateSubscriptionDefaultPaymentMethod(
|
|
subscription.get('subscription_id'),
|
|
setupIntent.payment_method
|
|
);
|
|
try {
|
|
await this.deps.memberRepository.linkSubscription({
|
|
id: member.id,
|
|
subscription: updatedSubscription
|
|
});
|
|
} catch (err) {
|
|
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
|
|
throw err;
|
|
}
|
|
throw new errors.ConflictError({
|
|
err
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (session.mode === 'subscription') {
|
|
const customer = await this.api.getCustomer(session.customer, {
|
|
expand: ['subscriptions.data.default_payment_method']
|
|
});
|
|
|
|
let member = await this.deps.memberRepository.get({
|
|
email: customer.email
|
|
});
|
|
|
|
const checkoutType = _.get(session, 'metadata.checkoutType');
|
|
|
|
if (!member) {
|
|
const metadataName = _.get(session, 'metadata.name');
|
|
const metadataNewsletters = _.get(session, 'metadata.newsletters');
|
|
const attribution = {
|
|
id: session.metadata.attribution_id ?? null,
|
|
url: session.metadata.attribution_url ?? null,
|
|
type: session.metadata.attribution_type ?? null,
|
|
referrerSource: session.metadata.referrer_source ?? null,
|
|
referrerMedium: session.metadata.referrer_medium ?? null,
|
|
referrerUrl: session.metadata.referrer_url ?? null
|
|
};
|
|
|
|
const payerName = _.get(customer, 'subscriptions.data[0].default_payment_method.billing_details.name');
|
|
const name = metadataName || payerName || null;
|
|
|
|
const memberData = {email: customer.email, name, attribution};
|
|
if (metadataNewsletters) {
|
|
try {
|
|
memberData.newsletters = JSON.parse(metadataNewsletters);
|
|
} catch (e) {
|
|
logging.error(`Ignoring invalid newsletters data - ${metadataNewsletters}.`);
|
|
}
|
|
}
|
|
|
|
const offerId = session.metadata?.offer;
|
|
|
|
const memberDataWithStripeCustomer = {
|
|
...memberData,
|
|
stripeCustomer: customer,
|
|
offerId
|
|
};
|
|
member = await this.deps.memberRepository.create(memberDataWithStripeCustomer);
|
|
} else {
|
|
const payerName = _.get(customer, 'subscriptions.data[0].default_payment_method.billing_details.name');
|
|
const attribution = {
|
|
id: session.metadata?.attribution_id ?? null,
|
|
url: session.metadata?.attribution_url ?? null,
|
|
type: session.metadata?.attribution_type ?? null,
|
|
referrerSource: session.metadata.referrer_source ?? null,
|
|
referrerMedium: session.metadata.referrer_medium ?? null,
|
|
referrerUrl: session.metadata.referrer_url ?? null
|
|
};
|
|
|
|
if (payerName && !member.get('name')) {
|
|
await this.deps.memberRepository.update({name: payerName}, {id: member.get('id')});
|
|
}
|
|
|
|
await this.deps.memberRepository.upsertCustomer({
|
|
customer_id: customer.id,
|
|
member_id: member.id,
|
|
name: customer.name,
|
|
email: customer.email
|
|
});
|
|
|
|
for (const subscription of customer.subscriptions.data) {
|
|
try {
|
|
const offerId = session.metadata?.offer;
|
|
|
|
await this.deps.memberRepository.linkSubscription({
|
|
id: member.id,
|
|
subscription,
|
|
offerId,
|
|
attribution
|
|
});
|
|
} catch (err) {
|
|
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
|
|
throw err;
|
|
}
|
|
throw new errors.ConflictError({
|
|
err
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (checkoutType !== 'upgrade') {
|
|
this.sendSignupEmail(customer.email);
|
|
}
|
|
}
|
|
}
|
|
};
|