mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 20:03:12 +03:00
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:
parent
31b308d475
commit
132726fe20
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
|
154
core/server/services/stats/lib/mrr-stats-service.js
Normal file
154
core/server/services/stats/lib/mrr-stats-service.js
Normal 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
|
||||||
|
*/
|
@ -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})
|
||||||
};
|
};
|
||||||
|
@ -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));
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
234
test/e2e-server/services/stats/mrr-stats-service.test.js
Normal file
234
test/e2e-server/services/stats/mrr-stats-service.test.js
Normal 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'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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());
|
||||||
|
377
test/unit/server/services/stats/mrr-stats-service.test.js
Normal file
377
test/unit/server/services/stats/mrr-stats-service.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user