mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-27 21:03:29 +03:00
188423b1ed
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.
335 lines
11 KiB
JavaScript
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
|
|
};
|
|
}
|
|
};
|