Ghost/ghost/members-api/lib/services/member-bread.js
Fabien O'Carroll c154be4581 Included Offer information for Subscriptions
refs https://github.com/TryGhost/Team/issues/1135

We use the OffersAPI to fetch Offers, so that we can be using the same
format for Offers in all of our APIs.

We will not attach the Offer to the Subscription if either the Tier or
the Cadence do not match. This is because the Offer would no longer
apply to this Subscription.

We do however retain the data, so that a Member can still be filtered on
the Offers which they've redeemed.
2021-10-21 18:10:08 +02:00

328 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);
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 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 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) => {
this.attachSubscriptionsToMember(member);
return member;
});
return {
data,
meta: page.meta
};
}
};