Refactored Stripe webhook controller (#20918)

no issue

- Moved business logic from `WebhookController` to dedicated service
classes (`SubscriptionEventService`, `InvoiceEventService`,
`CheckoutSessionEventService`).
- Reduced controller complexity.
- Added unit tests for individual services, increasing overall test
coverage.
- Improved maintainability and scalability by isolating responsibilities
in specific services, making future updates easier and safer.
This commit is contained in:
Ronald Langeveld 2024-09-09 12:54:22 +09:00 committed by GitHub
parent c96744156e
commit a64eaeccf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1460 additions and 387 deletions

View File

@ -4,6 +4,9 @@ const StripeMigrations = require('./StripeMigrations');
const WebhookController = require('./WebhookController');
const DomainEvents = require('@tryghost/domain-events');
const {StripeLiveEnabledEvent, StripeLiveDisabledEvent} = require('./events');
const SubscriptionEventService = require('./services/webhook/SubscriptionEventService');
const InvoiceEventService = require('./services/webhook/InvoiceEventService');
const CheckoutSessionEventService = require('./services/webhook/CheckoutSessionEventService');
module.exports = class StripeService {
constructor({
@ -15,30 +18,50 @@ module.exports = class StripeService {
models
}) {
const api = new StripeAPI({labs});
const webhookManager = new WebhookManager({
StripeWebhook,
api
});
const migrations = new StripeMigrations({
models,
api
});
const webhookController = new WebhookController({
webhookManager,
const webhookManager = new WebhookManager({
StripeWebhook,
api
});
const subscriptionEventService = new SubscriptionEventService({
get memberRepository(){
return membersService.api.members;
}
});
const invoiceEventService = new InvoiceEventService({
api,
get memberRepository(){
return membersService.api.members;
},
get productRepository() {
return membersService.api.productRepository;
},
get eventRepository() {
get eventRepository(){
return membersService.api.events;
},
get donationRepository() {
get productRepository(){
return membersService.api.productRepository;
}
});
const checkoutSessionEventService = new CheckoutSessionEventService({
api,
get memberRepository(){
return membersService.api.members;
},
get productRepository(){
return membersService.api.productRepository;
},
get eventRepository(){
return membersService.api.events;
},
get donationRepository(){
return donationService.repository;
},
get staffServiceEmails() {
get staffServiceEmails(){
return staffService.api.emails;
},
sendSignupEmail(email){
@ -53,6 +76,13 @@ module.exports = class StripeService {
}
});
const webhookController = new WebhookController({
webhookManager,
subscriptionEventService,
invoiceEventService,
checkoutSessionEventService
});
this.models = models;
this.api = api;
this.webhookManager = webhookManager;

View File

@ -1,25 +1,18 @@
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
* @param {import('./services/webhook/CheckoutSessionEventService')} deps.checkoutSessionEventService
* @param {import('./services/webhook/SubscriptionEventService')} deps.subscriptionEventService
* @param {import('./services/webhook/InvoiceEventService')} deps.invoiceEventService
*/
constructor(deps) {
this.deps = deps;
this.checkoutSessionEventService = deps.checkoutSessionEventService;
this.subscriptionEventService = deps.subscriptionEventService;
this.invoiceEventService = deps.invoiceEventService;
this.webhookManager = deps.webhookManager;
this.api = deps.api;
this.sendSignupEmail = deps.sendSignupEmail;
this.handlers = {
'customer.subscription.deleted': this.subscriptionEvent,
'customer.subscription.updated': this.subscriptionEvent,
@ -76,32 +69,7 @@ module.exports = class WebhookController {
* @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
});
}
}
await this.subscriptionEventService.handleSubscriptionEvent(subscription);
}
/**
@ -109,240 +77,13 @@ module.exports = class WebhookController {
* @private
*/
async invoiceEvent(invoice) {
if (!invoice.subscription) {
// Check if this is a one time payment, related to a donation
// this is being handled in checkoutSessionEvent because we need to handle the custom donation message
// which is not available in the invoice object
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}`
});
}
await this.invoiceEventService.handleInvoiceEvent(invoice);
}
/**
* @private
*/
async checkoutSessionEvent(session) {
if (session.mode === 'payment' && session.metadata?.ghost_donation) {
const donationField = session.custom_fields?.find(obj => obj?.key === 'donation_message');
// const customMessage = donationField?.text?.value ?? '';
// custom message should be null if it's empty
const donationMessage = donationField?.text?.value ? donationField.text.value : null;
const amount = session.amount_total;
const currency = session.currency;
const member = session.customer ? (await this.deps.memberRepository.get({
customer_id: session.customer
})) : null;
const data = DonationPaymentEvent.create({
name: member?.get('name') ?? session.customer_details.name,
email: member?.get('email') ?? session.customer_details.email,
memberId: member?.id ?? null,
amount,
currency,
donationMessage,
attributionId: session.metadata.attribution_id ?? null,
attributionUrl: session.metadata.attribution_url ?? null,
attributionType: session.metadata.attribution_type ?? null,
referrerSource: session.metadata.referrer_source ?? null,
referrerMedium: session.metadata.referrer_medium ?? null,
referrerUrl: session.metadata.referrer_url ?? null
});
await this.deps.donationRepository.save(data);
await this.deps.staffServiceEmails.notifyDonationReceived({
donationPaymentEvent: data
});
}
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);
}
}
await this.checkoutSessionEventService.handleEvent(session);
}
};

View File

@ -0,0 +1,218 @@
const {DonationPaymentEvent} = require('@tryghost/donations');
const _ = require('lodash');
const errors = require('@tryghost/errors');
const logging = require('@tryghost/logging');
module.exports = class CheckoutSessionEventService {
constructor(deps) {
this.api = deps.api;
this.deps = deps; // Store the deps object to access repositories dynamically later
}
async handleEvent(session) {
if (session.mode === 'setup') {
await this.handleSetupEvent(session);
}
if (session.mode === 'subscription') {
await this.handleSubscriptionEvent(session);
}
if (session.mode === 'payment' && session.metadata?.ghost_donation) {
await this.handleDonationEvent(session);
}
}
async handleDonationEvent(session) {
const donationField = session.custom_fields?.find(obj => obj?.key === 'donation_message');
const donationMessage = donationField?.text?.value ? donationField.text.value : null;
const amount = session.amount_total;
const currency = session.currency;
// Access the memberRepository dynamically when needed
const memberRepository = this.deps.memberRepository;
const member = session.customer ? (await memberRepository.get({customer_id: session.customer})) : null;
const data = DonationPaymentEvent.create({
name: member?.get('name') ?? session.customer_details.name,
email: member?.get('email') ?? session.customer_details.email,
memberId: member?.id ?? null,
amount,
currency,
donationMessage,
attributionId: session.metadata.attribution_id ?? null,
attributionUrl: session.metadata.attribution_url ?? null,
attributionType: session.metadata.attribution_type ?? null,
referrerSource: session.metadata.referrer_source ?? null,
referrerMedium: session.metadata.referrer_medium ?? null,
referrerUrl: session.metadata.referrer_url ?? null
});
// Access the donationRepository dynamically when needed
const donationRepository = this.deps.donationRepository;
await donationRepository.save(data);
// Access the staffServiceEmails dynamically when needed
const staffServiceEmails = this.deps.staffServiceEmails;
await staffServiceEmails.notifyDonationReceived({donationPaymentEvent: data});
}
async handleSetupEvent(session) {
const setupIntent = await this.api.getSetupIntent(session.setup_intent);
// Access the memberRepository dynamically when needed
const memberRepository = this.deps.memberRepository;
const member = await 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 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 => ['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 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
});
}
}
}
}
async handleSubscriptionEvent(session) {
const customer = await this.api.getCustomer(session.customer, {
expand: ['subscriptions.data.default_payment_method']
});
// Access the memberRepository dynamically when needed
const memberRepository = this.deps.memberRepository;
let member = await 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 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 memberRepository.update({name: payerName}, {id: member.get('id')});
}
await 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 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.deps.sendSignupEmail(customer.email);
}
}
};

View File

@ -0,0 +1,52 @@
const errors = require('@tryghost/errors');
// const _ = require('lodash');
module.exports = class InvoiceEventService {
constructor(deps) {
this.deps = deps;
}
async handleInvoiceEvent(invoice) {
const {api, memberRepository, eventRepository, productRepository} = this.deps;
if (!invoice.subscription) {
// Check if this is a one time payment, related to a donation
// this is being handled in checkoutSessionEvent because we need to handle the custom donation message
// which is not available in the invoice object
return;
}
const subscription = await api.getSubscription(invoice.subscription, {
expand: ['default_payment_method']
});
const member = await memberRepository.get({
customer_id: subscription.customer
});
if (member) {
if (invoice.paid && invoice.amount_paid !== 0) {
await 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 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}`
});
}
}
};

View File

@ -0,0 +1,36 @@
const errors = require('@tryghost/errors');
const _ = require('lodash');
module.exports = class SubscriptionEventService {
constructor(deps) {
this.deps = deps;
}
async handleSubscriptionEvent(subscription) {
const subscriptionPriceData = _.get(subscription, 'items.data');
if (!subscriptionPriceData || subscriptionPriceData.length !== 1) {
throw new errors.BadRequestError({
message: 'Subscription should have exactly 1 price item'
});
}
// Accessing the member repository dynamically from deps
const memberRepository = this.deps.memberRepository;
const member = await memberRepository.get({
customer_id: subscription.customer
});
if (member) {
try {
await 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});
}
}
}
};

View File

@ -1,8 +1,6 @@
const chai = require('chai');
const assert = require('assert/strict');
const sinon = require('sinon');
const {expect} = chai;
const WebhookController = require('../../../lib/WebhookController');
// const {DonationPaymentEvent} = require('@tryghost/donations');
describe('WebhookController', function () {
let controller;
@ -12,14 +10,10 @@ describe('WebhookController', function () {
beforeEach(function () {
deps = {
api: {getSubscription: sinon.stub(), getCustomer: sinon.stub(), getSetupIntent: sinon.stub(), attachPaymentMethodToCustomer: sinon.stub(), updateSubscriptionDefaultPaymentMethod: sinon.stub()},
webhookManager: {parseWebhook: sinon.stub()},
eventRepository: {registerPayment: sinon.stub()},
memberRepository: {get: sinon.stub(), create: sinon.stub(), update: sinon.stub(), linkSubscription: sinon.stub(), upsertCustomer: sinon.stub()},
donationRepository: {save: sinon.stub()},
productRepository: {get: sinon.stub()},
staffServiceEmails: {notifyDonationReceived: sinon.stub()},
sendSignupEmail: sinon.stub()
subscriptionEventService: {handleSubscriptionEvent: sinon.stub()},
invoiceEventService: {handleInvoiceEvent: sinon.stub()},
checkoutSessionEventService: {handleEvent: sinon.stub(), handleDonationEvent: sinon.stub()},
webhookManager: {parseWebhook: sinon.stub()}
};
controller = new WebhookController(deps);
@ -40,135 +34,134 @@ describe('WebhookController', function () {
it('should return 400 if request body or signature is missing', async function () {
req.body = null;
await controller.handle(req, res);
expect(res.writeHead.calledWith(400)).to.be.true;
expect(res.end.called).to.be.true;
assert(res.writeHead.calledWith(400));
});
it('should return 401 if webhook signature is invalid', async function () {
deps.webhookManager.parseWebhook.throws(new Error('Invalid signature'));
await controller.handle(req, res);
expect(res.writeHead.calledWith(401)).to.be.true;
expect(res.end.called).to.be.true;
assert(res.writeHead.calledWith(401));
assert(res.end.called);
});
it('should handle customer.subscription.created event', async function () {
const event = {
type: 'customer.subscription.created',
data: {
object: {customer: 'cust_123', items: {data: [{price: {id: 'price_123'}}]}}
object: {customer: 'cust_123'}
}
};
deps.webhookManager.parseWebhook.returns(event);
deps.memberRepository.get.resolves({id: 'member_123'});
await controller.handle(req, res);
expect(deps.memberRepository.get.calledWith({customer_id: 'cust_123'})).to.be.true;
expect(deps.memberRepository.linkSubscription.calledOnce).to.be.true;
expect(res.writeHead.calledWith(200)).to.be.true;
expect(res.end.called).to.be.true;
assert(deps.subscriptionEventService.handleSubscriptionEvent.calledOnce);
assert(res.writeHead.calledWith(200));
assert(res.end.called);
});
it('should handle a donation in checkoutSessionEvent', async function () {
const session = {
mode: 'payment',
metadata: {
ghost_donation: true,
attribution_id: 'attr_123',
attribution_url: 'https://example.com',
attribution_type: 'referral',
referrer_source: 'google',
referrer_medium: 'cpc',
referrer_url: 'https://referrer.com'
},
amount_total: 5000,
currency: 'usd',
customer: 'cust_123',
customer_details: {
name: 'John Doe',
email: 'john@example.com'
},
custom_fields: [{
key: 'donation_message',
text: {
value: 'Thank you for the awesome newsletter!'
}
}]
it('should handle invoice.payment_succeeded event', async function () {
const event = {
type: 'invoice.payment_succeeded',
data: {
object: {subscription: 'sub_123'}
}
};
deps.webhookManager.parseWebhook.returns(event);
const member = {
id: 'member_123',
get: sinon.stub()
};
await controller.handle(req, res);
member.get.withArgs('name').returns('John Doe');
member.get.withArgs('email').returns('john@example.com');
assert(deps.invoiceEventService.handleInvoiceEvent.calledOnce);
assert(res.writeHead.calledWith(200));
assert(res.end.called);
deps.memberRepository.get.resolves(member);
await controller.checkoutSessionEvent(session);
expect(deps.memberRepository.get.calledWith({customer_id: 'cust_123'})).to.be.true;
expect(deps.donationRepository.save.calledOnce).to.be.true;
expect(deps.staffServiceEmails.notifyDonationReceived.calledOnce).to.be.true;
const savedDonationEvent = deps.donationRepository.save.getCall(0).args[0];
expect(savedDonationEvent.amount).to.equal(5000);
expect(savedDonationEvent.currency).to.equal('usd');
expect(savedDonationEvent.name).to.equal('John Doe');
expect(savedDonationEvent.email).to.equal('john@example.com');
expect(savedDonationEvent.donationMessage).to.equal('Thank you for the awesome newsletter!');
expect(savedDonationEvent.attributionId).to.equal('attr_123');
expect(savedDonationEvent.attributionUrl).to.equal('https://example.com');
expect(savedDonationEvent.attributionType).to.equal('referral');
expect(savedDonationEvent.referrerSource).to.equal('google');
expect(savedDonationEvent.referrerMedium).to.equal('cpc');
expect(savedDonationEvent.referrerUrl).to.equal('https://referrer.com');
// expect(deps.invoiceEventService.handleInvoiceEvent.calledOnce).to.be.true;
// expect(res.writeHead.calledWith(200)).to.be.true;
// expect(res.end.called).to.be.true;
});
it('donation message is null if string is empty', async function () {
const session = {
mode: 'payment',
metadata: {
ghost_donation: true,
attribution_id: 'attr_123',
attribution_url: 'https://example.com',
attribution_type: 'referral',
referrer_source: 'google',
referrer_medium: 'cpc',
referrer_url: 'https://referrer.com'
},
amount_total: 5000,
currency: 'usd',
customer: 'cust_123',
customer_details: {
name: 'JW',
email: 'jw@ily.co'
},
custom_fields: [{
key: 'donation_message',
text: {
value: ''
}
}]
it('should handle checkout.session.completed event', async function () {
const event = {
type: 'checkout.session.completed',
data: {
object: {customer: 'cust_123'}
}
};
deps.webhookManager.parseWebhook.returns(event);
await controller.handle(req, res);
assert(deps.checkoutSessionEventService.handleEvent.calledOnce);
assert(res.writeHead.calledWith(200));
assert(res.end.called);
// expect(deps.checkoutSessionEventService.handleEvent.calledOnce).to.be.true;
// expect(res.writeHead.calledWith(200)).to.be.true;
// expect(res.end.called).to.be.true;
});
it('should handle customer subscription updated event', async function () {
const event = {
type: 'customer.subscription.updated',
data: {
object: {customer: 'cust_123'}
}
};
const member = {
id: 'member_123',
get: sinon.stub()
deps.webhookManager.parseWebhook.returns(event);
await controller.handle(req, res);
assert(deps.subscriptionEventService.handleSubscriptionEvent.calledOnce);
assert(res.writeHead.calledWith(200));
assert(res.end.called);
});
it('should handle customer.subscription.deleted event', async function () {
const event = {
type: 'customer.subscription.deleted',
data: {
object: {customer: 'cust_123'}
}
};
member.get.withArgs('name').returns('JW');
member.get.withArgs('email').returns('jw@ily.co');
deps.webhookManager.parseWebhook.returns(event);
deps.memberRepository.get.resolves(member);
await controller.handle(req, res);
await controller.checkoutSessionEvent(session);
assert(deps.subscriptionEventService.handleSubscriptionEvent.calledOnce);
assert(res.writeHead.calledWith(200));
assert(res.end.called);
});
expect(deps.memberRepository.get.calledWith({customer_id: 'cust_123'})).to.be.true;
it('should return 500 if an error occurs', async function () {
const event = {
type: 'customer.subscription.created',
data: {
object: {customer: 'cust_123'}
}
};
const savedDonationEvent = deps.donationRepository.save.getCall(0).args[0];
deps.webhookManager.parseWebhook.returns(event);
deps.subscriptionEventService.handleSubscriptionEvent.throws(new Error('Unexpected error'));
expect(savedDonationEvent.donationMessage).to.equal(null);
await controller.handle(req, res);
assert(res.writeHead.calledWith(500));
assert(res.end.called);
});
it('should not handle unknown event type', async function () {
const event = {
type: 'invalid.event',
data: {
object: {customer: 'cust_123'}
}
};
deps.webhookManager.parseWebhook.returns(event);
await controller.handle(req, res);
assert(res.writeHead.calledWith(200));
assert(res.end.called);
});
});

View File

@ -0,0 +1,677 @@
const assert = require('assert/strict');
const errors = require('@tryghost/errors');
const CheckoutSessionEventService = require('../../../../../lib/services/webhook/CheckoutSessionEventService');
describe('CheckoutSessionEventService', function () {
let api, memberRepository, donationRepository, staffServiceEmails, sendSignupEmail;
beforeEach(function () {
api = {
getSubscription: sinon.stub(),
getCustomer: sinon.stub(),
getSetupIntent: sinon.stub(),
attachPaymentMethodToCustomer: sinon.stub(),
updateSubscriptionDefaultPaymentMethod: sinon.stub()
};
memberRepository = {
get: sinon.stub(),
create: sinon.stub(),
update: sinon.stub(),
linkSubscription: sinon.stub(),
upsertCustomer: sinon.stub()
};
donationRepository = {
save: sinon.stub()
};
staffServiceEmails = {
notifyDonationReceived: sinon.stub()
};
sendSignupEmail = sinon.stub();
});
describe('handleEvent', function () {
it('should call handleSetupEvent if session mode is setup', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {mode: 'setup'};
const handleSetupEventStub = sinon.stub(service, 'handleSetupEvent');
await service.handleEvent(session);
assert(handleSetupEventStub.calledWith(session));
});
it('should call handleSubscriptionEvent if session mode is subscription', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {mode: 'subscription'};
const handleSubscriptionEventStub = sinon.stub(service, 'handleSubscriptionEvent');
await service.handleEvent(session);
assert(handleSubscriptionEventStub.calledWith(session));
});
it('should call handleDonationEvent if session mode is payment and session metadata ghost_donation is present', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {mode: 'payment', metadata: {ghost_donation: true}};
const handleDonationEventStub = sinon.stub(service, 'handleDonationEvent');
await service.handleEvent(session);
assert(handleDonationEventStub.calledWith(session));
});
it('should do nothing if session mode is not setup, subscription, or payment', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {mode: 'unsupported_mode'};
const handleSetupEventStub = sinon.stub(service, 'handleSetupEvent');
const handleSubscriptionEventStub = sinon.stub(service, 'handleSubscriptionEvent');
const handleDonationEventStub = sinon.stub(service, 'handleDonationEvent');
await service.handleEvent(session);
assert(!handleSetupEventStub.called);
assert(!handleSubscriptionEventStub.called);
assert(!handleDonationEventStub.called);
});
});
describe('handleDonationEvent', function () {
it('can handle donation event', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {
custom_fields: [{key: 'donation_message', text: {value: 'Test donation message'}}],
amount_total: 1000,
currency: 'usd',
customer: 'cust_123',
customer_details: {name: 'Test Name', email: ''},
metadata: {
attribution_id: 'attr_123',
attribution_url: 'https://example.com/blog/',
attribution_type: 'referral',
referrer_source: 'google',
referrer_medium: 'cpc',
referrer_url: 'https://referrer.com'
}
};
memberRepository.get.resolves(null);
await service.handleDonationEvent(session);
assert(donationRepository.save.calledOnce);
const savedDonationEvent = donationRepository.save.getCall(0).args[0];
assert.equal(savedDonationEvent.amount, 1000);
assert.equal(savedDonationEvent.currency, 'usd');
assert.equal(savedDonationEvent.name, 'Test Name');
assert.equal(savedDonationEvent.email, '');
assert.equal(savedDonationEvent.donationMessage, 'Test donation message');
assert.equal(savedDonationEvent.attributionId, 'attr_123');
assert.equal(savedDonationEvent.attributionUrl, 'https://example.com/blog/');
assert.equal(savedDonationEvent.attributionType, 'referral');
assert.equal(savedDonationEvent.referrerSource, 'google');
assert.equal(savedDonationEvent.referrerMedium, 'cpc');
assert.equal(savedDonationEvent.referrerUrl, 'https://referrer.com');
});
it('donation message is null if its empty', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {
custom_fields: [{key: 'donation_message', text: {value: ''}},
{key: 'donation_message', text: {value: null}}],
amount_total: 1000,
currency: 'usd',
customer: 'cust_123',
customer_details: {name: 'Test Name', email: ''},
metadata: {
attribution_id: 'attr_123',
attribution_url: 'https://example.com/blog/',
attribution_type: 'referral',
referrer_source: 'google',
referrer_medium: 'cpc',
referrer_url: 'https://referrer.com'
}
};
memberRepository.get.resolves(null);
await service.handleDonationEvent(session);
assert(donationRepository.save.calledOnce);
const savedDonationEvent = donationRepository.save.getCall(0).args[0];
assert.equal(savedDonationEvent.donationMessage, null);
});
it('can handle donation event with member', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {
custom_fields: [{key: 'donation_message', text: {value: 'Test donation message'}}],
amount_total: 1000,
currency: 'usd',
customer: 'cust_123',
customer_details: {name: 'Test Name', email: 'member@example.com'},
metadata: {
attribution_id: 'attr_123',
attribution_url: 'https://example.com/blog/',
attribution_type: 'referral',
referrer_source: 'google',
referrer_medium: 'cpc',
referrer_url: 'https://referrer.com'
}
};
const member = {
get: sinon.stub(),
id: 'member_123'
};
// Stub the `get` method on the member object
member.get.withArgs('name').returns('Test Name');
member.get.withArgs('email').returns('member@example.com');
memberRepository.get.resolves(member);
await service.handleDonationEvent(session);
// expect(donationRepository.save.calledOnce).to.be.true;
assert(donationRepository.save.calledOnce);
const savedDonationEvent = donationRepository.save.getCall(0).args[0];
assert.equal(savedDonationEvent.amount, 1000);
assert.equal(savedDonationEvent.currency, 'usd');
assert.equal(savedDonationEvent.name, 'Test Name');
assert.equal(savedDonationEvent.email, 'member@example.com');
assert.equal(savedDonationEvent.donationMessage, 'Test donation message');
assert.equal(savedDonationEvent.attributionId, 'attr_123');
assert.equal(savedDonationEvent.attributionUrl, 'https://example.com/blog/');
assert.equal(savedDonationEvent.attributionType, 'referral');
assert.equal(savedDonationEvent.referrerSource, 'google');
assert.equal(savedDonationEvent.referrerMedium, 'cpc');
assert.equal(savedDonationEvent.referrerUrl, 'https://referrer.com');
});
it('can handle donation event with empty customer email', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {
custom_fields: [{key: 'donation_message', text: {value: 'Test donation message'}}],
amount_total: 1000,
currency: 'usd',
customer: 'cust_123',
customer_details: {name: 'Test Name', email: ''},
metadata: {
attribution_id: 'attr_123',
attribution_url: 'https://example.com/blog/',
attribution_type: 'referral',
referrer_source: 'google',
referrer_medium: 'cpc',
referrer_url: 'https://referrer.com'
}
};
const member = {
get: sinon.stub(),
id: 'member_123'
};
member.get.withArgs('name').returns('Test Name');
member.get.withArgs('email').returns('');
memberRepository.get.resolves(member);
await service.handleDonationEvent(session);
assert(donationRepository.save.calledOnce);
const savedDonationEvent = donationRepository.save.getCall(0).args[0];
assert.equal(savedDonationEvent.amount, 1000);
});
});
describe('handleSetupEvent', function () {
it('fires getSetupIntent', function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {setup_intent: 'si_123'};
service.handleSetupEvent(session);
assert(api.getSetupIntent.calledWith('si_123'));
});
it('fires getSetupIntent and memberRepository.get', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {setup_intent: 'si_123'};
const setupIntent = {metadata: {customer_id: 'cust_123'}};
api.getSetupIntent.resolves(setupIntent);
await service.handleSetupEvent(session);
assert(api.getSetupIntent.calledWith('si_123'));
assert(memberRepository.get.calledWith({customer_id: 'cust_123'}));
});
it('fires getSetupIntent, memberRepository.get and attachPaymentMethodToCustomer', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {setup_intent: 'si_123'};
const setupIntent = {metadata: {customer_id: 'cust_123'}, payment_method: 'pm_123'};
const member = {id: 'member_123', related: sinon.stub()};
const fetchStub = sinon.stub();
member.related.withArgs('stripeSubscriptions').returns({fetch: fetchStub});
const mockSubscriptions = [
{get: sinon.stub().withArgs('status').returns('active')},
{get: sinon.stub().withArgs('status').returns('trialing')},
{get: sinon.stub().withArgs('status').returns('unpaid')}
];
fetchStub.resolves({models: mockSubscriptions});
api.getSetupIntent.resolves(setupIntent);
memberRepository.get.resolves(member);
await service.handleSetupEvent(session);
assert(api.getSetupIntent.calledWith('si_123'));
assert(memberRepository.get.calledWith({customer_id: 'cust_123'}));
assert(api.attachPaymentMethodToCustomer.called);
});
it('if member is not found, it should return early', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {setup_intent: 'si_123'};
const setupIntent = {metadata: {customer_id: 'cust_123'}};
api.getSetupIntent.resolves(setupIntent);
memberRepository.get.resolves(null);
await service.handleSetupEvent(session);
assert(api.getSetupIntent.calledWith('si_123'));
assert(memberRepository.get.calledWith({customer_id: 'cust_123'}));
assert(!api.attachPaymentMethodToCustomer.called);
});
it('if setupIntent has subscription_id, it should update subscription default payment method', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {setup_intent: 'si_123'};
const setupIntent = {metadata: {customer_id: 'cust_123', subscription_id: 'sub_123'}, payment_method: 'pm_123'};
const member = {id: 'member_123'};
const updatedSubscription = {id: 'sub_123'};
api.getSetupIntent.resolves(setupIntent);
memberRepository.get.resolves(member);
api.updateSubscriptionDefaultPaymentMethod.resolves(updatedSubscription);
await service.handleSetupEvent(session);
assert(api.updateSubscriptionDefaultPaymentMethod.calledWith('sub_123', 'pm_123'));
assert(memberRepository.linkSubscription.calledWith({id: 'member_123', subscription: updatedSubscription}));
});
it('if linkSubscription fails with ER_DUP_ENTRY, it should throw ConflictError', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {setup_intent: 'si_123'};
const setupIntent = {metadata: {customer_id: 'cust_123', subscription_id: 'sub_123'}, payment_method: 'pm_123'};
const member = {id: 'member_123'};
const updatedSubscription = {id: 'sub_123'};
api.getSetupIntent.resolves(setupIntent);
memberRepository.get.resolves(member);
api.updateSubscriptionDefaultPaymentMethod.resolves(updatedSubscription);
memberRepository.linkSubscription.rejects({code: 'ER_DUP_ENTRY'});
try {
await service.handleSetupEvent(session);
assert.fail('Expected ConflictError');
} catch (err) {
assert.equal(err.name, 'ConflictError');
}
});
it('if linkSubscription fails with SQLITE_CONSTRAINT, it should throw ConflictError', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {setup_intent: 'si_123'};
const setupIntent = {metadata: {customer_id: 'cust_123', subscription_id: 'sub_123'}, payment_method: 'pm_123'};
const member = {id: 'member_123'};
const updatedSubscription = {id: 'sub_123'};
api.getSetupIntent.resolves(setupIntent);
memberRepository.get.resolves(member);
api.updateSubscriptionDefaultPaymentMethod.resolves(updatedSubscription);
memberRepository.linkSubscription.rejects({code: 'SQLITE_CONSTRAINT'});
try {
await service.handleSetupEvent(session);
assert.fail('Expected ConflictError');
} catch (err) {
assert.equal(err.name, 'ConflictError');
}
});
it('if linkSubscription fails with unexpected error, it should throw', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {setup_intent: 'si_123'};
const setupIntent = {metadata: {customer_id: 'cust_123', subscription_id: 'sub_123'}, payment_method: 'pm_123'};
const member = {id: 'member_123'};
const updatedSubscription = {id: 'sub_123'};
api.getSetupIntent.resolves(setupIntent);
memberRepository.get.resolves(member);
api.updateSubscriptionDefaultPaymentMethod.resolves(updatedSubscription);
memberRepository.linkSubscription.rejects(new Error('Unexpected error'));
try {
await service.handleSetupEvent(session);
assert.fail('Expected error');
} catch (err) {
assert.equal(err.message, 'Unexpected error');
}
});
it('updateSubscriptionDefaultPaymentMethod of all active subscriptions', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {setup_intent: 'si_123'};
const setupIntent = {metadata: {customer_id: 'cust_123'}, payment_method: 'pm_123'};
const member = {id: 'member_123', related: sinon.stub()};
const fetchStub = sinon.stub();
member.related.withArgs('stripeSubscriptions').returns({fetch: fetchStub});
const mockSubscriptions = [
{
get: sinon.stub().callsFake((key) => {
if (key === 'status') {
return 'active';
}
if (key === 'customer_id') {
return 'cust_123';
}
if (key === 'subscription_id') {
return 'sub_123';
}
})
},
{
get: sinon.stub().callsFake((key) => {
if (key === 'status') {
return 'trialing';
}
if (key === 'customer_id') {
return 'cust_123';
}
if (key === 'subscription_id') {
return 'sub_456';
}
})
},
{
get: sinon.stub().callsFake((key) => {
if (key === 'status') {
return 'canceled';
}
if (key === 'customer_id') {
return 'cust_123';
}
})
}
];
fetchStub.resolves({models: mockSubscriptions});
api.getSetupIntent.resolves(setupIntent);
memberRepository.get.resolves(member);
await service.handleSetupEvent(session);
assert(api.updateSubscriptionDefaultPaymentMethod.calledTwice);
});
it('throws if updateSubscriptionDefaultPaymentMethod fires but cannot link subscription', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {setup_intent: 'si_123'};
const setupIntent = {metadata: {customer_id: 'cust_123'}, payment_method: 'pm_123'};
const member = {id: 'member_123', related: sinon.stub()};
const fetchStub = sinon.stub();
member.related.withArgs('stripeSubscriptions').returns({fetch: fetchStub});
const mockSubscriptions = [
{
get: sinon.stub().callsFake((key) => {
if (key === 'status') {
return 'active';
}
if (key === 'customer_id') {
return 'cust_123';
}
if (key === 'subscription_id') {
return 'sub_123';
}
})
}
];
fetchStub.resolves({models: mockSubscriptions});
api.getSetupIntent.resolves(setupIntent);
memberRepository.get.resolves(member);
api.updateSubscriptionDefaultPaymentMethod.resolves({id: 'sub_123'});
memberRepository.linkSubscription.rejects(new Error('Unexpected error'));
try {
await service.handleSetupEvent(session);
assert.fail('Expected error');
} catch (err) {
assert.equal(err.message, 'Unexpected error');
}
});
it('throws is linkSubscription fauls with conflict error', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {setup_intent: 'si_123'};
const setupIntent = {metadata: {customer_id: 'cust_123', subscription_id: 'sub_123'}, payment_method: 'pm_123'};
const member = {id: 'member_123'};
const updatedSubscription = {id: 'sub_123'};
api.getSetupIntent.resolves(setupIntent);
memberRepository.get.resolves(member);
api.updateSubscriptionDefaultPaymentMethod.resolves(updatedSubscription);
memberRepository.linkSubscription.rejects({code: 'ER_DUP_ENTRY'});
try {
await service.handleSetupEvent(session);
assert.fail('Expected ConflictError');
} catch (err) {
assert(err instanceof errors.ConflictError);
assert.equal(err.message, 'The server has encountered an conflict.');
}
});
it('should throw ConflictError if linkSubscription fails with ER_DUP_ENTRY', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {setup_intent: 'si_123'};
const setupIntent = {metadata: {customer_id: 'cust_123', subscription_id: 'sub_123'}, payment_method: 'pm_123'};
const member = {id: 'member_123'};
const updatedSubscription = {id: 'sub_123'};
api.getSetupIntent.resolves(setupIntent);
memberRepository.get.resolves(member);
api.updateSubscriptionDefaultPaymentMethod.resolves(updatedSubscription);
memberRepository.linkSubscription.rejects({code: 'ER_DUP_ENTRY'});
try {
await service.handleSetupEvent(session);
assert.fail('Expected ConflictError');
} catch (err) {
assert(err instanceof errors.ConflictError);
}
});
it('should throw ConflictError if linkSubscription fails with SQLITE_CONSTRAINT', async function () {
const service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails});
const session = {setup_intent: 'si_123'};
const setupIntent = {metadata: {customer_id: 'cust_123', subscription_id: 'sub_123'}, payment_method: 'pm_123'};
const member = {id: 'member_123'};
const updatedSubscription = {id: 'sub_123'};
api.getSetupIntent.resolves(setupIntent);
memberRepository.get.resolves(member);
api.updateSubscriptionDefaultPaymentMethod.resolves(updatedSubscription);
memberRepository.linkSubscription.rejects({code: 'SQLITE_CONSTRAINT'});
try {
await service.handleSetupEvent(session);
assert.fail('Expected ConflictError');
} catch (err) {
assert(err instanceof errors.ConflictError);
}
});
});
describe('handleSubscriptionEvent', function () {
let service;
let session;
let customer;
let member;
beforeEach(function () {
service = new CheckoutSessionEventService({api, memberRepository, donationRepository, staffServiceEmails, sendSignupEmail});
session = {
customer: 'cust_123',
metadata: {
name: 'Metadata Name',
newsletters: JSON.stringify([{id: 1, name: 'Newsletter'}]),
attribution_id: 'attr_123',
attribution_url: 'https://example.com',
attribution_type: 'referral',
referrer_source: 'google',
referrer_medium: 'cpc',
referrer_url: 'https://referrer.com',
offer: 'offer_123',
checkoutType: 'new'
}
};
customer = {
email: 'customer@example.com',
id: 'cust_123',
subscriptions: {
data: [
{
default_payment_method: {
billing_details: {name: 'Customer Name'}
}
}
]
}
};
member = {
get: sinon.stub(),
id: 'member_123'
};
});
it('should get customer and member', async function () {
api.getCustomer.resolves(customer);
memberRepository.get.resolves(member);
await service.handleSubscriptionEvent(session);
assert(api.getCustomer.calledWith('cust_123', {expand: ['subscriptions.data.default_payment_method']}));
assert(memberRepository.get.calledWith({email: 'customer@example.com'}));
});
it('should create new member if not found', async function () {
api.getCustomer.resolves(customer);
memberRepository.get.resolves(null);
await service.handleSubscriptionEvent(session);
assert(memberRepository.create.calledOnce);
const memberData = memberRepository.create.getCall(0).args[0];
assert.equal(memberData.email, 'customer@example.com');
assert.equal(memberData.name, 'Metadata Name'); // falls back to metadata name if payerName doesn't exist
assert.deepEqual(memberData.newsletters, [{id: 1, name: 'Newsletter'}]);
assert.deepEqual(memberData.attribution, {
id: 'attr_123',
url: 'https://example.com',
type: 'referral',
referrerSource: 'google',
referrerMedium: 'cpc',
referrerUrl: 'https://referrer.com'
});
});
it('should create new member with payerName if it exists', async function () {
api.getCustomer.resolves(customer);
memberRepository.get.resolves(null);
session.metadata.name = 'Session Name';
await service.handleSubscriptionEvent(session);
assert(memberRepository.create.calledOnce);
const memberData = memberRepository.create.getCall(0).args[0];
assert.equal(memberData.email, 'customer@example.com');
assert.equal(memberData.name, 'Session Name');
assert.deepEqual(memberData.newsletters, [{id: 1, name: 'Newsletter'}]);
});
it('should create new member with newsletters if metadata newsletters is not valid JSON', async function () {
api.getCustomer.resolves(customer);
memberRepository.get.resolves(null);
session.metadata.newsletters = 'invalid';
await service.handleSubscriptionEvent(session);
const memberData = memberRepository.create.getCall(0).args[0];
assert.equal(memberData.email, 'customer@example.com');
assert.equal(memberData.name, 'Metadata Name');
assert.equal(memberData.newsletters, undefined);
});
it('should update member if found', async function () {
api.getCustomer.resolves(customer);
memberRepository.get.resolves(member);
// change member name
customer.subscriptions.data[0].default_payment_method.billing_details.name = 'New Customer Name';
await service.handleSubscriptionEvent(session);
assert(memberRepository.update.calledOnce);
const memberData = memberRepository.update.getCall(0).args[0];
assert.equal(memberData.name, 'New Customer Name');
});
it('should update member with payerName if it exists', async function () {
api.getCustomer.resolves(customer);
memberRepository.get.resolves(member);
session.metadata.name = 'Session Name';
// change member name
customer.subscriptions.data[0].default_payment_method.billing_details.name = 'New Customer Name';
await service.handleSubscriptionEvent(session);
assert(memberRepository.update.calledOnce);
const memberData = memberRepository.update.getCall(0).args[0];
assert.equal(memberData.name, 'New Customer Name');
});
it('should update member with newsletters if metadata newsletters is not valid JSON', async function () {
api.getCustomer.resolves(customer);
memberRepository.get.resolves(member);
session.metadata.newsletters = 'invalid';
// change member name
customer.subscriptions.data[0].default_payment_method.billing_details.name = 'New Customer Name';
await service.handleSubscriptionEvent(session);
assert(memberRepository.update.calledOnce);
const memberData = memberRepository.update.getCall(0).args[0];
assert.equal(memberData.newsletters, undefined);
});
});
});

