@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.
- package/CHANGELOG.md +4 -0
- package/lib/api-keys.js +789 -0
- package/lib/barcodes.js +671 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/coupon-stacking.js +717 -0
- package/lib/customer-import.js +590 -0
- package/lib/customer-portal.js +359 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/experiments.js +697 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/index.js +25 -0
- package/lib/inventory-snapshots.js +691 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/print-receipts.js +675 -0
- package/lib/product-import.js +1034 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +945 -0
- package/lib/storefront-forms.js +884 -0
- package/lib/storefront-pages.js +701 -0
- package/lib/subscription-billing.js +644 -0
- package/lib/tax-rates.js +559 -0
- package/lib/tenants.js +665 -0
- package/lib/translations.js +553 -0
- package/lib/webhook-subscriptions.js +565 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|