@blamejs/blamejs-shop 0.0.72 → 0.0.76

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. 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
+ };