104f84f252
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
154 lines
4.8 KiB
JavaScript
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
|
|
*/
|