@blamejs/blamejs-shop 0.0.59 → 0.0.61

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.
@@ -0,0 +1,644 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.subscriptionBilling
4
+ * @title Subscription billing — invoice + payment + dunning ledger
5
+ *
6
+ * @intro
7
+ * The money side of recurring revenue. Sibling to
8
+ * `subscriptionControls` (which owns the customer-side pause /
9
+ * resume / skip / cancel FSM) — this primitive owns the
10
+ * processor-side invoice + payment + dunning ledger driven by
11
+ * Stripe (or any compatible processor) webhook deliveries.
12
+ *
13
+ * Three append-only tables back the surface:
14
+ *
15
+ * subscription_invoices — one row per billing period
16
+ * the processor cuts an invoice
17
+ * for. Mirrors the processor's
18
+ * hosted invoice + the
19
+ * customer-facing receipt URL.
20
+ * subscription_payment_attempts — one row per processor charge
21
+ * attempt. Keyed by
22
+ * (invoice_id, attempt_number);
23
+ * replay-safe under webhook
24
+ * redelivery.
25
+ * subscription_dunning_states — one row per dunning episode.
26
+ * Append-only history; the
27
+ * latest row's `state` is the
28
+ * current standing.
29
+ *
30
+ * Composition:
31
+ * var bill = bShop.subscriptionBilling.create({
32
+ * query: q,
33
+ * subscriptions: subs.subscriptions,
34
+ * payment: pay, // optional — reserved for
35
+ * // processor.refund hooks
36
+ * // in future minors.
37
+ * });
38
+ * await bill.recordInvoice({
39
+ * subscription_id, period_start, period_end,
40
+ * amount_minor, currency, invoice_url, processor_invoice_id,
41
+ * });
42
+ * await bill.recordPaymentAttempt({
43
+ * invoice_id, attempt_number: 1, status: "failed",
44
+ * processor_charge_id: "ch_…", failure_code: "card_declined",
45
+ * });
46
+ * await bill.markPaid({ invoice_id, paid_at });
47
+ * await bill.markFailed({ invoice_id, reason, attempt_number, next_retry_at });
48
+ * await bill.enterDunning({ subscription_id, reason });
49
+ * await bill.exitDunning({ subscription_id, outcome: "recovered" });
50
+ * await bill.invoicesForSubscription(subscription_id);
51
+ * await bill.failedInvoices({ from, to, limit });
52
+ * await bill.dunningRoster({ as_of });
53
+ * await bill.arpu({ from, to });
54
+ *
55
+ * The invoice FSM is small: pending → paid | failed | voided.
56
+ * `failed` is not terminal — a subsequent `markPaid` (driven by
57
+ * the processor's automatic-recovery webhook) flips the row back
58
+ * to paid; the prior failed attempt rows stay in the ledger.
59
+ * `voided` is terminal; reserved for operator-issued voids (e.g.
60
+ * customer dispute upheld).
61
+ *
62
+ * The `arpu` window query computes
63
+ * sum(paid_invoice.amount_minor) / distinct(subscription_id) over
64
+ * the [from, to] window. Currency is reported separately per
65
+ * bucket — the caller renders single-currency dashboards (the
66
+ * typical operator surface); cross-currency aggregation requires
67
+ * an FX layer the caller composes outside.
68
+ *
69
+ * `invoice_url` runs through `b.safeUrl.parse` with the
70
+ * `ALLOW_HTTP_TLS` allowlist (https-only) so a hostile webhook
71
+ * payload can't smuggle a `javascript:` / `data:` / `file:` URL
72
+ * into a receipt email rendered by the storefront.
73
+ *
74
+ * @related b.safeUrl, b.guardUuid, b.uuid.v7
75
+ */
76
+
77
+ var bShop;
78
+ function _b() {
79
+ if (!bShop) bShop = require("./index");
80
+ return bShop.framework;
81
+ }
82
+
83
+ // ---- constants ----------------------------------------------------------
84
+
85
+ var INVOICE_STATUSES = ["pending", "paid", "failed", "voided"];
86
+ var ATTEMPT_STATUSES = ["succeeded", "failed"];
87
+ var DUNNING_STATES = ["active", "dunning", "recovered", "cancelled", "written_off"];
88
+ var EXIT_OUTCOMES = ["recovered", "cancelled", "written_off"];
89
+
90
+ var MAX_INVOICE_URL_LEN = 2048;
91
+ var MAX_REASON_LEN = 280;
92
+ var MAX_PROCESSOR_ID_LEN = 255;
93
+ var MAX_FAILURE_CODE_LEN = 64;
94
+ var MAX_LIST_LIMIT = 500;
95
+ var DEFAULT_LIST_LIMIT = 100;
96
+
97
+ // Reuse the same control-byte / zero-width refusal posture as the
98
+ // sibling subscription-controls primitive — operator-authored prose
99
+ // flows into receipt emails + the operator dashboard, so the same
100
+ // "no direction-override / no invisible glyph" floor applies.
101
+ var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
102
+ var ZERO_WIDTH_RE = new RegExp(
103
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
104
+ );
105
+
106
+ // ---- validators ---------------------------------------------------------
107
+
108
+ function _uuid(s, label) {
109
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
110
+ catch (e) { throw new TypeError("subscriptionBilling: " + label + " — " + (e && e.message || "invalid UUID")); }
111
+ }
112
+
113
+ function _posIntOrZero(n, label) {
114
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
115
+ throw new TypeError("subscriptionBilling: " + label + " must be a non-negative integer");
116
+ }
117
+ return n;
118
+ }
119
+
120
+ function _posInt(n, label) {
121
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
122
+ throw new TypeError("subscriptionBilling: " + label + " must be a positive integer");
123
+ }
124
+ return n;
125
+ }
126
+
127
+ function _epochMs(n, label) {
128
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
129
+ throw new TypeError("subscriptionBilling: " + label + " must be a positive integer (epoch ms)");
130
+ }
131
+ return n;
132
+ }
133
+
134
+ function _epochMsOrNull(n, label) {
135
+ if (n == null) return null;
136
+ return _epochMs(n, label);
137
+ }
138
+
139
+ function _currency(c) {
140
+ if (typeof c !== "string" || !/^[A-Z]{3}$/.test(c)) {
141
+ throw new TypeError("subscriptionBilling: currency must be 3-letter uppercase ISO 4217");
142
+ }
143
+ return c;
144
+ }
145
+
146
+ function _reason(s) {
147
+ if (typeof s !== "string" || !s.length) {
148
+ throw new TypeError("subscriptionBilling: reason must be a non-empty string");
149
+ }
150
+ if (s.length > MAX_REASON_LEN) {
151
+ throw new TypeError("subscriptionBilling: reason must be <= " + MAX_REASON_LEN + " characters");
152
+ }
153
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
154
+ throw new TypeError("subscriptionBilling: reason contains control / zero-width bytes");
155
+ }
156
+ return s;
157
+ }
158
+
159
+ function _optReason(s) {
160
+ if (s == null) return null;
161
+ return _reason(s);
162
+ }
163
+
164
+ function _shortText(s, label, max) {
165
+ if (typeof s !== "string" || !s.length) {
166
+ throw new TypeError("subscriptionBilling: " + label + " must be a non-empty string");
167
+ }
168
+ if (s.length > max) {
169
+ throw new TypeError("subscriptionBilling: " + label + " must be <= " + max + " characters");
170
+ }
171
+ if (CONTROL_BYTE_RE.test(s)) {
172
+ throw new TypeError("subscriptionBilling: " + label + " contains control bytes");
173
+ }
174
+ return s;
175
+ }
176
+
177
+ function _optShortText(s, label, max) {
178
+ if (s == null) return null;
179
+ return _shortText(s, label, max);
180
+ }
181
+
182
+ // `invoice_url` runs through b.safeUrl so a hostile webhook payload
183
+ // can't smuggle a non-https hosted URL into the receipt email. The
184
+ // stored value goes straight to the storefront template; the
185
+ // safeUrl gate keeps `javascript:` / `data:` / `file:` / userinfo-
186
+ // bearing https:// URLs out.
187
+ function _invoiceUrl(url) {
188
+ if (url == null) return null;
189
+ if (typeof url !== "string" || !url.length) {
190
+ throw new TypeError("subscriptionBilling: invoice_url must be a non-empty string when provided");
191
+ }
192
+ if (url.length > MAX_INVOICE_URL_LEN) {
193
+ throw new TypeError("subscriptionBilling: invoice_url must be <= " + MAX_INVOICE_URL_LEN + " characters");
194
+ }
195
+ try {
196
+ _b().safeUrl.parse(url, { allowedProtocols: _b().safeUrl.ALLOW_HTTP_TLS });
197
+ } catch (e) {
198
+ throw new TypeError("subscriptionBilling: invoice_url — " + (e && e.message || "must be a valid https:// URL"));
199
+ }
200
+ return url;
201
+ }
202
+
203
+ function _invoiceStatus(s) {
204
+ if (typeof s !== "string" || INVOICE_STATUSES.indexOf(s) === -1) {
205
+ throw new TypeError("subscriptionBilling: invoice status must be one of " + INVOICE_STATUSES.join(", "));
206
+ }
207
+ return s;
208
+ }
209
+
210
+ function _attemptStatus(s) {
211
+ if (typeof s !== "string" || ATTEMPT_STATUSES.indexOf(s) === -1) {
212
+ throw new TypeError("subscriptionBilling: attempt status must be one of " + ATTEMPT_STATUSES.join(", "));
213
+ }
214
+ return s;
215
+ }
216
+
217
+ function _exitOutcome(s) {
218
+ if (typeof s !== "string" || EXIT_OUTCOMES.indexOf(s) === -1) {
219
+ throw new TypeError("subscriptionBilling: outcome must be one of " + EXIT_OUTCOMES.join(", "));
220
+ }
221
+ return s;
222
+ }
223
+
224
+ function _limit(n) {
225
+ if (n == null) return DEFAULT_LIST_LIMIT;
226
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
227
+ throw new TypeError("subscriptionBilling: limit must be a positive integer");
228
+ }
229
+ if (n > MAX_LIST_LIMIT) {
230
+ throw new TypeError("subscriptionBilling: limit must be <= " + MAX_LIST_LIMIT);
231
+ }
232
+ return n;
233
+ }
234
+
235
+ function _now() { return Date.now(); }
236
+
237
+ // ---- factory ------------------------------------------------------------
238
+
239
+ function create(opts) {
240
+ opts = opts || {};
241
+ var query = opts.query;
242
+ if (!query) {
243
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
244
+ }
245
+ var subscriptionsHandle = opts.subscriptions;
246
+ if (!subscriptionsHandle || typeof subscriptionsHandle.get !== "function") {
247
+ throw new TypeError("subscriptionBilling.create: opts.subscriptions handle required");
248
+ }
249
+ // `payment` handle is accepted for forward compatibility (a future
250
+ // minor will surface processor-driven refund + void hooks through
251
+ // it); the current surface composes only the database + the
252
+ // subscriptions handle.
253
+ var paymentHandle = opts.payment || null;
254
+
255
+ async function _getInvoice(invoiceId) {
256
+ var r = await query("SELECT * FROM subscription_invoices WHERE id = ?1", [invoiceId]);
257
+ return r.rows[0] || null;
258
+ }
259
+
260
+ async function _refetchInvoice(invoiceId) {
261
+ return _getInvoice(invoiceId);
262
+ }
263
+
264
+ async function _subscriptionExists(subscriptionId) {
265
+ var r = await query("SELECT id FROM subscriptions WHERE id = ?1", [subscriptionId]);
266
+ return r.rows.length > 0;
267
+ }
268
+
269
+ // Latest dunning row for a subscription — `null` when the
270
+ // subscription has never been in dunning. The append-only shape
271
+ // means "current state" === "most-recent row," ordered by
272
+ // (entered_at DESC, id DESC) so simultaneous rows fall back to v7
273
+ // UUID tail.
274
+ async function _latestDunning(subscriptionId) {
275
+ var r = await query(
276
+ "SELECT * FROM subscription_dunning_states WHERE subscription_id = ?1 " +
277
+ "ORDER BY entered_at DESC, id DESC LIMIT 1",
278
+ [subscriptionId],
279
+ );
280
+ return r.rows[0] || null;
281
+ }
282
+
283
+ return {
284
+ INVOICE_STATUSES: INVOICE_STATUSES.slice(),
285
+ ATTEMPT_STATUSES: ATTEMPT_STATUSES.slice(),
286
+ DUNNING_STATES: DUNNING_STATES.slice(),
287
+ EXIT_OUTCOMES: EXIT_OUTCOMES.slice(),
288
+ MAX_INVOICE_URL_LEN: MAX_INVOICE_URL_LEN,
289
+ MAX_REASON_LEN: MAX_REASON_LEN,
290
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
291
+ DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
292
+
293
+ // Reserved hook — surfaced so callers can inspect that the
294
+ // factory wired the payment handle through, useful for tests
295
+ // and for the future processor-driven refund path.
296
+ payment: paymentHandle,
297
+
298
+ recordInvoice: async function (input) {
299
+ if (!input || typeof input !== "object") {
300
+ throw new TypeError("subscriptionBilling.recordInvoice: input object required");
301
+ }
302
+ var subscriptionId = _uuid(input.subscription_id, "subscription_id");
303
+ var periodStart = _epochMs(input.period_start, "period_start");
304
+ var periodEnd = _epochMs(input.period_end, "period_end");
305
+ if (periodEnd < periodStart) {
306
+ throw new TypeError("subscriptionBilling.recordInvoice: period_end must be >= period_start");
307
+ }
308
+ var amountMinor = _posIntOrZero(input.amount_minor, "amount_minor");
309
+ var currency = _currency(input.currency);
310
+ var invoiceUrl = _invoiceUrl(input.invoice_url);
311
+ var processorId = _optShortText(input.processor_invoice_id, "processor_invoice_id", MAX_PROCESSOR_ID_LEN);
312
+
313
+ if (!(await _subscriptionExists(subscriptionId))) {
314
+ var notFound = new Error("subscriptionBilling.recordInvoice: subscription " + subscriptionId + " not found");
315
+ notFound.code = "SUBSCRIPTION_NOT_FOUND";
316
+ throw notFound;
317
+ }
318
+
319
+ // Webhook idempotency — Stripe redelivers `invoice.created`
320
+ // freely. When the processor_invoice_id is set and matches an
321
+ // existing row, return the existing row instead of inserting a
322
+ // duplicate (the UNIQUE constraint would refuse the second
323
+ // INSERT anyway; this surface gives the caller a clean replay
324
+ // path).
325
+ if (processorId != null) {
326
+ var existing = await query(
327
+ "SELECT * FROM subscription_invoices WHERE processor_invoice_id = ?1",
328
+ [processorId],
329
+ );
330
+ if (existing.rows.length) return existing.rows[0];
331
+ }
332
+
333
+ var id = _b().uuid.v7();
334
+ var ts = _now();
335
+ await query(
336
+ "INSERT INTO subscription_invoices " +
337
+ "(id, subscription_id, period_start, period_end, amount_minor, currency, " +
338
+ " invoice_url, processor_invoice_id, status, paid_at, voided_at, created_at) " +
339
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', NULL, NULL, ?9)",
340
+ [id, subscriptionId, periodStart, periodEnd, amountMinor, currency, invoiceUrl, processorId, ts],
341
+ );
342
+ return await _refetchInvoice(id);
343
+ },
344
+
345
+ recordPaymentAttempt: async function (input) {
346
+ if (!input || typeof input !== "object") {
347
+ throw new TypeError("subscriptionBilling.recordPaymentAttempt: input object required");
348
+ }
349
+ var invoiceId = _uuid(input.invoice_id, "invoice_id");
350
+ var attemptNumber = _posInt(input.attempt_number, "attempt_number");
351
+ var status = _attemptStatus(input.status);
352
+ var processorCharge = _optShortText(input.processor_charge_id, "processor_charge_id", MAX_PROCESSOR_ID_LEN);
353
+ var failureCode = _optShortText(input.failure_code, "failure_code", MAX_FAILURE_CODE_LEN);
354
+
355
+ var invoice = await _getInvoice(invoiceId);
356
+ if (!invoice) {
357
+ var notFound = new Error("subscriptionBilling.recordPaymentAttempt: invoice " + invoiceId + " not found");
358
+ notFound.code = "INVOICE_NOT_FOUND";
359
+ throw notFound;
360
+ }
361
+
362
+ // Replay-idempotent: if a row with the same (invoice_id,
363
+ // attempt_number) already exists, return it. The UNIQUE
364
+ // constraint would refuse a duplicate INSERT — this surface
365
+ // surfaces the existing row so webhook redelivery is a no-op.
366
+ var existing = await query(
367
+ "SELECT * FROM subscription_payment_attempts WHERE invoice_id = ?1 AND attempt_number = ?2",
368
+ [invoiceId, attemptNumber],
369
+ );
370
+ if (existing.rows.length) return existing.rows[0];
371
+
372
+ var id = _b().uuid.v7();
373
+ var ts = _now();
374
+ await query(
375
+ "INSERT INTO subscription_payment_attempts " +
376
+ "(id, invoice_id, attempt_number, status, processor_charge_id, failure_code, occurred_at) " +
377
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
378
+ [id, invoiceId, attemptNumber, status, processorCharge, failureCode, ts],
379
+ );
380
+ var r = await query("SELECT * FROM subscription_payment_attempts WHERE id = ?1", [id]);
381
+ return r.rows[0];
382
+ },
383
+
384
+ markPaid: async function (input) {
385
+ if (!input || typeof input !== "object") {
386
+ throw new TypeError("subscriptionBilling.markPaid: input object required");
387
+ }
388
+ var invoiceId = _uuid(input.invoice_id, "invoice_id");
389
+ var paidAt = _epochMs(input.paid_at, "paid_at");
390
+
391
+ var invoice = await _getInvoice(invoiceId);
392
+ if (!invoice) {
393
+ var notFound = new Error("subscriptionBilling.markPaid: invoice " + invoiceId + " not found");
394
+ notFound.code = "INVOICE_NOT_FOUND";
395
+ throw notFound;
396
+ }
397
+ // FSM: paid is reachable from pending OR failed (processor
398
+ // automatic-recovery flow). Refused from `voided` (terminal).
399
+ // Replaying markPaid on an already-paid invoice is a no-op
400
+ // (webhook redelivery idempotency).
401
+ if (invoice.status === "voided") {
402
+ var vErr = new Error("subscriptionBilling.markPaid: refused — invoice is voided (terminal)");
403
+ vErr.code = "INVOICE_STATE_REFUSED";
404
+ throw vErr;
405
+ }
406
+ if (invoice.status === "paid") return invoice;
407
+
408
+ await query(
409
+ "UPDATE subscription_invoices SET status = 'paid', paid_at = ?1 WHERE id = ?2",
410
+ [paidAt, invoiceId],
411
+ );
412
+ return await _refetchInvoice(invoiceId);
413
+ },
414
+
415
+ markFailed: async function (input) {
416
+ if (!input || typeof input !== "object") {
417
+ throw new TypeError("subscriptionBilling.markFailed: input object required");
418
+ }
419
+ var invoiceId = _uuid(input.invoice_id, "invoice_id");
420
+ var reason = _reason(input.reason);
421
+ var attemptNumber = _posInt(input.attempt_number, "attempt_number");
422
+ var nextRetryAt = _epochMsOrNull(input.next_retry_at, "next_retry_at");
423
+
424
+ var invoice = await _getInvoice(invoiceId);
425
+ if (!invoice) {
426
+ var notFound = new Error("subscriptionBilling.markFailed: invoice " + invoiceId + " not found");
427
+ notFound.code = "INVOICE_NOT_FOUND";
428
+ throw notFound;
429
+ }
430
+ if (invoice.status === "voided") {
431
+ var vErr = new Error("subscriptionBilling.markFailed: refused — invoice is voided (terminal)");
432
+ vErr.code = "INVOICE_STATE_REFUSED";
433
+ throw vErr;
434
+ }
435
+ if (invoice.status === "paid") {
436
+ var pErr = new Error("subscriptionBilling.markFailed: refused — invoice is already paid");
437
+ pErr.code = "INVOICE_STATE_REFUSED";
438
+ throw pErr;
439
+ }
440
+
441
+ // Record the matching failed attempt row when one doesn't
442
+ // already exist for this attempt_number — keeps the attempt
443
+ // ledger + the invoice FSM in lock-step so a markFailed call
444
+ // is a single composed transition the operator can replay.
445
+ var existing = await query(
446
+ "SELECT id FROM subscription_payment_attempts WHERE invoice_id = ?1 AND attempt_number = ?2",
447
+ [invoiceId, attemptNumber],
448
+ );
449
+ if (!existing.rows.length) {
450
+ var attemptId = _b().uuid.v7();
451
+ await query(
452
+ "INSERT INTO subscription_payment_attempts " +
453
+ "(id, invoice_id, attempt_number, status, processor_charge_id, failure_code, occurred_at) " +
454
+ "VALUES (?1, ?2, ?3, 'failed', NULL, ?4, ?5)",
455
+ [attemptId, invoiceId, attemptNumber, reason.slice(0, MAX_FAILURE_CODE_LEN), _now()],
456
+ );
457
+ }
458
+
459
+ await query(
460
+ "UPDATE subscription_invoices SET status = 'failed' WHERE id = ?1",
461
+ [invoiceId],
462
+ );
463
+ var refreshed = await _refetchInvoice(invoiceId);
464
+ // Surface next_retry_at on the returned row for the caller's
465
+ // scheduler hook without persisting it (the next retry is the
466
+ // processor's responsibility — the row carries it through as
467
+ // a non-stored hint).
468
+ refreshed.next_retry_at = nextRetryAt;
469
+ return refreshed;
470
+ },
471
+
472
+ enterDunning: async function (input) {
473
+ if (!input || typeof input !== "object") {
474
+ throw new TypeError("subscriptionBilling.enterDunning: input object required");
475
+ }
476
+ var subscriptionId = _uuid(input.subscription_id, "subscription_id");
477
+ var reason = _reason(input.reason);
478
+
479
+ if (!(await _subscriptionExists(subscriptionId))) {
480
+ var notFound = new Error("subscriptionBilling.enterDunning: subscription " + subscriptionId + " not found");
481
+ notFound.code = "SUBSCRIPTION_NOT_FOUND";
482
+ throw notFound;
483
+ }
484
+ var latest = await _latestDunning(subscriptionId);
485
+ if (latest && latest.state === "dunning" && latest.exited_at == null) {
486
+ var oErr = new Error("subscriptionBilling.enterDunning: refused — subscription is already in dunning");
487
+ oErr.code = "DUNNING_STATE_REFUSED";
488
+ throw oErr;
489
+ }
490
+
491
+ var id = _b().uuid.v7();
492
+ var ts = _now();
493
+ await query(
494
+ "INSERT INTO subscription_dunning_states " +
495
+ "(id, subscription_id, state, reason, entered_at, exited_at) " +
496
+ "VALUES (?1, ?2, 'dunning', ?3, ?4, NULL)",
497
+ [id, subscriptionId, reason, ts],
498
+ );
499
+ var r = await query("SELECT * FROM subscription_dunning_states WHERE id = ?1", [id]);
500
+ return r.rows[0];
501
+ },
502
+
503
+ exitDunning: async function (input) {
504
+ if (!input || typeof input !== "object") {
505
+ throw new TypeError("subscriptionBilling.exitDunning: input object required");
506
+ }
507
+ var subscriptionId = _uuid(input.subscription_id, "subscription_id");
508
+ var outcome = _exitOutcome(input.outcome);
509
+
510
+ var latest = await _latestDunning(subscriptionId);
511
+ if (!latest || latest.state !== "dunning" || latest.exited_at != null) {
512
+ var sErr = new Error("subscriptionBilling.exitDunning: refused — subscription is not currently in dunning");
513
+ sErr.code = "DUNNING_STATE_REFUSED";
514
+ throw sErr;
515
+ }
516
+
517
+ var ts = _now();
518
+ // Two writes: close the open dunning row (stamp exited_at)
519
+ // AND append a fresh row capturing the outcome state. The
520
+ // append-only shape keeps every transition discoverable on
521
+ // replay without an UPDATE-then-SELECT race against a
522
+ // concurrent dunningRoster reader.
523
+ await query(
524
+ "UPDATE subscription_dunning_states SET exited_at = ?1 WHERE id = ?2",
525
+ [ts, latest.id],
526
+ );
527
+ var id = _b().uuid.v7();
528
+ await query(
529
+ "INSERT INTO subscription_dunning_states " +
530
+ "(id, subscription_id, state, reason, entered_at, exited_at) " +
531
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?5)",
532
+ [id, subscriptionId, outcome, latest.reason, ts],
533
+ );
534
+ var r = await query("SELECT * FROM subscription_dunning_states WHERE id = ?1", [id]);
535
+ return r.rows[0];
536
+ },
537
+
538
+ invoicesForSubscription: async function (subscriptionId) {
539
+ subscriptionId = _uuid(subscriptionId, "subscription_id");
540
+ var r = await query(
541
+ "SELECT * FROM subscription_invoices WHERE subscription_id = ?1 " +
542
+ "ORDER BY created_at DESC, id DESC",
543
+ [subscriptionId],
544
+ );
545
+ return r.rows;
546
+ },
547
+
548
+ failedInvoices: async function (input) {
549
+ input = input || {};
550
+ var from = input.from == null ? 0 : _epochMs(input.from, "from");
551
+ var to = input.to == null ? Number.MAX_SAFE_INTEGER : _epochMs(input.to, "to");
552
+ if (from > to) {
553
+ throw new TypeError("subscriptionBilling.failedInvoices: from must be <= to");
554
+ }
555
+ var limit = _limit(input.limit);
556
+ var r = await query(
557
+ "SELECT * FROM subscription_invoices WHERE status = 'failed' " +
558
+ "AND created_at >= ?1 AND created_at <= ?2 " +
559
+ "ORDER BY created_at DESC, id DESC LIMIT ?3",
560
+ [from, to, limit],
561
+ );
562
+ return r.rows;
563
+ },
564
+
565
+ // Snapshot the subscriptions whose most-recent dunning row is
566
+ // open (`state = 'dunning'`, `exited_at IS NULL`) as of the
567
+ // caller-supplied epoch. Operator dashboards drive this from a
568
+ // per-minute scheduler walk so the working set tracks real-time
569
+ // dunning load.
570
+ dunningRoster: async function (input) {
571
+ if (!input || typeof input !== "object") {
572
+ throw new TypeError("subscriptionBilling.dunningRoster: input object required");
573
+ }
574
+ var asOf = _epochMs(input.as_of, "as_of");
575
+ // The partial index on (state, entered_at) WHERE exited_at IS
576
+ // NULL covers this query; the `entered_at <= asOf` clause lets
577
+ // operators "replay yesterday's roster" for trend analysis
578
+ // without a follow-up reporting table.
579
+ var r = await query(
580
+ "SELECT * FROM subscription_dunning_states " +
581
+ "WHERE state = 'dunning' AND exited_at IS NULL AND entered_at <= ?1 " +
582
+ "ORDER BY entered_at ASC, id ASC",
583
+ [asOf],
584
+ );
585
+ return r.rows;
586
+ },
587
+
588
+ // Average revenue per user: sum(amount_minor) over paid
589
+ // invoices in [from, to], divided by the distinct subscription
590
+ // count over the same window. Returned as a {currency:
591
+ // {total_minor, subscriptions, arpu_minor}} map so the caller
592
+ // renders single-currency dashboards (cross-currency
593
+ // aggregation requires an FX layer outside this primitive).
594
+ arpu: async function (input) {
595
+ if (!input || typeof input !== "object") {
596
+ throw new TypeError("subscriptionBilling.arpu: input object required");
597
+ }
598
+ var from = _epochMs(input.from, "from");
599
+ var to = _epochMs(input.to, "to");
600
+ if (from > to) {
601
+ throw new TypeError("subscriptionBilling.arpu: from must be <= to");
602
+ }
603
+ var r = await query(
604
+ "SELECT currency, SUM(amount_minor) AS total_minor, " +
605
+ " COUNT(DISTINCT subscription_id) AS subs " +
606
+ "FROM subscription_invoices " +
607
+ "WHERE status = 'paid' AND paid_at IS NOT NULL " +
608
+ "AND paid_at >= ?1 AND paid_at <= ?2 " +
609
+ "GROUP BY currency",
610
+ [from, to],
611
+ );
612
+ var out = {};
613
+ for (var i = 0; i < r.rows.length; i += 1) {
614
+ var row = r.rows[i];
615
+ var totalMinor = row.total_minor == null ? 0 : Number(row.total_minor);
616
+ var subs = row.subs == null ? 0 : Number(row.subs);
617
+ // Integer division (floor) — minor units stay integer-only.
618
+ // A fractional ARPU display is the caller's render-tier
619
+ // concern (locale-aware money formatting); the primitive
620
+ // returns the floor + the raw inputs so the caller can
621
+ // compute the fraction if needed.
622
+ var arpu = subs === 0 ? 0 : Math.floor(totalMinor / subs);
623
+ out[row.currency] = {
624
+ total_minor: totalMinor,
625
+ subscriptions: subs,
626
+ arpu_minor: arpu,
627
+ };
628
+ }
629
+ return out;
630
+ },
631
+ };
632
+ }
633
+
634
+ module.exports = {
635
+ create: create,
636
+ INVOICE_STATUSES: INVOICE_STATUSES.slice(),
637
+ ATTEMPT_STATUSES: ATTEMPT_STATUSES.slice(),
638
+ DUNNING_STATES: DUNNING_STATES.slice(),
639
+ EXIT_OUTCOMES: EXIT_OUTCOMES.slice(),
640
+ MAX_INVOICE_URL_LEN: MAX_INVOICE_URL_LEN,
641
+ MAX_REASON_LEN: MAX_REASON_LEN,
642
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
643
+ DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
644
+ };