@blamejs/blamejs-shop 0.0.65 → 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.
@@ -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
+ };