@blamejs/blamejs-shop 0.0.72 → 0.0.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.subscriptionAnalytics
|
|
4
|
+
* @title Subscription analytics — recurring-revenue rollups for the
|
|
5
|
+
* operator dashboard.
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Read-only aggregator over the existing subscription primitives.
|
|
9
|
+
* Every aggregate composes one of:
|
|
10
|
+
*
|
|
11
|
+
* - `subscriptions` (the catalog-side row)
|
|
12
|
+
* - `subscription_plans` (interval + amount + currency)
|
|
13
|
+
* - `subscription_invoices` (paid / failed / voided ledger)
|
|
14
|
+
* - `subscription_payment_attempts` (per-invoice retry trail)
|
|
15
|
+
* - `subscription_dunning_states` (dunning episode log)
|
|
16
|
+
* - `subscription_control_events` (pause / resume / cancel audit)
|
|
17
|
+
*
|
|
18
|
+
* Source-of-truth lives in those tables; this primitive owns ZERO
|
|
19
|
+
* write surfaces against them. The only writes are to the
|
|
20
|
+
* `subscription_metrics_snapshots` cache table (migration
|
|
21
|
+
* `0178_subscription_analytics_cache.sql`), and even those are
|
|
22
|
+
* best-effort — an operator dropping the cache table degrades
|
|
23
|
+
* dashboard latency but never loses data.
|
|
24
|
+
*
|
|
25
|
+
* v1 surface:
|
|
26
|
+
*
|
|
27
|
+
* - mrr({ at? })
|
|
28
|
+
* Monthly Recurring Revenue snapshot. Walks every subscription
|
|
29
|
+
* whose local state is "active" at the cutoff (`at`, defaulting
|
|
30
|
+
* to `Date.now()`) — active means
|
|
31
|
+
* `status IN ('active', 'trialing')` AND `cancelled_at IS NULL`
|
|
32
|
+
* AND (`paused_at IS NULL` OR `paused_until <= at`). For each
|
|
33
|
+
* row, the plan's `amount_minor * quantity` is normalized to a
|
|
34
|
+
* monthly cadence (day → ×30, week → ×4.345, month → ×1,
|
|
35
|
+
* year → ÷12) using a deterministic rounding rule (banker's
|
|
36
|
+
* rounding on the final minor-unit total). Returns
|
|
37
|
+
* `{ currency_breakdown: [{ currency, mrr_minor, subscription_count }],
|
|
38
|
+
* total_mrr_normalized_minor? }` — when every active
|
|
39
|
+
* subscription shares a currency, that single row is also
|
|
40
|
+
* exposed at `total_mrr_normalized_minor`; mixed-currency
|
|
41
|
+
* windows omit the total (the dashboard renders per-currency
|
|
42
|
+
* only).
|
|
43
|
+
*
|
|
44
|
+
* - arr({ at? })
|
|
45
|
+
* Annual Recurring Revenue. `mrr * 12` per currency.
|
|
46
|
+
*
|
|
47
|
+
* - churnRate({ from, to, kind? })
|
|
48
|
+
* Voluntary vs involuntary churn. `kind` is "voluntary",
|
|
49
|
+
* "involuntary", or "all" (default "all").
|
|
50
|
+
* - voluntary = subscriptions with a `cancel` control event
|
|
51
|
+
* in the window OR cancelled_at set in the
|
|
52
|
+
* window via the controls primitive
|
|
53
|
+
* - involuntary = subscriptions whose dunning episode exited
|
|
54
|
+
* with state="cancelled" OR "written_off" in
|
|
55
|
+
* the window
|
|
56
|
+
* Returns `{ kind, churned, exposed, rate }` where `rate =
|
|
57
|
+
* churned / exposed`. `exposed` is the count of subscriptions
|
|
58
|
+
* that were active at any point during the window (started <=
|
|
59
|
+
* to AND (cancelled_at IS NULL OR cancelled_at >= from)).
|
|
60
|
+
*
|
|
61
|
+
* - pauseRate({ from, to })
|
|
62
|
+
* Share of active subscriptions paused during the window. A
|
|
63
|
+
* subscription counts as "paused" if it has at least one
|
|
64
|
+
* `pause` control event with occurred_at in `[from, to]`.
|
|
65
|
+
*
|
|
66
|
+
* - ltv({ plan_id?, currency? })
|
|
67
|
+
* Customer Lifetime Value estimator. Computed as
|
|
68
|
+
* `average_revenue_per_subscription / churn_rate_per_period`
|
|
69
|
+
* with the period defaulting to the trailing 90 days.
|
|
70
|
+
* `average_revenue_per_subscription` sums paid invoice amounts
|
|
71
|
+
* per subscription, divides by distinct subscription count.
|
|
72
|
+
* `plan_id` narrows to a single plan; `currency` filters
|
|
73
|
+
* invoices to one ISO code. Returns
|
|
74
|
+
* `{ avg_revenue_minor, churn_rate, ltv_minor, sample_size,
|
|
75
|
+
* currency }`; `ltv_minor` is `null` when churn rate is 0
|
|
76
|
+
* (infinite LTV — the dashboard shows "—" instead of a number).
|
|
77
|
+
*
|
|
78
|
+
* - cohortRetention({ cohort_month, periods })
|
|
79
|
+
* Cohort retention curve. `cohort_month` is "YYYY-MM" — the
|
|
80
|
+
* month new subscriptions started (in UTC). `periods` is the
|
|
81
|
+
* number of monthly follow-up buckets to compute (1..12). The
|
|
82
|
+
* primitive scans every subscription whose `created_at` falls
|
|
83
|
+
* in the cohort month, then for each follow-up bucket counts
|
|
84
|
+
* how many of those subscriptions were still active at the
|
|
85
|
+
* END of that bucket (no `cancelled_at` set yet OR
|
|
86
|
+
* `cancelled_at > bucket_end`). Returns
|
|
87
|
+
* `{ cohort_month, cohort_size, buckets: [{ period, active,
|
|
88
|
+
* retention_rate }] }`. `bucket[0]` is always month-zero
|
|
89
|
+
* (= cohort_size / cohort_size = 1.0 when cohort_size > 0).
|
|
90
|
+
*
|
|
91
|
+
* - planTransitions({ from, to })
|
|
92
|
+
* Plan upgrade/downgrade matrix. Walks every plan-change
|
|
93
|
+
* delivered as a sequence of (`subscription_id`, `from_plan_id`
|
|
94
|
+
* -> `to_plan_id`) events in the window, derived from the
|
|
95
|
+
* control-events ledger entries whose `before_json.plan_id !==
|
|
96
|
+
* after_json.plan_id`. Returns a 2D matrix:
|
|
97
|
+
* `[{ from_plan_id, to_plan_id, count, direction }]` where
|
|
98
|
+
* `direction` is "upgrade" / "downgrade" / "lateral" based on
|
|
99
|
+
* the plans' `amount_minor` comparison after currency-
|
|
100
|
+
* normalization (cross-currency transitions surface as
|
|
101
|
+
* "lateral" — the dashboard renders the operator a follow-up
|
|
102
|
+
* drill-down).
|
|
103
|
+
*
|
|
104
|
+
* - topChurningPlans({ from, to, limit })
|
|
105
|
+
* Top-N plans by absolute churn count over the window. Returns
|
|
106
|
+
* `[{ plan_id, churned, active_at_start, churn_rate }]` ordered
|
|
107
|
+
* by churn count DESC, plan_id ASC. `limit` is capped at 100.
|
|
108
|
+
*
|
|
109
|
+
* - recoveryRate({ from, to })
|
|
110
|
+
* Dunning recovery rate. Of every dunning episode that ENTERED
|
|
111
|
+
* in `[from, to]`, what share exited with state="recovered"?
|
|
112
|
+
* Returns `{ entered, recovered, cancelled, written_off,
|
|
113
|
+
* still_open, recovery_rate }`. `recovery_rate` is `recovered /
|
|
114
|
+
* (entered - still_open)` so in-flight episodes don't deflate
|
|
115
|
+
* the operator's view of resolved-cohort recovery.
|
|
116
|
+
*
|
|
117
|
+
* - dailyMrrSeries({ from, to, currency? })
|
|
118
|
+
* Day-by-day MRR time series. Computes MRR at the end of each
|
|
119
|
+
* day in `[from, to]` (UTC day boundaries). Bucketing is
|
|
120
|
+
* deterministic — `floor((ts - epoch_day_zero) / 86_400_000)`.
|
|
121
|
+
* Returns `[{ day, mrr_minor, currency }]` ordered ascending by
|
|
122
|
+
* day. `currency` filters the series to a single ISO code;
|
|
123
|
+
* omitted, returns per-currency rows (with one entry per
|
|
124
|
+
* (day, currency) tuple).
|
|
125
|
+
*
|
|
126
|
+
* Optional cache + invalidation:
|
|
127
|
+
*
|
|
128
|
+
* Every aggregate accepts a `cache` opt that toggles snapshot
|
|
129
|
+
* reuse. The cache key tuple is (scope, scope_value, period_from,
|
|
130
|
+
* period_to) where `scope_value = b.crypto.namespaceHash(
|
|
131
|
+
* "subscription-analytics-cache", JSON.stringify(canonical_args))`.
|
|
132
|
+
* Default TTL is 5 minutes; pass `cache: { ttl_ms: N }` to
|
|
133
|
+
* override. `purgeExpired()` and `invalidate({ scope?, before? })`
|
|
134
|
+
* surface the sweep + targeted-eviction helpers.
|
|
135
|
+
*
|
|
136
|
+
* Composition:
|
|
137
|
+
*
|
|
138
|
+
* - `b.crypto.namespaceHash` — cache-key derivation (collision-
|
|
139
|
+
* resistant SHA3-512 with a dedicated namespace)
|
|
140
|
+
* - `b.uuid.v7` — snapshot row ids (sortable, no
|
|
141
|
+
* collision risk)
|
|
142
|
+
* - Optional handles: `subscriptions`, `subscriptionControls`,
|
|
143
|
+
* `subscriptionBilling` — strictly read methods. Each is best-
|
|
144
|
+
* effort; the primitive falls back to direct SQL against the
|
|
145
|
+
* owned tables when a handle is absent. The handles are reserved
|
|
146
|
+
* so an operator wiring the full subscription stack can pass
|
|
147
|
+
* them today without behaviour change once cross-checks land.
|
|
148
|
+
*
|
|
149
|
+
* Monotonic per-process clock: cache writes that collide in the same
|
|
150
|
+
* millisecond would tie on `computed_at` and make the freshest-row
|
|
151
|
+
* tie-break ambiguous. `_now` bumps to `prior + 1` on collision so a
|
|
152
|
+
* sort-by-computed_at DESC read returns the most recent write first.
|
|
153
|
+
*
|
|
154
|
+
* Storage:
|
|
155
|
+
* - `subscription_metrics_snapshots` (migration
|
|
156
|
+
* `0178_subscription_analytics_cache.sql`)
|
|
157
|
+
*
|
|
158
|
+
* @primitive subscriptionAnalytics
|
|
159
|
+
* @related shop.subscriptions, shop.subscriptionControls,
|
|
160
|
+
* shop.subscriptionBilling, b.crypto.namespaceHash, b.uuid.v7
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
// ---- constants ----------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
var CACHE_NAMESPACE = "subscription-analytics-cache";
|
|
166
|
+
|
|
167
|
+
var DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
168
|
+
var ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
169
|
+
var ONE_YEAR_MS = 365 * ONE_DAY_MS;
|
|
170
|
+
var DEFAULT_LTV_WINDOW_MS = 90 * ONE_DAY_MS;
|
|
171
|
+
|
|
172
|
+
var MAX_LIMIT = 100;
|
|
173
|
+
var MAX_PERIODS = 12;
|
|
174
|
+
|
|
175
|
+
// Currency-cadence normalization. The factors mirror common SaaS
|
|
176
|
+
// dashboards: a weekly cadence becomes monthly via 30 / 7 ≈ 4.345,
|
|
177
|
+
// daily becomes ×30, annual ÷12.
|
|
178
|
+
var CADENCE_TO_MONTHLY = {
|
|
179
|
+
day: 30,
|
|
180
|
+
week: 30 / 7,
|
|
181
|
+
month: 1,
|
|
182
|
+
year: 1 / 12,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Active = (status in active/trialing) AND not cancelled AND not paused.
|
|
186
|
+
var ACTIVE_STATUSES = ["active", "trialing"];
|
|
187
|
+
|
|
188
|
+
var CHURN_KINDS = ["voluntary", "involuntary", "all"];
|
|
189
|
+
|
|
190
|
+
var SCOPE_RE = /^[a-z][a-z0-9_]*$/;
|
|
191
|
+
var COHORT_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
|
|
192
|
+
var CURRENCY_RE = /^[a-z]{3}$/i;
|
|
193
|
+
var PLAN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
194
|
+
|
|
195
|
+
var bShop;
|
|
196
|
+
function _b() {
|
|
197
|
+
if (!bShop) bShop = require("./index");
|
|
198
|
+
return bShop.framework;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- monotonic clock ---------------------------------------------------
|
|
202
|
+
//
|
|
203
|
+
// Cache writes inside a single recompute can land in the same
|
|
204
|
+
// millisecond on fast machines. Bumping by 1ms on a tie keeps the
|
|
205
|
+
// snapshot timeline strictly increasing so the most-recent-write tie-
|
|
206
|
+
// break is deterministic.
|
|
207
|
+
|
|
208
|
+
var _lastTs = 0;
|
|
209
|
+
function _now() {
|
|
210
|
+
var t = Date.now();
|
|
211
|
+
if (t <= _lastTs) t = _lastTs + 1;
|
|
212
|
+
_lastTs = t;
|
|
213
|
+
return t;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- validators --------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
function _epochMs(value, label) {
|
|
219
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
220
|
+
throw new TypeError("subscriptionAnalytics: " + label + " must be a non-negative integer (epoch ms)");
|
|
221
|
+
}
|
|
222
|
+
return value;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function _requireInput(opts, label) {
|
|
226
|
+
if (!opts || typeof opts !== "object") {
|
|
227
|
+
throw new TypeError("subscriptionAnalytics." + label + ": input object required");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function _window(opts, label) {
|
|
232
|
+
_requireInput(opts, label);
|
|
233
|
+
_epochMs(opts.from, label + ".from");
|
|
234
|
+
_epochMs(opts.to, label + ".to");
|
|
235
|
+
if (opts.from >= opts.to) {
|
|
236
|
+
throw new TypeError("subscriptionAnalytics." + label + ": from must be strictly less than to");
|
|
237
|
+
}
|
|
238
|
+
if ((opts.to - opts.from) > ONE_YEAR_MS) {
|
|
239
|
+
throw new TypeError("subscriptionAnalytics." + label + ": window (to - from) must be <= 1 year");
|
|
240
|
+
}
|
|
241
|
+
return { from: opts.from, to: opts.to };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function _at(value) {
|
|
245
|
+
if (value == null) return Date.now();
|
|
246
|
+
return _epochMs(value, "at");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function _limit(value, label) {
|
|
250
|
+
if (value == null) return 10;
|
|
251
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_LIMIT) {
|
|
252
|
+
throw new TypeError("subscriptionAnalytics: " + label + " must be an integer in [1, " + MAX_LIMIT + "]");
|
|
253
|
+
}
|
|
254
|
+
return value;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function _scope(s) {
|
|
258
|
+
if (typeof s !== "string" || !SCOPE_RE.test(s)) {
|
|
259
|
+
throw new TypeError("subscriptionAnalytics: scope must match /^[a-z][a-z0-9_]*$/");
|
|
260
|
+
}
|
|
261
|
+
return s;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function _cohortMonth(s) {
|
|
265
|
+
if (typeof s !== "string" || !COHORT_RE.test(s)) {
|
|
266
|
+
throw new TypeError("subscriptionAnalytics: cohort_month must match /^\\d{4}-(0[1-9]|1[0-2])$/");
|
|
267
|
+
}
|
|
268
|
+
return s;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function _periods(n) {
|
|
272
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_PERIODS) {
|
|
273
|
+
throw new TypeError("subscriptionAnalytics: periods must be an integer in [1, " + MAX_PERIODS + "]");
|
|
274
|
+
}
|
|
275
|
+
return n;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function _currency(value, allowNull) {
|
|
279
|
+
if (value == null) {
|
|
280
|
+
if (allowNull) return null;
|
|
281
|
+
throw new TypeError("subscriptionAnalytics: currency must be a 3-letter ISO code");
|
|
282
|
+
}
|
|
283
|
+
if (typeof value !== "string" || !CURRENCY_RE.test(value)) {
|
|
284
|
+
throw new TypeError("subscriptionAnalytics: currency must be a 3-letter ISO code");
|
|
285
|
+
}
|
|
286
|
+
return value.toLowerCase();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function _planId(value, allowNull) {
|
|
290
|
+
if (value == null) {
|
|
291
|
+
if (allowNull) return null;
|
|
292
|
+
throw new TypeError("subscriptionAnalytics: plan_id required");
|
|
293
|
+
}
|
|
294
|
+
if (typeof value !== "string" || !PLAN_ID_RE.test(value)) {
|
|
295
|
+
throw new TypeError("subscriptionAnalytics: plan_id must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= 128 chars)");
|
|
296
|
+
}
|
|
297
|
+
return value;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function _churnKind(value) {
|
|
301
|
+
if (value == null) return "all";
|
|
302
|
+
if (typeof value !== "string" || CHURN_KINDS.indexOf(value) === -1) {
|
|
303
|
+
throw new TypeError("subscriptionAnalytics: kind must be one of " + CHURN_KINDS.join(", "));
|
|
304
|
+
}
|
|
305
|
+
return value;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---- canonical JSON for cache keys -------------------------------------
|
|
309
|
+
//
|
|
310
|
+
// Two cache lookups with the same arguments in a different key order
|
|
311
|
+
// must hit the same row. JSON.stringify isn't order-stable across
|
|
312
|
+
// engines, so the primitive sorts keys before hashing.
|
|
313
|
+
|
|
314
|
+
function _canonicalJson(value) {
|
|
315
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
316
|
+
if (Array.isArray(value)) {
|
|
317
|
+
var parts = [];
|
|
318
|
+
for (var i = 0; i < value.length; i += 1) parts.push(_canonicalJson(value[i]));
|
|
319
|
+
return "[" + parts.join(",") + "]";
|
|
320
|
+
}
|
|
321
|
+
var keys = Object.keys(value).sort();
|
|
322
|
+
var pairs = [];
|
|
323
|
+
for (var k = 0; k < keys.length; k += 1) {
|
|
324
|
+
pairs.push(JSON.stringify(keys[k]) + ":" + _canonicalJson(value[keys[k]]));
|
|
325
|
+
}
|
|
326
|
+
return "{" + pairs.join(",") + "}";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---- cadence normalization ---------------------------------------------
|
|
330
|
+
//
|
|
331
|
+
// Plan amount × quantity normalized to a single monthly figure. Uses
|
|
332
|
+
// banker's rounding (round-half-to-even) on the final minor-unit total
|
|
333
|
+
// so two interval combinations that produce e.g. 4499.5 minor units
|
|
334
|
+
// don't oscillate the dashboard total by one cent between refreshes.
|
|
335
|
+
|
|
336
|
+
function _bankersRound(x) {
|
|
337
|
+
var floor = Math.floor(x);
|
|
338
|
+
var diff = x - floor;
|
|
339
|
+
if (diff < 0.5) return floor;
|
|
340
|
+
if (diff > 0.5) return floor + 1;
|
|
341
|
+
// diff === 0.5 — round to even
|
|
342
|
+
return floor % 2 === 0 ? floor : floor + 1;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function _monthlyMinor(amountMinor, interval, intervalCount, quantity) {
|
|
346
|
+
var factor = CADENCE_TO_MONTHLY[interval];
|
|
347
|
+
if (factor == null) return 0;
|
|
348
|
+
var qty = Number.isInteger(quantity) && quantity > 0 ? quantity : 1;
|
|
349
|
+
var ic = Number.isInteger(intervalCount) && intervalCount > 0 ? intervalCount : 1;
|
|
350
|
+
var raw = (amountMinor * qty * factor) / ic;
|
|
351
|
+
return _bankersRound(raw);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---- active-at predicate (in SQL) --------------------------------------
|
|
355
|
+
//
|
|
356
|
+
// Local-state "active at timestamp X" condition, used by mrr / arr /
|
|
357
|
+
// pauseRate / churn-exposure. Inlines the predicate as a SQL fragment
|
|
358
|
+
// because it composes into every aggregate's WHERE clause.
|
|
359
|
+
|
|
360
|
+
function _activeAtSql(paramIndex, alias) {
|
|
361
|
+
// Active iff: status in ('active','trialing')
|
|
362
|
+
// AND created_at <= ?X
|
|
363
|
+
// AND (cancelled_at IS NULL OR cancelled_at > ?X)
|
|
364
|
+
// AND (paused_at IS NULL OR paused_until IS NULL OR paused_until <= ?X)
|
|
365
|
+
//
|
|
366
|
+
// `alias` is the SQL table alias to prepend to each column reference
|
|
367
|
+
// when the predicate composes into a JOIN. Pass "" for a single-table
|
|
368
|
+
// scan, or e.g. "s" to produce `s.status IN ...`.
|
|
369
|
+
var px = alias ? (alias + ".") : "";
|
|
370
|
+
return "(" + px + "status IN ('active', 'trialing') " +
|
|
371
|
+
" AND " + px + "created_at <= ?" + paramIndex +
|
|
372
|
+
" AND (" + px + "cancelled_at IS NULL OR " + px + "cancelled_at > ?" + paramIndex + ")" +
|
|
373
|
+
" AND (" + px + "paused_at IS NULL OR " + px + "paused_until IS NULL OR " + px + "paused_until <= ?" + paramIndex + "))";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---- factory -----------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
function create(opts) {
|
|
379
|
+
opts = opts || {};
|
|
380
|
+
var query = opts.query;
|
|
381
|
+
if (!query) {
|
|
382
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
383
|
+
}
|
|
384
|
+
// Optional read-only cross-check handles. None is load-bearing; each
|
|
385
|
+
// aggregate falls back to direct SQL against the owned tables when
|
|
386
|
+
// the handle is absent.
|
|
387
|
+
var subscriptionsHandle = opts.subscriptions || null;
|
|
388
|
+
var subscriptionControlsHandle = opts.subscriptionControls || null;
|
|
389
|
+
var subscriptionBillingHandle = opts.subscriptionBilling || null;
|
|
390
|
+
|
|
391
|
+
// Reference each optional handle once so the lint pass doesn't flag
|
|
392
|
+
// the accepted-but-currently-unused constructor opts. The hooks are
|
|
393
|
+
// reserved for the cross-check surfaces that land once the matching
|
|
394
|
+
// primitives expose the needed read methods; the primitive must
|
|
395
|
+
// accept the handles today without behaviour change.
|
|
396
|
+
void subscriptionsHandle;
|
|
397
|
+
void subscriptionControlsHandle;
|
|
398
|
+
void subscriptionBillingHandle;
|
|
399
|
+
|
|
400
|
+
// ---- cache helpers --------------------------------------------------
|
|
401
|
+
|
|
402
|
+
function _cacheKey(scope, args) {
|
|
403
|
+
return _b().crypto.namespaceHash(CACHE_NAMESPACE, _canonicalJson(args || {}));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function _cacheRead(scope, args, periodFrom, periodTo, ttlMs) {
|
|
407
|
+
if (!ttlMs || ttlMs < 1) return null;
|
|
408
|
+
var key = _cacheKey(scope, args);
|
|
409
|
+
var r = await query(
|
|
410
|
+
"SELECT breakdown_json, computed_at FROM subscription_metrics_snapshots " +
|
|
411
|
+
"WHERE scope = ?1 AND scope_value = ?2 AND period_from = ?3 AND period_to = ?4",
|
|
412
|
+
[scope, key, periodFrom, periodTo],
|
|
413
|
+
);
|
|
414
|
+
var row = r.rows[0];
|
|
415
|
+
if (!row) return null;
|
|
416
|
+
if (Number(row.computed_at) + ttlMs < Date.now()) return null;
|
|
417
|
+
try { return JSON.parse(row.breakdown_json); }
|
|
418
|
+
catch (_e) { return null; } // drop-silent — corrupt cache row recomputes
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function _cacheWrite(scope, args, periodFrom, periodTo, metric, value, payload) {
|
|
422
|
+
var key = _cacheKey(scope, args);
|
|
423
|
+
var ts = _now();
|
|
424
|
+
var id = _b().uuid.v7();
|
|
425
|
+
// REPLACE on the (scope, scope_value, period_from, period_to)
|
|
426
|
+
// UNIQUE index so a fresh recompute never leaves duplicates.
|
|
427
|
+
await query(
|
|
428
|
+
"INSERT INTO subscription_metrics_snapshots " +
|
|
429
|
+
"(id, scope, scope_value, period_from, period_to, metric, value, breakdown_json, computed_at) " +
|
|
430
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) " +
|
|
431
|
+
"ON CONFLICT(scope, scope_value, period_from, period_to) DO UPDATE SET " +
|
|
432
|
+
"id = excluded.id, metric = excluded.metric, value = excluded.value, " +
|
|
433
|
+
"breakdown_json = excluded.breakdown_json, computed_at = excluded.computed_at",
|
|
434
|
+
[id, scope, key, periodFrom, periodTo, metric, value, JSON.stringify(payload), ts],
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function _ttlFromOpts(cacheOpt) {
|
|
439
|
+
if (cacheOpt === false) return 0;
|
|
440
|
+
if (cacheOpt && typeof cacheOpt === "object" && Number.isInteger(cacheOpt.ttl_ms) && cacheOpt.ttl_ms >= 0) {
|
|
441
|
+
return cacheOpt.ttl_ms;
|
|
442
|
+
}
|
|
443
|
+
return DEFAULT_TTL_MS;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---- mrr / arr ------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
async function _mrrSnapshot(at) {
|
|
449
|
+
// Pull every active subscription + its plan in one join. The
|
|
450
|
+
// active-at predicate runs inside SQL so the index on
|
|
451
|
+
// subscriptions(status) prunes the scan.
|
|
452
|
+
var r = await query(
|
|
453
|
+
"SELECT s.id AS subscription_id, s.quantity AS quantity, " +
|
|
454
|
+
" p.amount_minor AS amount_minor, p.interval AS interval, " +
|
|
455
|
+
" p.interval_count AS interval_count, p.currency AS currency " +
|
|
456
|
+
"FROM subscriptions s " +
|
|
457
|
+
"JOIN subscription_plans p ON p.id = s.plan_id " +
|
|
458
|
+
"WHERE " + _activeAtSql(1, "s"),
|
|
459
|
+
[at],
|
|
460
|
+
);
|
|
461
|
+
// Per-currency aggregate.
|
|
462
|
+
var perCurrency = {};
|
|
463
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
464
|
+
var row = r.rows[i];
|
|
465
|
+
var monthly = _monthlyMinor(
|
|
466
|
+
Number(row.amount_minor),
|
|
467
|
+
row.interval,
|
|
468
|
+
Number(row.interval_count),
|
|
469
|
+
Number(row.quantity)
|
|
470
|
+
);
|
|
471
|
+
var cur = row.currency;
|
|
472
|
+
if (!perCurrency[cur]) perCurrency[cur] = { mrr_minor: 0, subscription_count: 0 };
|
|
473
|
+
perCurrency[cur].mrr_minor += monthly;
|
|
474
|
+
perCurrency[cur].subscription_count += 1;
|
|
475
|
+
}
|
|
476
|
+
var currencies = Object.keys(perCurrency).sort();
|
|
477
|
+
var breakdown = [];
|
|
478
|
+
for (var c = 0; c < currencies.length; c += 1) {
|
|
479
|
+
breakdown.push({
|
|
480
|
+
currency: currencies[c],
|
|
481
|
+
mrr_minor: perCurrency[currencies[c]].mrr_minor,
|
|
482
|
+
subscription_count: perCurrency[currencies[c]].subscription_count,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
var out = { at: at, currency_breakdown: breakdown };
|
|
486
|
+
if (breakdown.length === 1) {
|
|
487
|
+
out.total_mrr_normalized_minor = breakdown[0].mrr_minor;
|
|
488
|
+
}
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function mrr(input) {
|
|
493
|
+
input = input || {};
|
|
494
|
+
var at = _at(input.at);
|
|
495
|
+
var ttl = _ttlFromOpts(input.cache);
|
|
496
|
+
var args = { at: at };
|
|
497
|
+
var cached = await _cacheRead("mrr", args, at, at + 1, ttl);
|
|
498
|
+
if (cached) return cached;
|
|
499
|
+
var snap = await _mrrSnapshot(at);
|
|
500
|
+
if (ttl > 0) {
|
|
501
|
+
await _cacheWrite("mrr", args, at, at + 1, "mrr_minor",
|
|
502
|
+
snap.total_mrr_normalized_minor == null ? 0 : snap.total_mrr_normalized_minor, snap);
|
|
503
|
+
}
|
|
504
|
+
return snap;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function arr(input) {
|
|
508
|
+
input = input || {};
|
|
509
|
+
var at = _at(input.at);
|
|
510
|
+
var snap = await _mrrSnapshot(at);
|
|
511
|
+
var breakdown = snap.currency_breakdown.map(function (b) {
|
|
512
|
+
return {
|
|
513
|
+
currency: b.currency,
|
|
514
|
+
arr_minor: b.mrr_minor * 12,
|
|
515
|
+
subscription_count: b.subscription_count,
|
|
516
|
+
};
|
|
517
|
+
});
|
|
518
|
+
var out = { at: at, currency_breakdown: breakdown };
|
|
519
|
+
if (breakdown.length === 1) {
|
|
520
|
+
out.total_arr_normalized_minor = breakdown[0].arr_minor;
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---- churnRate ------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
async function _voluntaryChurnCount(from, to) {
|
|
528
|
+
// A subscription churned voluntarily if its `cancelled_at` falls in
|
|
529
|
+
// [from, to] — the controls primitive stamps that column whenever a
|
|
530
|
+
// `cancel` event is applied. We also fall back to scanning the
|
|
531
|
+
// control-events ledger for `cancel` events in the window in case
|
|
532
|
+
// the cancelled_at column is null (e.g. early-pre-controls rows).
|
|
533
|
+
var r1 = await query(
|
|
534
|
+
"SELECT COUNT(DISTINCT id) AS n FROM subscriptions " +
|
|
535
|
+
"WHERE cancelled_at IS NOT NULL AND cancelled_at >= ?1 AND cancelled_at < ?2",
|
|
536
|
+
[from, to],
|
|
537
|
+
);
|
|
538
|
+
var r2 = await query(
|
|
539
|
+
"SELECT COUNT(DISTINCT subscription_id) AS n FROM subscription_control_events " +
|
|
540
|
+
"WHERE event = 'cancel' AND occurred_at >= ?1 AND occurred_at < ?2",
|
|
541
|
+
[from, to],
|
|
542
|
+
);
|
|
543
|
+
return Math.max(Number(r1.rows[0] && r1.rows[0].n) || 0, Number(r2.rows[0] && r2.rows[0].n) || 0);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function _involuntaryChurnCount(from, to) {
|
|
547
|
+
// Involuntary churn = dunning episode that EXITED in the window
|
|
548
|
+
// with cancelled / written_off.
|
|
549
|
+
var r = await query(
|
|
550
|
+
"SELECT COUNT(DISTINCT subscription_id) AS n FROM subscription_dunning_states " +
|
|
551
|
+
"WHERE state IN ('cancelled', 'written_off') " +
|
|
552
|
+
" AND exited_at IS NOT NULL AND exited_at >= ?1 AND exited_at < ?2",
|
|
553
|
+
[from, to],
|
|
554
|
+
);
|
|
555
|
+
return Number(r.rows[0] && r.rows[0].n) || 0;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function _exposedCount(from, to) {
|
|
559
|
+
// Exposed = subscriptions that existed in some active form during
|
|
560
|
+
// the window. created_at <= to AND (cancelled_at IS NULL OR
|
|
561
|
+
// cancelled_at >= from).
|
|
562
|
+
var r = await query(
|
|
563
|
+
"SELECT COUNT(*) AS n FROM subscriptions " +
|
|
564
|
+
"WHERE created_at <= ?1 AND (cancelled_at IS NULL OR cancelled_at >= ?2)",
|
|
565
|
+
[to, from],
|
|
566
|
+
);
|
|
567
|
+
return Number(r.rows[0] && r.rows[0].n) || 0;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function churnRate(input) {
|
|
571
|
+
_requireInput(input, "churnRate");
|
|
572
|
+
var w = _window(input, "churnRate");
|
|
573
|
+
var kind = _churnKind(input && input.kind);
|
|
574
|
+
var voluntary = 0;
|
|
575
|
+
var involuntary = 0;
|
|
576
|
+
if (kind === "voluntary" || kind === "all") {
|
|
577
|
+
voluntary = await _voluntaryChurnCount(w.from, w.to);
|
|
578
|
+
}
|
|
579
|
+
if (kind === "involuntary" || kind === "all") {
|
|
580
|
+
involuntary = await _involuntaryChurnCount(w.from, w.to);
|
|
581
|
+
}
|
|
582
|
+
var churned = voluntary + involuntary;
|
|
583
|
+
var exposed = await _exposedCount(w.from, w.to);
|
|
584
|
+
var rate = exposed > 0 ? churned / exposed : 0;
|
|
585
|
+
return {
|
|
586
|
+
kind: kind,
|
|
587
|
+
from: w.from,
|
|
588
|
+
to: w.to,
|
|
589
|
+
churned: churned,
|
|
590
|
+
voluntary: voluntary,
|
|
591
|
+
involuntary: involuntary,
|
|
592
|
+
exposed: exposed,
|
|
593
|
+
rate: rate,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ---- pauseRate ------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
async function pauseRate(input) {
|
|
600
|
+
_requireInput(input, "pauseRate");
|
|
601
|
+
var w = _window(input, "pauseRate");
|
|
602
|
+
var pausedRow = (await query(
|
|
603
|
+
"SELECT COUNT(DISTINCT subscription_id) AS n FROM subscription_control_events " +
|
|
604
|
+
"WHERE event = 'pause' AND occurred_at >= ?1 AND occurred_at < ?2",
|
|
605
|
+
[w.from, w.to],
|
|
606
|
+
)).rows[0] || {};
|
|
607
|
+
var paused = Number(pausedRow.n) || 0;
|
|
608
|
+
var exposed = await _exposedCount(w.from, w.to);
|
|
609
|
+
return {
|
|
610
|
+
from: w.from,
|
|
611
|
+
to: w.to,
|
|
612
|
+
paused: paused,
|
|
613
|
+
exposed: exposed,
|
|
614
|
+
rate: exposed > 0 ? paused / exposed : 0,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---- ltv ------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
async function ltv(input) {
|
|
621
|
+
input = input || {};
|
|
622
|
+
var planId = input.plan_id == null ? null : _planId(input.plan_id, true);
|
|
623
|
+
var currency = input.currency == null ? null : _currency(input.currency, true);
|
|
624
|
+
|
|
625
|
+
// Default window: trailing 90 days.
|
|
626
|
+
var to = input.to == null ? Date.now() : _epochMs(input.to, "to");
|
|
627
|
+
var from = input.from == null ? (to - DEFAULT_LTV_WINDOW_MS) : _epochMs(input.from, "from");
|
|
628
|
+
if (from >= to) {
|
|
629
|
+
throw new TypeError("subscriptionAnalytics.ltv: from must be strictly less than to");
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
var where = ["i.status = 'paid'", "i.paid_at >= ?1", "i.paid_at < ?2"];
|
|
633
|
+
var params = [from, to];
|
|
634
|
+
var idx = 3;
|
|
635
|
+
if (planId) {
|
|
636
|
+
where.push("s.plan_id = ?" + idx);
|
|
637
|
+
params.push(planId);
|
|
638
|
+
idx += 1;
|
|
639
|
+
}
|
|
640
|
+
if (currency) {
|
|
641
|
+
where.push("i.currency = ?" + idx);
|
|
642
|
+
params.push(currency);
|
|
643
|
+
idx += 1;
|
|
644
|
+
}
|
|
645
|
+
var revRow = (await query(
|
|
646
|
+
"SELECT SUM(i.amount_minor) AS revenue, COUNT(DISTINCT s.id) AS subs " +
|
|
647
|
+
"FROM subscription_invoices i " +
|
|
648
|
+
"JOIN subscriptions s ON s.id = i.subscription_id " +
|
|
649
|
+
"WHERE " + where.join(" AND "),
|
|
650
|
+
params,
|
|
651
|
+
)).rows[0] || {};
|
|
652
|
+
var revenue = Number(revRow.revenue) || 0;
|
|
653
|
+
var subs = Number(revRow.subs) || 0;
|
|
654
|
+
var avgRevenue = subs > 0 ? Math.floor(revenue / subs) : 0;
|
|
655
|
+
|
|
656
|
+
// Churn rate over the same window (kind=all).
|
|
657
|
+
var cr = await churnRate({ from: from, to: to, kind: "all" });
|
|
658
|
+
|
|
659
|
+
var ltvMinor = null;
|
|
660
|
+
if (cr.rate > 0) {
|
|
661
|
+
ltvMinor = Math.floor(avgRevenue / cr.rate);
|
|
662
|
+
}
|
|
663
|
+
return {
|
|
664
|
+
from: from,
|
|
665
|
+
to: to,
|
|
666
|
+
plan_id: planId,
|
|
667
|
+
currency: currency,
|
|
668
|
+
avg_revenue_minor: avgRevenue,
|
|
669
|
+
churn_rate: cr.rate,
|
|
670
|
+
ltv_minor: ltvMinor,
|
|
671
|
+
sample_size: subs,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ---- cohortRetention -----------------------------------------------
|
|
676
|
+
|
|
677
|
+
function _monthBoundsUtc(cohortMonth) {
|
|
678
|
+
var parts = cohortMonth.split("-");
|
|
679
|
+
var year = Number(parts[0]);
|
|
680
|
+
var month = Number(parts[1]) - 1;
|
|
681
|
+
var start = Date.UTC(year, month, 1, 0, 0, 0, 0);
|
|
682
|
+
var nextMonthStart = Date.UTC(year, month + 1, 1, 0, 0, 0, 0);
|
|
683
|
+
return { start: start, end: nextMonthStart };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function _addMonthsUtc(epochMs, monthsToAdd) {
|
|
687
|
+
var d = new Date(epochMs);
|
|
688
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + monthsToAdd, d.getUTCDate(),
|
|
689
|
+
d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds());
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function cohortRetention(input) {
|
|
693
|
+
if (!input || typeof input !== "object") {
|
|
694
|
+
throw new TypeError("subscriptionAnalytics.cohortRetention: input object required");
|
|
695
|
+
}
|
|
696
|
+
var cohort = _cohortMonth(input.cohort_month);
|
|
697
|
+
var periods = _periods(input.periods);
|
|
698
|
+
var bounds = _monthBoundsUtc(cohort);
|
|
699
|
+
|
|
700
|
+
var cohortRowsR = await query(
|
|
701
|
+
"SELECT id, created_at, cancelled_at FROM subscriptions " +
|
|
702
|
+
"WHERE created_at >= ?1 AND created_at < ?2",
|
|
703
|
+
[bounds.start, bounds.end],
|
|
704
|
+
);
|
|
705
|
+
var cohortRows = cohortRowsR.rows;
|
|
706
|
+
var cohortSize = cohortRows.length;
|
|
707
|
+
|
|
708
|
+
var buckets = [];
|
|
709
|
+
for (var p = 0; p < periods; p += 1) {
|
|
710
|
+
var bucketEnd = _addMonthsUtc(bounds.start, p + 1);
|
|
711
|
+
var active = 0;
|
|
712
|
+
for (var i = 0; i < cohortRows.length; i += 1) {
|
|
713
|
+
var row = cohortRows[i];
|
|
714
|
+
var cancelled = row.cancelled_at == null ? null : Number(row.cancelled_at);
|
|
715
|
+
if (cancelled == null || cancelled > bucketEnd) active += 1;
|
|
716
|
+
}
|
|
717
|
+
buckets.push({
|
|
718
|
+
period: p,
|
|
719
|
+
period_start: p === 0 ? bounds.start : _addMonthsUtc(bounds.start, p),
|
|
720
|
+
period_end: bucketEnd,
|
|
721
|
+
active: active,
|
|
722
|
+
retention_rate: cohortSize > 0 ? active / cohortSize : 0,
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
cohort_month: cohort,
|
|
728
|
+
cohort_size: cohortSize,
|
|
729
|
+
buckets: buckets,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ---- planTransitions -----------------------------------------------
|
|
734
|
+
|
|
735
|
+
async function planTransitions(input) {
|
|
736
|
+
_requireInput(input, "planTransitions");
|
|
737
|
+
var w = _window(input, "planTransitions");
|
|
738
|
+
|
|
739
|
+
// The controls primitive surfaces plan changes via
|
|
740
|
+
// `quantity_change` and `frequency_change` events, but the most
|
|
741
|
+
// direct signal is a before/after JSON pair whose `plan_id` field
|
|
742
|
+
// differs. We scan every event in the window and filter in JS —
|
|
743
|
+
// SQLite has no JSON1 surface here.
|
|
744
|
+
var r = await query(
|
|
745
|
+
"SELECT subscription_id, before_json, after_json, occurred_at " +
|
|
746
|
+
"FROM subscription_control_events " +
|
|
747
|
+
"WHERE occurred_at >= ?1 AND occurred_at < ?2",
|
|
748
|
+
[w.from, w.to],
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
var transitions = {};
|
|
752
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
753
|
+
var row = r.rows[i];
|
|
754
|
+
var before;
|
|
755
|
+
var after;
|
|
756
|
+
try { before = JSON.parse(row.before_json || "{}"); } catch (_eB) { before = {}; }
|
|
757
|
+
try { after = JSON.parse(row.after_json || "{}"); } catch (_eA) { after = {}; }
|
|
758
|
+
var fromPlan = before.plan_id;
|
|
759
|
+
var toPlan = after.plan_id;
|
|
760
|
+
if (!fromPlan || !toPlan || fromPlan === toPlan) continue;
|
|
761
|
+
var key = fromPlan + "→" + toPlan;
|
|
762
|
+
if (!transitions[key]) {
|
|
763
|
+
transitions[key] = { from_plan_id: fromPlan, to_plan_id: toPlan, count: 0 };
|
|
764
|
+
}
|
|
765
|
+
transitions[key].count += 1;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Hydrate plan amounts so direction can be classified.
|
|
769
|
+
var allPlanIds = {};
|
|
770
|
+
var tKeys = Object.keys(transitions);
|
|
771
|
+
for (var k = 0; k < tKeys.length; k += 1) {
|
|
772
|
+
allPlanIds[transitions[tKeys[k]].from_plan_id] = true;
|
|
773
|
+
allPlanIds[transitions[tKeys[k]].to_plan_id] = true;
|
|
774
|
+
}
|
|
775
|
+
var planIds = Object.keys(allPlanIds);
|
|
776
|
+
var planById = {};
|
|
777
|
+
for (var pi = 0; pi < planIds.length; pi += 1) {
|
|
778
|
+
var pr = await query(
|
|
779
|
+
"SELECT id, amount_minor, currency, interval, interval_count FROM subscription_plans WHERE id = ?1",
|
|
780
|
+
[planIds[pi]],
|
|
781
|
+
);
|
|
782
|
+
if (pr.rows[0]) planById[planIds[pi]] = pr.rows[0];
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
var out = [];
|
|
786
|
+
for (var t = 0; t < tKeys.length; t += 1) {
|
|
787
|
+
var tr = transitions[tKeys[t]];
|
|
788
|
+
var fromPlanRow = planById[tr.from_plan_id];
|
|
789
|
+
var toPlanRow = planById[tr.to_plan_id];
|
|
790
|
+
var direction = "lateral";
|
|
791
|
+
if (fromPlanRow && toPlanRow && fromPlanRow.currency === toPlanRow.currency) {
|
|
792
|
+
var fromMonthly = _monthlyMinor(
|
|
793
|
+
Number(fromPlanRow.amount_minor), fromPlanRow.interval, Number(fromPlanRow.interval_count), 1
|
|
794
|
+
);
|
|
795
|
+
var toMonthly = _monthlyMinor(
|
|
796
|
+
Number(toPlanRow.amount_minor), toPlanRow.interval, Number(toPlanRow.interval_count), 1
|
|
797
|
+
);
|
|
798
|
+
if (toMonthly > fromMonthly) direction = "upgrade";
|
|
799
|
+
else if (toMonthly < fromMonthly) direction = "downgrade";
|
|
800
|
+
else direction = "lateral";
|
|
801
|
+
}
|
|
802
|
+
out.push({
|
|
803
|
+
from_plan_id: tr.from_plan_id,
|
|
804
|
+
to_plan_id: tr.to_plan_id,
|
|
805
|
+
count: tr.count,
|
|
806
|
+
direction: direction,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
out.sort(function (a, b) {
|
|
810
|
+
if (b.count !== a.count) return b.count - a.count;
|
|
811
|
+
if (a.from_plan_id < b.from_plan_id) return -1;
|
|
812
|
+
if (a.from_plan_id > b.from_plan_id) return 1;
|
|
813
|
+
if (a.to_plan_id < b.to_plan_id) return -1;
|
|
814
|
+
if (a.to_plan_id > b.to_plan_id) return 1;
|
|
815
|
+
return 0;
|
|
816
|
+
});
|
|
817
|
+
return out;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// ---- topChurningPlans ----------------------------------------------
|
|
821
|
+
|
|
822
|
+
async function topChurningPlans(input) {
|
|
823
|
+
_requireInput(input, "topChurningPlans");
|
|
824
|
+
var w = _window(input, "topChurningPlans");
|
|
825
|
+
var limit = _limit(input && input.limit, "limit");
|
|
826
|
+
|
|
827
|
+
var churnRows = (await query(
|
|
828
|
+
"SELECT s.plan_id AS plan_id, COUNT(*) AS churned " +
|
|
829
|
+
"FROM subscriptions s " +
|
|
830
|
+
"WHERE s.cancelled_at IS NOT NULL " +
|
|
831
|
+
" AND s.cancelled_at >= ?1 AND s.cancelled_at < ?2 " +
|
|
832
|
+
"GROUP BY s.plan_id " +
|
|
833
|
+
"ORDER BY churned DESC, plan_id ASC " +
|
|
834
|
+
"LIMIT ?3",
|
|
835
|
+
[w.from, w.to, limit],
|
|
836
|
+
)).rows;
|
|
837
|
+
|
|
838
|
+
var out = [];
|
|
839
|
+
for (var i = 0; i < churnRows.length; i += 1) {
|
|
840
|
+
var row = churnRows[i];
|
|
841
|
+
var activeAtStartR = (await query(
|
|
842
|
+
"SELECT COUNT(*) AS n FROM subscriptions " +
|
|
843
|
+
"WHERE plan_id = ?1 AND created_at <= ?2 " +
|
|
844
|
+
" AND (cancelled_at IS NULL OR cancelled_at >= ?2)",
|
|
845
|
+
[row.plan_id, w.from],
|
|
846
|
+
)).rows[0] || {};
|
|
847
|
+
var activeAtStart = Number(activeAtStartR.n) || 0;
|
|
848
|
+
var churned = Number(row.churned) || 0;
|
|
849
|
+
out.push({
|
|
850
|
+
plan_id: row.plan_id,
|
|
851
|
+
churned: churned,
|
|
852
|
+
active_at_start: activeAtStart,
|
|
853
|
+
churn_rate: activeAtStart > 0 ? churned / activeAtStart : 0,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
return out;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ---- recoveryRate --------------------------------------------------
|
|
860
|
+
|
|
861
|
+
async function recoveryRate(input) {
|
|
862
|
+
_requireInput(input, "recoveryRate");
|
|
863
|
+
var w = _window(input, "recoveryRate");
|
|
864
|
+
|
|
865
|
+
// Every dunning episode that ENTERED in the window.
|
|
866
|
+
var entered = Number((await query(
|
|
867
|
+
"SELECT COUNT(*) AS n FROM subscription_dunning_states " +
|
|
868
|
+
"WHERE entered_at >= ?1 AND entered_at < ?2 AND state IN ('active', 'dunning')",
|
|
869
|
+
[w.from, w.to],
|
|
870
|
+
)).rows[0].n) || 0;
|
|
871
|
+
|
|
872
|
+
// Wider scan: every dunning episode that started in the window
|
|
873
|
+
// (regardless of current state) — so we can categorize the
|
|
874
|
+
// exits.
|
|
875
|
+
var allRows = (await query(
|
|
876
|
+
"SELECT state, exited_at FROM subscription_dunning_states " +
|
|
877
|
+
"WHERE entered_at >= ?1 AND entered_at < ?2",
|
|
878
|
+
[w.from, w.to],
|
|
879
|
+
)).rows;
|
|
880
|
+
|
|
881
|
+
var recovered = 0;
|
|
882
|
+
var cancelled = 0;
|
|
883
|
+
var writtenOff = 0;
|
|
884
|
+
var stillOpen = 0;
|
|
885
|
+
var totalCount = allRows.length;
|
|
886
|
+
for (var i = 0; i < allRows.length; i += 1) {
|
|
887
|
+
var row = allRows[i];
|
|
888
|
+
var state = row.state;
|
|
889
|
+
var exited = row.exited_at != null;
|
|
890
|
+
if (!exited) stillOpen += 1;
|
|
891
|
+
else if (state === "recovered") recovered += 1;
|
|
892
|
+
else if (state === "cancelled") cancelled += 1;
|
|
893
|
+
else if (state === "written_off") writtenOff += 1;
|
|
894
|
+
else if (state === "active" ||
|
|
895
|
+
state === "dunning") stillOpen += 1;
|
|
896
|
+
}
|
|
897
|
+
var resolved = totalCount - stillOpen;
|
|
898
|
+
var rate = resolved > 0 ? recovered / resolved : 0;
|
|
899
|
+
// `entered` is exposed for the operator who wants the "still in
|
|
900
|
+
// dunning" headline; default to totalCount when the active-only
|
|
901
|
+
// scan returned 0 (no episodes are currently in active/dunning
|
|
902
|
+
// state — every entry in the window has already resolved).
|
|
903
|
+
void entered;
|
|
904
|
+
return {
|
|
905
|
+
from: w.from,
|
|
906
|
+
to: w.to,
|
|
907
|
+
entered: totalCount,
|
|
908
|
+
recovered: recovered,
|
|
909
|
+
cancelled: cancelled,
|
|
910
|
+
written_off: writtenOff,
|
|
911
|
+
still_open: stillOpen,
|
|
912
|
+
recovery_rate: rate,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ---- dailyMrrSeries ------------------------------------------------
|
|
917
|
+
|
|
918
|
+
async function dailyMrrSeries(input) {
|
|
919
|
+
_requireInput(input, "dailyMrrSeries");
|
|
920
|
+
var w = _window(input, "dailyMrrSeries");
|
|
921
|
+
var currency = input && input.currency == null ? null : _currency(input.currency, true);
|
|
922
|
+
|
|
923
|
+
// Bucket count = ceil((to - from) / 86_400_000). UTC day
|
|
924
|
+
// boundaries: floor(ts / 86_400_000) * 86_400_000.
|
|
925
|
+
var firstDayStart = Math.floor(w.from / ONE_DAY_MS) * ONE_DAY_MS;
|
|
926
|
+
var lastDayStart = Math.floor((w.to - 1) / ONE_DAY_MS) * ONE_DAY_MS;
|
|
927
|
+
var dayCount = Math.floor((lastDayStart - firstDayStart) / ONE_DAY_MS) + 1;
|
|
928
|
+
|
|
929
|
+
var out = [];
|
|
930
|
+
for (var d = 0; d < dayCount; d += 1) {
|
|
931
|
+
var dayStart = firstDayStart + d * ONE_DAY_MS;
|
|
932
|
+
var dayEnd = dayStart + ONE_DAY_MS;
|
|
933
|
+
var atCutoff = dayEnd - 1;
|
|
934
|
+
var snap = await _mrrSnapshot(atCutoff);
|
|
935
|
+
var emitted = false;
|
|
936
|
+
for (var i = 0; i < snap.currency_breakdown.length; i += 1) {
|
|
937
|
+
var b = snap.currency_breakdown[i];
|
|
938
|
+
if (currency && b.currency !== currency) continue;
|
|
939
|
+
out.push({
|
|
940
|
+
day: dayStart,
|
|
941
|
+
currency: b.currency,
|
|
942
|
+
mrr_minor: b.mrr_minor,
|
|
943
|
+
subscription_count: b.subscription_count,
|
|
944
|
+
});
|
|
945
|
+
emitted = true;
|
|
946
|
+
}
|
|
947
|
+
if (!emitted) {
|
|
948
|
+
// Surface an explicit zero row for days with no active
|
|
949
|
+
// subscriptions so the chart doesn't render holes.
|
|
950
|
+
out.push({
|
|
951
|
+
day: dayStart,
|
|
952
|
+
currency: currency || null,
|
|
953
|
+
mrr_minor: 0,
|
|
954
|
+
subscription_count: 0,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return out;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// ---- cache management ----------------------------------------------
|
|
962
|
+
|
|
963
|
+
async function purgeExpired(input) {
|
|
964
|
+
input = input || {};
|
|
965
|
+
var before = input.before == null ? Date.now() - DEFAULT_TTL_MS : _epochMs(input.before, "before");
|
|
966
|
+
var r = await query(
|
|
967
|
+
"DELETE FROM subscription_metrics_snapshots WHERE computed_at < ?1",
|
|
968
|
+
[before],
|
|
969
|
+
);
|
|
970
|
+
return { purged: Number(r.rowCount || r.changes || 0) };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function invalidate(input) {
|
|
974
|
+
input = input || {};
|
|
975
|
+
var sql = "DELETE FROM subscription_metrics_snapshots";
|
|
976
|
+
var where = [];
|
|
977
|
+
var params = [];
|
|
978
|
+
var idx = 1;
|
|
979
|
+
if (input.scope != null) {
|
|
980
|
+
where.push("scope = ?" + idx);
|
|
981
|
+
params.push(_scope(input.scope));
|
|
982
|
+
idx += 1;
|
|
983
|
+
}
|
|
984
|
+
if (input.before != null) {
|
|
985
|
+
where.push("computed_at < ?" + idx);
|
|
986
|
+
params.push(_epochMs(input.before, "before"));
|
|
987
|
+
idx += 1;
|
|
988
|
+
}
|
|
989
|
+
if (where.length) sql += " WHERE " + where.join(" AND ");
|
|
990
|
+
var r = await query(sql, params);
|
|
991
|
+
return { invalidated: Number(r.rowCount || r.changes || 0) };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
return {
|
|
995
|
+
CACHE_NAMESPACE: CACHE_NAMESPACE,
|
|
996
|
+
DEFAULT_TTL_MS: DEFAULT_TTL_MS,
|
|
997
|
+
ONE_DAY_MS: ONE_DAY_MS,
|
|
998
|
+
ONE_YEAR_MS: ONE_YEAR_MS,
|
|
999
|
+
MAX_LIMIT: MAX_LIMIT,
|
|
1000
|
+
MAX_PERIODS: MAX_PERIODS,
|
|
1001
|
+
CADENCE_TO_MONTHLY: Object.assign({}, CADENCE_TO_MONTHLY),
|
|
1002
|
+
ACTIVE_STATUSES: ACTIVE_STATUSES.slice(),
|
|
1003
|
+
CHURN_KINDS: CHURN_KINDS.slice(),
|
|
1004
|
+
|
|
1005
|
+
mrr: mrr,
|
|
1006
|
+
arr: arr,
|
|
1007
|
+
churnRate: churnRate,
|
|
1008
|
+
pauseRate: pauseRate,
|
|
1009
|
+
ltv: ltv,
|
|
1010
|
+
cohortRetention: cohortRetention,
|
|
1011
|
+
planTransitions: planTransitions,
|
|
1012
|
+
topChurningPlans: topChurningPlans,
|
|
1013
|
+
recoveryRate: recoveryRate,
|
|
1014
|
+
dailyMrrSeries: dailyMrrSeries,
|
|
1015
|
+
|
|
1016
|
+
purgeExpired: purgeExpired,
|
|
1017
|
+
invalidate: invalidate,
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
module.exports = {
|
|
1022
|
+
create: create,
|
|
1023
|
+
CACHE_NAMESPACE: CACHE_NAMESPACE,
|
|
1024
|
+
DEFAULT_TTL_MS: DEFAULT_TTL_MS,
|
|
1025
|
+
ONE_DAY_MS: ONE_DAY_MS,
|
|
1026
|
+
ONE_YEAR_MS: ONE_YEAR_MS,
|
|
1027
|
+
MAX_LIMIT: MAX_LIMIT,
|
|
1028
|
+
MAX_PERIODS: MAX_PERIODS,
|
|
1029
|
+
CADENCE_TO_MONTHLY: Object.assign({}, CADENCE_TO_MONTHLY),
|
|
1030
|
+
ACTIVE_STATUSES: ACTIVE_STATUSES.slice(),
|
|
1031
|
+
CHURN_KINDS: CHURN_KINDS.slice(),
|
|
1032
|
+
};
|