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:
commit
77819d261a
6
ghost/stats-service/.eslintrc.js
Normal file
6
ghost/stats-service/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: ['ghost'],
|
||||||
|
extends: [
|
||||||
|
'plugin:ghost/node'
|
||||||
|
]
|
||||||
|
};
|
21
ghost/stats-service/LICENSE
Normal file
21
ghost/stats-service/LICENSE
Normal 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.
|
41
ghost/stats-service/README.md
Normal file
41
ghost/stats-service/README.md
Normal 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).
|
1
ghost/stats-service/index.js
Normal file
1
ghost/stats-service/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = require('./lib/stats');
|
165
ghost/stats-service/lib/members.js
Normal file
165
ghost/stats-service/lib/members.js
Normal 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
|
||||||
|
*/
|
153
ghost/stats-service/lib/mrr.js
Normal file
153
ghost/stats-service/lib/mrr.js
Normal 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
|
||||||
|
*/
|
37
ghost/stats-service/lib/stats.js
Normal file
37
ghost/stats-service/lib/stats.js
Normal 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;
|
38
ghost/stats-service/package.json
Normal file
38
ghost/stats-service/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
6
ghost/stats-service/test/.eslintrc.js
Normal file
6
ghost/stats-service/test/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: ['ghost'],
|
||||||
|
extends: [
|
||||||
|
'plugin:ghost/test'
|
||||||
|
]
|
||||||
|
};
|
373
ghost/stats-service/test/lib/members.test.js
Normal file
373
ghost/stats-service/test/lib/members.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
386
ghost/stats-service/test/lib/mrr.test.js
Normal file
386
ghost/stats-service/test/lib/mrr.test.js
Normal 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'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
10
ghost/stats-service/test/lib/stats.test.js
Normal file
10
ghost/stats-service/test/lib/stats.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
101
ghost/stats-service/tsconfig.json
Normal file
101
ghost/stats-service/tsconfig.json
Normal 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. */
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user