@blamejs/blamejs-shop 0.0.64 → 0.0.66
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/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/business-hours.js +980 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +30 -0
- package/lib/metered-usage.js +782 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/preorder.js +595 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/site-redirects.js +690 -0
- package/lib/split-shipments.js +773 -0
- package/lib/theme-assets.js +711 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.paymentRetries
|
|
4
|
+
* @title Payment retries — failure-code-aware retry orchestration for
|
|
5
|
+
* failed one-time payments
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Recovery orchestration for one-time payment failures. Sibling to
|
|
9
|
+
* `dunning` (which owns the subscription-invoice escalation walk) —
|
|
10
|
+
* this primitive owns the per-payment retry sequence keyed on the
|
|
11
|
+
* processor's `failure_code`. Different decline reasons map to
|
|
12
|
+
* different retry strategies:
|
|
13
|
+
*
|
|
14
|
+
* insufficient_funds → wait 24h, retry once or twice; the bank
|
|
15
|
+
* account probably topped up overnight
|
|
16
|
+
* card_declined → wait 7d, retry once; an issuer hold may
|
|
17
|
+
* have cleared, but back-to-back retries
|
|
18
|
+
* burn fraud-score budget
|
|
19
|
+
* invalid_card → terminal immediately; no retry will ever
|
|
20
|
+
* succeed, prompt the customer for a new
|
|
21
|
+
* card instead
|
|
22
|
+
*
|
|
23
|
+
* The shape:
|
|
24
|
+
*
|
|
25
|
+
* var pr = bShop.paymentRetries.create({
|
|
26
|
+
* query: q,
|
|
27
|
+
* payment: pay, // optional — for retry composition
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* await pr.defineRetryPolicy({
|
|
31
|
+
* slug: "default-insufficient-funds",
|
|
32
|
+
* failure_code: "insufficient_funds",
|
|
33
|
+
* retry_schedule: [24, 48], // hours
|
|
34
|
+
* max_attempts: 2,
|
|
35
|
+
* terminal_after_attempts: 0,
|
|
36
|
+
* });
|
|
37
|
+
* await pr.defineRetryPolicy({
|
|
38
|
+
* slug: "default-card-declined",
|
|
39
|
+
* failure_code: "card_declined",
|
|
40
|
+
* retry_schedule: [168], // 7 days
|
|
41
|
+
* max_attempts: 1,
|
|
42
|
+
* terminal_after_attempts: 0,
|
|
43
|
+
* });
|
|
44
|
+
* await pr.defineRetryPolicy({
|
|
45
|
+
* slug: "default-invalid-card",
|
|
46
|
+
* failure_code: "invalid_card",
|
|
47
|
+
* retry_schedule: [],
|
|
48
|
+
* max_attempts: 1,
|
|
49
|
+
* terminal_after_attempts: 1, // first failure already counted; terminal at attempt 1
|
|
50
|
+
* });
|
|
51
|
+
*
|
|
52
|
+
* // Processor webhook: `payment_intent.payment_failed` arrives —
|
|
53
|
+
* // operator enrolls. The primitive picks the right policy by
|
|
54
|
+
* // failure_code; an `invalid_card` enrollment short-circuits to
|
|
55
|
+
* // abandoned at creation time so the storefront can prompt for a
|
|
56
|
+
* // fresh card immediately.
|
|
57
|
+
* await pr.enrollFailure({
|
|
58
|
+
* payment_intent_id: pi_id,
|
|
59
|
+
* order_id: order_id,
|
|
60
|
+
* customer_id: customer_id,
|
|
61
|
+
* failure_code: "insufficient_funds",
|
|
62
|
+
* });
|
|
63
|
+
*
|
|
64
|
+
* // Cron tick — walks every active enrollment whose
|
|
65
|
+
* // next_retry_at is due. The injected `payment` handle, if
|
|
66
|
+
* // wired, is composed via `payment.retryIntent(...)`; absent the
|
|
67
|
+
* // handle the tick still advances state so the operator can
|
|
68
|
+
* // drive retries out-of-band.
|
|
69
|
+
* await pr.tickRetries({ now: Date.now() });
|
|
70
|
+
*
|
|
71
|
+
* // Operator reports the outcome of an actual processor retry
|
|
72
|
+
* // (whether the tick fired it or an external job did). Success
|
|
73
|
+
* // closes the enrollment as recovered; failure stamps a new
|
|
74
|
+
* // failure_code + advances the schedule.
|
|
75
|
+
* await pr.recordRetryOutcome({
|
|
76
|
+
* retry_id: enrollment_id,
|
|
77
|
+
* succeeded: false,
|
|
78
|
+
* new_failure_code: "card_declined",
|
|
79
|
+
* });
|
|
80
|
+
*
|
|
81
|
+
* // Customer paid out-of-band, refunded, or operator wrote off —
|
|
82
|
+
* // clear the enrollment so the scheduler stops.
|
|
83
|
+
* await pr.unenrollPayment({
|
|
84
|
+
* payment_intent_id: pi_id,
|
|
85
|
+
* reason: "paid_out_of_band",
|
|
86
|
+
* });
|
|
87
|
+
*
|
|
88
|
+
* await pr.statusForPayment(pi_id);
|
|
89
|
+
* await pr.historyForPayment(pi_id);
|
|
90
|
+
* await pr.policiesForFailureCode("insufficient_funds");
|
|
91
|
+
* await pr.metricsForPolicy({ slug, from, to });
|
|
92
|
+
*
|
|
93
|
+
* The enrollment row's `status` is the FSM:
|
|
94
|
+
*
|
|
95
|
+
* active — scheduler walk still working
|
|
96
|
+
* recovered — recordRetryOutcome(succeeded: true) OR unenrollPayment
|
|
97
|
+
* with a recovery reason
|
|
98
|
+
* exhausted — max_attempts hit or the schedule ran out
|
|
99
|
+
* abandoned — terminal_after_attempts cap fired (invalid_card
|
|
100
|
+
* short-circuit) OR operator unenrolled with a non-
|
|
101
|
+
* recovery reason
|
|
102
|
+
*
|
|
103
|
+
* `terminal_after_attempts` is the short-circuit cap for failure
|
|
104
|
+
* codes that will never succeed on retry (invalid_card, expired_card).
|
|
105
|
+
* Setting it to 1 means the first failure already counts as the
|
|
106
|
+
* terminal one — enrollFailure closes the row as abandoned without
|
|
107
|
+
* scheduling any retry.
|
|
108
|
+
*
|
|
109
|
+
* `max_attempts` is the safety cap that prevents an over-eager
|
|
110
|
+
* schedule from spinning. A policy with `retry_schedule: [24, 48]`
|
|
111
|
+
* and `max_attempts: 2` walks at most two retries, then closes as
|
|
112
|
+
* exhausted regardless of outcome.
|
|
113
|
+
*
|
|
114
|
+
* `policiesForFailureCode` is the lookup the operator's webhook
|
|
115
|
+
* handler runs to pick the active (non-archived) policy when more
|
|
116
|
+
* than one is defined for the same failure_code (e.g. operator A/B
|
|
117
|
+
* testing two schedules). The primitive itself picks the most-
|
|
118
|
+
* recently-updated active policy by default; operators wanting more
|
|
119
|
+
* control inspect this list and call enrollFailure with an explicit
|
|
120
|
+
* `policy_slug`.
|
|
121
|
+
*
|
|
122
|
+
* Composition: zero npm runtime deps. Per-factory monotonic clock
|
|
123
|
+
* guarantees `occurred_at` on the attempts log is strictly
|
|
124
|
+
* increasing even when multiple recordRetryOutcome calls land
|
|
125
|
+
* inside a 1ms wall-clock tick.
|
|
126
|
+
*
|
|
127
|
+
* @related b.uuid, b.guardUuid, shop.dunning, shop.subscriptionBilling,
|
|
128
|
+
* shop.payment
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
var bShop;
|
|
132
|
+
function _b() {
|
|
133
|
+
if (!bShop) bShop = require("./index");
|
|
134
|
+
return bShop.framework;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- constants ----------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
var STATUSES = ["active", "recovered", "exhausted", "abandoned"];
|
|
140
|
+
|
|
141
|
+
var MAX_SLUG_LEN = 64;
|
|
142
|
+
var MAX_FAILURE_CODE_LEN = 64;
|
|
143
|
+
var MAX_REASON_LEN = 280;
|
|
144
|
+
var MAX_DELAY_HOURS = 24 * 365; // 1 year — sane cap
|
|
145
|
+
var MAX_SCHEDULE_STEPS = 64;
|
|
146
|
+
var MAX_ATTEMPTS_CAP = 64;
|
|
147
|
+
var MAX_LIST_LIMIT = 500;
|
|
148
|
+
var DEFAULT_LIST_LIMIT = 100;
|
|
149
|
+
var MS_PER_HOUR = 60 * 60 * 1000;
|
|
150
|
+
|
|
151
|
+
var SLUG_RE = /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
152
|
+
var FAILURE_CODE_RE = /^[a-z](?:[a-z0-9_]*[a-z0-9])?$/;
|
|
153
|
+
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
154
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
155
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// ---- validators ---------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
function _uuid(s, label) {
|
|
161
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
162
|
+
catch (e) {
|
|
163
|
+
throw new TypeError("paymentRetries: " + label + " — " + (e && e.message || "invalid UUID"));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _slug(s) {
|
|
168
|
+
if (typeof s !== "string" || !s.length) {
|
|
169
|
+
throw new TypeError("paymentRetries: slug must be a non-empty string");
|
|
170
|
+
}
|
|
171
|
+
if (s.length > MAX_SLUG_LEN) {
|
|
172
|
+
throw new TypeError("paymentRetries: slug must be <= " + MAX_SLUG_LEN + " characters");
|
|
173
|
+
}
|
|
174
|
+
if (!SLUG_RE.test(s)) {
|
|
175
|
+
throw new TypeError("paymentRetries: slug must match /[a-z][a-z0-9-]*[a-z0-9]/");
|
|
176
|
+
}
|
|
177
|
+
return s;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _failureCode(s) {
|
|
181
|
+
if (typeof s !== "string" || !s.length) {
|
|
182
|
+
throw new TypeError("paymentRetries: failure_code must be a non-empty string");
|
|
183
|
+
}
|
|
184
|
+
if (s.length > MAX_FAILURE_CODE_LEN) {
|
|
185
|
+
throw new TypeError("paymentRetries: failure_code must be <= " + MAX_FAILURE_CODE_LEN + " characters");
|
|
186
|
+
}
|
|
187
|
+
if (!FAILURE_CODE_RE.test(s)) {
|
|
188
|
+
throw new TypeError("paymentRetries: failure_code must match /[a-z][a-z0-9_]*[a-z0-9]/");
|
|
189
|
+
}
|
|
190
|
+
return s;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _retrySchedule(arr) {
|
|
194
|
+
if (!Array.isArray(arr)) {
|
|
195
|
+
throw new TypeError("paymentRetries: retry_schedule must be an array of integer hour offsets");
|
|
196
|
+
}
|
|
197
|
+
if (arr.length > MAX_SCHEDULE_STEPS) {
|
|
198
|
+
throw new TypeError("paymentRetries: retry_schedule must have <= " + MAX_SCHEDULE_STEPS + " steps");
|
|
199
|
+
}
|
|
200
|
+
var out = [];
|
|
201
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
202
|
+
var hrs = arr[i];
|
|
203
|
+
if (!Number.isInteger(hrs) || hrs < 0 || hrs > MAX_DELAY_HOURS) {
|
|
204
|
+
throw new TypeError(
|
|
205
|
+
"paymentRetries: retry_schedule[" + i + "] must be an integer in [0, " + MAX_DELAY_HOURS + "]"
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
out.push(hrs);
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function _maxAttempts(n) {
|
|
214
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_ATTEMPTS_CAP) {
|
|
215
|
+
throw new TypeError(
|
|
216
|
+
"paymentRetries: max_attempts must be an integer in [1, " + MAX_ATTEMPTS_CAP + "]"
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return n;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function _terminalAfterAttempts(n) {
|
|
223
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_ATTEMPTS_CAP) {
|
|
224
|
+
throw new TypeError(
|
|
225
|
+
"paymentRetries: terminal_after_attempts must be an integer in [0, " + MAX_ATTEMPTS_CAP + "]"
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return n;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function _reason(s) {
|
|
232
|
+
if (typeof s !== "string" || !s.length) {
|
|
233
|
+
throw new TypeError("paymentRetries: reason must be a non-empty string");
|
|
234
|
+
}
|
|
235
|
+
if (s.length > MAX_REASON_LEN) {
|
|
236
|
+
throw new TypeError("paymentRetries: reason must be <= " + MAX_REASON_LEN + " characters");
|
|
237
|
+
}
|
|
238
|
+
if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
239
|
+
throw new TypeError("paymentRetries: reason contains control / zero-width bytes");
|
|
240
|
+
}
|
|
241
|
+
return s;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function _epochMs(n, label) {
|
|
245
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
246
|
+
throw new TypeError("paymentRetries: " + label + " must be a positive integer (epoch ms)");
|
|
247
|
+
}
|
|
248
|
+
return n;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function _limit(n) {
|
|
252
|
+
if (n == null) return DEFAULT_LIST_LIMIT;
|
|
253
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
254
|
+
throw new TypeError("paymentRetries: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
255
|
+
}
|
|
256
|
+
return n;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function _parseSchedule(json) {
|
|
260
|
+
// Stored JSON is operator-authored + validated at write; re-parse
|
|
261
|
+
// defensively (a downstream DB tool could have rewritten the row)
|
|
262
|
+
// so a corrupt row fails LOUD at the tick instead of silently mis-
|
|
263
|
+
// scheduling.
|
|
264
|
+
try {
|
|
265
|
+
var parsed = JSON.parse(json);
|
|
266
|
+
return _retrySchedule(parsed);
|
|
267
|
+
} catch (e) {
|
|
268
|
+
var pErr = new Error("paymentRetries: stored retry_schedule_json is malformed (" + (e && e.message || "parse error") + ")");
|
|
269
|
+
pErr.code = "POLICY_CORRUPT";
|
|
270
|
+
throw pErr;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function _now() { return Date.now(); }
|
|
275
|
+
|
|
276
|
+
// ---- factory ------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
function create(opts) {
|
|
279
|
+
opts = opts || {};
|
|
280
|
+
var query = opts.query;
|
|
281
|
+
if (!query) {
|
|
282
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
283
|
+
}
|
|
284
|
+
// Injected handles. `payment` is surface-optional — when wired,
|
|
285
|
+
// tickRetries composes `payment.retryIntent({...})` to trigger the
|
|
286
|
+
// actual processor retry. Absent the handle, tickRetries still
|
|
287
|
+
// advances the schedule state so an operator can drive the retry
|
|
288
|
+
// out-of-band and report the outcome via recordRetryOutcome.
|
|
289
|
+
var paymentHandle = opts.payment || null;
|
|
290
|
+
|
|
291
|
+
// Per-factory monotonic clock — guarantees `occurred_at` on the
|
|
292
|
+
// attempts log is strictly increasing across calls emitted by the
|
|
293
|
+
// same primitive instance, even when the wall clock has 1ms
|
|
294
|
+
// resolution and the test loop emits multiple events inside a
|
|
295
|
+
// single tick. Forward-leap if Date.now() outpaces the counter;
|
|
296
|
+
// otherwise bump by 1ms.
|
|
297
|
+
var _lastEventTs = 0;
|
|
298
|
+
function _monotonicTs() {
|
|
299
|
+
var wall = _now();
|
|
300
|
+
if (wall > _lastEventTs) _lastEventTs = wall;
|
|
301
|
+
else _lastEventTs += 1;
|
|
302
|
+
return _lastEventTs;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// -------- internal helpers --------
|
|
306
|
+
|
|
307
|
+
async function _getPolicy(slug) {
|
|
308
|
+
var r = await query("SELECT * FROM payment_retry_policies WHERE slug = ?1", [slug]);
|
|
309
|
+
return r.rows[0] || null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Pick the most-recently-updated non-archived policy for a given
|
|
313
|
+
// failure_code. enrollFailure uses this when the caller didn't
|
|
314
|
+
// pass an explicit `policy_slug`; operators with multiple policies
|
|
315
|
+
// for the same failure code should call policiesForFailureCode and
|
|
316
|
+
// pass the slug they want.
|
|
317
|
+
async function _pickPolicyForFailureCode(failureCode) {
|
|
318
|
+
var r = await query(
|
|
319
|
+
"SELECT * FROM payment_retry_policies " +
|
|
320
|
+
"WHERE failure_code = ?1 AND archived_at IS NULL " +
|
|
321
|
+
"ORDER BY updated_at DESC, slug ASC LIMIT 1",
|
|
322
|
+
[failureCode],
|
|
323
|
+
);
|
|
324
|
+
return r.rows[0] || null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function _getEnrollment(paymentIntentId) {
|
|
328
|
+
var r = await query("SELECT * FROM payment_retry_enrollments WHERE payment_intent_id = ?1", [paymentIntentId]);
|
|
329
|
+
return r.rows[0] || null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function _refetchEnrollment(id) {
|
|
333
|
+
var r = await query("SELECT * FROM payment_retry_enrollments WHERE id = ?1", [id]);
|
|
334
|
+
return r.rows[0] || null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function _writeAttempt(enrollmentId, attemptNumber, succeeded, failureCode) {
|
|
338
|
+
var id = _b().uuid.v7();
|
|
339
|
+
var ts = _monotonicTs();
|
|
340
|
+
await query(
|
|
341
|
+
"INSERT INTO payment_retry_attempts " +
|
|
342
|
+
"(id, enrollment_id, attempt_number, succeeded, failure_code, occurred_at) " +
|
|
343
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
344
|
+
[id, enrollmentId, attemptNumber, succeeded ? 1 : 0, failureCode || null, ts],
|
|
345
|
+
);
|
|
346
|
+
return ts;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Compute the next_retry_at for the given attempt slot. Returns
|
|
350
|
+
// null when the schedule has been exhausted (the caller decides
|
|
351
|
+
// whether to close as exhausted or keep waiting on an external
|
|
352
|
+
// recordRetryOutcome call).
|
|
353
|
+
function _planNextRetry(schedule, nextAttemptIndex, fromTs) {
|
|
354
|
+
if (nextAttemptIndex >= schedule.length) return null;
|
|
355
|
+
var hrs = schedule[nextAttemptIndex];
|
|
356
|
+
return fromTs + hrs * MS_PER_HOUR;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Advance ONE enrollment by one scheduler tick. Records the
|
|
360
|
+
// attempt as failed-by-default (the actual processor call is
|
|
361
|
+
// composed via the injected `payment` handle; success requires the
|
|
362
|
+
// operator to call recordRetryOutcome with succeeded=true). Returns
|
|
363
|
+
// the refreshed row.
|
|
364
|
+
async function _tickAdvance(enrollment, policy, schedule, now) {
|
|
365
|
+
var attemptCount = Number(enrollment.attempt_count);
|
|
366
|
+
var maxAttempts = Number(policy.max_attempts);
|
|
367
|
+
var nextAttemptNumber = attemptCount + 1;
|
|
368
|
+
|
|
369
|
+
// Try composing the processor retry. The handle is best-effort;
|
|
370
|
+
// a throw is captured as a failed attempt with the thrown
|
|
371
|
+
// failure_code (when available) or "retry_error".
|
|
372
|
+
var attemptFailureCode = enrollment.last_failure_code || policy.failure_code;
|
|
373
|
+
var attemptSucceeded = false;
|
|
374
|
+
if (paymentHandle && typeof paymentHandle.retryIntent === "function") {
|
|
375
|
+
try {
|
|
376
|
+
var result = await paymentHandle.retryIntent({
|
|
377
|
+
payment_intent_id: enrollment.payment_intent_id,
|
|
378
|
+
order_id: enrollment.order_id,
|
|
379
|
+
attempt_number: nextAttemptNumber,
|
|
380
|
+
});
|
|
381
|
+
if (result && result.succeeded === true) {
|
|
382
|
+
attemptSucceeded = true;
|
|
383
|
+
attemptFailureCode = null;
|
|
384
|
+
} else if (result && typeof result.failure_code === "string") {
|
|
385
|
+
attemptFailureCode = result.failure_code;
|
|
386
|
+
}
|
|
387
|
+
} catch (_e) {
|
|
388
|
+
attemptFailureCode = "retry_error";
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
await _writeAttempt(enrollment.id, nextAttemptNumber, attemptSucceeded, attemptFailureCode);
|
|
393
|
+
|
|
394
|
+
if (attemptSucceeded) {
|
|
395
|
+
await query(
|
|
396
|
+
"UPDATE payment_retry_enrollments SET status = 'recovered', next_retry_at = NULL, " +
|
|
397
|
+
"attempt_count = ?1, last_failure_code = NULL, ended_at = ?2, end_reason = 'retry_succeeded' " +
|
|
398
|
+
"WHERE id = ?3",
|
|
399
|
+
[nextAttemptNumber, now, enrollment.id],
|
|
400
|
+
);
|
|
401
|
+
return await _refetchEnrollment(enrollment.id);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Cap check: if we just used the last allowed attempt, close as
|
|
405
|
+
// exhausted regardless of whether the schedule has more entries.
|
|
406
|
+
if (nextAttemptNumber >= maxAttempts) {
|
|
407
|
+
await query(
|
|
408
|
+
"UPDATE payment_retry_enrollments SET status = 'exhausted', next_retry_at = NULL, " +
|
|
409
|
+
"attempt_count = ?1, last_failure_code = ?2, ended_at = ?3, end_reason = 'max_attempts' " +
|
|
410
|
+
"WHERE id = ?4",
|
|
411
|
+
[nextAttemptNumber, attemptFailureCode, now, enrollment.id],
|
|
412
|
+
);
|
|
413
|
+
return await _refetchEnrollment(enrollment.id);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Schedule has more entries → plan the next retry. The schedule
|
|
417
|
+
// is indexed by retry slot (zero-based: schedule[0] is the
|
|
418
|
+
// delay before attempt #2 — the first scheduler-driven retry,
|
|
419
|
+
// since the initial failure is attempt #1; schedule[1] is the
|
|
420
|
+
// delay before attempt #3, and so on). When tickAdvance just
|
|
421
|
+
// ran attempt #N, the next slot index is N-1.
|
|
422
|
+
var nextAt = _planNextRetry(schedule, nextAttemptNumber - 1, now);
|
|
423
|
+
if (nextAt == null) {
|
|
424
|
+
// Cap not hit yet but no more schedule entries — close as
|
|
425
|
+
// exhausted so a poorly-authored schedule + cap pair can't
|
|
426
|
+
// leave the row spinning.
|
|
427
|
+
await query(
|
|
428
|
+
"UPDATE payment_retry_enrollments SET status = 'exhausted', next_retry_at = NULL, " +
|
|
429
|
+
"attempt_count = ?1, last_failure_code = ?2, ended_at = ?3, end_reason = 'schedule_exhausted' " +
|
|
430
|
+
"WHERE id = ?4",
|
|
431
|
+
[nextAttemptNumber, attemptFailureCode, now, enrollment.id],
|
|
432
|
+
);
|
|
433
|
+
return await _refetchEnrollment(enrollment.id);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
await query(
|
|
437
|
+
"UPDATE payment_retry_enrollments SET attempt_count = ?1, " +
|
|
438
|
+
"last_failure_code = ?2, next_retry_at = ?3 WHERE id = ?4",
|
|
439
|
+
[nextAttemptNumber, attemptFailureCode, nextAt, enrollment.id],
|
|
440
|
+
);
|
|
441
|
+
return await _refetchEnrollment(enrollment.id);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// -------- public surface --------
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
STATUSES: STATUSES.slice(),
|
|
448
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
449
|
+
MAX_FAILURE_CODE_LEN: MAX_FAILURE_CODE_LEN,
|
|
450
|
+
MAX_REASON_LEN: MAX_REASON_LEN,
|
|
451
|
+
MAX_DELAY_HOURS: MAX_DELAY_HOURS,
|
|
452
|
+
MAX_SCHEDULE_STEPS: MAX_SCHEDULE_STEPS,
|
|
453
|
+
MAX_ATTEMPTS_CAP: MAX_ATTEMPTS_CAP,
|
|
454
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
455
|
+
DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
|
|
456
|
+
|
|
457
|
+
defineRetryPolicy: async function (input) {
|
|
458
|
+
if (!input || typeof input !== "object") {
|
|
459
|
+
throw new TypeError("paymentRetries.defineRetryPolicy: input object required");
|
|
460
|
+
}
|
|
461
|
+
var slug = _slug(input.slug);
|
|
462
|
+
var failureCode = _failureCode(input.failure_code);
|
|
463
|
+
var schedule = _retrySchedule(input.retry_schedule);
|
|
464
|
+
var maxAttempts = _maxAttempts(input.max_attempts);
|
|
465
|
+
var terminalAfter = _terminalAfterAttempts(input.terminal_after_attempts);
|
|
466
|
+
var now = _now();
|
|
467
|
+
var json = JSON.stringify(schedule);
|
|
468
|
+
|
|
469
|
+
var existing = await _getPolicy(slug);
|
|
470
|
+
if (existing) {
|
|
471
|
+
// Re-define is an upsert. archived_at is preserved on a
|
|
472
|
+
// rewrite (a separate archive surface would clear it).
|
|
473
|
+
await query(
|
|
474
|
+
"UPDATE payment_retry_policies SET failure_code = ?1, retry_schedule_json = ?2, " +
|
|
475
|
+
"max_attempts = ?3, terminal_after_attempts = ?4, updated_at = ?5 WHERE slug = ?6",
|
|
476
|
+
[failureCode, json, maxAttempts, terminalAfter, now, slug],
|
|
477
|
+
);
|
|
478
|
+
} else {
|
|
479
|
+
await query(
|
|
480
|
+
"INSERT INTO payment_retry_policies " +
|
|
481
|
+
"(slug, failure_code, retry_schedule_json, max_attempts, terminal_after_attempts, archived_at, created_at, updated_at) " +
|
|
482
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?6)",
|
|
483
|
+
[slug, failureCode, json, maxAttempts, terminalAfter, now],
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
return await _getPolicy(slug);
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
enrollFailure: async function (input) {
|
|
490
|
+
if (!input || typeof input !== "object") {
|
|
491
|
+
throw new TypeError("paymentRetries.enrollFailure: input object required");
|
|
492
|
+
}
|
|
493
|
+
var paymentIntentId = _uuid(input.payment_intent_id, "payment_intent_id");
|
|
494
|
+
var orderId = _uuid(input.order_id, "order_id");
|
|
495
|
+
var customerId = input.customer_id == null
|
|
496
|
+
? null
|
|
497
|
+
: _uuid(input.customer_id, "customer_id");
|
|
498
|
+
var failureCode = _failureCode(input.failure_code);
|
|
499
|
+
var occurredAt = input.occurred_at == null ? _now() : _epochMs(input.occurred_at, "occurred_at");
|
|
500
|
+
|
|
501
|
+
// Idempotent replay — webhook redelivery of the same
|
|
502
|
+
// `payment_intent.payment_failed` collapses to one row.
|
|
503
|
+
var existing = await _getEnrollment(paymentIntentId);
|
|
504
|
+
if (existing) return existing;
|
|
505
|
+
|
|
506
|
+
// Operator may pass an explicit policy_slug to override the
|
|
507
|
+
// failure-code lookup. When absent, pick the most-recently-
|
|
508
|
+
// updated active policy for this failure_code.
|
|
509
|
+
var policy;
|
|
510
|
+
if (input.policy_slug != null) {
|
|
511
|
+
policy = await _getPolicy(_slug(input.policy_slug));
|
|
512
|
+
if (!policy) {
|
|
513
|
+
var nfErr = new Error("paymentRetries.enrollFailure: policy " + input.policy_slug + " not found");
|
|
514
|
+
nfErr.code = "POLICY_NOT_FOUND";
|
|
515
|
+
throw nfErr;
|
|
516
|
+
}
|
|
517
|
+
if (policy.archived_at != null) {
|
|
518
|
+
var aErr = new Error("paymentRetries.enrollFailure: policy " + input.policy_slug + " is archived");
|
|
519
|
+
aErr.code = "POLICY_ARCHIVED";
|
|
520
|
+
throw aErr;
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
policy = await _pickPolicyForFailureCode(failureCode);
|
|
524
|
+
if (!policy) {
|
|
525
|
+
var nfErr2 = new Error("paymentRetries.enrollFailure: no policy for failure_code " + failureCode);
|
|
526
|
+
nfErr2.code = "POLICY_NOT_FOUND";
|
|
527
|
+
throw nfErr2;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
var schedule = _parseSchedule(policy.retry_schedule_json);
|
|
532
|
+
var terminalAfter = Number(policy.terminal_after_attempts);
|
|
533
|
+
|
|
534
|
+
// The initial failure is attempt #1 from the customer's
|
|
535
|
+
// perspective (the original charge attempt). terminal_after_
|
|
536
|
+
// attempts is checked against attempt_count=1 — so a policy
|
|
537
|
+
// with terminal_after_attempts=1 closes the row as abandoned
|
|
538
|
+
// immediately (invalid_card / expired_card semantics).
|
|
539
|
+
var id = _b().uuid.v7();
|
|
540
|
+
|
|
541
|
+
if (terminalAfter > 0 && 1 >= terminalAfter) {
|
|
542
|
+
// Terminal at enrollment: write the row directly to
|
|
543
|
+
// abandoned + log the initial failure as attempt #1.
|
|
544
|
+
await query(
|
|
545
|
+
"INSERT INTO payment_retry_enrollments " +
|
|
546
|
+
"(id, payment_intent_id, order_id, customer_id, policy_slug, status, attempt_count, " +
|
|
547
|
+
" next_retry_at, last_failure_code, ended_at, end_reason, created_at) " +
|
|
548
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 'abandoned', 1, NULL, ?6, ?7, 'terminal_failure_code', ?7)",
|
|
549
|
+
[id, paymentIntentId, orderId, customerId, policy.slug, failureCode, occurredAt],
|
|
550
|
+
);
|
|
551
|
+
await _writeAttempt(id, 1, false, failureCode);
|
|
552
|
+
return await _refetchEnrollment(id);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Schedule the first retry. schedule[0] is the delay before
|
|
556
|
+
// the first scheduler-driven retry; an empty schedule means
|
|
557
|
+
// there's no scheduled retry, so close as exhausted (operator
|
|
558
|
+
// can still call recordRetryOutcome to drive things manually,
|
|
559
|
+
// but the scheduler walk is a no-op).
|
|
560
|
+
var nextAt = _planNextRetry(schedule, 0, occurredAt);
|
|
561
|
+
var statusStr = nextAt == null ? "exhausted" : "active";
|
|
562
|
+
var endedAt = nextAt == null ? occurredAt : null;
|
|
563
|
+
var endReason = nextAt == null ? "schedule_exhausted" : null;
|
|
564
|
+
|
|
565
|
+
await query(
|
|
566
|
+
"INSERT INTO payment_retry_enrollments " +
|
|
567
|
+
"(id, payment_intent_id, order_id, customer_id, policy_slug, status, attempt_count, " +
|
|
568
|
+
" next_retry_at, last_failure_code, ended_at, end_reason, created_at) " +
|
|
569
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9, ?10, ?11)",
|
|
570
|
+
[id, paymentIntentId, orderId, customerId, policy.slug, statusStr, nextAt, failureCode, endedAt, endReason, occurredAt],
|
|
571
|
+
);
|
|
572
|
+
await _writeAttempt(id, 1, false, failureCode);
|
|
573
|
+
return await _refetchEnrollment(id);
|
|
574
|
+
},
|
|
575
|
+
|
|
576
|
+
tickRetries: async function (input) {
|
|
577
|
+
if (!input || typeof input !== "object") {
|
|
578
|
+
throw new TypeError("paymentRetries.tickRetries: input object required");
|
|
579
|
+
}
|
|
580
|
+
var now = _epochMs(input.now, "now");
|
|
581
|
+
var batchSize = _limit(input.batch_size);
|
|
582
|
+
|
|
583
|
+
var due = await query(
|
|
584
|
+
"SELECT * FROM payment_retry_enrollments " +
|
|
585
|
+
"WHERE status = 'active' AND next_retry_at IS NOT NULL AND next_retry_at <= ?1 " +
|
|
586
|
+
"ORDER BY next_retry_at ASC, id ASC LIMIT ?2",
|
|
587
|
+
[now, batchSize],
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
var advanced = [];
|
|
591
|
+
for (var i = 0; i < due.rows.length; i += 1) {
|
|
592
|
+
var enrollment = due.rows[i];
|
|
593
|
+
var policy = await _getPolicy(enrollment.policy_slug);
|
|
594
|
+
if (!policy) {
|
|
595
|
+
// Policy went missing between enrollment + tick — close
|
|
596
|
+
// as abandoned so the row doesn't spin forever.
|
|
597
|
+
await query(
|
|
598
|
+
"UPDATE payment_retry_enrollments SET status = 'abandoned', next_retry_at = NULL, " +
|
|
599
|
+
"ended_at = ?1, end_reason = 'policy_missing' WHERE id = ?2",
|
|
600
|
+
[now, enrollment.id],
|
|
601
|
+
);
|
|
602
|
+
advanced.push(await _refetchEnrollment(enrollment.id));
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
var schedule = _parseSchedule(policy.retry_schedule_json);
|
|
606
|
+
var refreshed = await _tickAdvance(enrollment, policy, schedule, now);
|
|
607
|
+
advanced.push(refreshed);
|
|
608
|
+
}
|
|
609
|
+
return advanced;
|
|
610
|
+
},
|
|
611
|
+
|
|
612
|
+
recordRetryOutcome: async function (input) {
|
|
613
|
+
if (!input || typeof input !== "object") {
|
|
614
|
+
throw new TypeError("paymentRetries.recordRetryOutcome: input object required");
|
|
615
|
+
}
|
|
616
|
+
var retryId = _uuid(input.retry_id, "retry_id");
|
|
617
|
+
if (typeof input.succeeded !== "boolean") {
|
|
618
|
+
throw new TypeError("paymentRetries.recordRetryOutcome: succeeded must be a boolean");
|
|
619
|
+
}
|
|
620
|
+
var succeeded = input.succeeded;
|
|
621
|
+
var newFailureCode = input.new_failure_code == null
|
|
622
|
+
? null
|
|
623
|
+
: _failureCode(input.new_failure_code);
|
|
624
|
+
|
|
625
|
+
var r = await query("SELECT * FROM payment_retry_enrollments WHERE id = ?1", [retryId]);
|
|
626
|
+
var enrollment = r.rows[0];
|
|
627
|
+
if (!enrollment) {
|
|
628
|
+
var nfErr = new Error("paymentRetries.recordRetryOutcome: retry_id " + retryId + " not found");
|
|
629
|
+
nfErr.code = "ENROLLMENT_NOT_FOUND";
|
|
630
|
+
throw nfErr;
|
|
631
|
+
}
|
|
632
|
+
if (enrollment.status !== "active") {
|
|
633
|
+
// Already terminal — no-op idempotent return so webhook
|
|
634
|
+
// redelivery doesn't fail.
|
|
635
|
+
return enrollment;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
var policy = await _getPolicy(enrollment.policy_slug);
|
|
639
|
+
if (!policy) {
|
|
640
|
+
var pErr = new Error("paymentRetries.recordRetryOutcome: policy " + enrollment.policy_slug + " missing");
|
|
641
|
+
pErr.code = "POLICY_NOT_FOUND";
|
|
642
|
+
throw pErr;
|
|
643
|
+
}
|
|
644
|
+
var schedule = _parseSchedule(policy.retry_schedule_json);
|
|
645
|
+
var maxAttempts = Number(policy.max_attempts);
|
|
646
|
+
|
|
647
|
+
var now = _now();
|
|
648
|
+
var attemptCount = Number(enrollment.attempt_count);
|
|
649
|
+
var nextAttempt = attemptCount + 1;
|
|
650
|
+
|
|
651
|
+
await _writeAttempt(enrollment.id, nextAttempt, succeeded, succeeded ? null : newFailureCode);
|
|
652
|
+
|
|
653
|
+
if (succeeded) {
|
|
654
|
+
await query(
|
|
655
|
+
"UPDATE payment_retry_enrollments SET status = 'recovered', next_retry_at = NULL, " +
|
|
656
|
+
"attempt_count = ?1, last_failure_code = NULL, ended_at = ?2, end_reason = 'retry_succeeded' " +
|
|
657
|
+
"WHERE id = ?3",
|
|
658
|
+
[nextAttempt, now, enrollment.id],
|
|
659
|
+
);
|
|
660
|
+
return await _refetchEnrollment(enrollment.id);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Failure recorded out-of-band. Apply the same cap + schedule-
|
|
664
|
+
// exhaustion checks as the scheduler tick.
|
|
665
|
+
var effectiveFailureCode = newFailureCode || enrollment.last_failure_code || policy.failure_code;
|
|
666
|
+
|
|
667
|
+
if (nextAttempt >= maxAttempts) {
|
|
668
|
+
await query(
|
|
669
|
+
"UPDATE payment_retry_enrollments SET status = 'exhausted', next_retry_at = NULL, " +
|
|
670
|
+
"attempt_count = ?1, last_failure_code = ?2, ended_at = ?3, end_reason = 'max_attempts' " +
|
|
671
|
+
"WHERE id = ?4",
|
|
672
|
+
[nextAttempt, effectiveFailureCode, now, enrollment.id],
|
|
673
|
+
);
|
|
674
|
+
return await _refetchEnrollment(enrollment.id);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
var nextAt = _planNextRetry(schedule, nextAttempt - 1, now);
|
|
678
|
+
if (nextAt == null) {
|
|
679
|
+
await query(
|
|
680
|
+
"UPDATE payment_retry_enrollments SET status = 'exhausted', next_retry_at = NULL, " +
|
|
681
|
+
"attempt_count = ?1, last_failure_code = ?2, ended_at = ?3, end_reason = 'schedule_exhausted' " +
|
|
682
|
+
"WHERE id = ?4",
|
|
683
|
+
[nextAttempt, effectiveFailureCode, now, enrollment.id],
|
|
684
|
+
);
|
|
685
|
+
return await _refetchEnrollment(enrollment.id);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
await query(
|
|
689
|
+
"UPDATE payment_retry_enrollments SET attempt_count = ?1, " +
|
|
690
|
+
"last_failure_code = ?2, next_retry_at = ?3 WHERE id = ?4",
|
|
691
|
+
[nextAttempt, effectiveFailureCode, nextAt, enrollment.id],
|
|
692
|
+
);
|
|
693
|
+
return await _refetchEnrollment(enrollment.id);
|
|
694
|
+
},
|
|
695
|
+
|
|
696
|
+
unenrollPayment: async function (input) {
|
|
697
|
+
if (!input || typeof input !== "object") {
|
|
698
|
+
throw new TypeError("paymentRetries.unenrollPayment: input object required");
|
|
699
|
+
}
|
|
700
|
+
var paymentIntentId = _uuid(input.payment_intent_id, "payment_intent_id");
|
|
701
|
+
var reason = _reason(input.reason);
|
|
702
|
+
|
|
703
|
+
var enrollment = await _getEnrollment(paymentIntentId);
|
|
704
|
+
if (!enrollment) {
|
|
705
|
+
var nfErr = new Error("paymentRetries.unenrollPayment: payment_intent " + paymentIntentId + " has no enrollment");
|
|
706
|
+
nfErr.code = "ENROLLMENT_NOT_FOUND";
|
|
707
|
+
throw nfErr;
|
|
708
|
+
}
|
|
709
|
+
if (enrollment.status !== "active") {
|
|
710
|
+
return enrollment;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Recovery vs abandonment: a reason that signals the payment
|
|
714
|
+
// resolved successfully → recovered; everything else →
|
|
715
|
+
// abandoned. The regex is intentionally narrow so a typo
|
|
716
|
+
// doesn't accidentally count as a recovery.
|
|
717
|
+
var recoveredRe = /^(paid|paid_out_of_band|processor_recovered|recovered|refunded)$/i;
|
|
718
|
+
var nextStatus = recoveredRe.test(reason) ? "recovered" : "abandoned";
|
|
719
|
+
|
|
720
|
+
var now = _now();
|
|
721
|
+
await query(
|
|
722
|
+
"UPDATE payment_retry_enrollments SET status = ?1, next_retry_at = NULL, " +
|
|
723
|
+
"ended_at = ?2, end_reason = ?3 WHERE id = ?4",
|
|
724
|
+
[nextStatus, now, reason, enrollment.id],
|
|
725
|
+
);
|
|
726
|
+
return await _refetchEnrollment(enrollment.id);
|
|
727
|
+
},
|
|
728
|
+
|
|
729
|
+
statusForPayment: async function (paymentIntentId) {
|
|
730
|
+
paymentIntentId = _uuid(paymentIntentId, "payment_intent_id");
|
|
731
|
+
return await _getEnrollment(paymentIntentId);
|
|
732
|
+
},
|
|
733
|
+
|
|
734
|
+
historyForPayment: async function (paymentIntentId) {
|
|
735
|
+
paymentIntentId = _uuid(paymentIntentId, "payment_intent_id");
|
|
736
|
+
var enrollment = await _getEnrollment(paymentIntentId);
|
|
737
|
+
if (!enrollment) return [];
|
|
738
|
+
var r = await query(
|
|
739
|
+
"SELECT * FROM payment_retry_attempts WHERE enrollment_id = ?1 " +
|
|
740
|
+
"ORDER BY occurred_at ASC, id ASC",
|
|
741
|
+
[enrollment.id],
|
|
742
|
+
);
|
|
743
|
+
return r.rows;
|
|
744
|
+
},
|
|
745
|
+
|
|
746
|
+
policiesForFailureCode: async function (failureCode) {
|
|
747
|
+
failureCode = _failureCode(failureCode);
|
|
748
|
+
var r = await query(
|
|
749
|
+
"SELECT * FROM payment_retry_policies " +
|
|
750
|
+
"WHERE failure_code = ?1 AND archived_at IS NULL " +
|
|
751
|
+
"ORDER BY updated_at DESC, slug ASC",
|
|
752
|
+
[failureCode],
|
|
753
|
+
);
|
|
754
|
+
return r.rows;
|
|
755
|
+
},
|
|
756
|
+
|
|
757
|
+
metricsForPolicy: async function (input) {
|
|
758
|
+
if (!input || typeof input !== "object") {
|
|
759
|
+
throw new TypeError("paymentRetries.metricsForPolicy: input object required");
|
|
760
|
+
}
|
|
761
|
+
var slug = _slug(input.slug);
|
|
762
|
+
var from = _epochMs(input.from, "from");
|
|
763
|
+
var to = _epochMs(input.to, "to");
|
|
764
|
+
if (from > to) {
|
|
765
|
+
throw new TypeError("paymentRetries.metricsForPolicy: from must be <= to");
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
var r = await query(
|
|
769
|
+
"SELECT status, COUNT(*) AS n FROM payment_retry_enrollments " +
|
|
770
|
+
"WHERE policy_slug = ?1 AND created_at >= ?2 AND created_at <= ?3 " +
|
|
771
|
+
"GROUP BY status",
|
|
772
|
+
[slug, from, to],
|
|
773
|
+
);
|
|
774
|
+
var counts = { active: 0, recovered: 0, exhausted: 0, abandoned: 0 };
|
|
775
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
776
|
+
var row = r.rows[i];
|
|
777
|
+
counts[row.status] = Number(row.n);
|
|
778
|
+
}
|
|
779
|
+
var total = counts.active + counts.recovered + counts.exhausted + counts.abandoned;
|
|
780
|
+
// Recovery rate denominator: total enrolled in the window.
|
|
781
|
+
// Returned as a float in [0, 1] — render-tier formatting
|
|
782
|
+
// (percent, locale) is the caller's concern. A zero-total
|
|
783
|
+
// window returns 0 (no enrollments → no recovery to measure);
|
|
784
|
+
// the caller checks `total_enrolled` to distinguish "no data"
|
|
785
|
+
// from "all failed."
|
|
786
|
+
var recoveryRate = total === 0 ? 0 : counts.recovered / total;
|
|
787
|
+
var exhaustionRate = total === 0 ? 0 : counts.exhausted / total;
|
|
788
|
+
|
|
789
|
+
return {
|
|
790
|
+
policy_slug: slug,
|
|
791
|
+
from: from,
|
|
792
|
+
to: to,
|
|
793
|
+
total_enrolled: total,
|
|
794
|
+
active: counts.active,
|
|
795
|
+
recovered: counts.recovered,
|
|
796
|
+
exhausted: counts.exhausted,
|
|
797
|
+
abandoned: counts.abandoned,
|
|
798
|
+
recovery_rate: recoveryRate,
|
|
799
|
+
exhaustion_rate: exhaustionRate,
|
|
800
|
+
};
|
|
801
|
+
},
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
module.exports = {
|
|
806
|
+
create: create,
|
|
807
|
+
STATUSES: STATUSES.slice(),
|
|
808
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
809
|
+
MAX_FAILURE_CODE_LEN: MAX_FAILURE_CODE_LEN,
|
|
810
|
+
MAX_REASON_LEN: MAX_REASON_LEN,
|
|
811
|
+
MAX_DELAY_HOURS: MAX_DELAY_HOURS,
|
|
812
|
+
MAX_SCHEDULE_STEPS: MAX_SCHEDULE_STEPS,
|
|
813
|
+
MAX_ATTEMPTS_CAP: MAX_ATTEMPTS_CAP,
|
|
814
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
815
|
+
DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
|
|
816
|
+
};
|