From a64eaeccf219fd40c2cf40727cdbb129b0240f37 Mon Sep 17 00:00:00 2001 From: Ronald Langeveld Date: Mon, 9 Sep 2024 12:54:22 +0900 Subject: [PATCH] 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. --- ghost/stripe/lib/StripeService.js | 54 +- ghost/stripe/lib/WebhookController.js | 277 +------ .../webhook/CheckoutSessionEventService.js | 218 ++++++ .../services/webhook/InvoiceEventService.js | 52 ++ .../webhook/SubscriptionEventService.js | 36 + .../test/unit/lib/WebhookController.test.js | 207 +++--- .../CheckoutSessionEventService.test.js | 677 ++++++++++++++++++ .../webhooks/InvoiceEventService.test.js | 213 ++++++ .../webhooks/SubscriptionEventService.test.js | 113 +++ 9 files changed, 1460 insertions(+), 387 deletions(-) create mode 100644 ghost/stripe/lib/services/webhook/CheckoutSessionEventService.js create mode 100644 ghost/stripe/lib/services/webhook/InvoiceEventService.js create mode 100644 ghost/stripe/lib/services/webhook/SubscriptionEventService.js create mode 100644 ghost/stripe/test/unit/lib/services/webhooks/CheckoutSessionEventService.test.js create mode 100644 ghost/stripe/test/unit/lib/services/webhooks/InvoiceEventService.test.js create mode 100644 ghost/stripe/test/unit/lib/services/webhooks/SubscriptionEventService.test.js diff --git a/ghost/stripe/lib/StripeService.js b/ghost/stripe/lib/StripeService.js index 1ec78f3a1d..286bc0c568 100644 --- a/ghost/stripe/lib/StripeService.js +++ b/ghost/stripe/lib/StripeService.js @@ -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; diff --git a/ghost/stripe/lib/WebhookController.js b/ghost/stripe/lib/WebhookController.js index 1877bc2698..4455b50e2a 100644 --- a/ghost/stripe/lib/WebhookController.js +++ b/ghost/stripe/lib/WebhookController.js @@ -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); } }; diff --git a/ghost/stripe/lib/services/webhook/CheckoutSessionEventService.js b/ghost/stripe/lib/services/webhook/CheckoutSessionEventService.js new file mode 100644 index 0000000000..a77acd22a9 --- /dev/null +++ b/ghost/stripe/lib/services/webhook/CheckoutSessionEventService.js @@ -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); + } + } +}; diff --git a/ghost/stripe/lib/services/webhook/InvoiceEventService.js b/ghost/stripe/lib/services/webhook/InvoiceEventService.js new file mode 100644 index 0000000000..22f862eb96 --- /dev/null +++ b/ghost/stripe/lib/services/webhook/InvoiceEventService.js @@ -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}` + }); + } + } +}; diff --git a/ghost/stripe/lib/services/webhook/SubscriptionEventService.js b/ghost/stripe/lib/services/webhook/SubscriptionEventService.js new file mode 100644 index 0000000000..de296c3842 --- /dev/null +++ b/ghost/stripe/lib/services/webhook/SubscriptionEventService.js @@ -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}); + } + } + } +}; diff --git a/ghost/stripe/test/unit/lib/WebhookController.test.js b/ghost/stripe/test/unit/lib/WebhookController.test.js index 7cb407d97c..53a3db64b1 100644 --- a/ghost/stripe/test/unit/lib/WebhookController.test.js +++ b/ghost/stripe/test/unit/lib/WebhookController.test.js @@ -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); }); }); diff --git a/ghost/stripe/test/unit/lib/services/webhooks/CheckoutSessionEventService.test.js b/ghost/stripe/test/unit/lib/services/webhooks/CheckoutSessionEventService.test.js new file mode 100644 index 0000000000..5819f61266 --- /dev/null +++ b/ghost/stripe/test/unit/lib/services/webhooks/CheckoutSessionEventService.test.js @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/ghost/stripe/test/unit/lib/services/webhooks/InvoiceEventService.test.js b/ghost/stripe/test/unit/lib/services/webhooks/InvoiceEventService.test.js new file mode 100644 index 0000000000..407a047994 --- /dev/null +++ b/ghost/stripe/test/unit/lib/services/webhooks/InvoiceEventService.test.js @@ -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); + }); +}); diff --git a/ghost/stripe/test/unit/lib/services/webhooks/SubscriptionEventService.test.js b/ghost/stripe/test/unit/lib/services/webhooks/SubscriptionEventService.test.js new file mode 100644 index 0000000000..42beff74a8 --- /dev/null +++ b/ghost/stripe/test/unit/lib/services/webhooks/SubscriptionEventService.test.js @@ -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})); + }); +});