2022-04-21 17:42:02 +03:00
|
|
|
const knex = require('knex').default;
|
|
|
|
const assert = require('assert');
|
|
|
|
const SubscriptionStatsService = require('../../lib/subscriptions');
|
|
|
|
|
|
|
|
describe('SubscriptionStatsService', function () {
|
|
|
|
describe('getSubscriptionHistory', function () {
|
|
|
|
/** @type {import('knex').Knex} */
|
|
|
|
let db;
|
|
|
|
|
|
|
|
beforeEach(async function () {
|
|
|
|
db = knex({
|
|
|
|
client: 'sqlite3',
|
|
|
|
useNullAsDefault: true,
|
|
|
|
connection: {
|
|
|
|
filename: ':memory:'
|
|
|
|
}
|
|
|
|
});
|
|
|
|
await db.schema.createTable('products', function (table) {
|
|
|
|
table.string('id');
|
|
|
|
});
|
|
|
|
await db.schema.createTable('stripe_products', function (table) {
|
|
|
|
table.string('stripe_product_id');
|
|
|
|
table.string('product_id');
|
|
|
|
});
|
|
|
|
await db.schema.createTable('stripe_prices', function (table) {
|
|
|
|
table.string('id');
|
|
|
|
table.string('stripe_price_id');
|
|
|
|
table.string('stripe_product_id');
|
|
|
|
table.string('interval');
|
|
|
|
});
|
|
|
|
await db.schema.createTable('members_paid_subscription_events', function (table) {
|
|
|
|
table.string('type');
|
|
|
|
table.string('from_plan');
|
|
|
|
table.string('to_plan');
|
|
|
|
table.integer('mrr_delta');
|
|
|
|
table.date('created_at');
|
|
|
|
});
|
|
|
|
await db.schema.createTable('members_stripe_customers_subscriptions', function (table) {
|
|
|
|
table.string('id');
|
|
|
|
table.string('stripe_price_id');
|
|
|
|
table.integer('mrr');
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
afterEach(async function () {
|
|
|
|
await db.destroy();
|
|
|
|
});
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef {object} TierCadence
|
|
|
|
* @prop {string} tier
|
|
|
|
* @prop {string} cadence
|
|
|
|
* @prop {number} mrr
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef {object} MockedTier
|
|
|
|
* @prop {TierCadence} monthly
|
|
|
|
* @prop {TierCadence} yearly
|
|
|
|
**/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {string[]} tiers
|
|
|
|
* @returns {Promise<Object<string, MockedTier>>}
|
|
|
|
**/
|
|
|
|
async function createTiers(tiers) {
|
|
|
|
/** @type {Object<string, MockedTier>} */
|
|
|
|
const results = {};
|
2022-05-19 18:59:48 +03:00
|
|
|
let i = 1;
|
2022-04-21 17:42:02 +03:00
|
|
|
for (const tier of tiers) {
|
|
|
|
await db('products').insert({id: tier});
|
|
|
|
await db('stripe_products').insert({
|
|
|
|
product_id: tier,
|
|
|
|
stripe_product_id: `stripe_product_${tier}`
|
|
|
|
});
|
|
|
|
await db('stripe_prices').insert({
|
|
|
|
id: `stripe_price_month_${tier}`,
|
|
|
|
stripe_price_id: `stripe_price_month_${tier}`,
|
|
|
|
stripe_product_id: `stripe_product_${tier}`,
|
|
|
|
interval: 'month'
|
|
|
|
});
|
|
|
|
await db('stripe_prices').insert({
|
|
|
|
id: `stripe_price_year_${tier}`,
|
|
|
|
stripe_price_id: `stripe_price_year_${tier}`,
|
|
|
|
stripe_product_id: `stripe_product_${tier}`,
|
|
|
|
interval: 'year'
|
|
|
|
});
|
|
|
|
results[tier] = {
|
|
|
|
monthly: {
|
|
|
|
tier: tier,
|
|
|
|
cadence: 'month',
|
2022-05-19 18:59:48 +03:00
|
|
|
mrr: 100 * i
|
2022-04-21 17:42:02 +03:00
|
|
|
},
|
|
|
|
yearly: {
|
|
|
|
tier: tier,
|
|
|
|
cadence: 'year',
|
2022-05-19 18:59:48 +03:00
|
|
|
mrr: 80 * i
|
2022-04-21 17:42:02 +03:00
|
|
|
}
|
|
|
|
};
|
2022-05-19 18:59:48 +03:00
|
|
|
i += 1;
|
2022-04-21 17:42:02 +03:00
|
|
|
}
|
|
|
|
return results;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef {object} FakeEvent
|
|
|
|
* @prop {string} id
|
|
|
|
* @prop {string} type
|
|
|
|
* @prop {string|null} tier
|
|
|
|
* @prop {string|null} cadence
|
|
|
|
* @prop {number|null} mrr
|
|
|
|
**/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {string} type
|
|
|
|
* @returns {(id: string, attr?: any) => FakeEvent}
|
|
|
|
**/
|
|
|
|
const createEvent = type => (id, attr = {}) => Object.assign({tier: null, cadence: null, mrr: null}, attr, {id, type});
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {FakeEvent[][]} days
|
|
|
|
**/
|
|
|
|
async function insertEvents(days) {
|
|
|
|
const {DateTime} = require('luxon');
|
|
|
|
/** @type {Object<string, FakeEvent[]>}*/
|
|
|
|
const subscriptions = {};
|
|
|
|
const toInsert = [];
|
|
|
|
for (let index = 0; index < days.length; index++) {
|
|
|
|
const events = days[index];
|
|
|
|
const day = DateTime.fromISO('1970-01-01').plus({days: index}).toISODate();
|
|
|
|
toInsert.push(...events.map(function (event) {
|
2022-05-19 18:59:48 +03:00
|
|
|
let last = event;
|
|
|
|
if (!subscriptions[event.id] || !subscriptions[event.id][0]) {
|
2022-04-21 17:42:02 +03:00
|
|
|
subscriptions[event.id] = [event];
|
|
|
|
} else {
|
|
|
|
last = subscriptions[event.id][0];
|
|
|
|
subscriptions[event.id] = [event].concat(subscriptions[event.id]);
|
|
|
|
}
|
|
|
|
|
2022-05-19 18:59:48 +03:00
|
|
|
let fromPlan = null;
|
|
|
|
let toPlan = null;
|
|
|
|
let mrr = 0;
|
|
|
|
|
|
|
|
switch (event.type) {
|
|
|
|
case 'created':
|
|
|
|
fromPlan = null;
|
|
|
|
toPlan = `stripe_price_${event.cadence}_${event.tier}`;
|
|
|
|
mrr = event.mrr;
|
|
|
|
break;
|
|
|
|
case 'canceled':
|
|
|
|
fromPlan = `stripe_price_${last.cadence}_${last.tier}`;
|
|
|
|
toPlan = `stripe_price_${last.cadence}_${last.tier}`;
|
|
|
|
mrr = -last.mrr;
|
|
|
|
break;
|
|
|
|
case 'reactivated':
|
|
|
|
fromPlan = `stripe_price_${last.cadence}_${last.tier}`;
|
|
|
|
toPlan = `stripe_price_${last.cadence}_${last.tier}`;
|
|
|
|
mrr = last.mrr;
|
|
|
|
break;
|
|
|
|
case 'updated':
|
|
|
|
fromPlan = `stripe_price_${last.cadence}_${last.tier}`;
|
|
|
|
toPlan = `stripe_price_${event.cadence}_${event.tier}`;
|
|
|
|
mrr = event.mrr - last.mrr;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!event.tier) {
|
|
|
|
event.tier = last.tier;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!event.cadence) {
|
|
|
|
event.cadence = last.cadence;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!event.mrr) {
|
|
|
|
event.mrr = last.mrr;
|
|
|
|
}
|
2022-04-21 17:42:02 +03:00
|
|
|
|
|
|
|
return {
|
|
|
|
type: event.type,
|
2022-05-19 18:59:48 +03:00
|
|
|
from_plan: fromPlan,
|
|
|
|
to_plan: toPlan,
|
2022-04-21 17:42:02 +03:00
|
|
|
mrr_delta: mrr,
|
|
|
|
created_at: day
|
|
|
|
};
|
|
|
|
}));
|
|
|
|
}
|
2022-05-19 18:59:48 +03:00
|
|
|
|
2022-04-21 17:42:02 +03:00
|
|
|
await db('members_paid_subscription_events').insert(toInsert);
|
|
|
|
|
|
|
|
const subscriptionsToInsert = Object.keys(subscriptions).map((id) => {
|
|
|
|
const event = subscriptions[id][0];
|
|
|
|
let cadence = event.cadence;
|
|
|
|
let tier = event.tier;
|
|
|
|
if (!event.tier) {
|
|
|
|
cadence = subscriptions[id][1].cadence;
|
|
|
|
tier = subscriptions[id][1].tier;
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
id,
|
|
|
|
stripe_price_id: `stripe_price_${cadence}_${tier}`,
|
2022-05-19 18:59:48 +03:00
|
|
|
mrr: event.type !== 'canceled' ? event.mrr : 0
|
2022-04-21 17:42:02 +03:00
|
|
|
};
|
|
|
|
});
|
|
|
|
|
|
|
|
await db('members_stripe_customers_subscriptions').insert(subscriptionsToInsert);
|
|
|
|
}
|
|
|
|
|
|
|
|
it('Responds with correct data', async function () {
|
|
|
|
const tiers = await createTiers(['basic', 'advanced']);
|
|
|
|
|
|
|
|
const NEW = createEvent('created');
|
|
|
|
const CANCEL = createEvent('canceled');
|
|
|
|
|
|
|
|
const events = [
|
|
|
|
[NEW('A', tiers.basic.monthly), NEW('B', tiers.advanced.yearly)],
|
|
|
|
[CANCEL('B'), NEW('C', tiers.advanced.monthly)],
|
|
|
|
[CANCEL('A'), NEW('D', tiers.basic.monthly)]
|
|
|
|
];
|
|
|
|
|
|
|
|
await insertEvents(events);
|
|
|
|
|
|
|
|
const stats = new SubscriptionStatsService({knex: db});
|
|
|
|
|
|
|
|
const results = await stats.getSubscriptionHistory();
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {string} tier
|
|
|
|
* @param {string} cadence
|
|
|
|
* @param {string} date
|
|
|
|
*
|
|
|
|
* @returns {(result: import('../../lib/subscriptions').SubscriptionHistoryEntry) => boolean}
|
|
|
|
**/
|
|
|
|
const finder = (tier, cadence, date) => (result) => {
|
|
|
|
return result.tier === tier && result.cadence === cadence && result.date === date;
|
|
|
|
};
|
|
|
|
|
|
|
|
const firstDayBasicMonthly = results.data.find(finder('basic', 'month', '1970-01-01'));
|
|
|
|
const firstDayAdvancedYearly = results.data.find(finder('advanced', 'year', '1970-01-01'));
|
|
|
|
const secondDayAdvancedYearly = results.data.find(finder('advanced', 'year', '1970-01-02'));
|
|
|
|
const secondDayAdvancedMonthly = results.data.find(finder('advanced', 'month', '1970-01-02'));
|
|
|
|
const thirdDayBasicMonthly = results.data.find(finder('basic', 'month', '1970-01-03'));
|
|
|
|
|
|
|
|
assert(firstDayBasicMonthly);
|
|
|
|
assert(firstDayBasicMonthly.positive_delta === 1);
|
|
|
|
assert(firstDayBasicMonthly.negative_delta === 0);
|
2022-07-13 13:20:50 +03:00
|
|
|
assert(firstDayBasicMonthly.signups === 1);
|
|
|
|
assert(firstDayBasicMonthly.cancellations === 0);
|
2022-04-21 17:42:02 +03:00
|
|
|
assert(firstDayBasicMonthly.count === 1);
|
|
|
|
|
|
|
|
assert(firstDayAdvancedYearly);
|
|
|
|
assert(firstDayAdvancedYearly.positive_delta === 1);
|
|
|
|
assert(firstDayAdvancedYearly.negative_delta === 0);
|
2022-07-13 13:20:50 +03:00
|
|
|
assert(firstDayAdvancedYearly.signups === 1);
|
|
|
|
assert(firstDayAdvancedYearly.cancellations === 0);
|
2022-04-21 17:42:02 +03:00
|
|
|
assert(firstDayAdvancedYearly.count === 1);
|
|
|
|
|
|
|
|
assert(secondDayAdvancedYearly);
|
|
|
|
assert(secondDayAdvancedYearly.positive_delta === 0);
|
|
|
|
assert(secondDayAdvancedYearly.negative_delta === 1);
|
2022-07-13 13:20:50 +03:00
|
|
|
assert(secondDayAdvancedYearly.signups === 0);
|
|
|
|
assert(secondDayAdvancedYearly.cancellations === 1);
|
2022-04-21 17:42:02 +03:00
|
|
|
assert(secondDayAdvancedYearly.count === 0);
|
|
|
|
|
|
|
|
assert(secondDayAdvancedMonthly);
|
|
|
|
assert(secondDayAdvancedMonthly.positive_delta === 1);
|
|
|
|
assert(secondDayAdvancedMonthly.negative_delta === 0);
|
2022-07-13 13:20:50 +03:00
|
|
|
assert(secondDayAdvancedMonthly.signups === 1);
|
|
|
|
assert(secondDayAdvancedMonthly.cancellations === 0);
|
2022-04-21 17:42:02 +03:00
|
|
|
assert(secondDayAdvancedMonthly.count === 1);
|
|
|
|
|
|
|
|
assert(thirdDayBasicMonthly);
|
|
|
|
assert(thirdDayBasicMonthly.positive_delta === 1);
|
|
|
|
assert(thirdDayBasicMonthly.negative_delta === 1);
|
2022-07-13 13:20:50 +03:00
|
|
|
assert(thirdDayBasicMonthly.signups === 1);
|
|
|
|
assert(thirdDayBasicMonthly.cancellations === 1);
|
2022-04-21 17:42:02 +03:00
|
|
|
assert(thirdDayBasicMonthly.count === 1);
|
|
|
|
});
|
2022-05-19 18:59:48 +03:00
|
|
|
|
|
|
|
it('Correctly handles upgrades', async function () {
|
|
|
|
const tiers = await createTiers(['basic', 'beyond']);
|
|
|
|
|
|
|
|
const CREATE = createEvent('created');
|
|
|
|
const UPDATE = createEvent('updated');
|
|
|
|
const REACTIVATE = createEvent('reactivated');
|
|
|
|
const CANCEL = createEvent('canceled');
|
|
|
|
|
|
|
|
const events = [
|
|
|
|
[
|
|
|
|
CREATE('A', tiers.beyond.yearly),
|
|
|
|
CREATE('B', tiers.basic.yearly),
|
|
|
|
CANCEL('B'),
|
|
|
|
CREATE('C', tiers.beyond.monthly),
|
|
|
|
UPDATE('C', tiers.basic.monthly),
|
|
|
|
REACTIVATE('B'),
|
|
|
|
UPDATE('B', tiers.beyond.yearly)
|
|
|
|
],
|
|
|
|
[
|
|
|
|
CREATE('D', tiers.beyond.monthly),
|
|
|
|
CREATE('E', tiers.basic.yearly),
|
|
|
|
CREATE('F', tiers.beyond.yearly)
|
|
|
|
]
|
|
|
|
];
|
|
|
|
|
|
|
|
await insertEvents(events);
|
|
|
|
|
|
|
|
const stats = new SubscriptionStatsService({knex: db});
|
|
|
|
|
|
|
|
const result = await stats.getSubscriptionHistory();
|
|
|
|
|
|
|
|
// Check totals
|
|
|
|
assert(result.meta.totals.find(item => item.tier === 'basic' && item.cadence === 'month').count = 1);
|
|
|
|
assert(result.meta.totals.find(item => item.tier === 'basic' && item.cadence === 'year').count = 1);
|
|
|
|
assert(result.meta.totals.find(item => item.tier === 'beyond' && item.cadence === 'month').count = 1);
|
|
|
|
assert(result.meta.totals.find(item => item.tier === 'beyond' && item.cadence === 'year').count = 3);
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {string} tier
|
|
|
|
* @param {string} cadence
|
|
|
|
* @param {string} date
|
|
|
|
*
|
|
|
|
* @returns {(result: import('../../lib/subscriptions').SubscriptionHistoryEntry) => boolean}
|
|
|
|
**/
|
|
|
|
const finder = (tier, cadence, date) => (result) => {
|
|
|
|
return result.tier === tier && result.cadence === cadence && result.date === date;
|
|
|
|
};
|
|
|
|
|
|
|
|
const days = [{
|
|
|
|
basic: {
|
|
|
|
monthly: result.data.find(finder('basic', 'month', '1970-01-01')),
|
|
|
|
yearly: result.data.find(finder('basic', 'year', '1970-01-01'))
|
|
|
|
},
|
|
|
|
beyond: {
|
|
|
|
monthly: result.data.find(finder('beyond', 'month', '1970-01-01')),
|
|
|
|
yearly: result.data.find(finder('beyond', 'year', '1970-01-01'))
|
|
|
|
}
|
|
|
|
}, {
|
|
|
|
basic: {
|
|
|
|
monthly: result.data.find(finder('basic', 'month', '1970-01-02')),
|
|
|
|
yearly: result.data.find(finder('basic', 'year', '1970-01-02'))
|
|
|
|
},
|
|
|
|
beyond: {
|
|
|
|
monthly: result.data.find(finder('beyond', 'month', '1970-01-02')),
|
|
|
|
yearly: result.data.find(finder('beyond', 'year', '1970-01-02'))
|
|
|
|
}
|
|
|
|
}];
|
|
|
|
|
|
|
|
// First day
|
|
|
|
assert.equal(days[0].basic.monthly.positive_delta, 1);
|
|
|
|
assert.equal(days[0].basic.monthly.negative_delta, 0);
|
2022-07-13 13:20:50 +03:00
|
|
|
assert.equal(days[0].basic.monthly.signups, 0); // We only have a subscription that switched tier
|
|
|
|
assert.equal(days[0].basic.monthly.cancellations, 0);
|
2022-05-19 18:59:48 +03:00
|
|
|
|
|
|
|
assert.equal(days[0].basic.yearly.positive_delta, 2);
|
|
|
|
assert.equal(days[0].basic.yearly.negative_delta, 2);
|
2022-07-13 13:20:50 +03:00
|
|
|
assert.equal(days[0].basic.yearly.signups, 2);
|
|
|
|
assert.equal(days[0].basic.yearly.cancellations, 1);
|
2022-05-19 18:59:48 +03:00
|
|
|
|
|
|
|
assert.equal(days[0].beyond.monthly.positive_delta, 1);
|
|
|
|
assert.equal(days[0].beyond.monthly.negative_delta, 1);
|
2022-07-13 13:20:50 +03:00
|
|
|
assert.equal(days[0].beyond.monthly.signups, 1);
|
|
|
|
assert.equal(days[0].beyond.monthly.cancellations, 0);
|
2022-05-19 18:59:48 +03:00
|
|
|
|
|
|
|
assert.equal(days[0].beyond.yearly.positive_delta, 2);
|
|
|
|
assert.equal(days[0].beyond.yearly.negative_delta, 0);
|
2022-07-13 13:20:50 +03:00
|
|
|
assert.equal(days[0].beyond.yearly.signups, 1);
|
|
|
|
assert.equal(days[0].beyond.yearly.cancellations, 0);
|
2022-05-19 18:59:48 +03:00
|
|
|
|
|
|
|
// Second day
|
|
|
|
assert.equal(days[1].basic.yearly.positive_delta, 1);
|
|
|
|
assert.equal(days[1].basic.yearly.negative_delta, 0);
|
2022-07-13 13:20:50 +03:00
|
|
|
assert.equal(days[1].basic.yearly.signups, 1);
|
|
|
|
assert.equal(days[1].basic.yearly.cancellations, 0);
|
2022-05-19 18:59:48 +03:00
|
|
|
|
|
|
|
assert.equal(days[1].beyond.monthly.positive_delta, 1);
|
|
|
|
assert.equal(days[1].beyond.monthly.negative_delta, 0);
|
2022-07-13 13:20:50 +03:00
|
|
|
assert.equal(days[1].beyond.monthly.signups, 1);
|
|
|
|
assert.equal(days[1].beyond.monthly.cancellations, 0);
|
2022-05-19 18:59:48 +03:00
|
|
|
|
|
|
|
assert.equal(days[1].beyond.yearly.positive_delta, 1);
|
|
|
|
assert.equal(days[1].beyond.yearly.negative_delta, 0);
|
2022-07-13 13:20:50 +03:00
|
|
|
assert.equal(days[1].beyond.yearly.signups, 1);
|
|
|
|
assert.equal(days[1].beyond.yearly.cancellations, 0);
|
2022-05-19 18:59:48 +03:00
|
|
|
});
|
2022-04-21 17:42:02 +03:00
|
|
|
});
|
|
|
|
});
|