Ghost/ghost/members-api/lib/repositories/member.js
Rishabh Garg 2eac41b1f4
🐛 Fixed invalid email getting saved for members (#16021)
refs https://github.com/TryGhost/Team/issues/2235

We found some cases which can cause a site to have member emails that have invalid characters like `member@example.com�`. This happened due to the `validator` version used by Ghost not able to catch some specific cases as invalid email, allowing members to be created with them either via Admin or Importer or direct signup. Portal UI already blocked these email as invalid. This change:

- updates `@tryghost/validator` to include a latest version of email validator that catches these invalid cases
- doesn't allow member creation with invalid email like above
- doesn't allow existing member emails to be edited to invalid
2022-12-16 16:47:52 +05:30

1573 lines
60 KiB
JavaScript

const _ = require('lodash');
const errors = require('@tryghost/errors');
const logging = require('@tryghost/logging');
const tpl = require('@tryghost/tpl');
const DomainEvents = require('@tryghost/domain-events');
const {MemberCreatedEvent, SubscriptionCreatedEvent, MemberSubscribeEvent, SubscriptionCancelledEvent} = require('@tryghost/member-events');
const ObjectId = require('bson-objectid').default;
const {NotFoundError} = require('@tryghost/errors');
const validator = require('@tryghost/validator');
const messages = {
noStripeConnection: 'Cannot {action} without a Stripe Connection',
moreThanOneProduct: 'A member cannot have more than one Product',
addProductWithActiveSubscription: 'Cannot add comped Products to a Member with active Subscriptions',
deleteProductWithActiveSubscription: 'Cannot delete a non-comped Product from a Member, because it has an active Subscription for the same product',
memberNotFound: 'Could not find Member {id}',
subscriptionNotFound: 'Could not find Subscription {id}',
productNotFound: 'Could not find Product {id}',
bulkActionRequiresFilter: 'Cannot perform {action} without a filter or all=true',
tierArchived: 'Cannot use archived Tiers',
invalidEmail: 'Invalid Email'
};
/**
* @typedef {object} ITokenService
* @prop {(token: string) => Promise<import('jsonwebtoken').JwtPayload>} decodeToken
*/
module.exports = class MemberRepository {
/**
* @param {object} deps
* @param {any} deps.Member
* @param {any} deps.MemberNewsletter
* @param {any} deps.MemberCancelEvent
* @param {any} deps.MemberSubscribeEventModel
* @param {any} deps.MemberEmailChangeEvent
* @param {any} deps.MemberPaidSubscriptionEvent
* @param {any} deps.MemberStatusEvent
* @param {any} deps.MemberProductEvent
* @param {any} deps.StripeCustomer
* @param {any} deps.StripeCustomerSubscription
* @param {any} deps.OfferRedemption
* @param {import('../../services/stripe-api')} deps.stripeAPIService
* @param {any} deps.labsService
* @param {any} deps.productRepository
* @param {any} deps.offerRepository
* @param {ITokenService} deps.tokenService
* @param {any} deps.newslettersService
*/
constructor({
Member,
MemberNewsletter,
MemberCancelEvent,
MemberSubscribeEventModel,
MemberEmailChangeEvent,
MemberPaidSubscriptionEvent,
MemberStatusEvent,
MemberProductEvent,
StripeCustomer,
StripeCustomerSubscription,
OfferRedemption,
stripeAPIService,
labsService,
productRepository,
offerRepository,
tokenService,
newslettersService
}) {
this._Member = Member;
this._MemberNewsletter = MemberNewsletter;
this._MemberCancelEvent = MemberCancelEvent;
this._MemberSubscribeEvent = MemberSubscribeEventModel;
this._MemberEmailChangeEvent = MemberEmailChangeEvent;
this._MemberPaidSubscriptionEvent = MemberPaidSubscriptionEvent;
this._MemberStatusEvent = MemberStatusEvent;
this._MemberProductEvent = MemberProductEvent;
this._StripeCustomer = StripeCustomer;
this._StripeCustomerSubscription = StripeCustomerSubscription;
this._stripeAPIService = stripeAPIService;
this._productRepository = productRepository;
this._offerRepository = offerRepository;
this.tokenService = tokenService;
this._newslettersService = newslettersService;
this._labsService = labsService;
DomainEvents.subscribe(SubscriptionCreatedEvent, async function (event) {
if (!event.data.offerId) {
return;
}
await OfferRedemption.add({
member_id: event.data.memberId,
subscription_id: event.data.subscriptionId,
offer_id: event.data.offerId
});
});
}
dispatchEvent(event, options) {
if (options?.transacting) {
// Only dispatch the event after the transaction has finished
options.transacting.executionPromise.then(async () => {
DomainEvents.dispatch(event);
}).catch(() => {
// catches transaction errors/rollback to not dispatch event
});
} else {
DomainEvents.dispatch(event);
}
}
isActiveSubscriptionStatus(status) {
return ['active', 'trialing', 'unpaid', 'past_due'].includes(status);
}
isComplimentarySubscription(subscription) {
return subscription.plan && subscription.plan.nickname && subscription.plan.nickname.toLowerCase() === 'complimentary';
}
/**
* Maps the framework context to members_*.source table record value
* @param {Object} context instance of ghost framework context object
* @returns {'import' | 'system' | 'api' | 'admin' | 'member'}
*/
_resolveContextSource(context) {
let source;
if (context.import || context.importer) {
source = 'import';
} else if (context.internal) {
source = 'system';
} else if (context.api_key) {
source = 'api';
} else if (context.user) {
source = 'admin';
} else {
source = 'member';
}
return source;
}
getMRR({interval, amount, status = null, canceled = false, discount = null}) {
if (status === 'trialing') {
return 0;
}
if (status === 'incomplete') {
return 0;
}
if (status === 'incomplete_expired') {
return 0;
}
if (status === 'canceled') {
return 0;
}
if (canceled) {
return 0;
}
let amountWithDiscount = amount;
if (discount && discount.end === null && discount.coupon && discount.coupon.duration === 'forever') {
// Discounts should only get applied when they are 'forever' discounts / they don't have an end date
if (discount.coupon.amount_off !== null) {
amountWithDiscount = Math.max(0, amountWithDiscount - discount.coupon.amount_off);
} else {
amountWithDiscount = Math.round((amountWithDiscount * (100 - discount.coupon.percent_off)) / 100);
}
}
if (interval === 'year') {
return Math.floor(amountWithDiscount / 12);
}
if (interval === 'month') {
return amountWithDiscount;
}
if (interval === 'week') {
return amountWithDiscount * 4;
}
if (interval === 'day') {
return amountWithDiscount * 30;
}
}
async get(data, options) {
if (data.customer_id) {
const customer = await this._StripeCustomer.findOne({
customer_id: data.customer_id
}, {
withRelated: ['member']
});
if (customer) {
return customer.related('member');
}
return null;
}
return this._Member.findOne(data, options);
}
async getByToken(token, options) {
const data = await this.tokenService.decodeToken(token);
return this.get({
email: data.sub
}, options);
}
/**
* Create a member
* @param {Object} data
* @param {string} data.email
* @param {string} [data.name]
* @param {string} [data.note]
* @param {(string|Object)[]} [data.labels]
* @param {boolean} [data.subscribed] (deprecated)
* @param {string} [data.geolocation]
* @param {Date} [data.created_at]
* @param {Object[]} [data.products]
* @param {Object[]} [data.newsletters]
* @param {Object} [data.stripeCustomer]
* @param {string} [data.offerId]
* @param {import('@tryghost/member-attribution/lib/attribution').AttributionResource} [data.attribution]
* @param {*} options
* @returns
*/
async create(data, options) {
if (!options) {
options = {};
}
if (!options.batch_id) {
// We'll use this to link related events
options.batch_id = ObjectId().toHexString();
}
const {labels, stripeCustomer, offerId, attribution} = data;
if (labels) {
labels.forEach((label, index) => {
if (typeof label === 'string') {
labels[index] = {name: label};
}
});
}
const memberData = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation', 'created_at', 'products', 'newsletters']);
// Throw error if email is invalid using latest validator
if (!validator.isEmail(memberData.email, {legacy: false})) {
throw new errors.ValidationError({
message: tpl(messages.invalidEmail),
property: 'email'
});
}
if (memberData.products && memberData.products.length > 1) {
throw new errors.BadRequestError({message: tpl(messages.moreThanOneProduct)});
}
if (memberData.products) {
for (const productData of memberData.products) {
const product = await this._productRepository.get(productData);
if (product.get('active') !== true) {
throw new errors.BadRequestError({message: tpl(messages.tierArchived)});
}
}
}
const memberStatusData = {
status: 'free'
};
if (memberData.products && memberData.products.length === 1) {
memberStatusData.status = 'comped';
}
// Subscribe member to default newsletters
if (memberData.subscribed !== false && !memberData.newsletters) {
const browseOptions = _.pick(options, 'transacting');
memberData.newsletters = await this.getSubscribeOnSignupNewsletters(browseOptions);
}
const withRelated = options.withRelated ? options.withRelated : [];
if (!withRelated.includes('labels')) {
withRelated.push('labels');
}
if (!withRelated.includes('newsletters')) {
withRelated.push('newsletters');
}
const member = await this._Member.add({
...memberData,
...memberStatusData,
labels
}, {...options, withRelated});
for (const product of member.related('products').models) {
await this._MemberProductEvent.add({
member_id: member.id,
product_id: product.id,
action: 'added'
}, options);
}
const context = options && options.context || {};
const source = this._resolveContextSource(context);
const eventData = _.pick(data, ['created_at']);
if (!eventData.created_at) {
eventData.created_at = member.get('created_at');
}
await this._MemberStatusEvent.add({
member_id: member.id,
from_status: null,
to_status: member.get('status'),
...eventData
}, options);
const newsletters = member.related('newsletters').models;
for (const newsletter of newsletters) {
await this._MemberSubscribeEvent.add({
member_id: member.id,
newsletter_id: newsletter.id,
subscribed: true,
source,
...eventData
}, options);
}
if (newsletters && newsletters.length > 0) {
this.dispatchEvent(MemberSubscribeEvent.create({
memberId: member.id,
source: source
}, eventData.created_at), options);
}
// For paid members created via stripe checkout webhook event, link subscription
if (stripeCustomer) {
await this.upsertCustomer({
member_id: member.id,
customer_id: stripeCustomer.id,
name: stripeCustomer.name,
email: stripeCustomer.email
});
for (const subscription of stripeCustomer.subscriptions.data) {
try {
await this.linkSubscription({
id: member.id,
subscription,
offerId,
attribution
}, {batch_id: options.batch_id});
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
throw err;
}
throw new errors.ConflictError({
err
});
}
}
}
this.dispatchEvent(MemberCreatedEvent.create({
memberId: member.id,
batchId: options.batch_id,
attribution: data.attribution,
source
}, eventData.created_at), options);
return member;
}
async getSubscribeOnSignupNewsletters(browseOptions) {
// By default subscribe to all active auto opt-in newsletters with members visibility
//TODO: Will mostly need to be updated later for paid-only newsletters
browseOptions.filter = 'status:active+subscribe_on_signup:true+visibility:members';
const newsletters = await this._newslettersService.browse(browseOptions);
return newsletters || [];
}
async update(data, options) {
const sharedOptions = {
transacting: options.transacting
};
if (!options) {
options = {};
}
const withRelated = options.withRelated ? options.withRelated : [];
if (!withRelated.includes('labels')) {
withRelated.push('labels');
}
if (!withRelated.includes('newsletters')) {
withRelated.push('newsletters');
}
const memberData = _.pick(data, [
'email',
'name',
'note',
'subscribed',
'labels',
'geolocation',
'products',
'newsletters',
'enable_comment_notifications',
'last_seen_at',
'last_commented_at',
'expertise'
]);
// Trim whitespaces from expertise
if (memberData.expertise) {
memberData.expertise = memberData.expertise.trim();
}
// Determine if we need to fetch the initial member with relations
const needsProducts = this._stripeAPIService.configured && data.products;
const needsNewsletters = memberData.newsletters || typeof memberData.subscribed === 'boolean';
// Build list for withRelated
const requiredRelations = [];
if (needsProducts) {
requiredRelations.push('products');
}
if (needsNewsletters) {
requiredRelations.push('newsletters');
}
// Fetch the member with relations if required
let initialMember = null;
if (requiredRelations.length > 0) {
initialMember = await this._Member.findOne({
id: options.id
}, {...sharedOptions, withRelated: requiredRelations, require: false});
// Make sure we throw the right error if it doesn't exist
if (!initialMember) {
throw new NotFoundError({message: tpl(messages.memberNotFound, {id: options.id})});
}
}
// Throw error if email is invalid and it's been changed
if (
initialMember?.get('email') && memberData.email
&& initialMember.get('email') !== memberData.email
&& !validator.isEmail(memberData.email, {legacy: false})
) {
throw new errors.ValidationError({
message: tpl(messages.invalidEmail),
property: 'email'
});
}
const memberStatusData = {};
let productsToAdd = [];
let productsToRemove = [];
if (needsProducts) {
const existingProducts = initialMember.related('products').models;
const existingProductIds = existingProducts.map(product => product.id);
const incomingProductIds = data.products.map(product => product.id);
if (incomingProductIds.length > 1 && incomingProductIds.length > existingProductIds.length) {
throw new errors.BadRequestError({message: tpl(messages.moreThanOneProduct)});
}
productsToAdd = _.differenceWith(incomingProductIds, existingProductIds);
productsToRemove = _.differenceWith(existingProductIds, incomingProductIds);
const productsToModify = productsToAdd.concat(productsToRemove);
if (productsToModify.length !== 0) {
// Load active subscriptions information
await initialMember.load(
[
'stripeSubscriptions',
'stripeSubscriptions.stripePrice',
'stripeSubscriptions.stripePrice.stripeProduct',
'stripeSubscriptions.stripePrice.stripeProduct.product'
], sharedOptions);
const exisitingSubscriptions = initialMember.related('stripeSubscriptions')?.models ?? [];
if (productsToRemove.length > 0) {
// Only allow to delete comped products without a subscription attached to them
// Other products should be removed by canceling them via the related stripe subscription
const dontAllowToRemoveProductsIds = exisitingSubscriptions
.filter(sub => this.isActiveSubscriptionStatus(sub.get('status')))
.map(sub => sub.related('stripePrice')?.related('stripeProduct')?.get('product_id'));
for (const deleteId of productsToRemove) {
if (dontAllowToRemoveProductsIds.includes(deleteId)) {
throw new errors.BadRequestError({message: tpl(messages.deleteProductWithActiveSubscription)});
}
}
if (incomingProductIds.length === 0) {
// CASE: We are removing all (comped) products from a member & there were no active subscriptions - the member is "free"
memberStatusData.status = 'free';
}
}
if (productsToAdd.length > 0) {
// Don't allow to add complimentary subscriptions (= creating a new product) when the member already has an active
// subscription
const existingActiveSubscriptions = exisitingSubscriptions.filter((subscription) => {
return this.isActiveSubscriptionStatus(subscription.get('status'));
});
if (existingActiveSubscriptions.length) {
throw new errors.BadRequestError({message: tpl(messages.addProductWithActiveSubscription)});
}
// CASE: We are changing products & there were not active stripe subscriptions - the member is "comped"
memberStatusData.status = 'comped';
}
}
}
for (const productId of productsToAdd) {
const product = await this._productRepository.get({id: productId}, sharedOptions);
if (product.get('active') !== true) {
throw new errors.BadRequestError({message: tpl(messages.tierArchived)});
}
}
// Keep track of the newsletters that were added and removed of a member so we can generate the corresponding events
let newslettersToAdd = [];
let newslettersToRemove = [];
if (needsNewsletters) {
const existingNewsletters = initialMember.related('newsletters').models;
// This maps the old subscribed property to the new newsletters field
if (!memberData.newsletters) {
if (memberData.subscribed === false) {
memberData.newsletters = [];
} else if (memberData.subscribed === true && !existingNewsletters.find(n => n.get('status') === 'active')) {
const browseOptions = _.pick(options, 'transacting');
memberData.newsletters = await this.getSubscribeOnSignupNewsletters(browseOptions);
}
}
if (memberData.newsletters) {
const existingNewsletterIds = existingNewsletters.map(newsletter => newsletter.id);
const incomingNewsletterIds = memberData.newsletters.map(newsletter => newsletter.id);
newslettersToAdd = _.differenceWith(incomingNewsletterIds, existingNewsletterIds);
newslettersToRemove = _.differenceWith(existingNewsletterIds, incomingNewsletterIds);
}
}
const member = await this._Member.edit({
...memberData,
...memberStatusData
}, {...options, withRelated});
for (const productToAdd of productsToAdd) {
await this._MemberProductEvent.add({
member_id: member.id,
product_id: productToAdd,
action: 'added'
}, options);
}
for (const productToRemove of productsToRemove) {
await this._MemberProductEvent.add({
member_id: member.id,
product_id: productToRemove,
action: 'removed'
}, options);
}
// Add subscribe events for all (un)subscribed newsletters
const context = options && options.context || {};
const source = this._resolveContextSource(context);
for (const newsletterToAdd of newslettersToAdd) {
await this._MemberSubscribeEvent.add({
member_id: member.id,
newsletter_id: newsletterToAdd,
subscribed: true,
source
}, sharedOptions);
}
for (const newsletterToRemove of newslettersToRemove) {
await this._MemberSubscribeEvent.add({
member_id: member.id,
newsletter_id: newsletterToRemove,
subscribed: false,
source
}, sharedOptions);
}
if (newslettersToAdd.length > 0 || newslettersToRemove.length > 0) {
this.dispatchEvent(MemberSubscribeEvent.create({
memberId: member.id,
source: source
}, member.updated_at), sharedOptions);
}
if (member.attributes.email !== member._previousAttributes.email) {
await this._MemberEmailChangeEvent.add({
member_id: member.id,
from_email: member._previousAttributes.email,
to_email: member.get('email')
}, sharedOptions);
}
if (member.attributes.status !== member._previousAttributes.status) {
await this._MemberStatusEvent.add({
member_id: member.id,
from_status: member._previousAttributes.status,
to_status: member.get('status')
}, sharedOptions);
}
if (this._stripeAPIService.configured && member._changed.email) {
await member.related('stripeCustomers').fetch();
const customers = member.related('stripeCustomers');
for (const customer of customers.models) {
await this._stripeAPIService.updateCustomerEmail(
customer.get('customer_id'),
member.get('email')
);
}
}
return member;
}
async list(options) {
return this._Member.findPage(options);
}
async destroy(data, options) {
const member = await this._Member.findOne(data, options);
if (!member) {
// throw error?
return;
}
if (this._stripeAPIService.configured && options.cancelStripeSubscriptions) {
await member.related('stripeSubscriptions').fetch();
const subscriptions = member.related('stripeSubscriptions');
for (const subscription of subscriptions.models) {
if (subscription.get('status') !== 'canceled') {
const updatedSubscription = await this._stripeAPIService.cancelSubscription(
subscription.get('subscription_id')
);
await this._StripeCustomerSubscription.upsert({
status: updatedSubscription.status,
mrr: 0
}, {
subscription_id: updatedSubscription.id
});
await this._MemberPaidSubscriptionEvent.add({
member_id: member.id,
source: 'stripe',
subscription_id: subscription.id,
from_plan: subscription.get('plan_id'),
to_plan: null,
currency: subscription.get('plan_currency'),
mrr_delta: -1 * subscription.get('mrr')
}, options);
}
}
}
return this._Member.destroy({
id: data.id
}, options);
}
async bulkDestroy(options) {
const {all, filter, search} = options;
if (!filter && !search && (!all || all !== true)) {
throw new errors.IncorrectUsageError({
message: tpl(messages.bulkActionRequiresFilter, {action: 'bulk delete'})
});
}
const filterOptions = _.pick(options, ['transacting', 'context']);
if (all !== true) {
// Include mongoTransformer to apply subscribed:{true|false} => newsletter relation mapping
Object.assign(filterOptions, _.pick(options, ['filter', 'search', 'mongoTransformer']));
}
const memberRows = await this._Member.getFilteredCollectionQuery(filterOptions)
.select('members.id')
.distinct();
const memberIds = memberRows.map(row => row.id);
const bulkDestroyResult = await this._Member.bulkDestroy(memberIds);
bulkDestroyResult.unsuccessfulIds = bulkDestroyResult.unsuccessfulData;
delete bulkDestroyResult.unsuccessfulData;
return bulkDestroyResult;
}
async bulkEdit(data, options) {
const {all, filter, search} = options;
if (!['unsubscribe', 'addLabel', 'removeLabel'].includes(data.action)) {
throw new errors.IncorrectUsageError({
message: 'Unsupported bulk action'
});
}
if (!filter && !search && (!all || all !== true)) {
throw new errors.IncorrectUsageError({
message: tpl(messages.bulkActionRequiresFilter, {action: 'bulk edit'})
});
}
const filterOptions = _.pick(options, ['transacting', 'context']);
if (all !== true) {
// Include mongoTransformer to apply subscribed:{true|false} => newsletter relation mapping
Object.assign(filterOptions, _.pick(options, ['filter', 'search', 'mongoTransformer']));
}
const memberRows = await this._Member.getFilteredCollectionQuery(filterOptions)
.select('members.id')
.distinct();
const memberIds = memberRows.map(row => row.id);
if (data.action === 'unsubscribe') {
const hasNewsletterSelected = (Object.prototype.hasOwnProperty.call(data, 'newsletter') && data.newsletter !== null);
if (hasNewsletterSelected) {
const membersArr = memberIds.join(',');
const unsubscribeRows = await this._MemberNewsletter.getFilteredCollectionQuery({
filter: `newsletter_id:${data.newsletter}+member_id:[${membersArr}]`
});
const toUnsubscribe = unsubscribeRows.map(row => row.id);
return await this._MemberNewsletter.bulkDestroy(toUnsubscribe);
}
if (!hasNewsletterSelected) {
return await this._Member.bulkDestroy(memberIds, 'members_newsletters', {column: 'member_id'});
}
}
if (data.action === 'removeLabel') {
const membersLabelsRows = await this._Member.getLabelRelations({
labelId: data.meta.label.id,
memberIds
});
const membersLabelsIds = membersLabelsRows.map(row => row.id);
return this._Member.bulkDestroy(membersLabelsIds, 'members_labels');
}
if (data.action === 'addLabel') {
const relations = memberIds.map((id) => {
return {
member_id: id,
label_id: data.meta.label.id,
id: ObjectId().toHexString()
};
});
return this._Member.bulkAdd(relations, 'members_labels');
}
}
async upsertCustomer(data) {
return await this._StripeCustomer.upsert({
customer_id: data.customer_id,
member_id: data.member_id,
name: data.name,
email: data.email
});
}
async linkStripeCustomer(data, options) {
if (!this._stripeAPIService.configured) {
throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'link Stripe Customer'})});
}
const customer = await this._stripeAPIService.getCustomer(data.customer_id);
if (!customer) {
return;
}
// Add instead of upsert ensures that we do not link existing customer
await this._StripeCustomer.add({
customer_id: data.customer_id,
member_id: data.member_id,
name: customer.name,
email: customer.email
}, options);
for (const subscription of customer.subscriptions.data) {
await this.linkSubscription({
id: data.member_id,
subscription
}, options);
}
}
async getSubscriptionByStripeID(id, options) {
const subscription = await this._StripeCustomerSubscription.findOne({
subscription_id: id
}, options);
return subscription;
}
/**
*
* @param {Object} data
* @param {String} data.id - member ID
* @param {Object} data.subscription
* @param {String} data.offerId
* @param {import('@tryghost/member-attribution/lib/attribution').AttributionResource} [data.attribution]
* @param {*} options
* @returns
*/
async linkSubscription(data, options = {}) {
if (!this._stripeAPIService.configured) {
throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'link Stripe Subscription'})});
}
if (!options.transacting) {
return this._Member.transaction((transacting) => {
return this.linkSubscription(data, {
...options,
transacting
});
});
}
if (!options.batch_id) {
options.batch_id = ObjectId().toHexString();
}
const member = await this._Member.findOne({
id: data.id
}, {...options, forUpdate: true});
const customer = await member.related('stripeCustomers').query({
where: {
customer_id: data.subscription.customer
}
}).fetchOne(options);
if (!customer) {
// Maybe just link the customer?
throw new errors.NotFoundError({message: tpl(messages.subscriptionNotFound)});
}
const subscription = await this._stripeAPIService.getSubscription(data.subscription.id);
let paymentMethodId;
if (!subscription.default_payment_method) {
paymentMethodId = null;
} else if (typeof subscription.default_payment_method === 'string') {
paymentMethodId = subscription.default_payment_method;
} else {
paymentMethodId = subscription.default_payment_method.id;
}
const paymentMethod = paymentMethodId ? await this._stripeAPIService.getCardPaymentMethod(paymentMethodId) : null;
const model = await this.getSubscriptionByStripeID(subscription.id, {...options, forUpdate: true});
const subscriptionPriceData = _.get(subscription, 'items.data[0].price');
let ghostProduct;
try {
ghostProduct = await this._productRepository.get({stripe_product_id: subscriptionPriceData.product}, {...options, forUpdate: true});
// Use first Ghost product as default product in case of missing link
if (!ghostProduct) {
ghostProduct = await this._productRepository.getDefaultProduct({
forUpdate: true,
...options
});
}
// Link Stripe Product & Price to Ghost Product
if (ghostProduct) {
await this._productRepository.update({
id: ghostProduct.get('id'),
name: ghostProduct.get('name'),
stripe_prices: [
{
stripe_price_id: subscriptionPriceData.id,
stripe_product_id: subscriptionPriceData.product,
active: subscriptionPriceData.active,
nickname: subscriptionPriceData.nickname,
currency: subscriptionPriceData.currency,
amount: subscriptionPriceData.unit_amount,
type: subscriptionPriceData.type,
interval: (subscriptionPriceData.recurring && subscriptionPriceData.recurring.interval) || null
}
]
}, options);
} else {
// Log error if no Ghost products found
logging.error(`There was an error linking subscription - ${subscription.id}, no Products exist.`);
}
} catch (e) {
logging.error(`Failed to handle prices and product for - ${subscription.id}.`);
logging.error(e);
}
let stripeCouponId = subscription.discount && subscription.discount.coupon ? subscription.discount.coupon.id : null;
// For trial offers, offer id is passed from metadata as there is no stripe coupon
let offerId = data.offerId || null;
let offer = null;
if (stripeCouponId) {
// Get the offer from our database
offer = await this._offerRepository.getByStripeCouponId(stripeCouponId, {transacting: options.transacting});
if (offer) {
offerId = offer.id;
} else {
logging.error(`Received an unknown stripe coupon id (${stripeCouponId}) for subscription - ${subscription.id}.`);
}
} else if (offerId) {
offer = await this._offerRepository.getById(offerId, {transacting: options.transacting});
}
const subscriptionData = {
customer_id: subscription.customer,
subscription_id: subscription.id,
status: subscription.status,
cancel_at_period_end: subscription.cancel_at_period_end,
cancellation_reason: subscription.metadata && subscription.metadata.cancellation_reason || null,
current_period_end: new Date(subscription.current_period_end * 1000),
start_date: new Date(subscription.start_date * 1000),
default_payment_card_last4: paymentMethod && paymentMethod.card && paymentMethod.card.last4 || null,
stripe_price_id: subscriptionPriceData.id,
plan_id: subscriptionPriceData.id,
// trial start and end are returned as Stripe timestamps and need coversion
trial_start_at: subscription.trial_start ? new Date(subscription.trial_start * 1000) : null,
trial_end_at: subscription.trial_end ? new Date(subscription.trial_end * 1000) : null,
// NOTE: Defaulting to interval as migration to nullable field
// turned out to be much bigger problem.
// Ideally, would need nickname field to be nullable on the DB level
// condition can be simplified once this is done
plan_nickname: subscriptionPriceData.nickname || _.get(subscriptionPriceData, 'recurring.interval'),
plan_interval: _.get(subscriptionPriceData, 'recurring.interval', ''),
plan_amount: subscriptionPriceData.unit_amount,
plan_currency: subscriptionPriceData.currency,
mrr: this.getMRR({
interval: _.get(subscriptionPriceData, 'recurring.interval', ''),
amount: subscriptionPriceData.unit_amount,
status: subscription.status,
canceled: subscription.cancel_at_period_end,
discount: subscription.discount
}),
offer_id: offerId
};
let eventData = {};
if (model) {
// CASE: Offer is already mapped against sub, don't overwrite it with NULL
// Needed for trial offers, which don't have a stripe coupon/discount attached to sub
if (!subscriptionData.offer_id) {
delete subscriptionData.offer_id;
}
const updated = await this._StripeCustomerSubscription.edit(subscriptionData, {
...options,
id: model.id
});
if (model.get('mrr') !== updated.get('mrr') || model.get('plan_id') !== updated.get('plan_id') || model.get('status') !== updated.get('status') || model.get('cancel_at_period_end') !== updated.get('cancel_at_period_end')) {
const originalMrrDelta = model.get('mrr');
const updatedMrrDelta = updated.get('mrr');
const getStatus = (modelToCheck) => {
const status = modelToCheck.get('status');
const canceled = modelToCheck.get('cancel_at_period_end');
if (status === 'canceled') {
return 'expired';
}
if (canceled) {
return 'canceled';
}
if (this.isActiveSubscriptionStatus(status)) {
return 'active';
}
return 'inactive';
};
const getEventName = (originalStatus, updatedStatus) => {
if (originalStatus === updatedStatus) {
return 'updated';
}
if (originalStatus === 'canceled' && updatedStatus === 'active') {
return 'reactivated';
}
return updatedStatus;
};
const originalStatus = getStatus(model);
const updatedStatus = getStatus(updated);
const mrrDelta = updatedMrrDelta - originalMrrDelta;
await this._MemberPaidSubscriptionEvent.add({
member_id: member.id,
source: 'stripe',
type: getEventName(originalStatus, updatedStatus),
subscription_id: updated.id,
from_plan: model.get('plan_id'),
to_plan: updated.get('status') === 'canceled' ? null : updated.get('plan_id'),
currency: subscriptionPriceData.currency,
mrr_delta: mrrDelta
}, options);
}
} else {
eventData.created_at = new Date(subscription.start_date * 1000);
const subscriptionModel = await this._StripeCustomerSubscription.add(subscriptionData, options);
await this._MemberPaidSubscriptionEvent.add({
member_id: member.id,
subscription_id: subscriptionModel.id,
type: 'created',
source: 'stripe',
from_plan: null,
to_plan: subscriptionPriceData.id,
currency: subscriptionPriceData.currency,
mrr_delta: subscriptionModel.get('mrr'),
...eventData
}, options);
const context = options?.context || {};
const source = this._resolveContextSource(context);
const event = SubscriptionCreatedEvent.create({
source,
tierId: ghostProduct?.get('id'),
memberId: member.id,
subscriptionId: subscriptionModel.get('id'),
offerId: offerId,
attribution: data.attribution,
batchId: options.batch_id
});
this.dispatchEvent(event, options);
}
let memberProducts = (await member.related('products').fetch(options)).toJSON();
const oldMemberProducts = member.related('products').toJSON();
let status = memberProducts.length === 0 ? 'free' : 'comped';
if (this.isActiveSubscriptionStatus(subscription.status)) {
if (this.isComplimentarySubscription(subscription)) {
status = 'comped';
} else {
status = 'paid';
}
// This is an active subscription! Add the product
if (ghostProduct) {
// memberProducts.push(ghostProduct.toJSON());
memberProducts = [ghostProduct.toJSON()];
}
if (model) {
if (model.get('stripe_price_id') !== subscriptionData.stripe_price_id) {
// The subscription has changed plan - we may need to update the products
const subscriptions = await member.related('stripeSubscriptions').fetch(options);
const changedProduct = await this._productRepository.get({
stripe_price_id: model.get('stripe_price_id')
}, options);
let activeSubscriptionForChangedProduct = false;
for (const subscriptionModel of subscriptions.models) {
if (this.isActiveSubscriptionStatus(subscriptionModel.get('status'))) {
try {
const subscriptionProduct = await this._productRepository.get({stripe_price_id: subscriptionModel.get('stripe_price_id')}, options);
if (subscriptionProduct && changedProduct && subscriptionProduct.id === changedProduct.id) {
activeSubscriptionForChangedProduct = true;
}
} catch (e) {
logging.error(`Failed to attach products to member - ${data.id}`);
logging.error(e);
}
}
}
if (!activeSubscriptionForChangedProduct) {
memberProducts = memberProducts.filter((product) => {
return product.id !== changedProduct.id;
});
}
}
}
} else {
const subscriptions = await member.related('stripeSubscriptions').fetch(options);
let activeSubscriptionForGhostProduct = false;
for (const subscriptionModel of subscriptions.models) {
if (this.isActiveSubscriptionStatus(subscriptionModel.get('status'))) {
status = 'paid';
try {
const subscriptionProduct = await this._productRepository.get({stripe_price_id: subscriptionModel.get('stripe_price_id')}, options);
if (subscriptionProduct && ghostProduct && subscriptionProduct.id === ghostProduct.id) {
activeSubscriptionForGhostProduct = true;
}
} catch (e) {
logging.error(`Failed to attach products to member - ${data.id}`);
logging.error(e);
}
}
}
if (!activeSubscriptionForGhostProduct) {
memberProducts = memberProducts.filter((product) => {
return product.id !== ghostProduct.id;
});
}
if (memberProducts.length === 0) {
status = 'free';
}
}
let updatedMember;
try {
// Remove duplicate products from the list
memberProducts = _.uniqBy(memberProducts, function (e) {
return e.id;
});
// Edit member with updated products assoicated
updatedMember = await this._Member.edit({status: status, products: memberProducts}, {...options, id: data.id});
} catch (e) {
logging.error(`Failed to update member - ${data.id} - with related products`);
logging.error(e);
updatedMember = await this._Member.edit({status: status}, {...options, id: data.id});
}
const newMemberProductIds = memberProducts.map(product => product.id);
const oldMemberProductIds = oldMemberProducts.map(product => product.id);
const productsToAdd = _.differenceWith(newMemberProductIds, oldMemberProductIds);
const productsToRemove = _.differenceWith(oldMemberProductIds, newMemberProductIds);
for (const productToAdd of productsToAdd) {
await this._MemberProductEvent.add({
member_id: member.id,
product_id: productToAdd,
action: 'added'
}, options);
}
for (const productToRemove of productsToRemove) {
await this._MemberProductEvent.add({
member_id: member.id,
product_id: productToRemove,
action: 'removed'
}, options);
}
if (updatedMember.attributes.status !== updatedMember._previousAttributes.status) {
await this._MemberStatusEvent.add({
member_id: data.id,
from_status: updatedMember._previousAttributes.status,
to_status: updatedMember.get('status'),
...eventData
}, options);
}
}
async getSubscription(data, options) {
if (!this._stripeAPIService.configured) {
throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'get Stripe Subscription'})});
}
const member = await this._Member.findOne({
email: data.email
});
const subscription = await member.related('stripeSubscriptions').query({
where: {
subscription_id: data.subscription.subscription_id
}
}).fetchOne(options);
if (!subscription) {
throw new errors.NotFoundError({message: tpl(messages.subscriptionNotFound, {id: data.subscription.subscription_id})});
}
return subscription.toJSON();
}
async cancelSubscription(data, options) {
const sharedOptions = {
transacting: options ? options.transacting : null
};
if (!this._stripeAPIService.configured) {
throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'update Stripe Subscription'})});
}
let findQuery = null;
if (data.id) {
findQuery = {id: data.id};
} else if (data.email) {
findQuery = {email: data.email};
}
if (!findQuery) {
throw new errors.NotFoundError({message: tpl(messages.subscriptionNotFound)});
}
const member = await this._Member.findOne(findQuery);
const subscription = await member.related('stripeSubscriptions').query({
where: {
subscription_id: data.subscription.subscription_id
}
}).fetchOne(options);
if (!subscription) {
throw new errors.NotFoundError({message: tpl(messages.subscriptionNotFound, {id: data.subscription.subscription_id})});
}
const updatedSubscription = await this._stripeAPIService.cancelSubscription(data.subscription.subscription_id);
await this.linkSubscription({
id: member.id,
subscription: updatedSubscription
}, options);
await this._MemberCancelEvent.add({
member_id: member.id,
from_plan: subscription.get('plan_id')
}, sharedOptions);
}
async updateSubscription(data, options) {
const sharedOptions = {
transacting: options ? options.transacting : null
};
if (!this._stripeAPIService.configured) {
throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'update Stripe Subscription'})});
}
let findQuery = null;
if (data.id) {
findQuery = {id: data.id};
} else if (data.email) {
findQuery = {email: data.email};
}
if (!findQuery) {
throw new errors.NotFoundError({message: tpl(messages.subscriptionNotFound)});
}
const member = await this._Member.findOne(findQuery);
const subscriptionModel = await member.related('stripeSubscriptions').query({
where: {
subscription_id: data.subscription.subscription_id
}
}).fetchOne(options);
if (!subscriptionModel) {
throw new errors.NotFoundError({message: tpl(messages.subscriptionNotFound, {id: data.subscription.subscription_id})});
}
let updatedSubscription;
if (data.subscription.price) {
const subscription = await this._stripeAPIService.getSubscription(
data.subscription.subscription_id
);
const subscriptionItem = subscription.items.data[0];
if (data.subscription.price !== subscription.price) {
updatedSubscription = await this._stripeAPIService.updateSubscriptionItemPrice(
subscription.id,
subscriptionItem.id,
data.subscription.price
);
updatedSubscription = await this._stripeAPIService.removeCouponFromSubscription(subscription.id);
}
}
if (data.subscription.cancel_at_period_end !== undefined) {
if (data.subscription.cancel_at_period_end) {
updatedSubscription = await this._stripeAPIService.cancelSubscriptionAtPeriodEnd(
data.subscription.subscription_id,
data.subscription.cancellationReason
);
await this._MemberCancelEvent.add({
member_id: member.id,
from_plan: subscriptionModel.get('plan_id')
}, sharedOptions);
} else {
updatedSubscription = await this._stripeAPIService.continueSubscriptionAtPeriodEnd(
data.subscription.subscription_id
);
}
}
if (updatedSubscription) {
await this.linkSubscription({
id: member.id,
subscription: updatedSubscription
}, options);
// Dispatch cancellation event
if (data.subscription.cancel_at_period_end) {
const stripeProductId = _.get(updatedSubscription, 'items.data[0].price.product');
let ghostProduct;
try {
ghostProduct = await this._productRepository.get(
{stripe_product_id: stripeProductId},
{...sharedOptions, forUpdate: true}
);
} catch (e) {
ghostProduct = null;
}
const context = options?.context || {};
const source = this._resolveContextSource(context);
const cancellationTimestamp = updatedSubscription.canceled_at
? new Date(updatedSubscription.canceled_at * 1000)
: new Date();
const cancelEventData = {
source,
memberId: member.id,
subscriptionId: subscriptionModel.get('id'),
tierId: ghostProduct?.get('id')
};
this.dispatchEvent(SubscriptionCancelledEvent.create(cancelEventData, cancellationTimestamp), options);
}
}
}
async createSubscription(data, options) {
if (!this._stripeAPIService.configured) {
throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'create Stripe Subscription'})});
}
const member = await this._Member.findOne({
id: data.id
}, options);
let stripeCustomer;
await member.related('stripeCustomers').fetch(options);
for (const customer of member.related('stripeCustomers').models) {
try {
const fetchedCustomer = await this._stripeAPIService.getCustomer(customer.get('customer_id'));
stripeCustomer = fetchedCustomer;
} catch (err) {
logging.info('Ignoring error for fetching customer for checkout');
}
}
if (!stripeCustomer) {
stripeCustomer = await this._stripeAPIService.createCustomer({
email: member.get('email')
});
await this._StripeCustomer.add({
customer_id: stripeCustomer.id,
member_id: data.id,
email: stripeCustomer.email,
name: stripeCustomer.name
}, options);
}
const subscription = await this._stripeAPIService.createSubscription(stripeCustomer.id, data.subscription.stripe_price_id);
await this.linkSubscription({
id: member.id,
subscription
}, options);
}
/**
*
* @param {Object} data
* @param {String} data.id - member ID
* @param {Object} options
* @param {Object} [options.transacting]
*/
async setComplimentarySubscription(data, options = {}) {
if (!options.transacting) {
return this._Member.transaction((transacting) => {
return this.setComplimentarySubscription(data, {
...options,
transacting
});
});
}
if (!this._stripeAPIService.configured) {
throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'create Complimentary Subscription'})});
}
const member = await this._Member.findOne({
id: data.id
}, options);
const subscriptions = await member.related('stripeSubscriptions').fetch(options);
const activeSubscriptions = subscriptions.models.filter((subscription) => {
return this.isActiveSubscriptionStatus(subscription.get('status'));
});
const sharedOptions = _.pick(options, ['context', 'transacting']);
const ghostProductModel = await this._productRepository.getDefaultProduct({
withRelated: ['stripePrices'],
...sharedOptions
});
const defaultProduct = ghostProductModel?.toJSON();
if (!defaultProduct) {
throw new errors.NotFoundError({
message: tpl(messages.productNotFound, {id: '"default"'})
});
}
const zeroValuePrices = defaultProduct.stripePrices.filter((price) => {
return price.amount === 0;
});
if (activeSubscriptions.length) {
for (const subscription of activeSubscriptions) {
const price = await subscription.related('stripePrice').fetch(options);
let zeroValuePrice = zeroValuePrices.find((p) => {
return p.currency.toLowerCase() === price.get('currency').toLowerCase();
});
if (!zeroValuePrice) {
const product = (await this._productRepository.update({
id: defaultProduct.id,
name: defaultProduct.name,
description: defaultProduct.description,
stripe_prices: [{
nickname: 'Complimentary',
currency: price.get('currency'),
type: 'recurring',
interval: 'year',
amount: 0
}]
}, options)).toJSON();
zeroValuePrice = product.stripePrices.find((p) => {
return p.currency.toLowerCase() === price.get('currency').toLowerCase() && p.amount === 0;
});
zeroValuePrices.push(zeroValuePrice);
}
const stripeSubscription = await this._stripeAPIService.getSubscription(
subscription.get('subscription_id')
);
const subscriptionItem = stripeSubscription.items.data[0];
const updatedSubscription = await this._stripeAPIService.updateSubscriptionItemPrice(
stripeSubscription.id,
subscriptionItem.id,
zeroValuePrice.stripe_price_id
);
await this.linkSubscription({
id: member.id,
subscription: updatedSubscription
}, sharedOptions);
}
} else {
const stripeCustomer = await this._stripeAPIService.createCustomer({
email: member.get('email')
});
await this._StripeCustomer.upsert({
customer_id: stripeCustomer.id,
member_id: data.id,
email: stripeCustomer.email,
name: stripeCustomer.name
}, sharedOptions);
let zeroValuePrice = zeroValuePrices[0];
if (!zeroValuePrice) {
const product = (await this._productRepository.update({
id: defaultProduct.id,
name: defaultProduct.name,
description: defaultProduct.description,
stripe_prices: [{
nickname: 'Complimentary',
currency: 'USD',
type: 'recurring',
interval: 'year',
amount: 0
}]
}, sharedOptions)).toJSON();
zeroValuePrice = product.stripePrices.find((price) => {
return price.currency.toLowerCase() === 'usd' && price.amount === 0;
});
zeroValuePrices.push(zeroValuePrice);
}
const subscription = await this._stripeAPIService.createSubscription(
stripeCustomer.id,
zeroValuePrice.stripe_price_id
);
await this.linkSubscription({
id: member.id,
subscription
}, sharedOptions);
}
}
/**
*
* @param {Object} data
* @param {String} data.id - member ID
* @param {Object} options
* @param {Object} [options.transacting]
*/
async cancelComplimentarySubscription({id}, options) {
if (!this._stripeAPIService.configured) {
throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'cancel Complimentary Subscription'})});
}
const member = await this._Member.findOne({
id: id
});
const subscriptions = await member.related('stripeSubscriptions').fetch();
for (const subscription of subscriptions.models) {
if (subscription.get('status') !== 'canceled') {
try {
const updatedSubscription = await this._stripeAPIService.cancelSubscription(
subscription.get('subscription_id')
);
// Only needs to update `status`
await this.linkSubscription({
id: id,
subscription: updatedSubscription
}, options);
} catch (err) {
logging.error(`There was an error cancelling subscription ${subscription.get('subscription_id')}`);
logging.error(err);
}
}
}
return true;
}
};