Added MRR stats service and endpoint (#14427)

refs https://github.com/TryGhost/Team/issues/1470

Instead of counting the MRR by resolving all the deltas from the past until now, we should start with the current calculated MRR and resolve it until the first event. That would give a more accurate recent MRR (in exchange for a less accurate MRR for older data) and allows us to limit the amount of returned days in the future.

- Includes MRR stats service that can fetch the current MRR per currency
- The service can return a history of the MRR for every day and currency
- New admin API endpoint /stats/mrr that returns the MRR history
- Includes tests for these new service and endpoint
This commit is contained in:
Simon Backx 2022-04-08 09:18:04 +02:00 committed by GitHub
parent 31b308d475
commit 132726fe20
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 834 additions and 11 deletions

View File

@ -10,5 +10,14 @@ module.exports = {
async query() { async query() {
return await statsService.members.getCountHistory(); return await statsService.members.getCountHistory();
} }
},
mrr: {
permissions: {
docName: 'members',
method: 'browse'
},
async query() {
return await statsService.mrr.getHistory();
}
} }
}; };

View File

@ -1,4 +1,4 @@
const {DateTime} = require('luxon'); const moment = require('moment');
class MembersStatsService { class MembersStatsService {
constructor({db}) { constructor({db}) {
@ -69,8 +69,8 @@ class MembersStatsService {
const totals = await this.getCount(); const totals = await this.getCount();
let {paid, free, comped} = totals; let {paid, free, comped} = totals;
// Get today in UTC (default timezone for Luxon) // Get today in UTC (default timezone)
const today = DateTime.local().toISODate(); const today = moment().format('YYYY-MM-DD');
const cumulativeResults = []; const cumulativeResults = [];
@ -79,7 +79,7 @@ class MembersStatsService {
const row = rows[i]; const row = rows[i];
// Convert JSDates to YYYY-MM-DD (in UTC) // Convert JSDates to YYYY-MM-DD (in UTC)
const date = DateTime.fromJSDate(row.date).toISODate(); const date = moment(row.date).format('YYYY-MM-DD');
if (date > today) { if (date > today) {
// Skip results that are in the future (fix for invalid events) // Skip results that are in the future (fix for invalid events)
continue; continue;
@ -102,7 +102,7 @@ class MembersStatsService {
} }
// Now also add the oldest day we have left over (this one will be zero, which is also needed as a data point for graphs) // Now also add the oldest day we have left over (this one will be zero, which is also needed as a data point for graphs)
const oldestDate = rows.length > 0 ? DateTime.fromJSDate(rows[0].date).plus({days: -1}).toISODate() : today; const oldestDate = rows.length > 0 ? moment(rows[0].date).add(-1, 'days').format('YYYY-MM-DD') : today;
cumulativeResults.unshift({ cumulativeResults.unshift({
date: oldestDate, date: oldestDate,

View File

@ -0,0 +1,154 @@
const moment = require('moment');
class MrrStatsService {
constructor({db}) {
this.db = db;
}
/**
* Get the current total MRR, grouped by currency (ascending order)
* @returns {Promise<MrrByCurrency[]>}
*/
async getCurrentMrr() {
const knex = this.db.knex;
const rows = await knex('members_stripe_customers_subscriptions')
.select(knex.raw(`plan_currency as currency`))
.select(knex.raw(`SUM(
CASE WHEN plan_interval = 'year' THEN
FLOOR(plan_amount / 12)
ELSE
plan_amount
END
) AS mrr`))
.whereIn('status', ['active', 'unpaid', 'past_due'])
.where('cancel_at_period_end', 0)
.groupBy('plan_currency')
.orderBy('currency');
if (rows.length === 0) {
// Add a USD placeholder to always have at least one currency
rows.push({
currency: 'usd',
mrr: 0
});
}
return rows;
}
/**
* Get the MRR deltas for all days (from old to new), grouped by currency (ascending alphabetically)
* @returns {Promise<MrrDelta[]>} The deltas sorted from new to old
*/
async fetchAllDeltas() {
const knex = this.db.knex;
const rows = await knex('members_paid_subscription_events')
.select('currency')
// In SQLite, DATE(created_at) would map to a string value, while DATE(created_at) would map to a JSDate object in MySQL
// That is why we need the cast here (to have some consistency)
.select(knex.raw('CAST(DATE(created_at) as CHAR) as date'))
.select(knex.raw(`SUM(mrr_delta) as delta`))
.groupByRaw('CAST(DATE(created_at) as CHAR), currency')
.orderByRaw('CAST(DATE(created_at) as CHAR), currency');
return rows;
}
/**
* Returns a list of the MRR history for each day and currency, including the current MRR per currency as meta data.
* The respons is in ascending date order, and currencies for the same date are always in ascending order.
* @returns {Promise<MrrHistory>}
*/
async getHistory() {
// Fetch current total amounts and start counting from there
const totals = await this.getCurrentMrr();
const rows = await this.fetchAllDeltas();
// Get today in UTC (default timezone)
const today = moment().format('YYYY-MM-DD');
const results = [];
// Create a map of the totals by currency for fast lookup and editing
const currentTotals = {};
for (const total of totals) {
currentTotals[total.currency] = total.mrr;
}
// Loop in reverse order (needed to have correct sorted result)
for (let i = rows.length - 1; i >= 0; i -= 1) {
const row = rows[i];
if (currentTotals[row.currency] === undefined) {
// Skip unexpected currencies that are not in the totals
continue;
}
// Convert JSDates to YYYY-MM-DD (in UTC)
const date = moment(row.date).format('YYYY-MM-DD');
if (date > today) {
// Skip results that are in the future for some reason
continue;
}
results.unshift({
date,
mrr: Math.max(0, currentTotals[row.currency]),
currency: row.currency
});
currentTotals[row.currency] -= row.delta;
}
// Now also add the oldest days we have left over and do not have deltas
const oldestDate = rows.length > 0 ? moment(rows[0].date).add(-1, 'days').format('YYYY-MM-DD') : today;
// Note that we also need to loop the totals in reverse order because we need to unshift
for (let i = totals.length - 1; i >= 0; i -= 1) {
const total = totals[i];
results.unshift({
date: oldestDate,
mrr: Math.max(0, currentTotals[total.currency]),
currency: total.currency
});
}
return {
data: results,
meta: {
totals
}
};
}
}
module.exports = MrrStatsService;
/**
* @typedef MrrByCurrency
* @type {Object}
* @property {number} mrr
* @property {string} currency
*/
/**
* @typedef MrrDelta
* @type {Object}
* @property {Date} date
* @property {string} currency
* @property {number} delta MRR change on this day
*/
/**
* @typedef {Object} MrrRecord
* @property {string} date In YYYY-MM-DD format
* @property {string} currency
* @property {number} mrr MRR on this day
*/
/**
* @typedef {Object} MrrHistory
* @property {MrrRecord[]} data List of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
* @property {Object} meta
*/

View File

@ -1,6 +1,8 @@
const db = require('../../data/db'); const db = require('../../data/db');
const MemberStatsService = require('./lib/members-stats-service'); const MemberStatsService = require('./lib/members-stats-service');
const MrrStatsService = require('./lib/mrr-stats-service');
module.exports = { module.exports = {
members: new MemberStatsService({db}) members: new MemberStatsService({db}),
mrr: new MrrStatsService({db})
}; };

View File

@ -139,6 +139,7 @@ module.exports = function apiRoutes() {
// ## Stats // ## Stats
router.get('/stats/member_count', mw.authAdminApi, http(api.stats.memberCountHistory)); router.get('/stats/member_count', mw.authAdminApi, http(api.stats.memberCountHistory));
router.get('/stats/mrr', mw.authAdminApi, http(api.stats.mrr));
// ## Labels // ## Labels
router.get('/labels', mw.authAdminApi, http(api.labels.browse)); router.get('/labels', mw.authAdminApi, http(api.labels.browse));

View File

@ -1,5 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Stats API Can fetch MRR history 1: [body] 1`] = `
Object {
"meta": Object {
"totals": Array [
Object {
"currency": "usd",
"mrr": 1000,
},
],
},
"stats": Array [
Object {
"currency": "usd",
"date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/,
"mrr": 1000,
},
],
}
`;
exports[`Stats API Can fetch MRR history 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "111",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Stats API Can fetch member count history 1: [body] 1`] = ` exports[`Stats API Can fetch member count history 1: [body] 1`] = `
Object { Object {
"meta": Object { "meta": Object {

View File

@ -23,4 +23,18 @@ describe('Stats API', function () {
etag: anyEtag etag: anyEtag
}); });
}); });
it('Can fetch MRR history', async function () {
await agent
.get(`/stats/mrr`)
.expectStatus(200)
.matchBodySnapshot({
stats: [{
date: anyISODate
}]
})
.matchHeaderSnapshot({
etag: anyEtag
});
});
}); });

View File

@ -0,0 +1,234 @@
const statsService = require('../../../../core/server/services/stats');
const {agentProvider, fixtureManager} = require('../../../utils/e2e-framework');
const moment = require('moment');
require('should');
const nock = require('nock');
let agent;
let counter = 0;
async function createMemberWithSubscription(interval, amount, currency, date) {
counter += 1;
const fakePrice = {
id: 'price_' + counter,
product: '',
active: true,
nickname: 'Paid',
unit_amount: amount,
currency,
type: 'recurring',
recurring: {
interval
}
};
const fakeSubscription = {
id: 'sub_' + counter,
customer: 'cus_' + counter,
status: 'active',
cancel_at_period_end: false,
metadata: {},
current_period_end: Date.now() / 1000 + 1000,
start_date: moment(date).unix(),
plan: fakePrice,
items: {
data: [{
price: fakePrice
}]
}
};
const fakeCustomer = {
id: 'cus_' + counter,
name: 'Test Member',
email: 'create-member-subscription-' + counter + '@email.com',
subscriptions: {
type: 'list',
data: [fakeSubscription]
}
};
nock('https://api.stripe.com')
.persist()
.get(/v1\/.*/)
.reply((uri, body) => {
const [match, resource, id] = uri.match(/\/?v1\/(\w+)\/?(\w+)/) || [null];
if (!match) {
return [500];
}
if (resource === 'customers') {
return [200, fakeCustomer];
}
if (resource === 'subscriptions') {
return [200, fakeSubscription];
}
});
const initialMember = {
name: fakeCustomer.name,
email: fakeCustomer.email,
subscribed: true,
stripe_customer_id: fakeCustomer.id
};
await agent
.post(`/members/`)
.body({members: [initialMember]})
.expectStatus(201);
nock.cleanAll();
}
describe('MRR Stats Service', function () {
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init();
await agent.loginAsOwner();
});
afterEach(function () {
nock.cleanAll();
});
describe('getCurrentMrr', function () {
it('Always returns at least one currency', async function () {
const result = await statsService.mrr.getCurrentMrr();
result.should.eql([
{
currency: 'usd',
mrr: 0
}
]);
});
it('Can handle multiple currencies', async function () {
await createMemberWithSubscription('month', 500, 'eur', '2000-01-10');
const result = await statsService.mrr.getCurrentMrr();
result.should.eql([
{
currency: 'eur',
mrr: 500
}
]);
});
it('Increases MRR by 1 / 12 of yearly subscriptions', async function () {
await createMemberWithSubscription('year', 12, 'usd', '2000-01-10');
const result = await statsService.mrr.getCurrentMrr();
result.should.eql([
{
currency: 'eur',
mrr: 500
},
{
currency: 'usd',
mrr: 1
}
]);
});
it('Increases MRR with monthly subscriptions', async function () {
await createMemberWithSubscription('month', 1, 'usd', '2000-01-11');
const result = await statsService.mrr.getCurrentMrr();
result.should.eql([
{
currency: 'eur',
mrr: 500
},
{
currency: 'usd',
mrr: 2
}
]);
});
it('Floors results', async function () {
await createMemberWithSubscription('year', 17, 'usd', '2000-01-12');
let result = await statsService.mrr.getCurrentMrr();
result.should.eql([
{
currency: 'eur',
mrr: 500
},
{
currency: 'usd',
mrr: 3
}
]);
// Floor 11/12 to 0 (same as getMRRDelta method)
await createMemberWithSubscription('year', 11, 'usd', '2000-01-12');
result = await statsService.mrr.getCurrentMrr();
result.should.eql([
{
currency: 'eur',
mrr: 500
},
{
currency: 'usd',
mrr: 3
}
]);
// Floor 11/12 to 0, don't combine with previous addition
await createMemberWithSubscription('year', 11, 'usd', '2000-01-12');
result = await statsService.mrr.getCurrentMrr();
result.should.eql([
{
currency: 'eur',
mrr: 500
},
{
currency: 'usd',
mrr: 3
}
]);
// Floor 13/12 to 1
await createMemberWithSubscription('year', 13, 'usd', '2000-01-12');
result = await statsService.mrr.getCurrentMrr();
result.should.eql([
{
currency: 'eur',
mrr: 500
},
{
currency: 'usd',
mrr: 4
}
]);
});
});
describe('fetchAllDeltas', function () {
it('Returns deltas in ascending order', async function () {
const results = await statsService.mrr.fetchAllDeltas();
results.length.should.equal(4);
results.should.match([
{
date: '2000-01-10',
delta: 500,
currency: 'eur'
},
{
date: '2000-01-10',
delta: 1,
currency: 'usd'
},
{
date: '2000-01-11',
delta: 1,
currency: 'usd'
},
{
date: '2000-01-12',
delta: 2,
currency: 'usd'
}
]);
});
});
});

View File

@ -1,5 +1,5 @@
const MembersStatsService = require('../../../../../core/server/services/stats/lib/members-stats-service'); const MembersStatsService = require('../../../../../core/server/services/stats/lib/members-stats-service');
const {DateTime} = require('luxon'); const moment = require('moment');
const sinon = require('sinon'); const sinon = require('sinon');
require('should'); require('should');
@ -22,10 +22,10 @@ describe('MembersStatsService', function () {
const yesterday = '2000-01-09'; const yesterday = '2000-01-09';
const dayBeforeYesterday = '2000-01-08'; const dayBeforeYesterday = '2000-01-08';
const twoDaysBeforeYesterday = '2000-01-07'; const twoDaysBeforeYesterday = '2000-01-07';
const todayDate = DateTime.fromISO(today).toJSDate(); const todayDate = moment(today).toDate();
const tomorrowDate = DateTime.fromISO(tomorrow).toJSDate(); const tomorrowDate = moment(tomorrow).toDate();
const yesterdayDate = DateTime.fromISO(yesterday).toJSDate(); const yesterdayDate = moment(yesterday).toDate();
const dayBeforeYesterdayDate = DateTime.fromISO(dayBeforeYesterday).toJSDate(); const dayBeforeYesterdayDate = moment(dayBeforeYesterday).toDate();
before(function () { before(function () {
sinon.useFakeTimers(todayDate.getTime()); sinon.useFakeTimers(todayDate.getTime());

View File

@ -0,0 +1,377 @@
const MrrStatsService = require('../../../../../core/server/services/stats/lib/mrr-stats-service');
const moment = require('moment');
const sinon = require('sinon');
require('should');
describe('MrrStatsService', function () {
describe('getHistory', function () {
let mrrStatsService;
let fakeDeltas;
let fakeTotal;
/**
* @type {Object.<string, number>}
*/
let currentMrr = {};
/**
* @type {MrrStatsService.MrrDelta[]}
*/
let deltas = [];
const today = '2000-01-10';
const tomorrow = '2000-01-11';
const yesterday = '2000-01-09';
const dayBeforeYesterday = '2000-01-08';
const twoDaysBeforeYesterday = '2000-01-07';
const todayDate = moment(today).toDate();
const tomorrowDate = moment(tomorrow).toDate();
const yesterdayDate = moment(yesterday).toDate();
const dayBeforeYesterdayDate = moment(dayBeforeYesterday).toDate();
before(function () {
sinon.useFakeTimers(todayDate.getTime());
mrrStatsService = new MrrStatsService({db: null});
fakeTotal = sinon.stub(mrrStatsService, 'getCurrentMrr').callsFake(() => {
const arr = [];
const sortedCurrencies = Object.keys(currentMrr).sort();
for (const currency of sortedCurrencies) {
arr.push({
mrr: currentMrr[currency],
currency
});
}
// Make sure we sort by currency
return Promise.resolve(arr);
});
fakeDeltas = sinon.stub(mrrStatsService, 'fetchAllDeltas').callsFake(() => {
// Sort here alphabetically to mimic same ordering of fetchAllDeltas
// Not a real problem we sort in place
deltas.sort((a, b) => {
if (a.date === b.date) {
return a.currency < b.currency ? -1 : 1;
}
return a.date < b.date ? -1 : 1;
});
return Promise.resolve(deltas);
});
});
afterEach(function () {
fakeDeltas.resetHistory();
fakeTotal.resetHistory();
});
it('Always returns at least one value', async function () {
// No events
deltas = [];
currentMrr = {usd: 1, eur: 2};
const {data: results, meta} = await mrrStatsService.getHistory();
results.length.should.eql(2);
// Note that currencies should always be sorted ascending, so EUR should be first.
results[0].should.eql({
date: today,
mrr: 2,
currency: 'eur'
});
results[1].should.eql({
date: today,
mrr: 1,
currency: 'usd'
});
meta.totals.should.eql([
{
mrr: 2,
currency: 'eur'
},
{
mrr: 1,
currency: 'usd'
}
]);
fakeDeltas.calledOnce.should.eql(true);
fakeTotal.calledOnce.should.eql(true);
});
it('Does not substract delta of first event', async function () {
deltas = [
{
date: todayDate,
delta: 5,
currency: 'usd'
}
];
currentMrr = {usd: 5};
const {data: results, meta} = await mrrStatsService.getHistory();
results.length.should.eql(2);
results[0].should.eql({
date: yesterday,
mrr: 0,
currency: 'usd'
});
results[1].should.eql({
date: today,
mrr: 5,
currency: 'usd'
});
meta.totals.should.eql([
{
mrr: 5,
currency: 'usd'
}
]);
fakeDeltas.calledOnce.should.eql(true);
fakeTotal.calledOnce.should.eql(true);
});
it('Correctly calculates deltas', async function () {
deltas = [
{
date: yesterdayDate,
delta: 2,
currency: 'usd'
},
{
date: todayDate,
delta: 5,
currency: 'usd'
}
];
currentMrr = {usd: 7};
const {data: results, meta} = await mrrStatsService.getHistory();
results.length.should.eql(3);
results[0].should.eql({
date: dayBeforeYesterday,
mrr: 0,
currency: 'usd'
});
results[1].should.eql({
date: yesterday,
mrr: 2,
currency: 'usd'
});
results[2].should.eql({
date: today,
mrr: 7,
currency: 'usd'
});
meta.totals.should.eql([
{
mrr: 7,
currency: 'usd'
}
]);
fakeDeltas.calledOnce.should.eql(true);
fakeTotal.calledOnce.should.eql(true);
});
it('Correctly calculates deltas for multiple currencies', async function () {
deltas = [
{
date: yesterdayDate,
delta: 200,
currency: 'eur'
},
{
date: yesterdayDate,
delta: 2,
currency: 'usd'
},
{
date: todayDate,
delta: 800,
currency: 'eur'
},
{
date: todayDate,
delta: 5,
currency: 'usd'
}
];
currentMrr = {usd: 7, eur: 1200};
const {data: results, meta} = await mrrStatsService.getHistory();
results.length.should.eql(6);
results[0].should.eql({
date: dayBeforeYesterday,
mrr: 200,
currency: 'eur'
});
results[1].should.eql({
date: dayBeforeYesterday,
mrr: 0,
currency: 'usd'
});
results[2].should.eql({
date: yesterday,
mrr: 400,
currency: 'eur'
});
results[3].should.eql({
date: yesterday,
mrr: 2,
currency: 'usd'
});
results[4].should.eql({
date: today,
mrr: 1200,
currency: 'eur'
});
results[5].should.eql({
date: today,
mrr: 7,
currency: 'usd'
});
meta.totals.should.eql([
{
mrr: 1200,
currency: 'eur'
},
{
mrr: 7,
currency: 'usd'
}
]);
fakeDeltas.calledOnce.should.eql(true);
fakeTotal.calledOnce.should.eql(true);
});
it('Ignores invalid currencies in deltas', async function () {
deltas = [
{
date: todayDate,
delta: 200,
currency: 'abc'
}
];
currentMrr = {usd: 7};
const {data: results, meta} = await mrrStatsService.getHistory();
results.length.should.eql(1);
results[0].should.eql({
date: yesterday,
mrr: 7,
currency: 'usd'
});
meta.totals.should.eql([
{
mrr: 7,
currency: 'usd'
}
]);
fakeDeltas.calledOnce.should.eql(true);
fakeTotal.calledOnce.should.eql(true);
});
it('Ignores events in the future', async function () {
deltas = [
{
date: yesterdayDate,
delta: 2,
currency: 'usd'
},
{
date: todayDate,
delta: 5,
currency: 'usd'
},
{
date: tomorrowDate,
delta: 10,
currency: 'usd'
}
];
currentMrr = {usd: 7};
const {data: results, meta} = await mrrStatsService.getHistory();
results.length.should.eql(3);
results[0].should.eql({
date: dayBeforeYesterday,
mrr: 0,
currency: 'usd'
});
results[1].should.eql({
date: yesterday,
mrr: 2,
currency: 'usd'
});
results[2].should.eql({
date: today,
mrr: 7,
currency: 'usd'
});
meta.totals.should.eql([
{
mrr: 7,
currency: 'usd'
}
]);
fakeDeltas.calledOnce.should.eql(true);
fakeTotal.calledOnce.should.eql(true);
});
it('Correctly handles negative total MRR', async function () {
deltas = [
{
date: dayBeforeYesterdayDate,
delta: 2,
currency: 'usd'
},
{
date: yesterdayDate,
delta: -1000,
currency: 'usd'
},
{
date: todayDate,
delta: 1000,
currency: 'usd'
}
];
currentMrr = {usd: 7};
const {data: results, meta} = await mrrStatsService.getHistory();
results.length.should.eql(4);
results[0].should.eql({
date: twoDaysBeforeYesterday,
mrr: 5,
currency: 'usd'
});
results[1].should.eql({
date: dayBeforeYesterday,
// We are mainly testing that this should not be 1000!
mrr: 7,
currency: 'usd'
});
results[2].should.eql({
date: yesterday,
// Should never be shown negative (in fact it is -993 here)
mrr: 0,
currency: 'usd'
});
results[3].should.eql({
date: today,
mrr: 7,
currency: 'usd'
});
meta.totals.should.eql([
{
mrr: 7,
currency: 'usd'
}
]);
fakeDeltas.calledOnce.should.eql(true);
fakeTotal.calledOnce.should.eql(true);
});
});
});