0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-10 23:36:14 -05:00

Added initial support for Member events (#241)

refs https://github.com/TryGhost/Ghost/issues/12602

* Added Event Repository
** Added method for MRR over time
** Added method for newsletter subscriptions over time
** Added method for gross volume over time
** Added method for status segment size over time
* Captured login events
* Captured newsletter subscription/unsubscription
* Captured email address change events
* Captured paid subscription events
* Captured payment events
* Captured status events
This commit is contained in:
Fabien 'egg' O'Carroll 2021-02-15 14:16:58 +00:00 committed by Fabien O'Carroll
parent b8a17f2be0
commit 9081298517
4 changed files with 332 additions and 19 deletions

View file

@ -9,6 +9,7 @@ const StripeWebhookService = require('./lib/services/stripe-webhook');
const TokenService = require('./lib/services/token');
const GeolocationSerice = require('./lib/services/geolocation');
const MemberRepository = require('./lib/repositories/member');
const EventRepository = require('./lib/repositories/event');
const RouterController = require('./lib/controllers/router');
module.exports = function MembersApi({
@ -33,7 +34,13 @@ module.exports = function MembersApi({
StripeWebhook,
StripeCustomer,
StripeCustomerSubscription,
Member
Member,
MemberSubscribeEvent,
MemberLoginEvent,
MemberPaidSubscriptionEvent,
MemberPaymentEvent,
MemberStatusEvent,
MemberEmailChangeEvent
},
logger
}) {
@ -62,14 +69,28 @@ module.exports = function MembersApi({
stripePlansService,
logger,
Member,
MemberSubscribeEvent,
MemberPaidSubscriptionEvent,
MemberEmailChangeEvent,
MemberStatusEvent,
StripeCustomer,
StripeCustomerSubscription
});
const eventRepository = new EventRepository({
logger,
MemberSubscribeEvent,
MemberPaidSubscriptionEvent,
MemberPaymentEvent,
MemberStatusEvent,
MemberEmailChangeEvent
});
const stripeWebhookService = new StripeWebhookService({
StripeWebhook,
stripeAPIService,
memberRepository,
eventRepository,
sendEmailWithMagicLink
});
@ -184,6 +205,7 @@ module.exports = function MembersApi({
const member = oldEmail ? await getMemberIdentityData(oldEmail) : await getMemberIdentityData(email);
if (member) {
await MemberLoginEvent.add({member_id: member.id});
if (oldEmail) {
// user exists but wants to change their email address
if (oldEmail) {
@ -195,7 +217,8 @@ module.exports = function MembersApi({
return member;
}
await users.create({name, email, labels});
const newMember = await users.create({name, email, labels});
await MemberLoginEvent.add({member_id: newMember.id});
return getMemberIdentityData(email);
}
@ -319,6 +342,7 @@ module.exports = function MembersApi({
sendEmailWithMagicLink,
getMagicLink,
hasActiveStripeSubscriptions,
members: users
members: users,
events: eventRepository
};
};

View file

@ -0,0 +1,134 @@
module.exports = class EventRepository {
constructor({
MemberSubscribeEvent,
MemberPaymentEvent,
MemberStatusEvent,
MemberPaidSubscriptionEvent,
logger
}) {
this._MemberSubscribeEvent = MemberSubscribeEvent;
this._MemberPaidSubscriptionEvent = MemberPaidSubscriptionEvent;
this._MemberPaymentEvent = MemberPaymentEvent;
this._MemberStatusEvent = MemberStatusEvent;
this._logging = logger;
}
async registerPayment(data) {
await this._MemberPaymentEvent.add({
...data,
source: 'stripe'
});
}
async getSubscriptions() {
const results = await this._MemberSubscribeEvent.findAll({
aggregateSubscriptionDeltas: true
});
const resultsJSON = results.toJSON();
const cumulativeResults = resultsJSON.reduce((cumulativeResults, result, index) => {
if (index === 0) {
return [{
date: result.date,
subscribed: result.subscribed_delta
}];
}
return cumulativeResults.concat([{
date: result.date,
subscribed: result.subscribed_delta + cumulativeResults[index - 1].subscribed
}]);
}, []);
return cumulativeResults;
}
async getMRR() {
const results = await this._MemberPaidSubscriptionEvent.findAll({
aggregateMRRDeltas: true
});
const resultsJSON = results.toJSON();
const cumulativeResults = resultsJSON.reduce((cumulativeResults, result) => {
if (!cumulativeResults[result.currency]) {
return {
...cumulativeResults,
[result.currency]: [{
date: result.date,
mrr: result.mrr_delta,
currency: result.currency
}]
};
}
return {
...cumulativeResults,
[result.currency]: cumulativeResults[result.currency].concat([{
date: result.date,
mrr: result.mrr_delta + cumulativeResults[result.currency].slice(-1)[0],
currency: result.currency
}])
};
}, {});
return cumulativeResults;
}
async getVolume() {
const results = await this._MemberPaymentEvent.findAll({
aggregatePaymentVolume: true
});
const resultsJSON = results.toJSON();
const cumulativeResults = resultsJSON.reduce((cumulativeResults, result) => {
if (!cumulativeResults[result.currency]) {
return {
...cumulativeResults,
[result.currency]: [{
date: result.date,
volume: result.volume_delta,
currency: result.currency
}]
};
}
return {
...cumulativeResults,
[result.currency]: cumulativeResults[result.currency].concat([{
date: result.date,
volume: result.volume_delta + cumulativeResults[result.currency].slice(-1)[0],
currency: result.currency
}])
};
}, {});
return cumulativeResults;
}
async getStatuses() {
const results = await this._MemberStatusEvent.findAll({
aggregateStatusCounts: true
});
const resultsJSON = results.toJSON();
const cumulativeResults = resultsJSON.reduce((cumulativeResults, result, index) => {
if (index === 0) {
return [{
date: result.date,
paid: result.paid_delta,
comped: result.comped_delta,
free: result.free_delta
}];
}
return cumulativeResults.concat([{
date: result.date,
paid: result.paid_delta + cumulativeResults[index - 1].paid,
comped: result.comped_delta + cumulativeResults[index - 1].comped,
free: result.free_delta + cumulativeResults[index - 1].free
}]);
}, []);
return cumulativeResults;
}
};

View file

@ -3,6 +3,10 @@ module.exports = class MemberRepository {
/**
* @param {object} deps
* @param {any} deps.Member
* @param {any} deps.MemberSubscribeEvent
* @param {any} deps.MemberEmailChangeEvent
* @param {any} deps.MemberPaidSubscriptionEvent
* @param {any} deps.MemberStatusEvent
* @param {any} deps.StripeCustomer
* @param {any} deps.StripeCustomerSubscription
* @param {import('../../services/stripe-api')} deps.stripeAPIService
@ -11,6 +15,10 @@ module.exports = class MemberRepository {
*/
constructor({
Member,
MemberSubscribeEvent,
MemberEmailChangeEvent,
MemberPaidSubscriptionEvent,
MemberStatusEvent,
StripeCustomer,
StripeCustomerSubscription,
stripeAPIService,
@ -18,6 +26,10 @@ module.exports = class MemberRepository {
logger
}) {
this._Member = Member;
this._MemberSubscribeEvent = MemberSubscribeEvent;
this._MemberEmailChangeEvent = MemberEmailChangeEvent;
this._MemberPaidSubscriptionEvent = MemberPaidSubscriptionEvent;
this._MemberStatusEvent = MemberStatusEvent;
this._StripeCustomer = StripeCustomer;
this._StripeCustomerSubscription = StripeCustomerSubscription;
this._stripeAPIService = stripeAPIService;
@ -29,6 +41,10 @@ module.exports = class MemberRepository {
return ['active', 'trialing', 'unpaid', 'past_due'].includes(status);
}
isComplimentarySubscription(subscription) {
return subscription.plan.nickname.toLowerCase() === 'complimentary';
}
async get(data, options) {
if (data.customer_id) {
const customer = await this._StripeCustomer.findOne({
@ -57,10 +73,37 @@ module.exports = class MemberRepository {
const memberData = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation', 'created_at']);
return this._Member.add({
const member = await this._Member.add({
...memberData,
labels
}, options);
const context = options && options.context || {};
let source;
if (context.internal) {
source = 'system';
} else if (context.user) {
source = 'admin';
} else {
source = 'member';
}
if (member.get('subscribed')) {
await this._MemberSubscribeEvent.add({
member_id: member.id,
subscribed: true,
source
}, options);
}
await this._MemberStatusEvent.add({
member_id: data.id,
from_status: null,
to_status: member.get('status')
}, options);
return member;
}
async update(data, options) {
@ -73,6 +116,32 @@ module.exports = class MemberRepository {
'geolocation'
]), options);
// member._changed.subscribed has a value if the `subscribed` attribute is passed in the update call, regardless of the previous value
if (member.attributes.subscribed !== member._previousAttributes.subscribed) {
const context = options && options.context || {};
let source;
if (context.internal) {
source = 'system';
} else if (context.user) {
source = 'admin';
} else {
source = 'member';
}
await this._MemberSubscribeEvent.add({
member_id: member.id,
subscribed: member.get('subscribed'),
source
}, options);
}
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')
});
}
if (this._stripeAPIService && member._changed.email) {
await member.related('stripeCustomers').fetch();
const customers = member.related('stripeCustomers');
@ -109,7 +178,15 @@ module.exports = class MemberRepository {
);
await this._StripeCustomerSubscription.update({
status: updatedSubscription.status
});
}, options);
await this._MemberPaidSubscriptionEvent.add({
member_id: member.id,
source: 'stripe',
from_plan: subscription.get('plan_id'),
to_plan: null,
currency: subscription.get('plan_currency'),
mrr_delta: -1 * (subscription.get('plan_interval') === 'month' ? subscription.get('plan_amount') : Math.floor(subscription.get('plan_amount') / 12))
}, options);
}
}
}
@ -183,7 +260,12 @@ module.exports = class MemberRepository {
paymentMethodId = subscription.default_payment_method.id;
}
const paymentMethod = paymentMethodId ? await this._stripeAPIService.getCardPaymentMethod(paymentMethodId) : null;
await this._StripeCustomerSubscription.upsert({
const model = await this._StripeCustomerSubscription.findOne({
subscription_id: subscription.id
}, options);
const subscriptionData = {
customer_id: subscription.customer,
subscription_id: subscription.id,
status: subscription.status,
@ -202,23 +284,85 @@ module.exports = class MemberRepository {
plan_interval: subscription.plan.interval,
plan_amount: subscription.plan.amount,
plan_currency: subscription.plan.currency
}, {
};
function getMRRDelta({interval, amount, status}) {
if (status === 'trialing') {
return 0;
}
if (status === 'incomplete') {
return 0;
}
if (status === 'incomplete_expired') {
return 0;
}
const modifier = status === 'canceled' ? -1 : 1;
if (interval === 'year') {
return modifier * Math.floor(amount / 12);
}
if (interval === 'month') {
return modifier * amount;
}
}
if (model) {
const updated = await this._StripeCustomerSubscription.edit(subscriptionData, {
...options,
subscription_id: subscription.id
id: model.id
});
if (model.get('plan_id') !== updated.get('plan_id') || model.get('status') !== updated.get('status')) {
const originalMrrDelta = getMRRDelta({interval: model.get('plan_interval'), amount: model.get('plan_amount'), status: model.get('status')});
const updatedMrrDelta = getMRRDelta({interval: updated.get('plan_interval'), amount: updated.get('plan_amount'), status: updated.get('status')});
const mrrDelta = updatedMrrDelta - originalMrrDelta;
await this._MemberPaidSubscriptionEvent.add({
member_id: member.id,
source: 'stripe',
from_plan: model.get('plan_id'),
to_plan: updated.get('plan_id'),
currency: subscription.plan.currency,
mrr_delta: mrrDelta
});
}
} else {
await this._StripeCustomerSubscription.add(subscriptionData, options);
await this._MemberPaidSubscriptionEvent.add({
member_id: member.id,
source: 'stripe',
from_plan: null,
to_plan: subscription.plan.id,
currency: subscription.plan.currency,
mrr_delta: getMRRDelta({interval: subscription.plan.interval, amount: subscription.plan.amount, status: subscription.status})
});
}
let status = 'free';
if (this.isActiveSubscriptionStatus(subscription.status)) {
await this._Member.edit({status: 'paid'}, {...options, id: data.id});
if (this.isComplimentarySubscription(subscription)) {
status = 'comped';
} else {
status = 'paid';
}
} else {
const subscriptions = await member.related('stripeSubscriptions').fetch(options);
let status = 'free';
for (const subscription of subscriptions.models) {
if (this.isActiveSubscriptionStatus(subscription.get('status'))) {
if (status === 'comped' || this.isComplimentarySubscription(subscription)) {
status = 'comped';
} else {
status = 'paid';
break;
}
}
await this._Member.edit({status: status}, {...options, id: data.id});
}
}
const updatedMember = await this._Member.edit({status: status}, {...options, id: data.id});
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')
}, options);
}
}

View file

@ -6,17 +6,20 @@ module.exports = class StripeWebhookService {
* @param {any} deps.StripeWebhook
* @param {import('../stripe-api')} deps.stripeAPIService
* @param {import('../../repositories/member')} deps.memberRepository
* @param {import('../../repositories/event')} deps.eventRepository
* @param {any} deps.sendEmailWithMagicLink
*/
constructor({
StripeWebhook,
stripeAPIService,
memberRepository,
eventRepository,
sendEmailWithMagicLink
}) {
this._StripeWebhook = StripeWebhook;
this._stripeAPIService = stripeAPIService;
this._memberRepository = memberRepository;
this._eventRepository = eventRepository;
this._sendEmailWithMagicLink = sendEmailWithMagicLink;
this.handlers = {};
this.registerHandler('customer.subscription.deleted', this.subscriptionEvent);
@ -134,6 +137,11 @@ module.exports = class StripeWebhookService {
}
}
/**
* @param {import('stripe').invoices.IInvoice} invoice
*
* @returns {Promise<void>}
*/
async invoiceEvent(invoice) {
const subscription = await this._stripeAPIService.getSubscription(invoice.subscription, {
expand: ['default_payment_method']
@ -144,12 +152,15 @@ module.exports = class StripeWebhookService {
});
if (member) {
await this._memberRepository.linkSubscription({
id: member.id,
subscription
if (invoice.paid) {
await this._eventRepository.registerPayment({
member_id: member.id,
currency: invoice.currency,
amount: invoice.amount_paid
});
}
}
}
async checkoutSessionEvent(session) {
if (session.mode === 'setup') {