Ghost/ghost/stats-service/lib/subscriptions.js
Simon Backx db717f446f 🐛 Fixed free trials not visible in paid subscriptions graph
fixes https://github.com/TryGhost/Team/issues/2607

When a free trial converts to a paid subscription, and increases the MRR, it just creates a 'updated' paid subscription event.

To fix this, we need to count updated events that didn't change plan but do have a positive MRR. As an extension we could also check if the MRR change matches the expected MRR for the corresponsing Stripe plan, but that requires a more complex condition check (because for yearly subscriptions we need to convert to monthly), I don't think that is required here.
2023-03-10 16:16:17 +01:00

181 lines
6.8 KiB
JavaScript

const moment = require('moment');
class SubscriptionStatsService {
/**
* @param {object} deps
* @param {import('knex').Knex} deps.knex*/
constructor({knex}) {
this.knex = knex;
}
/**
* @returns {Promise<{data: SubscriptionHistoryEntry[]}>}
**/
async getSubscriptionHistory() {
const subscriptionDeltaEntries = await this.fetchAllSubscriptionDeltas();
const counts = await this.fetchSubscriptionCounts();
/** @type {Object.<string, Object.<string, number>>} */
const countData = {};
counts.forEach((count) => {
if (!countData[count.tier]) {
countData[count.tier] = {};
}
countData[count.tier][count.cadence] = count.count;
});
/** @type {SubscriptionHistoryEntry[]} */
let subscriptionHistoryEntries = [];
/** @type {string[]} */
let cadences = [];
/** @type {string[]} */
let tiers = [];
for (let index = subscriptionDeltaEntries.length - 1; index >= 0; index -= 1) {
const entry = subscriptionDeltaEntries[index];
if (!countData[entry.tier]) {
countData[entry.tier] = {};
}
if (!countData[entry.tier][entry.cadence]) {
countData[entry.tier][entry.cadence] = 0;
}
subscriptionHistoryEntries.unshift({
...entry,
date: moment(entry.date).format('YYYY-MM-DD'),
count: countData[entry.tier][entry.cadence]
});
countData[entry.tier][entry.cadence] += entry.negative_delta;
countData[entry.tier][entry.cadence] -= entry.positive_delta;
if (!cadences.includes(entry.cadence)) {
cadences.push(entry.cadence);
}
if (!tiers.includes(entry.tier)) {
tiers.push(entry.tier);
}
}
return {
data: subscriptionHistoryEntries,
meta: {
cadences,
tiers,
totals: counts
}
};
}
/**
* @returns {Promise<SubscriptionDelta[]>}
**/
async fetchAllSubscriptionDeltas() {
const knex = this.knex;
const rows = await knex('members_paid_subscription_events')
.join('stripe_prices AS price', function () {
this.on('price.stripe_price_id', '=', 'members_paid_subscription_events.from_plan')
.orOn('price.stripe_price_id', '=', 'members_paid_subscription_events.to_plan');
})
.join('stripe_products AS product', 'product.stripe_product_id', '=', 'price.stripe_product_id')
.join('products AS tier', 'tier.id', '=', 'product.product_id')
.leftJoin('stripe_prices AS from_price', 'from_price.stripe_price_id', '=', 'members_paid_subscription_events.from_plan')
.leftJoin('stripe_prices AS to_price', 'to_price.stripe_price_id', '=', 'members_paid_subscription_events.to_plan')
.select(knex.raw(`
DATE(members_paid_subscription_events.created_at) as date
`))
.select(knex.raw(`
tier.id as tier
`))
.select(knex.raw(`
price.interval as cadence
`))
.select(knex.raw(`SUM(
CASE
WHEN members_paid_subscription_events.type IN ('created','reactivated','active') AND members_paid_subscription_events.mrr_delta != 0 THEN 1
WHEN members_paid_subscription_events.type='updated' AND price.id = to_price.id THEN 1
WHEN members_paid_subscription_events.type='updated' AND members_paid_subscription_events.from_plan = members_paid_subscription_events.to_plan AND members_paid_subscription_events.mrr_delta > 0 THEN 1
ELSE 0
END
) as positive_delta`))
.select(knex.raw(`SUM(
CASE
WHEN members_paid_subscription_events.type IN ('canceled', 'expired','inactive') AND members_paid_subscription_events.mrr_delta != 0 THEN 1
WHEN members_paid_subscription_events.type='updated' AND price.id = from_price.id THEN 1
ELSE 0
END
) as negative_delta`))
.select(knex.raw(`SUM(
CASE
WHEN members_paid_subscription_events.type IN ('created','reactivated','active') AND members_paid_subscription_events.mrr_delta != 0 THEN 1
WHEN members_paid_subscription_events.type='updated' AND members_paid_subscription_events.from_plan = members_paid_subscription_events.to_plan AND members_paid_subscription_events.mrr_delta > 0 THEN 1
ELSE 0
END
) as signups`))
.select(knex.raw(`SUM(
CASE
WHEN members_paid_subscription_events.type IN ('canceled', 'expired','inactive') AND members_paid_subscription_events.mrr_delta != 0 THEN 1
ELSE 0
END
) as cancellations`))
.groupBy('date', 'tier', 'cadence')
.orderBy('date');
return rows;
}
/**
* Get the current total subscriptions grouped by Cadence and Tier
* @returns {Promise<SubscriptionCount[]>}
**/
async fetchSubscriptionCounts() {
const knex = this.knex;
const data = await knex('members_stripe_customers_subscriptions')
.select(knex.raw(`
COUNT(members_stripe_customers_subscriptions.id) AS count,
products.id AS tier,
stripe_prices.interval AS cadence
`))
.join('stripe_prices', 'stripe_prices.stripe_price_id', '=', 'members_stripe_customers_subscriptions.stripe_price_id')
.join('stripe_products', 'stripe_products.stripe_product_id', '=', 'stripe_prices.stripe_product_id')
.join('products', 'products.id', '=', 'stripe_products.product_id')
.whereNot('members_stripe_customers_subscriptions.mrr', 0)
.groupBy('tier', 'cadence');
return data;
}
}
/** @typedef {object} SubscriptionCount
* @prop {string} tier
* @prop {string} cadence
* @prop {number} count
**/
/**
* @typedef {object} SubscriptionDelta
* @prop {string} tier
* @prop {string} cadence
* @prop {string} date
* @prop {number} positive_delta
* @prop {number} negative_delta
* @prop {number} signups
* @prop {number} cancellations
**/
/**
* @typedef {object} SubscriptionHistoryEntry
* @prop {string} tier
* @prop {string} cadence
* @prop {string} date
* @prop {number} positive_delta
* @prop {number} negative_delta
* @prop {number} signups
* @prop {number} cancellations
* @prop {number} count
**/
module.exports = SubscriptionStatsService;