Ghost/ghost/members-api/lib/services/member-bread.js
Fabien egg O'Carroll 188423b1ed Removed Subscriptions without Prices from BREAD API
refs https://github.com/TryGhost/Team/issues/1243

It's possible to get into strange states where a subscription in Ghost
doesn't have an associated Price. This then has knock on effects because
we're dealing with data in an undefined state. Rather than add guards
against this throughout the entire stack, we stop returning it from the
BREAD API. It might be worth considering removing these subscriptions
from the response of the repository, but for now this is the most
minimal change that fixes the problem.
2021-12-01 10:45:50 +02:00

335 lines
11 KiB
JavaScript

const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const moment = require('moment');
const messages = {
stripeNotConnected: 'Missing Stripe connection.',
memberAlreadyExists: 'Member already exists.'
};
/**
* @typedef {object} ILabsService
* @prop {(key: string) => boolean} isSet
*/
/**
* @typedef {object} IEmailService
* @prop {(data: {email: string, requestedType: string}) => Promise<any>} sendEmailWithMagicLink
*/
/**
* @typedef {object} IStripeService
* @prop {boolean} configured
*/
module.exports = class MemberBREADService {
/**
* @param {object} deps
* @param {import('../repositories/member')} deps.memberRepository
* @param {import('@tryghost/members-offers/lib/application/OffersAPI')} deps.offersAPI
* @param {ILabsService} deps.labsService
* @param {IEmailService} deps.emailService
* @param {IStripeService} deps.stripeService
*/
constructor({memberRepository, labsService, emailService, stripeService, offersAPI}) {
this.offersAPI = offersAPI;
/** @private */
this.memberRepository = memberRepository;
/** @private */
this.labsService = labsService;
/** @private */
this.emailService = emailService;
/** @private */
this.stripeService = stripeService;
}
/**
* @private
*/
attachSubscriptionsToMember(member) {
if (!member.products || !Array.isArray(member.products)) {
return member;
}
const subscriptionProducts = (member.subscriptions || []).map(sub => sub.price.product.product_id);
for (const product of member.products) {
if (!subscriptionProducts.includes(product.id)) {
const productAddEvent = member.productEvents.find(event => event.product_id === product.id);
let startDate;
if (!productAddEvent || productAddEvent.action !== 'added') {
startDate = moment();
} else {
startDate = moment(productAddEvent.created_at);
}
member.subscriptions.push({
id: '',
customer: {
id: '',
name: member.name,
email: member.email
},
plan: {
id: '',
nickname: 'Complimentary',
interval: 'year',
currency: 'USD',
amount: 0
},
status: 'active',
start_date: startDate,
default_payment_card_last4: '****',
cancel_at_period_end: false,
cancellation_reason: null,
current_period_end: moment(startDate).add(1, 'year'),
price: {
id: '',
price_id: '',
nickname: 'Complimentary',
amount: 0,
interval: 'year',
type: 'recurring',
currency: 'USD',
product: {
id: '',
product_id: product.id
}
}
});
}
}
}
/**
* @private
*/
attachOffersToSubscriptions(member, subscriptionOffers) {
member.subscriptions = member.subscriptions.map((subscription) => {
const offer = subscriptionOffers[subscription.id];
if (offer) {
subscription.offer = offer;
} else {
subscription.offer = null;
}
return subscription;
});
}
async read(data, options = {}) {
const defaultWithRelated = [
'labels',
'stripeSubscriptions',
'stripeSubscriptions.customer',
'stripeSubscriptions.stripePrice',
'stripeSubscriptions.stripePrice.stripeProduct',
'products',
'offerRedemptions'
];
const withRelated = new Set((options.withRelated || []).concat(defaultWithRelated));
if (!withRelated.has('productEvents')) {
withRelated.add('productEvents');
}
if (withRelated.has('email_recipients')) {
withRelated.add('email_recipients.email');
}
const model = await this.memberRepository.get(data, {
...options,
withRelated: Array.from(withRelated)
});
if (!model) {
return null;
}
const subscriptionOffers = await model.related('offerRedemptions').toJSON().reduce(async (promiseObj, offerRedemption) => {
const obj = await promiseObj;
const offer = await this.offersAPI.getOffer({id: offerRedemption.offer_id});
return {
...obj,
[offerRedemption.subscription_id]: offer
};
}, Promise.resolve({}));
model.related('stripeSubscriptions').forEach((subscriptionModel) => {
const offer = subscriptionOffers[subscriptionModel.id];
if (!offer) {
return;
}
if (offer.cadence !== subscriptionModel.related('stripePrice').get('interval')) {
return;
}
if (offer.tier.id !== subscriptionModel.related('stripePrice').related('stripeProduct').get('product_id')) {
return;
}
subscriptionOffers[subscriptionModel.get('subscription_id')] = offer;
});
const member = model.toJSON(options);
member.subscriptions = member.subscriptions.filter(sub => !!sub.price);
this.attachSubscriptionsToMember(member);
this.attachOffersToSubscriptions(member, subscriptionOffers);
return member;
}
async add(data, options) {
if (!this.labsService.isSet('multipleProducts')) {
delete data.products;
}
if (!this.stripeService.configured && (data.comped || data.stripe_customer_id)) {
const property = data.comped ? 'comped' : 'stripe_customer_id';
throw new errors.ValidationError({
message: tpl(messages.stripeNotConnected),
context: 'Attempting to import members with Stripe data when there is no Stripe account connected.',
help: 'You need to connect to Stripe to import Stripe customers. ',
property
});
}
let model;
try {
model = await this.memberRepository.create(data, options);
} catch (error) {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
throw new errors.ValidationError({
message: tpl(messages.memberAlreadyExists),
context: 'Attempting to add member with existing email address'
});
}
throw error;
}
try {
if (data.stripe_customer_id) {
await this.memberRepository.linkStripeCustomer({
customer_id: data.stripe_customer_id,
member_id: model.id
}, options);
}
} catch (error) {
const isStripeLinkingError = error.message && (error.message.match(/customer|plan|subscription/g));
if (isStripeLinkingError) {
if (error.message.indexOf('customer') && error.code === 'resource_missing') {
error.message = `Member not imported. ${error.message}`;
error.context = 'Missing Stripe Customer';
error.help = 'Make sure you\'re connected to the correct Stripe Account';
}
await this.memberRepository.destroy({
id: model.id
}, options);
}
throw error;
}
if (!this.labsService.isSet('multipleProducts')) {
if (data.comped) {
await this.memberRepository.setComplimentarySubscription(model, options);
}
}
if (options.send_email) {
await this.emailService.sendEmailWithMagicLink({
email: model.get('email'), requestedType: options.email_type
});
}
return this.read({id: model.id}, options);
}
async edit(data, options) {
if (!this.labsService.isSet('multipleProducts')) {
delete data.products;
}
let model;
try {
model = await this.memberRepository.update(data, options);
} catch (error) {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
throw new errors.ValidationError({
message: tpl(messages.memberAlreadyExists),
context: 'Attempting to edit member with existing email address'
});
}
throw error;
}
const hasCompedSubscription = !!model.related('stripeSubscriptions').find((sub) => {
return sub.get('plan_nickname') === 'Complimentary' && sub.get('status') === 'active';
});
if (!this.labsService.isSet('multipleProducts')) {
if (typeof data.comped === 'boolean') {
if (data.comped && !hasCompedSubscription) {
await this.memberRepository.setComplimentarySubscription(model);
} else if (!data.comped && hasCompedSubscription) {
await this.memberRepository.cancelComplimentarySubscription(model);
}
}
}
return this.read({id: model.id}, options);
}
async browse(options) {
const defaultWithRelated = [
'labels',
'stripeSubscriptions',
'stripeSubscriptions.customer',
'stripeSubscriptions.stripePrice',
'stripeSubscriptions.stripePrice.stripeProduct',
'products'
];
const originalWithRelated = options.withRelated || [];
const withRelated = new Set((originalWithRelated).concat(defaultWithRelated));
if (!withRelated.has('productEvents')) {
withRelated.add('productEvents');
}
if (withRelated.has('email_recipients')) {
withRelated.add('email_recipients.email');
}
const page = await this.memberRepository.list({
...options,
withRelated: Array.from(withRelated)
});
if (!page) {
return null;
}
const members = page.data.map(model => model.toJSON(options));
const data = members.map((member) => {
member.subscriptions = member.subscriptions.filter(sub => !!sub.price);
this.attachSubscriptionsToMember(member);
if (!originalWithRelated.includes('products')) {
delete member.products;
}
return member;
});
return {
data,
meta: page.meta
};
}
};