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