diff --git a/ghost/members-api/index.js b/ghost/members-api/index.js index 7e0f0fc649..1ab22c98fe 100644 --- a/ghost/members-api/index.js +++ b/ghost/members-api/index.js @@ -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 }; }; diff --git a/ghost/members-api/lib/repositories/event/index.js b/ghost/members-api/lib/repositories/event/index.js new file mode 100644 index 0000000000..3bff1b5bce --- /dev/null +++ b/ghost/members-api/lib/repositories/event/index.js @@ -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; + } +}; diff --git a/ghost/members-api/lib/repositories/member/index.js b/ghost/members-api/lib/repositories/member/index.js index b6cdac2989..08f99dfe20 100644 --- a/ghost/members-api/lib/repositories/member/index.js +++ b/ghost/members-api/lib/repositories/member/index.js @@ -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 - }, { - ...options, - subscription_id: subscription.id - }); + }; + 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, + 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'))) { - status = 'paid'; - break; + if (status === 'comped' || this.isComplimentarySubscription(subscription)) { + status = 'comped'; + } else { + status = 'paid'; + } } } - 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); } } diff --git a/ghost/members-api/lib/services/stripe-webhook/index.js b/ghost/members-api/lib/services/stripe-webhook/index.js index ce71da9bc0..91f7954b71 100644 --- a/ghost/members-api/lib/services/stripe-webhook/index.js +++ b/ghost/members-api/lib/services/stripe-webhook/index.js @@ -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} + */ async invoiceEvent(invoice) { const subscription = await this._stripeAPIService.getSubscription(invoice.subscription, { expand: ['default_payment_method'] @@ -144,10 +152,13 @@ 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 + }); + } } }