2021-09-14 14:18:34 +03:00
|
|
|
const errors = require('@tryghost/errors');
|
|
|
|
const tpl = require('@tryghost/tpl');
|
2021-08-25 14:33:13 +03:00
|
|
|
const moment = require('moment');
|
|
|
|
|
2021-09-14 14:18:34 +03:00
|
|
|
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
|
|
|
|
*/
|
|
|
|
|
2021-08-25 14:30:49 +03:00
|
|
|
module.exports = class MemberBREADService {
|
|
|
|
/**
|
|
|
|
* @param {object} deps
|
|
|
|
* @param {import('../repositories/member')} deps.memberRepository
|
2021-10-20 15:32:41 +03:00
|
|
|
* @param {import('@tryghost/members-offers/lib/application/OffersAPI')} deps.offersAPI
|
2021-09-14 14:18:34 +03:00
|
|
|
* @param {ILabsService} deps.labsService
|
|
|
|
* @param {IEmailService} deps.emailService
|
|
|
|
* @param {IStripeService} deps.stripeService
|
2021-08-25 14:30:49 +03:00
|
|
|
*/
|
2021-10-20 15:32:41 +03:00
|
|
|
constructor({memberRepository, labsService, emailService, stripeService, offersAPI}) {
|
|
|
|
this.offersAPI = offersAPI;
|
2021-09-14 14:18:34 +03:00
|
|
|
/** @private */
|
|
|
|
this.memberRepository = memberRepository;
|
|
|
|
/** @private */
|
|
|
|
this.labsService = labsService;
|
|
|
|
/** @private */
|
|
|
|
this.emailService = emailService;
|
|
|
|
/** @private */
|
|
|
|
this.stripeService = stripeService;
|
2021-08-25 14:30:49 +03:00
|
|
|
}
|
|
|
|
|
2021-09-14 14:18:34 +03:00
|
|
|
/**
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
attachSubscriptionsToMember(member) {
|
2021-08-26 13:58:39 +03:00
|
|
|
if (!member.products || !Array.isArray(member.products)) {
|
|
|
|
return member;
|
|
|
|
}
|
|
|
|
|
2021-09-14 14:18:34 +03:00
|
|
|
const subscriptionProducts = (member.subscriptions || []).map(sub => sub.price.product.product_id);
|
2021-08-25 14:33:13 +03:00
|
|
|
for (const product of member.products) {
|
|
|
|
if (!subscriptionProducts.includes(product.id)) {
|
|
|
|
const productAddEvent = member.productEvents.find(event => event.product_id === product.id);
|
2021-08-26 12:50:16 +03:00
|
|
|
let startDate;
|
2021-08-25 14:33:13 +03:00
|
|
|
if (!productAddEvent || productAddEvent.action !== 'added') {
|
2021-08-26 12:50:16 +03:00
|
|
|
startDate = moment();
|
|
|
|
} else {
|
|
|
|
startDate = moment(productAddEvent.created_at);
|
2021-08-25 14:33:13 +03:00
|
|
|
}
|
|
|
|
member.subscriptions.push({
|
|
|
|
id: '',
|
2022-01-31 14:01:23 +03:00
|
|
|
tier: product,
|
2021-08-25 14:33:13 +03:00
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
2022-01-31 14:01:23 +03:00
|
|
|
|
|
|
|
for (const subscription of member.subscriptions) {
|
|
|
|
if (!subscription.tier) {
|
|
|
|
subscription.tier = member.products.find(product => product.id === subscription.price.product.product_id);
|
|
|
|
}
|
|
|
|
}
|
2021-09-14 14:18:34 +03:00
|
|
|
}
|
|
|
|
|
2021-10-20 15:32:41 +03:00
|
|
|
/**
|
|
|
|
* @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;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-09-14 14:18:34 +03:00
|
|
|
async read(data, options = {}) {
|
|
|
|
const defaultWithRelated = [
|
|
|
|
'labels',
|
|
|
|
'stripeSubscriptions',
|
|
|
|
'stripeSubscriptions.customer',
|
|
|
|
'stripeSubscriptions.stripePrice',
|
|
|
|
'stripeSubscriptions.stripePrice.stripeProduct',
|
2021-10-20 15:32:41 +03:00
|
|
|
'products',
|
|
|
|
'offerRedemptions'
|
2021-09-14 14:18:34 +03:00
|
|
|
];
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2021-10-20 15:32:41 +03:00
|
|
|
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;
|
|
|
|
});
|
|
|
|
|
2021-09-14 14:18:34 +03:00
|
|
|
const member = model.toJSON(options);
|
|
|
|
|
2021-12-01 11:45:50 +03:00
|
|
|
member.subscriptions = member.subscriptions.filter(sub => !!sub.price);
|
2021-09-14 14:18:34 +03:00
|
|
|
this.attachSubscriptionsToMember(member);
|
2021-08-25 14:33:13 +03:00
|
|
|
|
2021-10-20 15:32:41 +03:00
|
|
|
this.attachOffersToSubscriptions(member, subscriptionOffers);
|
|
|
|
|
2021-08-25 14:30:49 +03:00
|
|
|
return member;
|
|
|
|
}
|
2021-09-14 14:18:34 +03:00
|
|
|
|
|
|
|
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'
|
|
|
|
];
|
|
|
|
|
2021-10-25 14:34:20 +03:00
|
|
|
const originalWithRelated = options.withRelated || [];
|
|
|
|
|
|
|
|
const withRelated = new Set((originalWithRelated).concat(defaultWithRelated));
|
2021-09-14 14:18:34 +03:00
|
|
|
|
|
|
|
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) => {
|
2021-12-01 11:45:50 +03:00
|
|
|
member.subscriptions = member.subscriptions.filter(sub => !!sub.price);
|
2021-09-14 14:18:34 +03:00
|
|
|
this.attachSubscriptionsToMember(member);
|
2021-10-25 14:34:20 +03:00
|
|
|
if (!originalWithRelated.includes('products')) {
|
|
|
|
delete member.products;
|
|
|
|
}
|
2021-09-14 14:18:34 +03:00
|
|
|
return member;
|
|
|
|
});
|
|
|
|
|
|
|
|
return {
|
|
|
|
data,
|
|
|
|
meta: page.meta
|
|
|
|
};
|
|
|
|
}
|
2021-08-25 14:30:49 +03:00
|
|
|
};
|