mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-20 22:42:53 -05:00
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:
parent
c96744156e
commit
a64eaeccf2
9 changed files with 1460 additions and 387 deletions
|
@ -4,6 +4,9 @@ const StripeMigrations = require('./StripeMigrations');
|
||||||
const WebhookController = require('./WebhookController');
|
const WebhookController = require('./WebhookController');
|
||||||
const DomainEvents = require('@tryghost/domain-events');
|
const DomainEvents = require('@tryghost/domain-events');
|
||||||
const {StripeLiveEnabledEvent, StripeLiveDisabledEvent} = require('./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 {
|
module.exports = class StripeService {
|
||||||
constructor({
|
constructor({
|
||||||
|
@ -15,30 +18,50 @@ module.exports = class StripeService {
|
||||||
models
|
models
|
||||||
}) {
|
}) {
|
||||||
const api = new StripeAPI({labs});
|
const api = new StripeAPI({labs});
|
||||||
const webhookManager = new WebhookManager({
|
|
||||||
StripeWebhook,
|
|
||||||
api
|
|
||||||
});
|
|
||||||
const migrations = new StripeMigrations({
|
const migrations = new StripeMigrations({
|
||||||
models,
|
models,
|
||||||
api
|
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,
|
api,
|
||||||
get memberRepository(){
|
get memberRepository(){
|
||||||
return membersService.api.members;
|
return membersService.api.members;
|
||||||
},
|
},
|
||||||
get productRepository() {
|
get eventRepository(){
|
||||||
return membersService.api.productRepository;
|
|
||||||
},
|
|
||||||
get eventRepository() {
|
|
||||||
return membersService.api.events;
|
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;
|
return donationService.repository;
|
||||||
},
|
},
|
||||||
get staffServiceEmails() {
|
get staffServiceEmails(){
|
||||||
return staffService.api.emails;
|
return staffService.api.emails;
|
||||||
},
|
},
|
||||||
sendSignupEmail(email){
|
sendSignupEmail(email){
|
||||||
|
@ -53,6 +76,13 @@ module.exports = class StripeService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const webhookController = new WebhookController({
|
||||||
|
webhookManager,
|
||||||
|
subscriptionEventService,
|
||||||
|
invoiceEventService,
|
||||||
|
checkoutSessionEventService
|
||||||
|
});
|
||||||
|
|
||||||
this.models = models;
|
this.models = models;
|
||||||
this.api = api;
|
this.api = api;
|
||||||
this.webhookManager = webhookManager;
|
this.webhookManager = webhookManager;
|
||||||
|
|
|
@ -1,25 +1,18 @@
|
||||||
const _ = require('lodash');
|
|
||||||
const logging = require('@tryghost/logging');
|
const logging = require('@tryghost/logging');
|
||||||
const errors = require('@tryghost/errors');
|
|
||||||
const {DonationPaymentEvent} = require('@tryghost/donations');
|
|
||||||
|
|
||||||
module.exports = class WebhookController {
|
module.exports = class WebhookController {
|
||||||
/**
|
/**
|
||||||
* @param {object} deps
|
* @param {object} deps
|
||||||
* @param {import('./StripeAPI')} deps.api
|
|
||||||
* @param {import('./WebhookManager')} deps.webhookManager
|
* @param {import('./WebhookManager')} deps.webhookManager
|
||||||
* @param {any} deps.eventRepository
|
* @param {import('./services/webhook/CheckoutSessionEventService')} deps.checkoutSessionEventService
|
||||||
* @param {any} deps.memberRepository
|
* @param {import('./services/webhook/SubscriptionEventService')} deps.subscriptionEventService
|
||||||
* @param {any} deps.productRepository
|
* @param {import('./services/webhook/InvoiceEventService')} deps.invoiceEventService
|
||||||
* @param {import('@tryghost/donations').DonationRepository} deps.donationRepository
|
|
||||||
* @param {any} deps.staffServiceEmails
|
|
||||||
* @param {any} deps.sendSignupEmail
|
|
||||||
*/
|
*/
|
||||||
constructor(deps) {
|
constructor(deps) {
|
||||||
this.deps = deps;
|
this.checkoutSessionEventService = deps.checkoutSessionEventService;
|
||||||
|
this.subscriptionEventService = deps.subscriptionEventService;
|
||||||
|
this.invoiceEventService = deps.invoiceEventService;
|
||||||
this.webhookManager = deps.webhookManager;
|
this.webhookManager = deps.webhookManager;
|
||||||
this.api = deps.api;
|
|
||||||
this.sendSignupEmail = deps.sendSignupEmail;
|
|
||||||
this.handlers = {
|
this.handlers = {
|
||||||
'customer.subscription.deleted': this.subscriptionEvent,
|
'customer.subscription.deleted': this.subscriptionEvent,
|
||||||
'customer.subscription.updated': this.subscriptionEvent,
|
'customer.subscription.updated': this.subscriptionEvent,
|
||||||
|
@ -76,32 +69,7 @@ module.exports = class WebhookController {
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
async subscriptionEvent(subscription) {
|
async subscriptionEvent(subscription) {
|
||||||
const subscriptionPriceData = _.get(subscription, 'items.data');
|
await this.subscriptionEventService.handleSubscriptionEvent(subscription);
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -109,240 +77,13 @@ module.exports = class WebhookController {
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
async invoiceEvent(invoice) {
|
async invoiceEvent(invoice) {
|
||||||
if (!invoice.subscription) {
|
await this.invoiceEventService.handleInvoiceEvent(invoice);
|
||||||
// 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}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
async checkoutSessionEvent(session) {
|
async checkoutSessionEvent(session) {
|
||||||
if (session.mode === 'payment' && session.metadata?.ghost_donation) {
|
await this.checkoutSessionEventService.handleEvent(session);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
218
ghost/stripe/lib/services/webhook/CheckoutSessionEventService.js
Normal file
218
ghost/stripe/lib/services/webhook/CheckoutSessionEventService.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
52
ghost/stripe/lib/services/webhook/InvoiceEventService.js
Normal file
52
ghost/stripe/lib/services/webhook/InvoiceEventService.js
Normal 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}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
|
@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,8 +1,6 @@
|
||||||
const chai = require('chai');
|
const assert = require('assert/strict');
|
||||||
const sinon = require('sinon');
|
const sinon = require('sinon');
|
||||||
const {expect} = chai;
|
|
||||||
const WebhookController = require('../../../lib/WebhookController');
|
const WebhookController = require('../../../lib/WebhookController');
|
||||||
// const {DonationPaymentEvent} = require('@tryghost/donations');
|
|
||||||
|
|
||||||
describe('WebhookController', function () {
|
describe('WebhookController', function () {
|
||||||
let controller;
|
let controller;
|
||||||
|
@ -12,14 +10,10 @@ describe('WebhookController', function () {
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
deps = {
|
deps = {
|
||||||
api: {getSubscription: sinon.stub(), getCustomer: sinon.stub(), getSetupIntent: sinon.stub(), attachPaymentMethodToCustomer: sinon.stub(), updateSubscriptionDefaultPaymentMethod: sinon.stub()},
|
subscriptionEventService: {handleSubscriptionEvent: sinon.stub()},
|
||||||
webhookManager: {parseWebhook: sinon.stub()},
|
invoiceEventService: {handleInvoiceEvent: sinon.stub()},
|
||||||
eventRepository: {registerPayment: sinon.stub()},
|
checkoutSessionEventService: {handleEvent: sinon.stub(), handleDonationEvent: sinon.stub()},
|
||||||
memberRepository: {get: sinon.stub(), create: sinon.stub(), update: sinon.stub(), linkSubscription: sinon.stub(), upsertCustomer: sinon.stub()},
|
webhookManager: {parseWebhook: sinon.stub()}
|
||||||
donationRepository: {save: sinon.stub()},
|
|
||||||
productRepository: {get: sinon.stub()},
|
|
||||||
staffServiceEmails: {notifyDonationReceived: sinon.stub()},
|
|
||||||
sendSignupEmail: sinon.stub()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
controller = new WebhookController(deps);
|
controller = new WebhookController(deps);
|
||||||
|
@ -40,135 +34,134 @@ describe('WebhookController', function () {
|
||||||
it('should return 400 if request body or signature is missing', async function () {
|
it('should return 400 if request body or signature is missing', async function () {
|
||||||
req.body = null;
|
req.body = null;
|
||||||
await controller.handle(req, res);
|
await controller.handle(req, res);
|
||||||
expect(res.writeHead.calledWith(400)).to.be.true;
|
assert(res.writeHead.calledWith(400));
|
||||||
expect(res.end.called).to.be.true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 401 if webhook signature is invalid', async function () {
|
it('should return 401 if webhook signature is invalid', async function () {
|
||||||
deps.webhookManager.parseWebhook.throws(new Error('Invalid signature'));
|
deps.webhookManager.parseWebhook.throws(new Error('Invalid signature'));
|
||||||
await controller.handle(req, res);
|
await controller.handle(req, res);
|
||||||
expect(res.writeHead.calledWith(401)).to.be.true;
|
assert(res.writeHead.calledWith(401));
|
||||||
expect(res.end.called).to.be.true;
|
assert(res.end.called);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle customer.subscription.created event', async function () {
|
it('should handle customer.subscription.created event', async function () {
|
||||||
const event = {
|
const event = {
|
||||||
type: 'customer.subscription.created',
|
type: 'customer.subscription.created',
|
||||||
data: {
|
data: {
|
||||||
object: {customer: 'cust_123', items: {data: [{price: {id: 'price_123'}}]}}
|
object: {customer: 'cust_123'}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
deps.webhookManager.parseWebhook.returns(event);
|
deps.webhookManager.parseWebhook.returns(event);
|
||||||
deps.memberRepository.get.resolves({id: 'member_123'});
|
|
||||||
|
|
||||||
await controller.handle(req, res);
|
await controller.handle(req, res);
|
||||||
|
|
||||||
expect(deps.memberRepository.get.calledWith({customer_id: 'cust_123'})).to.be.true;
|
assert(deps.subscriptionEventService.handleSubscriptionEvent.calledOnce);
|
||||||
expect(deps.memberRepository.linkSubscription.calledOnce).to.be.true;
|
assert(res.writeHead.calledWith(200));
|
||||||
expect(res.writeHead.calledWith(200)).to.be.true;
|
assert(res.end.called);
|
||||||
expect(res.end.called).to.be.true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a donation in checkoutSessionEvent', async function () {
|
it('should handle invoice.payment_succeeded event', async function () {
|
||||||
const session = {
|
const event = {
|
||||||
mode: 'payment',
|
type: 'invoice.payment_succeeded',
|
||||||
metadata: {
|
data: {
|
||||||
ghost_donation: true,
|
object: {subscription: 'sub_123'}
|
||||||
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!'
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
};
|
};
|
||||||
|
deps.webhookManager.parseWebhook.returns(event);
|
||||||
|
|
||||||
const member = {
|
await controller.handle(req, res);
|
||||||
id: 'member_123',
|
|
||||||
get: sinon.stub()
|
|
||||||
};
|
|
||||||
|
|
||||||
member.get.withArgs('name').returns('John Doe');
|
assert(deps.invoiceEventService.handleInvoiceEvent.calledOnce);
|
||||||
member.get.withArgs('email').returns('john@example.com');
|
assert(res.writeHead.calledWith(200));
|
||||||
|
assert(res.end.called);
|
||||||
|
|
||||||
deps.memberRepository.get.resolves(member);
|
// expect(deps.invoiceEventService.handleInvoiceEvent.calledOnce).to.be.true;
|
||||||
|
// expect(res.writeHead.calledWith(200)).to.be.true;
|
||||||
await controller.checkoutSessionEvent(session);
|
// expect(res.end.called).to.be.true;
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('donation message is null if string is empty', async function () {
|
it('should handle checkout.session.completed event', async function () {
|
||||||
const session = {
|
const event = {
|
||||||
mode: 'payment',
|
type: 'checkout.session.completed',
|
||||||
metadata: {
|
data: {
|
||||||
ghost_donation: true,
|
object: {customer: 'cust_123'}
|
||||||
attribution_id: 'attr_123',
|
}
|
||||||
attribution_url: 'https://example.com',
|
};
|
||||||
attribution_type: 'referral',
|
deps.webhookManager.parseWebhook.returns(event);
|
||||||
referrer_source: 'google',
|
|
||||||
referrer_medium: 'cpc',
|
await controller.handle(req, res);
|
||||||
referrer_url: 'https://referrer.com'
|
assert(deps.checkoutSessionEventService.handleEvent.calledOnce);
|
||||||
},
|
assert(res.writeHead.calledWith(200));
|
||||||
amount_total: 5000,
|
assert(res.end.called);
|
||||||
currency: 'usd',
|
// expect(deps.checkoutSessionEventService.handleEvent.calledOnce).to.be.true;
|
||||||
customer: 'cust_123',
|
// expect(res.writeHead.calledWith(200)).to.be.true;
|
||||||
customer_details: {
|
// expect(res.end.called).to.be.true;
|
||||||
name: 'JW',
|
});
|
||||||
email: 'jw@ily.co'
|
|
||||||
},
|
it('should handle customer subscription updated event', async function () {
|
||||||
custom_fields: [{
|
const event = {
|
||||||
key: 'donation_message',
|
type: 'customer.subscription.updated',
|
||||||
text: {
|
data: {
|
||||||
value: ''
|
object: {customer: 'cust_123'}
|
||||||
}
|
}
|
||||||
}]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const member = {
|
deps.webhookManager.parseWebhook.returns(event);
|
||||||
id: 'member_123',
|
|
||||||
get: sinon.stub()
|
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');
|
deps.webhookManager.parseWebhook.returns(event);
|
||||||
member.get.withArgs('email').returns('jw@ily.co');
|
|
||||||
|
|
||||||
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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}));
|
||||||
|
});
|
||||||
|
});
|
Loading…
Add table
Reference in a new issue