From d94859f2e57829a7038ff647d74262efc5d3e1b9 Mon Sep 17 00:00:00 2001 From: Fabien 'egg' O'Carroll Date: Wed, 27 Apr 2022 14:53:32 +0100 Subject: [PATCH] Added /stats/subscriptions API (#14547) refs https://github.com/TryGhost/Team/issues/1505 refs https://github.com/TryGhost/Team/issues/1466 Exposes an API for historical counts broken down by tier and cadence. Counts backwards from the current stats like MRR to minimize inaccruate data due to missing/superfluous events. --- core/server/api/canary/stats.js | 9 +++ core/server/web/api/canary/admin/routes.js | 1 + package.json | 2 +- .../admin/__snapshots__/stats.test.js.snap | 58 ++++++++++++++++++- test/e2e-api/admin/stats.test.js | 28 ++++++++- test/utils/fixture-utils.js | 4 ++ test/utils/fixtures/data-generator.js | 49 +++++++++++++++- yarn.lock | 8 +-- 8 files changed, 151 insertions(+), 8 deletions(-) diff --git a/core/server/api/canary/stats.js b/core/server/api/canary/stats.js index bd40c4723c..6c1370ba6d 100644 --- a/core/server/api/canary/stats.js +++ b/core/server/api/canary/stats.js @@ -19,5 +19,14 @@ module.exports = { async query() { return await statsService.getMRRHistory(); } + }, + subscriptions: { + permissions: { + docName: 'members', + method: 'browse' + }, + async query() { + return await statsService.getSubscriptionCountHistory(); + } } }; diff --git a/core/server/web/api/canary/admin/routes.js b/core/server/web/api/canary/admin/routes.js index 3e227bef80..1b7f0e1d38 100644 --- a/core/server/web/api/canary/admin/routes.js +++ b/core/server/web/api/canary/admin/routes.js @@ -140,6 +140,7 @@ module.exports = function apiRoutes() { // ## Stats router.get('/stats/member_count', mw.authAdminApi, http(api.stats.memberCountHistory)); router.get('/stats/mrr', mw.authAdminApi, http(api.stats.mrr)); + router.get('/stats/subscriptions', mw.authAdminApi, http(api.stats.subscriptions)); // ## Labels router.get('/labels', mw.authAdminApi, http(api.labels.browse)); diff --git a/package.json b/package.json index 12942b13cc..f5461be939 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "@tryghost/session-service": "0.1.39", "@tryghost/settings-path-manager": "0.1.5", "@tryghost/social-urls": "0.1.29", - "@tryghost/stats-service": "0.1.0", + "@tryghost/stats-service": "0.2.1", "@tryghost/string": "0.1.23", "@tryghost/tpl": "0.1.16", "@tryghost/update-check-service": "0.3.2", diff --git a/test/e2e-api/admin/__snapshots__/stats.test.js.snap b/test/e2e-api/admin/__snapshots__/stats.test.js.snap index daf9aa3e62..da460eb212 100644 --- a/test/e2e-api/admin/__snapshots__/stats.test.js.snap +++ b/test/e2e-api/admin/__snapshots__/stats.test.js.snap @@ -11,6 +11,11 @@ Object { ], }, "stats": Array [ + Object { + "currency": "usd", + "date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "mrr": 0, + }, Object { "currency": "usd", "date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, @@ -24,7 +29,7 @@ exports[`Stats API Can fetch MRR history 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "111", + "content-length": "158", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", @@ -65,3 +70,54 @@ Object { "x-powered-by": "Express", } `; + +exports[`Stats API Can fetch subscriptions history 1: [body] 1`] = ` +Object { + "meta": Object { + "cadences": Array [ + "year", + "month", + ], + "tiers": Array [ + StringMatching /\\[a-f0-9\\]\\{24\\}/, + ], + "totals": Array [ + Object { + "cadence": "month", + "count": 1, + "tier": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + }, + "stats": Array [ + Object { + "cadence": "month", + "count": 1, + "date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "negative_delta": 0, + "positive_delta": 1, + "tier": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "cadence": "year", + "count": 0, + "date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "negative_delta": 0, + "positive_delta": 0, + "tier": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], +} +`; + +exports[`Stats API Can fetch subscriptions history 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "403", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/test/e2e-api/admin/stats.test.js b/test/e2e-api/admin/stats.test.js index cf82f99414..b3185d7cc8 100644 --- a/test/e2e-api/admin/stats.test.js +++ b/test/e2e-api/admin/stats.test.js @@ -1,5 +1,5 @@ const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-framework'); -const {anyEtag, anyISODate} = matchers; +const {anyEtag, anyISODate, anyObjectId} = matchers; let agent; @@ -31,10 +31,36 @@ describe('Stats API', function () { .matchBodySnapshot({ stats: [{ date: anyISODate + }, { + date: anyISODate }] }) .matchHeaderSnapshot({ etag: anyEtag }); }); + + it('Can fetch subscriptions history', async function () { + await agent + .get(`/stats/subscriptions`) + .expectStatus(200) + .matchBodySnapshot({ + stats: [{ + date: anyISODate, + tier: anyObjectId + }, { + date: anyISODate, + tier: anyObjectId + }], + meta: { + tiers: [anyObjectId], + totals: [{ + tier: anyObjectId + }] + } + }) + .matchHeaderSnapshot({ + etag: anyEtag + }); + }); }); diff --git a/test/utils/fixture-utils.js b/test/utils/fixture-utils.js index 64e0befd6d..ce266381be 100644 --- a/test/utils/fixture-utils.js +++ b/test/utils/fixture-utils.js @@ -562,6 +562,10 @@ const fixtures = { })}, {id: member.id}); } } + }).then(async function () { + for (const event of DataGenerator.forKnex.members_paid_subscription_events) { + await models.MemberPaidSubscriptionEvent.add(event); + } }); }, diff --git a/test/utils/fixtures/data-generator.js b/test/utils/fixtures/data-generator.js index 6f3a72fe40..26877040e5 100644 --- a/test/utils/fixtures/data-generator.js +++ b/test/utils/fixtures/data-generator.js @@ -492,6 +492,42 @@ DataGenerator.Content = { } ], + members_paid_subscription_events: [ + { + id: ObjectId().toHexString(), + type: 'created', + mrr_delta: 1000, + currency: 'usd', + source: 'stripe', + created_at: null, + subscription_id: null, + member_id: null, + from_plan: null, + to_plan: '173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730bb8' + }, { + id: ObjectId().toHexString(), + type: 'created', + mrr_delta: 0, + currency: 'usd', + source: 'stripe', + created_at: null, + subscription_id: null, + member_id: null, + from_plan: null, + to_plan: '173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730bb9' + }, { + id: ObjectId().toHexString(), + type: 'created', + mrr_delta: 0, + currency: 'usd', + source: 'stripe', + created_at: null, + subscription_id: null, + member_id: null, + from_plan: null, + to_plan: '173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730ba0' + } + ], members_stripe_customers_subscriptions: [ { id: ObjectId().toHexString(), @@ -780,6 +816,9 @@ DataGenerator.Content.members_stripe_customers[1].member_id = DataGenerator.Cont DataGenerator.Content.members_stripe_customers[2].member_id = DataGenerator.Content.members[4].id; DataGenerator.Content.members_stripe_customers[3].member_id = DataGenerator.Content.members[6].id; DataGenerator.Content.members_stripe_customers[4].member_id = DataGenerator.Content.members[7].id; +DataGenerator.Content.members_paid_subscription_events[0].member_id = DataGenerator.Content.members[2].id; +DataGenerator.Content.members_paid_subscription_events[1].member_id = DataGenerator.Content.members[3].id; +DataGenerator.Content.members_paid_subscription_events[2].member_id = DataGenerator.Content.members[4].id; DataGenerator.forKnex = (function () { function createBasic(overrides) { @@ -1473,6 +1512,12 @@ DataGenerator.forKnex = (function () { createBasic(DataGenerator.Content.members_stripe_customers_subscriptions[2]) ]; + const members_paid_subscription_events = [ + createBasic(DataGenerator.Content.members_paid_subscription_events[0]), + createBasic(DataGenerator.Content.members_paid_subscription_events[1]), + createBasic(DataGenerator.Content.members_paid_subscription_events[2]) + ]; + const snippets = [ createBasic(DataGenerator.Content.snippets[0]) ]; @@ -1537,7 +1582,9 @@ DataGenerator.forKnex = (function () { stripe_prices, stripe_products, snippets, - custom_theme_settings + custom_theme_settings, + + members_paid_subscription_events }; }()); diff --git a/yarn.lock b/yarn.lock index f52cdfd0a5..0211befe66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2447,10 +2447,10 @@ resolved "https://registry.yarnpkg.com/@tryghost/social-urls/-/social-urls-0.1.29.tgz#09177858959e9521244d0192bf219237f0fb6094" integrity sha512-PUgSQj/Y8x4Sbp0/Lq/Pq6ZN8ICSoAIHpVouJwblW3LvY/C0eeDrDLQMrUmR2SSBx59mS+Blavwy5mFlt23lwg== -"@tryghost/stats-service@0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@tryghost/stats-service/-/stats-service-0.1.0.tgz#5638ac07b6de6de40ea4f7a9963e832530eef2e2" - integrity sha512-YJRdomtJ8y3f0UDvYjSnK4/RMu5xvZoMvtFAZfxiDC1d0yKA4YostxFQHAKh32t2y0FNO6IJzO7yJ5HnUXjueg== +"@tryghost/stats-service@0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@tryghost/stats-service/-/stats-service-0.2.1.tgz#d9b0488d705b9a697dc9ebff9ec8e64b33902649" + integrity sha512-2w2C8f9BR7QjcyvgPLQmzNW+cDHT6eceCMa8Ihp3PoQtcikE8TKAOx1cwlEDxs+0Blf5BQ8HILA2bJYsfsylAg== dependencies: moment "^2.29.3"