mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-19 08:31:43 +03:00
ed8a3ca27c
no-issue The Members API does not currently include 'products' by default when browsing. This ensures the functionality is maintained.
333 lines
11 KiB
JavaScript
333 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 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) => {
|
|
this.attachSubscriptionsToMember(member);
|
|
if (!originalWithRelated.includes('products')) {
|
|
delete member.products;
|
|
}
|
|
return member;
|
|
});
|
|
|
|
return {
|
|
data,
|
|
meta: page.meta
|
|
};
|
|
}
|
|
};
|