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:
parent
b8a17f2be0
commit
9081298517
4 changed files with 332 additions and 19 deletions
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
134
ghost/members-api/lib/repositories/event/index.js
Normal file
134
ghost/members-api/lib/repositories/event/index.js
Normal 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;
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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') {
|
||||
|
|
Loading…
Add table
Reference in a new issue