View File

@ -0,0 +1,213 @@
// const chai = require('chai');
const sinon = require('sinon');
// const {expect} = chai;
const assert = require('assert/strict');
const errors = require('@tryghost/errors');
const InvoiceEventService = require('../../../../../lib/services/webhook/InvoiceEventService');
describe('InvoiceEventService', function () {
let memberRepositoryStub, eventRepositoryStub, productRepositoryStub, apiStub, service;
beforeEach(function () {
memberRepositoryStub = {
get: sinon.stub()
};
eventRepositoryStub = {
registerPayment: sinon.stub()
};
productRepositoryStub = {
get: sinon.stub()
};
apiStub = {
getSubscription: sinon.stub()
};
service = new InvoiceEventService({
memberRepository: memberRepositoryStub,
eventRepository: eventRepositoryStub,
productRepository: productRepositoryStub,
api: apiStub
});
});
it('should return early if invoice does not have a subscription, because its probably a donation', async function () {
const invoice = {subscription: null};
await service.handleInvoiceEvent(invoice);
sinon.assert.notCalled(apiStub.getSubscription);
sinon.assert.notCalled(memberRepositoryStub.get);
sinon.assert.notCalled(eventRepositoryStub.registerPayment);
// expect(apiStub.getSubscription.called).to.be.false;
assert(apiStub.getSubscription.called === false);
});
it('should return early if invoice is a one-time payment', async function () {
const invoice = {subscription: null};
await service.handleInvoiceEvent(invoice);
sinon.assert.notCalled(apiStub.getSubscription);
sinon.assert.notCalled(memberRepositoryStub.get);
sinon.assert.notCalled(eventRepositoryStub.registerPayment);
// expect(apiStub.getSubscription.called).to.be.false;
assert(apiStub.getSubscription.called === false);
});
it('should throw NotFoundError if no member is found for subscription customer', async function () {
const invoice = {
customer: 'cust_123',
plan: 'plan_123',
subscription: 'sub_123'
};
apiStub.getSubscription.resolves(invoice);
memberRepositoryStub.get.resolves(null);
productRepositoryStub.get.resolves({
stripe_product_id: 'product_123'
});
// expect throw
let error;
try {
await service.handleInvoiceEvent(invoice);
} catch (err) {
error = err;
}
// Use Sinon to assert that the error is a NotFoundError with the expected message
// expect(error).to.be.instanceOf(errors.NotFoundError);
assert(error instanceof errors.NotFoundError);
// expect(error.message).to.equal('No member found for customer cust_123');
assert(error.message === 'No member found for customer cust_123');
});
it('should return early if subscription has more than one plan or no plans', async function () {
const invoice = {
subscription: 'sub_123',
plan: null
};
apiStub.getSubscription.resolves(invoice);
memberRepositoryStub.get.resolves(null);
productRepositoryStub.get.resolves(null);
await service.handleInvoiceEvent(invoice);
// sinon.assert.calledOnce(apiStub.getSubscription);
// sinon.assert.calledOnce(memberRepositoryStub.get);
// sinon.assert.notCalled(productRepositoryStub.get);
assert(apiStub.getSubscription.calledOnce);
assert(memberRepositoryStub.get.calledOnce);
assert(productRepositoryStub.get.notCalled);
});
it('should return early if product is not found', async function () {
const invoice = {
subscription: 'sub_123',
plan: 'plan_123'
};
apiStub.getSubscription.resolves(invoice);
memberRepositoryStub.get.resolves(null);
productRepositoryStub.get.resolves(null);
await service.handleInvoiceEvent(invoice);
assert(apiStub.getSubscription.calledOnce);
assert(memberRepositoryStub.get.calledOnce);
assert(productRepositoryStub.get.calledOnce);
});
it('can registerPayment', async function () {
const invoice = {
subscription: 'sub_123',
plan: 'plan_123',
amount_paid: 100,
paid: true
};
apiStub.getSubscription.resolves(invoice);
memberRepositoryStub.get.resolves({id: 'member_123'});
productRepositoryStub.get.resolves({stripe_product_id: 'product_123'});
await service.handleInvoiceEvent(invoice);
// sinon.assert.calledOnce(eventRepositoryStub.registerPayment);
assert(eventRepositoryStub.registerPayment.calledOnce);
});
it('should not registerPayment if invoice is not paid', async function () {
const invoice = {
subscription: 'sub_123',
plan: 'plan_123',
amount_paid: 0,
paid: false
};
apiStub.getSubscription.resolves(invoice);
memberRepositoryStub.get.resolves({id: 'member_123'});
productRepositoryStub.get.resolves({stripe_product_id: 'product_123'});
await service.handleInvoiceEvent(invoice);
// sinon.assert.notCalled(eventRepositoryStub.registerPayment);
assert(eventRepositoryStub.registerPayment.notCalled);
});
it('should not registerPayment if invoice amount paid is 0', async function () {
const invoice = {
subscription: 'sub_123',
plan: 'plan_123',
amount_paid: 0,
paid: true
};
apiStub.getSubscription.resolves(invoice);
memberRepositoryStub.get.resolves({id: 'member_123'});
productRepositoryStub.get.resolves({stripe_product_id: 'product_123'});
await service.handleInvoiceEvent(invoice);
// sinon.assert.notCalled(eventRepositoryStub.registerPayment);
assert(eventRepositoryStub.registerPayment.notCalled);
});
it('should not register payment if amount paid is 0 and invoice is not paid', async function () {
const invoice = {
subscription: 'sub_123',
plan: 'plan_123',
amount_paid: 0,
paid: false
};
apiStub.getSubscription.resolves(invoice);
memberRepositoryStub.get.resolves({id: 'member_123'});
productRepositoryStub.get.resolves({stripe_product_id: 'product_123'});
await service.handleInvoiceEvent(invoice);
assert(eventRepositoryStub.registerPayment.notCalled);
});
it('should not registerPayment if member is not found', async function () {
const invoice = {
subscription: 'sub_123',
plan: 'plan_123',
amount_paid: 100,
paid: true
};
apiStub.getSubscription.resolves(invoice);
memberRepositoryStub.get.resolves(null);
productRepositoryStub.get.resolves({stripe_product_id: 'product_123'});
let error;
try {
await service.handleInvoiceEvent(invoice);
} catch (err) {
error = err;
}
assert(error instanceof errors.NotFoundError);
assert(eventRepositoryStub.registerPayment.notCalled);
});
});

