Moved Stats Service from Ghost

The functionality hasn't changed this has just moved the code
and updated the tests to provide better coverage
This commit is contained in:
Fabien "egg" O'Carroll 2022-04-21 13:10:33 +01:00
commit 77819d261a
13 changed files with 1338 additions and 0 deletions

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,41 @@
# Stats
Stats service
## Install
`npm install @tryghost/stats --save`
or
`yarn add @tryghost/stats`
## Usage
## Develop
This is a mono repository, managed with [lerna](https://lernajs.io/).
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Run
- `yarn dev`
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

View File

@ -0,0 +1 @@
module.exports = require('./lib/stats');

View File

@ -0,0 +1,165 @@
const moment = require('moment');
class MembersStatsService {
/**
* @param {object} deps
* @param {import('knex').Knex} deps.knex*/
constructor({knex}) {
this.knex = knex;
}
/**
* Get the current total members grouped by status
* @returns {Promise<TotalMembersByStatus>}
*/
async getCount() {
const knex = this.knex;
const rows = await knex('members')
.select('status')
.select(knex.raw('COUNT(id) AS total'))
.groupBy('status');
const paidEvent = rows.find(c => c.status === 'paid');
const freeEvent = rows.find(c => c.status === 'free');
const compedEvent = rows.find(c => c.status === 'comped');
return {
paid: paidEvent ? paidEvent.total : 0,
free: freeEvent ? freeEvent.total : 0,
comped: compedEvent ? compedEvent.total : 0
};
}
/**
* Get the member deltas by status for all days, sorted ascending
* @returns {Promise<MemberStatusDelta[]>} The deltas of paid, free and comped users per day, sorted ascending
*/
async fetchAllStatusDeltas() {
const knex = this.knex;
const rows = await knex('members_status_events')
.select(knex.raw('DATE(created_at) as date'))
.select(knex.raw(`SUM(
CASE WHEN to_status='paid' THEN 1
ELSE 0 END
) as paid_subscribed`))
.select(knex.raw(`SUM(
CASE WHEN from_status='paid' THEN 1
ELSE 0 END
) as paid_canceled`))
.select(knex.raw(`SUM(
CASE WHEN to_status='comped' THEN 1
WHEN from_status='comped' THEN -1
ELSE 0 END
) as comped_delta`))
.select(knex.raw(`SUM(
CASE WHEN to_status='free' THEN 1
WHEN from_status='free' THEN -1
ELSE 0 END
) as free_delta`))
.groupByRaw('DATE(created_at)')
.orderByRaw('DATE(created_at)');
return rows;
}
/**
* Returns a list of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
* @returns {Promise<CountHistory>}
*/
async getCountHistory() {
const rows = await this.fetchAllStatusDeltas();
// Fetch current total amounts and start counting from there
const totals = await this.getCount();
let {paid, free, comped} = totals;
// Get today in UTC (default timezone)
const today = moment().format('YYYY-MM-DD');
const cumulativeResults = [];
// Loop in reverse order (needed to have correct sorted result)
for (let i = rows.length - 1; i >= 0; i -= 1) {
const row = rows[i];
// 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 (fix for invalid events)
continue;
}
cumulativeResults.unshift({
date,
paid: Math.max(0, paid),
free: Math.max(0, free),
comped: Math.max(0, comped),
// Deltas
paid_subscribed: row.paid_subscribed,
paid_canceled: row.paid_canceled
});
// Update current counts
paid -= row.paid_subscribed - row.paid_canceled;
free -= row.free_delta;
comped -= row.comped_delta;
}
// 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 ? moment(rows[0].date).add(-1, 'days').format('YYYY-MM-DD') : today;
cumulativeResults.unshift({
date: oldestDate,
paid: Math.max(0, paid),
free: Math.max(0, free),
comped: Math.max(0, comped),
// Deltas
paid_subscribed: 0,
paid_canceled: 0
});
return {
data: cumulativeResults,
meta: {
totals
}
};
}
}
module.exports = MembersStatsService;
/**
* @typedef MemberStatusDelta
* @type {Object}
* @property {Date} date
* @property {number} paid_subscribed Paid members that subscribed on this day
* @property {number} paid_canceled Paid members that canceled on this day
* @property {number} comped_delta Total net comped members on this day
* @property {number} free_delta Total net members on this day
*/
/**
* @typedef TotalMembersByStatus
* @type {Object}
* @property {number} paid Total paid members
* @property {number} free Total free members
* @property {number} comped Total comped members
*/
/**
* @typedef {Object} TotalMembersByStatusItem
* @property {string} date In YYYY-MM-DD format
* @property {number} paid Total paid members
* @property {number} free Total free members
* @property {number} comped Total comped members
* @property {number} paid_subscribed Paid members that subscribed on this day
* @property {number} paid_canceled Paid members that canceled on this day
*/
/**
* @typedef {Object} CountHistory
* @property {TotalMembersByStatusItem[]} data List of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
* @property {Object} meta
* @property {TotalMembersByStatus} meta.totals
*/

