mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-23 03:42:27 +03: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
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
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 {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);
|
||||
});
|
||||
});
|
||||
|
@ -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…
Reference in New Issue
Block a user