0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-17 23:44:39 -05:00

Used models internally and for exported API (#195)

no-issue

Using models internally and in the exported API means that we avoid expensive
`toJSON` calls, which affects performance when looping through large lists of
members. It also allows us to take advantage of the new relations used in the
models.

The addition of "ByID" methods for linking stripe customers and setting
complimentary subscriptions allows bulk imports to avoid the overhead of creating
a model for each members, instead passing an id string. n.b. currently the impl
_does_ still create models, but it makes it easier to optimise and refactor in the 
future.
This commit is contained in:
Fabien 'egg' O'Carroll 2020-08-12 12:57:28 +01:00 committed by GitHub
parent 85800c57f7
commit 117309b4e8
4 changed files with 108 additions and 233 deletions

View file

@ -100,6 +100,11 @@ module.exports = function MembersApi({
getSubject getSubject
}); });
const users = Users({
stripe,
Member
});
async function sendEmailWithMagicLink({email, requestedType, payload, options = {forceEmailType: false}}) { async function sendEmailWithMagicLink({email, requestedType, payload, options = {forceEmailType: false}}) {
if (options.forceEmailType) { if (options.forceEmailType) {
return magicLinkService.sendMagicLink({email, payload, subject: email, type: requestedType}); return magicLinkService.sendMagicLink({email, payload, subject: email, type: requestedType});
@ -117,11 +122,6 @@ module.exports = function MembersApi({
return magicLinkService.getMagicLink({email, subject: email, type: 'signin'}); return magicLinkService.getMagicLink({email, subject: email, type: 'signin'});
} }
const users = Users({
stripe,
Member
});
async function getMemberDataFromMagicLinkToken(token) { async function getMemberDataFromMagicLinkToken(token) {
const email = await magicLinkService.getUserFromToken(token); const email = await magicLinkService.getUserFromToken(token);
const {labels = [], name = '', oldEmail} = await magicLinkService.getPayloadFromToken(token); const {labels = [], name = '', oldEmail} = await magicLinkService.getPayloadFromToken(token);
@ -148,7 +148,11 @@ module.exports = function MembersApi({
} }
async function getMemberIdentityData(email) { async function getMemberIdentityData(email) {
return users.get({email}); const model = await users.get({email}, {withRelated: ['stripeSubscriptions', 'stripeSubscriptions.customer']});
if (!model) {
return null;
}
return model.toJSON();
} }
async function getMemberIdentityToken(email) { async function getMemberIdentityToken(email) {
@ -261,10 +265,10 @@ module.exports = function MembersApi({
return res.end('Unauthorized'); return res.end('Unauthorized');
} }
const member = email ? await users.get({email}) : null; const member = email ? await users.get({email}, {withRelated: ['stripeSubscriptions']}) : null;
// Do not allow members already with a subscription to initiate a new checkout session // Do not allow members already with a subscription to initiate a new checkout session
if (member && member.stripe.subscriptions.length > 0) { if (member && member.related('stripeSubscriptions').length > 0) {
res.writeHead(403); res.writeHead(403);
return res.end('No permission'); return res.end('No permission');
} }
@ -374,8 +378,8 @@ module.exports = function MembersApi({
const payerName = _.get(customer, 'subscriptions.data[0].default_payment_method.billing_details.name'); const payerName = _.get(customer, 'subscriptions.data[0].default_payment_method.billing_details.name');
if (payerName && !member.name) { if (payerName && !member.get('name')) {
await users.update({name: payerName}, {id: member.id}); await users.update({name: payerName}, {id: member.get('id')});
} }
const emailType = 'signup'; const emailType = 'signup';
@ -411,7 +415,7 @@ module.exports = function MembersApi({
const claims = await decodeToken(identity); const claims = await decodeToken(identity);
const email = claims.sub; const email = claims.sub;
member = email ? await users.get({email}) : null; member = email ? await users.get({email}, {withRelated: ['stripeSubscriptions']}) : null;
if (!member) { if (!member) {
throw new common.errors.BadRequestError({ throw new common.errors.BadRequestError({
@ -429,7 +433,9 @@ module.exports = function MembersApi({
message: 'Updating subscription failed! Could not find plan' message: 'Updating subscription failed! Could not find plan'
}); });
} }
const subscription = member.stripe.subscriptions.find(sub => sub.id === subscriptionId); const subscription = member.related('stripeSubscriptions').models.find(
subscription => subscription.get('subscription_id') === subscriptionId
);
if (!subscription) { if (!subscription) {
res.writeHead(403); res.writeHead(403);
return res.end('No permission'); return res.end('No permission');
@ -442,7 +448,7 @@ module.exports = function MembersApi({
}); });
} }
const subscriptionUpdate = { const subscriptionUpdate = {
id: subscription.id id: subscription.get('subscription_id')
}; };
if (cancelAtPeriodEnd !== undefined) { if (cancelAtPeriodEnd !== undefined) {
subscriptionUpdate.cancel_at_period_end = !!(cancelAtPeriodEnd); subscriptionUpdate.cancel_at_period_end = !!(cancelAtPeriodEnd);

View file

@ -46,16 +46,16 @@ module.exports = function ({
return; return;
} }
const customers = (await StripeCustomer.findAll({ if (!member.relations.stripeCustomers) {
filter: `member_id:${member.id}` await member.load(['stripeCustomers']);
})).toJSON(); }
const subscriptions = await customers.reduce(async (subscriptionsPromise, customer) => { if (!member.relations.stripeSubscriptions) {
const customerSubscriptions = await StripeCustomerSubscription.findAll({ await member.load(['stripeSubscriptions', 'stripeSubscriptions.customer']);
filter: `customer_id:${customer.customer_id}` }
});
return (await subscriptionsPromise).concat(customerSubscriptions.toJSON()); const customers = member.related('stripeCustomers').toJSON();
}, []); const subscriptions = member.related('stripeSubscriptions').toJSON();
return { return {
customers: customers, customers: customers,

View file

@ -5,15 +5,6 @@ const api = require('./api');
const STRIPE_API_VERSION = '2019-09-09'; const STRIPE_API_VERSION = '2019-09-09';
const CURRENCY_SYMBOLS = {
usd: '$',
aud: '$',
cad: '$',
gbp: '£',
eur: '€',
inr: '₹'
};
module.exports = class StripePaymentProcessor { module.exports = class StripePaymentProcessor {
constructor(config, storage, logging) { constructor(config, storage, logging) {
this.logging = logging; this.logging = logging;
@ -244,7 +235,7 @@ module.exports = class StripePaymentProcessor {
payment_method_types: ['card'], payment_method_types: ['card'],
success_url: options.successUrl || this._billingSuccessUrl, success_url: options.successUrl || this._billingSuccessUrl,
cancel_url: options.cancelUrl || this._billingCancelUrl, cancel_url: options.cancelUrl || this._billingCancelUrl,
customer_email: member.email, customer_email: member.get('email'),
setup_intent_data: { setup_intent_data: {
metadata: { metadata: {
customer_id: customer.id customer_id: customer.id
@ -296,35 +287,7 @@ module.exports = class StripePaymentProcessor {
async getSubscriptions(member) { async getSubscriptions(member) {
const metadata = await this.storage.get(member); const metadata = await this.storage.get(member);
const customers = metadata.customers.reduce((customers, customer) => { return metadata.subscriptions;
return Object.assign(customers, {
[customer.customer_id]: {
id: customer.customer_id,
name: customer.name,
email: customer.email
}
});
}, {});
return metadata.subscriptions.map((subscription) => {
return {
id: subscription.subscription_id,
customer: customers[subscription.customer_id],
plan: {
id: subscription.plan_id,
nickname: subscription.plan_nickname,
interval: subscription.plan_interval,
amount: subscription.plan_amount,
currency: String.prototype.toUpperCase.call(subscription.plan_currency),
currency_symbol: CURRENCY_SYMBOLS[subscription.plan_currency]
},
status: subscription.status,
start_date: subscription.start_date,
default_payment_card_last4: subscription.default_payment_card_last4,
cancel_at_period_end: subscription.cancel_at_period_end,
current_period_end: subscription.current_period_end
};
});
} }
async setComplimentarySubscription(member) { async setComplimentarySubscription(member) {
@ -438,11 +401,11 @@ module.exports = class StripePaymentProcessor {
} }
async _updateCustomer(member, customer) { async _updateCustomer(member, customer) {
debug(`Attaching customer to member ${member.email} ${customer.id}`); debug(`Attaching customer to member ${member.get('email')} ${customer.id}`);
await this.storage.set({ await this.storage.set({
customer: { customer: {
customer_id: customer.id, customer_id: customer.id,
member_id: member.id, member_id: member.get('id'),
name: customer.name, name: customer.name,
email: customer.email email: customer.email
} }
@ -500,9 +463,9 @@ module.exports = class StripePaymentProcessor {
} }
} }
debug(`Creating customer for member ${member.email}`); debug(`Creating customer for member ${member.get('email')}`);
const customer = await create(this._stripe, 'customers', { const customer = await create(this._stripe, 'customers', {
email: member.email email: member.get('email')
}); });
await this._updateCustomer(member, customer); await this._updateCustomer(member, customer);

View file

@ -1,184 +1,89 @@
const _ = require('lodash'); const _ = require('lodash');
const debug = require('ghost-ignition').debug('users'); const debug = require('ghost-ignition').debug('users');
const common = require('./common');
module.exports = function ({ module.exports = function ({
stripe, stripe,
Member Member
}) { }) {
async function createMember({email, name, note, labels, geolocation}) { async function get(data, options) {
const model = await Member.add({ debug(`get id:${data.id} email:${data.email}`);
return Member.findOne(data, options);
}
async function destroy(data, options) {
debug(`destroy id:${data.id} email:${data.email}`);
const member = await Member.findOne(data, options);
if (!member) {
return;
}
if (stripe && options.cancelStripeSubscriptions) {
await stripe.cancelStripeSubscriptions(member);
}
return Member.destroy({
id: data.id
}, options);
}
async function update(data, options) {
debug(`update id:${options.id}`);
return Member.edit(_.pick(data, [
'email',
'name',
'note',
'labels',
'geolocation'
]), options);
}
async function list(options = {}) {
return Member.findPage(options);
}
async function create({email, name, note, labels, geolocation}, options) {
debug(`create email:${email}`);
/** Member.add model method expects label object array*/
if (labels) {
labels.forEach((label, index) => {
if (_.isString(label)) {
labels[index] = {name: label};
}
});
}
return Member.add({
email, email,
name, name,
note, note,
labels, labels,
geolocation geolocation
}); }, options);
const member = model.toJSON();
return member;
} }
async function getMember(data, options = {}) { function safeStripe(methodName) {
if (!data.email && !data.id && !data.uuid) { return async function (...args) {
return null; if (stripe) {
} return await stripe[methodName](...args);
const model = await Member.findOne(data, options); }
if (!model) {
return null;
}
const member = model.toJSON(options);
return member;
}
async function updateMember(data, options = {}) {
const attrs = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation']);
const model = await Member.edit(attrs, options);
const member = model.toJSON(options);
return member;
}
function deleteMember(options) {
options = options || {};
return Member.destroy(options);
}
function listMembers(options) {
return Member.findPage(options).then((models) => {
return {
members: models.data.map(model => model.toJSON(options)),
meta: models.meta
};
});
}
async function getStripeSubscriptions(member) {
if (!stripe) {
return [];
}
return await stripe.getActiveSubscriptions(member);
}
async function cancelStripeSubscriptions(member) {
if (stripe) {
await stripe.cancelAllSubscriptions(member);
}
}
async function setComplimentarySubscription(member) {
if (stripe) {
await stripe.setComplimentarySubscription(member);
}
}
async function cancelComplimentarySubscription(member) {
if (stripe) {
await stripe.cancelComplimentarySubscription(member);
}
}
async function linkStripeCustomer(id, member) {
if (stripe) {
await stripe.linkStripeCustomer(id, member);
}
}
async function getStripeCustomer(id) {
if (stripe) {
return await stripe.getStripeCustomer(id);
}
}
async function get(data, options) {
debug(`get id:${data.id} email:${data.email}`);
const member = await getMember(data, options);
if (!member) {
return member;
}
try {
const subscriptions = await getStripeSubscriptions(member);
return Object.assign(member, {
stripe: {
subscriptions
}
});
} catch (err) {
common.logging.error(err);
return null;
}
}
async function destroy(data, options) {
debug(`destroy id:${data.id} email:${data.email}`);
const member = await getMember(data, options);
if (!member) {
return;
}
await cancelStripeSubscriptions(member);
return deleteMember(data);
}
async function update(data, options) {
debug(`update id:${options.id}`);
const member = await updateMember(data, options);
if (!member) {
return member;
}
try {
const subscriptions = await getStripeSubscriptions(member);
return Object.assign(member, {
stripe: {
subscriptions
}
});
} catch (err) {
common.logging.error(err);
return null;
}
}
async function list(options) {
const {meta, members} = await listMembers(options);
const membersWithSubscriptions = await Promise.all(members.map(async function (member) {
const subscriptions = await getStripeSubscriptions(member);
return Object.assign(member, {
stripe: {
subscriptions
}
});
}));
return {
meta,
members: membersWithSubscriptions
}; };
} }
async function create(data) { async function linkStripeCustomerById(customerId, memberId) {
debug(`create email:${data.email}`); if (!stripe) {
return;
/** Member.add model method expects label object array*/
if (data.labels) {
data.labels.forEach((label, index) => {
if (_.isString(label)) {
data.labels[index] = {name: label};
}
});
} }
const member = await get({id: memberId});
return stripe.linkStripeCustomer(customerId, member);
}
const member = await createMember(data); async function setComplimentarySubscriptionById(memberId) {
return member; if (!stripe) {
return;
}
const member = await get({id: memberId});
return stripe.setComplimentarySubscription(member);
} }
return { return {
@ -187,11 +92,12 @@ module.exports = function ({
list, list,
get, get,
destroy, destroy,
getStripeSubscriptions, setComplimentarySubscription: safeStripe('setComplimentarySubscription'),
setComplimentarySubscription, setComplimentarySubscriptionById,
cancelComplimentarySubscription, cancelComplimentarySubscription: safeStripe('cancelComplimentarySubscription'),
cancelStripeSubscriptions, cancelStripeSubscriptions: safeStripe('cancelComplimentarySubscription'),
getStripeCustomer, getStripeCustomer: safeStripe('getStripeCustomer'),
linkStripeCustomer linkStripeCustomer: safeStripe('linkStripeCustomer'),
linkStripeCustomerById
}; };
}; };