View File

@ -0,0 +1,153 @@
const moment = require('moment');
class MrrStatsService {
/**
* @param {object} deps
* @param {import('knex').Knex} deps.knex
**/
constructor({knex}) {
this.knex = knex;
}
/**
* Get the current total MRR, grouped by currency (ascending order)
* @returns {Promise<MrrByCurrency[]>}
*/
async getCurrentMrr() {
const knex = this.knex;
const rows = await knex('members_stripe_customers_subscriptions')
.select(knex.raw(`plan_currency as currency`))
.select(knex.raw(`SUM(mrr) AS mrr`))
.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.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
/** @type {Object.<string, number>}*/
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
* @property {MrrByCurrency[]} meta.totals
*/

View File

@ -0,0 +1,37 @@
const MRRService = require('./mrr');
const MembersService = require('./members');
class StatsService {
/**
* @param {object} deps
* @param {MRRService} deps.mrr
* @param {MembersService} deps.members
**/
constructor(deps) {
this.mrr = deps.mrr;
this.members = deps.members;
}
async getMRRHistory() {
return this.mrr.getHistory();
}
async getMemberCountHistory() {
return this.members.getCountHistory();
}
/**
* @param {object} deps
* @param {import('knex').Knex} deps.knex
*
* @returns {StatsService}
**/
static create(deps) {
return new StatsService({
mrr: new MRRService(deps),
members: new MembersService(deps)
});
}
}
module.exports = StatsService;

View File

@ -0,0 +1,38 @@
{
"name": "@tryghost/stats",
"version": "0.0.0",
"repository": "https://github.com/TryGhost/Analytics/tree/main/packages/stats",
"author": "Ghost Foundation",
"license": "MIT",
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing c8 --all --check-coverage --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"lint:code": "eslint *.js lib/ --ext .js --cache",
"lint": "yarn lint:code && yarn lint:test",
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache",
"posttest": "yarn lint"
},
"files": [
"index.js",
"lib"
],
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/mocha": "^9.1.0",
"@types/node": "^17.0.25",
"@types/sinon": "^10.0.11",
"@vscode/sqlite3": "^5.0.8",
"c8": "7.11.2",
"knex": "^1.0.7",
"mocha": "9.2.2",
"should": "13.2.3",
"sinon": "13.0.2",
"typescript": "^4.6.3"
},
"dependencies": {
"moment": "^2.29.3"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View File

@ -0,0 +1,373 @@
const MembersStatsService = require('../../lib/members');
const knex = require('knex').default;
const assert = require('assert');
const moment = require('moment');
const sinon = require('sinon');
describe('MembersStatsService', function () {
describe('getCountHistory', function () {
/** @type {MembersStatsService} */
let membersStatsService;
/**
* @type {MembersStatsService.TotalMembersByStatus}
*/
const currentCounts = {paid: 0, free: 0, comped: 0};
/**
* @type {MembersStatsService.MemberStatusDelta[]}
*/
let events = [];
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';
/** @type {Date} */
let todayDate;
/** @type {Date} */
let tomorrowDate;
/** @type {Date} */
let yesterdayDate;
/** @type {Date} */
let dayBeforeYesterdayDate;
after(function () {
sinon.restore();
});
/** @type {import('knex').Knex} */
let db;
before(function () {
todayDate = moment(today).toDate();
tomorrowDate = moment(tomorrow).toDate();
yesterdayDate = moment(yesterday).toDate();
dayBeforeYesterdayDate = moment(dayBeforeYesterday).toDate();
sinon.useFakeTimers(todayDate.getTime());
membersStatsService = new MembersStatsService({knex: knex({client: 'sqlite3', connection: {filename: ':memory:'}})});
});
beforeEach(async function () {
db = knex({client: 'sqlite3', connection: {filename: ':memory:'}, useNullAsDefault: true});
membersStatsService = new MembersStatsService({knex: db});
await db.schema.createTable('members_status_events', function (table) {
table.string('from_status');
table.string('to_status');
table.date('created_at');
});
await db.schema.createTable('members', function (table) {
table.string('id');
table.string('status');
});
});
async function setupDB() {
const paidMembers = Array.from({length: currentCounts.paid}).map(() => ({
id: 'id',
status: 'paid'
}));
const freeMembers = Array.from({length: currentCounts.free}).map(() => ({
id: 'id',
status: 'free'
}));
const compedMembers = Array.from({length: currentCounts.comped}).map(() => ({
id: 'id',
status: 'comped'
}));
await db('members').insert(paidMembers.concat(freeMembers, compedMembers));
/**
* @typedef {object} StatusEvent
* @prop {string} created_at
* @prop {string|null} from_status
* @prop {string|null} to_status
**/
/**
* @param {string} status
* @param {number} number
* @param {Date} date
* @returns {StatusEvent[]}
**/
function generateEvents(status, number, date) {
return Array.from({length: Math.abs(number)}).map(() => ({
created_at: date.toISOString(),
from_status: number > 0 ? null : status,
to_status: number < 0 ? null : status
}));
}
const toInsert = events.reduce((/** @type {StatusEvent[]} */memo, event) => {
const paidSubscribed = generateEvents('paid', event.paid_subscribed, event.date);
const paidCanceled = generateEvents('paid', -event.paid_canceled, event.date);
const freeSubscribed = generateEvents('free', event.free_delta, event.date);
const compedSubscribed = generateEvents('comped', event.comped_delta, event.date);
return memo.concat(paidSubscribed, paidCanceled, freeSubscribed, compedSubscribed);
}, []);
if (toInsert.length) {
await db('members_status_events').insert(toInsert);
}
}
it('Always returns at least one value', async function () {
// No status events
events = [];
currentCounts.paid = 1;
currentCounts.free = 2;
currentCounts.comped = 3;
await setupDB();
const {data: results, meta} = await membersStatsService.getCountHistory();
assert(results.length === 1, 'Should have one result');
assert.deepEqual(results[0], {
date: today,
paid: 1,
free: 2,
comped: 3,
paid_subscribed: 0,
paid_canceled: 0
});
assert.deepEqual(meta.totals, currentCounts);
});
it('Passes paid_subscribers and paid_canceled', async function () {
// Update faked status events
events = [
{
date: todayDate,
paid_subscribed: 4,
paid_canceled: 3,
free_delta: 2,
comped_delta: 3
}
];
// Update current faked counts
currentCounts.paid = 1;
currentCounts.free = 2;
currentCounts.comped = 3;
await setupDB();
const {data: results, meta} = await membersStatsService.getCountHistory();
assert.deepEqual(results, [
{
date: yesterday,
paid: 0,
free: 0,
comped: 0,
paid_subscribed: 0,
paid_canceled: 0
},
{
date: today,
paid: 1,
free: 2,
comped: 3,
paid_subscribed: 4,
paid_canceled: 3
}
]);
assert.deepEqual(meta.totals, currentCounts);
});
it('Correctly resolves deltas', async function () {
// Update faked status events
events = [
{
date: yesterdayDate,
paid_subscribed: 2,
paid_canceled: 1,
free_delta: 0,
comped_delta: 0
},
{
date: todayDate,
paid_subscribed: 4,
paid_canceled: 3,
free_delta: 2,
comped_delta: 3
}
];
// Update current faked counts
currentCounts.paid = 2;
currentCounts.free = 3;
currentCounts.comped = 4;
await setupDB();
const {data: results, meta} = await membersStatsService.getCountHistory();
assert.deepEqual(results, [
{
date: dayBeforeYesterday,
paid: 0,
free: 1,
comped: 1,
paid_subscribed: 0,
paid_canceled: 0
},
{
date: yesterday,
paid: 1,
free: 1,
comped: 1,
paid_subscribed: 2,
paid_canceled: 1
},
{
date: today,
paid: 2,
free: 3,
comped: 4,
paid_subscribed: 4,
paid_canceled: 3
}
]);
assert.deepEqual(meta.totals, currentCounts);
});
it('Correctly handles negative numbers', async function () {
// Update faked status events
events = [
{
date: dayBeforeYesterdayDate,
paid_subscribed: 2,
paid_canceled: 1,
free_delta: 2,
comped_delta: 10
},
{
date: yesterdayDate,
paid_subscribed: 2,
paid_canceled: 1,
free_delta: -100,
comped_delta: 0
},
{
date: todayDate,
paid_subscribed: 4,
paid_canceled: 3,
free_delta: 100,
comped_delta: 3
}
];
// Update current faked counts
currentCounts.paid = 2;
currentCounts.free = 3;
currentCounts.comped = 4;
await setupDB();
const {data: results, meta} = await membersStatsService.getCountHistory();
assert.deepEqual(results, [
{
date: twoDaysBeforeYesterday,
paid: 0,
free: 1,
comped: 0,
paid_subscribed: 0,
paid_canceled: 0
},
{
date: dayBeforeYesterday,
paid: 0,
// note that this shouldn't be 100 (which is also what we test here):
free: 3,
comped: 1,
paid_subscribed: 2,
paid_canceled: 1
},
{
date: yesterday,
paid: 1,
// never return negative numbers, this is in fact -997:
free: 0,
comped: 1,
paid_subscribed: 2,
paid_canceled: 1
},
{
date: today,
paid: 2,
free: 3,
comped: 4,
paid_subscribed: 4,
paid_canceled: 3
}
]);
assert.deepEqual(meta.totals, currentCounts);
});
it('Ignores events in the future', async function () {
// Update faked status events
events = [
{
date: yesterdayDate,
paid_subscribed: 1,
paid_canceled: 0,
free_delta: 1,
comped_delta: 0
},
{
date: todayDate,
paid_subscribed: 4,
paid_canceled: 3,
free_delta: 2,
comped_delta: 3
},
{
date: tomorrowDate,
paid_subscribed: 10,
paid_canceled: 5,
free_delta: 8,
comped_delta: 9
}
];
// Update current faked counts
currentCounts.paid = 1;
currentCounts.free = 2;
currentCounts.comped = 3;
await setupDB();
const {data: results, meta} = await membersStatsService.getCountHistory();
assert.deepEqual(results, [
{
date: dayBeforeYesterday,
paid: 0,
free: 0,
comped: 0,
paid_subscribed: 0,
paid_canceled: 0
},
{
date: yesterday,
paid: 0,
free: 0,
comped: 0,
paid_subscribed: 1,
paid_canceled: 0
},
{
date: today,
paid: 1,
free: 2,
comped: 3,
paid_subscribed: 4,
paid_canceled: 3
}
]);
assert.deepEqual(meta.totals, currentCounts);
});
});
});

View File

@ -0,0 +1,386 @@
const MrrStatsService = require('../../lib/mrr');
const moment = require('moment');
const sinon = require('sinon');
const knex = require('knex').default;
require('should');
describe('MrrStatsService', function () {
describe('getHistory', function () {
/** @type {MrrStatsService} */
let mrrStatsService;
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';
after(function () {
sinon.restore();
});
/** @type {import('knex').Knex} */
let db;
before(function () {
const todayDate = moment(today).toDate();
sinon.useFakeTimers(todayDate.getTime());
});
beforeEach(async function () {
db = knex({client: 'sqlite3', connection: {filename: ':memory:'}, useNullAsDefault: true});
mrrStatsService = new MrrStatsService({knex: db});
await db.schema.createTable('members_paid_subscription_events', function (table) {
table.string('currency');
table.string('mrr_delta');
table.date('created_at');
});
await db.schema.createTable('members_stripe_customers_subscriptions', function (table) {
table.string('plan_currency');
table.string('mrr');
});
});
it('Handles no data', async function () {
const {data: results, meta} = await mrrStatsService.getHistory();
results.length.should.eql(1);
// Note that currencies should always be sorted ascending, so EUR should be first.
results[0].should.eql({
date: today,
mrr: 0,
currency: 'usd'
});
meta.totals.should.eql([
{
mrr: 0,
currency: 'usd'
}
]);
});
it('Always returns at least one value', async function () {
await db('members_stripe_customers_subscriptions').insert([{
plan_currency: 'usd',
mrr: 1
}, {
plan_currency: 'eur',
mrr: 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'
}
]);
});
it('Does not substract delta of first event', async function () {
await db('members_stripe_customers_subscriptions').insert([{
plan_currency: 'usd',
mrr: 5
}]);
await db('members_paid_subscription_events').insert([{
created_at: today,
mrr_delta: 5,
currency: 'usd'
}]);
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'
}
]);
});
it('Correctly calculates deltas', async function () {
await db('members_paid_subscription_events').insert([{
created_at: yesterday,
mrr_delta: 2,
currency: 'usd'
},
{
created_at: today,
mrr_delta: 5,
currency: 'usd'
}]);
await db('members_stripe_customers_subscriptions').insert([{
plan_currency: 'usd',
mrr: 2
}, {
plan_currency: 'usd',
mrr: 5
}]);
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'
}
]);
});
it('Correctly calculates deltas for multiple currencies', async function () {
await db('members_paid_subscription_events').insert([
{
created_at: yesterday,
mrr_delta: 200,
currency: 'eur'
},
{
created_at: yesterday,
mrr_delta: 2,
currency: 'usd'
},
{
created_at: today,
mrr_delta: 800,
currency: 'eur'
},
{
created_at: today,
mrr_delta: 5,
currency: 'usd'
}
]);
await db('members_stripe_customers_subscriptions').insert([{
plan_currency: 'eur',
mrr: 200
}, {
plan_currency: 'usd',
mrr: 2
}, {
plan_currency: 'eur',
mrr: 800
}, {
plan_currency: 'usd',
mrr: 5
}, {
plan_currency: 'eur',
mrr: 200
}]);
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'
}
]);
});
it('Ignores invalid currencies in deltas', async function () {
await db('members_paid_subscription_events').insert({
created_at: today,
mrr_delta: 200,
currency: 'abc'
});
await db('members_stripe_customers_subscriptions').insert({
plan_currency: 'usd',
mrr: 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'
}
]);
});
it('Ignores events in the future', async function () {
await db('members_paid_subscription_events').insert([
{
created_at: yesterday,
mrr_delta: 2,
currency: 'usd'
},
{
created_at: today,
mrr_delta: 5,
currency: 'usd'
},
{
created_at: tomorrow,
mrr_delta: 10,
currency: 'usd'
}
]);
await db('members_stripe_customers_subscriptions').insert({plan_currency: 'usd', mrr: 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'
}
]);
});
it('Correctly handles negative total MRR', async function () {
await db('members_paid_subscription_events').insert([
{
created_at: dayBeforeYesterday,
mrr_delta: 2,
currency: 'usd'
},
{
created_at: yesterday,
mrr_delta: -1000,
currency: 'usd'
},
{
created_at: today,
mrr_delta: 1000,
currency: 'usd'
}
]);
await db('members_stripe_customers_subscriptions').insert({plan_currency: 'usd', mrr: 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'
}
]);
});
});
});

View File

@ -0,0 +1,10 @@
const StatsService = require('../../lib/stats');
const knex = require('knex').default;
const assert = require('assert');
describe('StatsService', function () {
it('Exposes a create factory', function () {
const service = StatsService.create({knex: knex({client: 'sqlite3', connection: {filename: ':memory:'}})});
assert(service instanceof StatsService);
});
});

View File

@ -0,0 +1,101 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
"checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
"noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}