View File

@ -0,0 +1,113 @@
const sinon = require('sinon');
const assert = require('assert/strict');
const SubscriptionEventService = require('../../../../../lib/services/webhook/SubscriptionEventService');
describe('SubscriptionEventService', function () {
let service;
let memberRepository;
beforeEach(function () {
memberRepository = {get: sinon.stub(), linkSubscription: sinon.stub()};
service = new SubscriptionEventService({memberRepository});
});
it('should throw BadRequestError if subscription has no price item', async function () {
const subscription = {
items: {
data: []
}
};
try {
await service.handleSubscriptionEvent(subscription);
assert.fail('Expected BadRequestError');
} catch (err) {
assert.equal(err.message, 'Subscription should have exactly 1 price item');
}
});
it('should throw ConflictError if linkSubscription fails with ER_DUP_ENTRY', async function () {
const subscription = {
items: {
data: [{price: {id: 'price_123'}}]
},
customer: 'cust_123'
};
memberRepository.get.resolves({id: 'member_123'});
memberRepository.linkSubscription.rejects({code: 'ER_DUP_ENTRY'});
try {
await service.handleSubscriptionEvent(subscription);
assert.fail('Expected ConflictError');
} catch (err) {
assert(err.name, 'ConflictError');
}
});
it('should throw ConflictError if linkSubscription fails with SQLITE_CONSTRAINT', async function () {
const subscription = {
items: {
data: [{price: {id: 'price_123'}}]
},
customer: 'cust_123'
};
memberRepository.get.resolves({id: 'member_123'});
memberRepository.linkSubscription.rejects({code: 'SQLITE_CONSTRAINT'});
try {
await service.handleSubscriptionEvent(subscription);
assert.fail('Expected ConflictError');
} catch (err) {
assert(err.name, 'ConflictError');
}
});
it('should throw if linkSubscription fails with unexpected error', async function () {
const subscription = {
items: {
data: [{price: {id: 'price_123'}}]
},
customer: 'cust_123'
};
memberRepository.get.resolves({id: 'member_123'});
memberRepository.linkSubscription.rejects(new Error('Unexpected error'));
try {
await service.handleSubscriptionEvent(subscription);
assert.fail('Expected error');
} catch (err) {
assert.equal(err.message, 'Unexpected error');
}
});
it('should catch and rethrow unexpected errors from member repository', async function () {
memberRepository.get.rejects(new Error('Unexpected error'));
try {
await service.handleSubscriptionEvent({items: {data: [{price: {id: 'price_123'}}]}});
assert.fail('Expected error');
} catch (err) {
assert.equal(err.message, 'Unexpected error');
}
});
it('should call linkSubscription with correct arguments', async function () {
const subscription = {
items: {
data: [{price: {id: 'price_123'}}]
},
customer: 'cust_123'
};
memberRepository.get.resolves({id: 'member_123'});
await service.handleSubscriptionEvent(subscription);
assert(memberRepository.linkSubscription.calledWith({id: 'member_123', subscription}));
});
});