Ghost/ghost/stats-service/lib/MrrStatsService.js
Fabien "egg" O'Carroll 104f84f252 Added eslint rule for file naming convention
As discussed with the product team we want to enforce kebab-case file names for
all files, with the exception of files which export a single class, in which
case they should be PascalCase and reflect the class which they export.

This will help find classes faster, and should push better naming for them too.

Some files and packages have been excluded from this linting, specifically when
a library or framework depends on the naming of a file for the functionality
e.g. Ember, knex-migrator, adapter-manager
2023-05-09 12:34:34 -04:00

154 lines
4.8 KiB
JavaScript

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
*/