@blamejs/blamejs-shop 0.0.60 → 0.0.62

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/lib/dunning.js ADDED
@@ -0,0 +1,700 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.dunning
4
+ * @title Dunning — operator-defined retry/escalation policy for failed invoices
5
+ *
6
+ * @intro
7
+ * Payment recovery for failed subscription invoices. Sibling to
8
+ * `subscriptionBilling` (which owns the processor-driven invoice +
9
+ * attempt + dunning-episode LEDGER) — this primitive owns the
10
+ * OPERATOR-DEFINED retry policy and the per-invoice scheduler walk
11
+ * that progressively escalates a failed invoice through reminder
12
+ * emails, charge retries, an optional pause, and (after the
13
+ * policy's attempt cap) subscription cancellation.
14
+ *
15
+ * The shape:
16
+ *
17
+ * var dun = bShop.dunning.create({
18
+ * query: q,
19
+ * subscriptionBilling: bill, // for cancelSubscription / pause / retry lookups
20
+ * email: eml, // for send_reminder dispatch
21
+ * notifications: notif, // for in-app reminder fan-out
22
+ * });
23
+ *
24
+ * await dun.defineDunningPolicy({
25
+ * slug: "default-3-step",
26
+ * retry_schedule: [
27
+ * { delay_hours: 0, action: "send_reminder" },
28
+ * { delay_hours: 24, action: "retry_charge" },
29
+ * { delay_hours: 72, action: "send_reminder" },
30
+ * { delay_hours: 168, action: "pause_subscription" },
31
+ * ],
32
+ * cancel_after_attempts: 4,
33
+ * });
34
+ *
35
+ * await dun.enrollInvoice({ invoice_id, policy_slug: "default-3-step" });
36
+ *
37
+ * // Scheduler tick — operator wires this to a cron / Workers
38
+ * // Cron Trigger. Walks every active enrollment whose
39
+ * // next_action_at is due and executes the next step.
40
+ * await dun.tickDunning({ now: Date.now() });
41
+ *
42
+ * // Invoice paid out-of-band (customer phoned in, processor
43
+ * // automatic recovery fired) — operator clears the enrollment
44
+ * // so the scheduler stops escalating.
45
+ * await dun.unsetEnrollment({ invoice_id, reason: "paid_out_of_band" });
46
+ *
47
+ * await dun.statusForInvoice(invoice_id);
48
+ * await dun.historyForInvoice(invoice_id);
49
+ * await dun.metricsForPolicy({ slug, from, to });
50
+ *
51
+ * The policy schedule is a list of `{ delay_hours, action }` steps.
52
+ * `delay_hours` is the wait BEFORE the step executes (measured from
53
+ * the prior step's execution, or from enrollment time for step 0).
54
+ * `action` is one of:
55
+ *
56
+ * send_reminder — email the customer + enqueue an in-app
57
+ * notification. The injected `email` /
58
+ * `notifications` handles do the actual
59
+ * dispatch; this primitive only records that
60
+ * the action ran.
61
+ * retry_charge — record an event noting a retry attempt.
62
+ * The injected subscriptionBilling handle's
63
+ * `recordPaymentAttempt` is the operator's
64
+ * tool to actually re-charge — this surface
65
+ * just stamps the dunning row so the next
66
+ * tick advances.
67
+ * pause_subscription — compose `subscriptionBilling.enterDunning`
68
+ * so the subscription-side state is also
69
+ * updated. Operators that wired the
70
+ * `subscriptionControls` handle (separately)
71
+ * apply their own pause as a side effect.
72
+ * cancel_subscription — terminal. Marks the enrollment cancelled
73
+ * + composes `subscriptionBilling.exitDunning`
74
+ * with outcome `cancelled` (if the
75
+ * subscription is currently in dunning).
76
+ *
77
+ * `cancel_after_attempts` is the safety cap: if the schedule walks
78
+ * off the end (every step consumed, the invoice still isn't
79
+ * recovered, and the attempt_count >= cancel_after_attempts), the
80
+ * tick auto-cancels the subscription. This is the "tried everything"
81
+ * exit so a poorly-authored schedule can't leave the row spinning.
82
+ *
83
+ * The enrollment row's `status` is the FSM:
84
+ *
85
+ * active — the scheduler walk is still working the row
86
+ * recovered — `unsetEnrollment` (or a future processor webhook
87
+ * that observes the invoice flip to paid) closed it
88
+ * successfully
89
+ * cancelled — the schedule reached `cancel_subscription` OR the
90
+ * attempt cap fired
91
+ * abandoned — operator manually unset with reason !== "recovered"
92
+ * (e.g. "customer disputed and operator wrote off")
93
+ *
94
+ * `metricsForPolicy` aggregates over the [from, to] window keyed on
95
+ * the enrollment's `created_at`. The result is the recovery rate
96
+ * (recovered / total_enrolled) + cancellation rate (cancelled /
97
+ * total_enrolled) — exactly what an operator dashboard renders to
98
+ * tell whether a given policy is paying for itself.
99
+ *
100
+ * Composition: zero npm runtime deps; every primitive composes
101
+ * blamejs (`b.uuid.v7`, `b.guardUuid`) + the injected handles.
102
+ *
103
+ * @related b.uuid, b.guardUuid, shop.subscriptionBilling, shop.email,
104
+ * shop.notifications
105
+ */
106
+
107
+ var bShop;
108
+ function _b() {
109
+ if (!bShop) bShop = require("./index");
110
+ return bShop.framework;
111
+ }
112
+
113
+ // ---- constants ----------------------------------------------------------
114
+
115
+ var ACTIONS = ["retry_charge", "send_reminder", "pause_subscription", "cancel_subscription"];
116
+ var STATUSES = ["active", "recovered", "cancelled", "abandoned"];
117
+ var OUTCOMES = ["ok", "skipped", "failed"];
118
+
119
+ var MAX_SLUG_LEN = 64;
120
+ var MAX_REASON_LEN = 280;
121
+ var MAX_DELAY_HOURS = 24 * 365; // 1 year — sane cap on retry delay
122
+ var MAX_SCHEDULE_STEPS = 64;
123
+ var MAX_CANCEL_ATTEMPTS = 64;
124
+ var MAX_LIST_LIMIT = 500;
125
+ var DEFAULT_LIST_LIMIT = 100;
126
+ var MS_PER_HOUR = 60 * 60 * 1000;
127
+
128
+ var SLUG_RE = /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/;
129
+ var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
130
+ var ZERO_WIDTH_RE = new RegExp(
131
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
132
+ );
133
+
134
+ // ---- validators ---------------------------------------------------------
135
+
136
+ function _uuid(s, label) {
137
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
138
+ catch (e) {
139
+ throw new TypeError("dunning: " + label + " — " + (e && e.message || "invalid UUID"));
140
+ }
141
+ }
142
+
143
+ function _slug(s) {
144
+ if (typeof s !== "string" || !s.length) {
145
+ throw new TypeError("dunning: slug must be a non-empty string");
146
+ }
147
+ if (s.length > MAX_SLUG_LEN) {
148
+ throw new TypeError("dunning: slug must be <= " + MAX_SLUG_LEN + " characters");
149
+ }
150
+ if (!SLUG_RE.test(s)) {
151
+ throw new TypeError("dunning: slug must match /[a-z][a-z0-9-]*[a-z0-9]/");
152
+ }
153
+ return s;
154
+ }
155
+
156
+ function _action(s) {
157
+ if (typeof s !== "string" || ACTIONS.indexOf(s) === -1) {
158
+ throw new TypeError("dunning: action must be one of " + ACTIONS.join(", "));
159
+ }
160
+ return s;
161
+ }
162
+
163
+ function _retrySchedule(arr) {
164
+ if (!Array.isArray(arr) || arr.length === 0) {
165
+ throw new TypeError("dunning: retry_schedule must be a non-empty array");
166
+ }
167
+ if (arr.length > MAX_SCHEDULE_STEPS) {
168
+ throw new TypeError("dunning: retry_schedule must have <= " + MAX_SCHEDULE_STEPS + " steps");
169
+ }
170
+ var out = [];
171
+ for (var i = 0; i < arr.length; i += 1) {
172
+ var step = arr[i];
173
+ if (!step || typeof step !== "object") {
174
+ throw new TypeError("dunning: retry_schedule[" + i + "] must be an object");
175
+ }
176
+ var delay = step.delay_hours;
177
+ if (!Number.isInteger(delay) || delay < 0 || delay > MAX_DELAY_HOURS) {
178
+ throw new TypeError(
179
+ "dunning: retry_schedule[" + i + "].delay_hours must be an integer in [0, " + MAX_DELAY_HOURS + "]"
180
+ );
181
+ }
182
+ var act = _action(step.action);
183
+ out.push({ delay_hours: delay, action: act });
184
+ }
185
+ return out;
186
+ }
187
+
188
+ function _cancelAfterAttempts(n) {
189
+ if (!Number.isInteger(n) || n < 1 || n > MAX_CANCEL_ATTEMPTS) {
190
+ throw new TypeError(
191
+ "dunning: cancel_after_attempts must be an integer in [1, " + MAX_CANCEL_ATTEMPTS + "]"
192
+ );
193
+ }
194
+ return n;
195
+ }
196
+
197
+ function _reason(s) {
198
+ if (typeof s !== "string" || !s.length) {
199
+ throw new TypeError("dunning: reason must be a non-empty string");
200
+ }
201
+ if (s.length > MAX_REASON_LEN) {
202
+ throw new TypeError("dunning: reason must be <= " + MAX_REASON_LEN + " characters");
203
+ }
204
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
205
+ throw new TypeError("dunning: reason contains control / zero-width bytes");
206
+ }
207
+ return s;
208
+ }
209
+
210
+ function _epochMs(n, label) {
211
+ if (!Number.isInteger(n) || n <= 0) {
212
+ throw new TypeError("dunning: " + label + " must be a positive integer (epoch ms)");
213
+ }
214
+ return n;
215
+ }
216
+
217
+ function _limit(n) {
218
+ if (n == null) return DEFAULT_LIST_LIMIT;
219
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
220
+ throw new TypeError("dunning: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
221
+ }
222
+ return n;
223
+ }
224
+
225
+ function _parseSchedule(json) {
226
+ // Stored JSON is operator-authored + already validated at write;
227
+ // re-parse defensively (a downstream DB tool could have rewritten
228
+ // the row) and re-validate the shape so a corrupt row fails LOUD
229
+ // at the tick instead of silently mis-scheduling.
230
+ try {
231
+ var parsed = JSON.parse(json);
232
+ return _retrySchedule(parsed);
233
+ } catch (e) {
234
+ var pErr = new Error("dunning: stored retry_schedule_json is malformed (" + (e && e.message || "parse error") + ")");
235
+ pErr.code = "DUNNING_POLICY_CORRUPT";
236
+ throw pErr;
237
+ }
238
+ }
239
+
240
+ function _now() { return Date.now(); }
241
+
242
+ // ---- factory ------------------------------------------------------------
243
+
244
+ function create(opts) {
245
+ opts = opts || {};
246
+ var query = opts.query;
247
+ if (!query) {
248
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
249
+ }
250
+ // Injected handles. `subscriptionBilling` is the only strictly
251
+ // required one — `email` / `notifications` are surface-optional
252
+ // (a `send_reminder` step short-circuits to outcome `skipped` with
253
+ // reason `no_email_handle` so the scheduler still advances).
254
+ var billing = opts.subscriptionBilling || null;
255
+ var emailHandle = opts.email || null;
256
+ var notifHandle = opts.notifications || null;
257
+
258
+ // -------- internal helpers --------
259
+
260
+ async function _getPolicy(slug) {
261
+ var r = await query("SELECT * FROM dunning_policies WHERE slug = ?1", [slug]);
262
+ return r.rows[0] || null;
263
+ }
264
+
265
+ async function _getEnrollment(invoiceId) {
266
+ var r = await query("SELECT * FROM dunning_enrollments WHERE invoice_id = ?1", [invoiceId]);
267
+ return r.rows[0] || null;
268
+ }
269
+
270
+ async function _refetchEnrollment(id) {
271
+ var r = await query("SELECT * FROM dunning_enrollments WHERE id = ?1", [id]);
272
+ return r.rows[0] || null;
273
+ }
274
+
275
+ async function _writeEvent(enrollmentId, attemptNumber, action, outcome, ts) {
276
+ var id = _b().uuid.v7();
277
+ await query(
278
+ "INSERT INTO dunning_events " +
279
+ "(id, enrollment_id, attempt_number, action, outcome, occurred_at) " +
280
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
281
+ [id, enrollmentId, attemptNumber, action, outcome, ts],
282
+ );
283
+ }
284
+
285
+ // Compute the next_action_at + the index of the next step. Returns
286
+ // `null` when the schedule has been exhausted (the caller decides
287
+ // whether to auto-cancel or mark the enrollment recovered).
288
+ function _planNextStep(schedule, attemptCount, fromTs) {
289
+ if (attemptCount >= schedule.length) return null;
290
+ var step = schedule[attemptCount];
291
+ var nextAt = fromTs + step.delay_hours * MS_PER_HOUR;
292
+ return { action: step.action, next_action_at: nextAt };
293
+ }
294
+
295
+ // Execute a single action for an enrollment. Returns the outcome
296
+ // string so the caller can write the event + decide what to do
297
+ // next. `send_reminder` and `retry_charge` are non-terminal —
298
+ // `pause_subscription` and `cancel_subscription` flip status.
299
+ async function _executeAction(action, enrollment) {
300
+ if (action === "send_reminder") {
301
+ // Best-effort dispatch. Missing handles → `skipped`. Handle
302
+ // throws → `failed`. Operators that haven't wired email yet
303
+ // can still observe the schedule advancing.
304
+ if (!emailHandle && !notifHandle) return "skipped";
305
+ try {
306
+ if (emailHandle && typeof emailHandle.sendDunningReminder === "function") {
307
+ await emailHandle.sendDunningReminder({
308
+ invoice_id: enrollment.invoice_id,
309
+ attempt_number: enrollment.attempt_count + 1,
310
+ });
311
+ }
312
+ if (notifHandle && typeof notifHandle.enqueue === "function") {
313
+ await notifHandle.enqueue({
314
+ recipient_id: "invoice:" + enrollment.invoice_id,
315
+ channel: "in-app",
316
+ event_type: "dunning.reminder",
317
+ title: "Payment due",
318
+ body: "We were unable to charge your subscription. Please update your payment method.",
319
+ payload: {
320
+ invoice_id: enrollment.invoice_id,
321
+ attempt_number: enrollment.attempt_count + 1,
322
+ },
323
+ });
324
+ }
325
+ return "ok";
326
+ } catch (_e) {
327
+ return "failed";
328
+ }
329
+ }
330
+ if (action === "retry_charge") {
331
+ // The dunning primitive doesn't own the processor charge —
332
+ // the operator's webhook handler does. We record the retry
333
+ // INTENT here; if a `recordPaymentAttempt` handle is wired
334
+ // we call it (idempotent under the existing UNIQUE
335
+ // (invoice_id, attempt_number) constraint, so a webhook +
336
+ // tick recording the same attempt collapses to one row).
337
+ if (!billing || typeof billing.recordPaymentAttempt !== "function") return "skipped";
338
+ try {
339
+ await billing.recordPaymentAttempt({
340
+ invoice_id: enrollment.invoice_id,
341
+ attempt_number: enrollment.attempt_count + 1,
342
+ status: "failed",
343
+ failure_code: "dunning_retry",
344
+ });
345
+ return "ok";
346
+ } catch (_e) {
347
+ return "failed";
348
+ }
349
+ }
350
+ if (action === "pause_subscription") {
351
+ if (!billing || typeof billing.enterDunning !== "function") return "skipped";
352
+ try {
353
+ // Resolve the subscription_id off the invoice. The
354
+ // billing handle exposes invoicesForSubscription but not
355
+ // a direct `getInvoice` — operators typically inject a
356
+ // thin wrapper. Skip when the subscription_id isn't
357
+ // resolvable so the tick still advances.
358
+ if (typeof billing._getInvoiceForDunning !== "function") return "skipped";
359
+ var inv = await billing._getInvoiceForDunning(enrollment.invoice_id);
360
+ if (!inv || !inv.subscription_id) return "skipped";
361
+ await billing.enterDunning({
362
+ subscription_id: inv.subscription_id,
363
+ reason: "dunning: pause_subscription step",
364
+ });
365
+ return "ok";
366
+ } catch (_e) {
367
+ return "failed";
368
+ }
369
+ }
370
+ if (action === "cancel_subscription") {
371
+ // Terminal — record the event; the surrounding _advance
372
+ // loop flips status to cancelled + close the enrollment.
373
+ if (!billing || typeof billing.exitDunning !== "function") return "ok";
374
+ try {
375
+ if (typeof billing._getInvoiceForDunning !== "function") return "ok";
376
+ var inv2 = await billing._getInvoiceForDunning(enrollment.invoice_id);
377
+ if (!inv2 || !inv2.subscription_id) return "ok";
378
+ await billing.exitDunning({
379
+ subscription_id: inv2.subscription_id,
380
+ outcome: "cancelled",
381
+ });
382
+ return "ok";
383
+ } catch (_e) {
384
+ return "failed";
385
+ }
386
+ }
387
+ return "skipped";
388
+ }
389
+
390
+ // Advance ONE enrollment by one step. Returns the refreshed row.
391
+ // The function is the heart of tickDunning — it's surfaced
392
+ // internally so tests can drive a single advance deterministically.
393
+ async function _advance(enrollment, schedule, cancelAfterAttempts, now) {
394
+ var attemptCount = Number(enrollment.attempt_count);
395
+ var stepIndex = attemptCount;
396
+ if (stepIndex >= schedule.length) {
397
+ // Walked off the end. Either we've hit the attempt cap or
398
+ // we close the enrollment as abandoned (the schedule was
399
+ // exhausted without recovery and without the operator
400
+ // configuring an explicit cancel step).
401
+ var closeAs = attemptCount >= cancelAfterAttempts ? "cancelled" : "abandoned";
402
+ await query(
403
+ "UPDATE dunning_enrollments SET status = ?1, next_action_at = NULL, " +
404
+ "ended_at = ?2, end_reason = ?3, last_action = ?4, last_action_at = ?2 " +
405
+ "WHERE id = ?5",
406
+ [closeAs, now, "schedule_exhausted", "schedule_exhausted", enrollment.id],
407
+ );
408
+ await _writeEvent(enrollment.id, attemptCount, "cancel_subscription", "ok", now);
409
+ return await _refetchEnrollment(enrollment.id);
410
+ }
411
+
412
+ var action = schedule[stepIndex].action;
413
+ var outcome = await _executeAction(action, enrollment);
414
+ var newAttemptCount = attemptCount + 1;
415
+
416
+ var planned = _planNextStep(schedule, newAttemptCount, now);
417
+ var terminal = (action === "cancel_subscription") || (newAttemptCount >= cancelAfterAttempts);
418
+
419
+ if (terminal) {
420
+ await query(
421
+ "UPDATE dunning_enrollments SET status = 'cancelled', next_action_at = NULL, " +
422
+ "attempt_count = ?1, last_action = ?2, last_action_at = ?3, " +
423
+ "ended_at = ?3, end_reason = ?4 WHERE id = ?5",
424
+ [newAttemptCount, action, now, action === "cancel_subscription" ? "policy_cancel_step" : "attempt_cap", enrollment.id],
425
+ );
426
+ await _writeEvent(enrollment.id, newAttemptCount, action, outcome, now);
427
+ return await _refetchEnrollment(enrollment.id);
428
+ }
429
+
430
+ if (planned == null) {
431
+ // No more steps + not at the cap yet. Close as abandoned —
432
+ // operator authored a schedule that runs out without
433
+ // resolution, the primitive refuses to spin.
434
+ await query(
435
+ "UPDATE dunning_enrollments SET status = 'abandoned', next_action_at = NULL, " +
436
+ "attempt_count = ?1, last_action = ?2, last_action_at = ?3, " +
437
+ "ended_at = ?3, end_reason = 'schedule_exhausted' WHERE id = ?4",
438
+ [newAttemptCount, action, now, enrollment.id],
439
+ );
440
+ await _writeEvent(enrollment.id, newAttemptCount, action, outcome, now);
441
+ return await _refetchEnrollment(enrollment.id);
442
+ }
443
+
444
+ await query(
445
+ "UPDATE dunning_enrollments SET attempt_count = ?1, last_action = ?2, " +
446
+ "last_action_at = ?3, next_action_at = ?4 WHERE id = ?5",
447
+ [newAttemptCount, action, now, planned.next_action_at, enrollment.id],
448
+ );
449
+ await _writeEvent(enrollment.id, newAttemptCount, action, outcome, now);
450
+ return await _refetchEnrollment(enrollment.id);
451
+ }
452
+
453
+ // -------- public surface --------
454
+
455
+ return {
456
+ ACTIONS: ACTIONS.slice(),
457
+ STATUSES: STATUSES.slice(),
458
+ OUTCOMES: OUTCOMES.slice(),
459
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
460
+ MAX_REASON_LEN: MAX_REASON_LEN,
461
+ MAX_DELAY_HOURS: MAX_DELAY_HOURS,
462
+ MAX_SCHEDULE_STEPS: MAX_SCHEDULE_STEPS,
463
+ MAX_CANCEL_ATTEMPTS: MAX_CANCEL_ATTEMPTS,
464
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
465
+ DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
466
+
467
+ defineDunningPolicy: async function (input) {
468
+ if (!input || typeof input !== "object") {
469
+ throw new TypeError("dunning.defineDunningPolicy: input object required");
470
+ }
471
+ var slug = _slug(input.slug);
472
+ var schedule = _retrySchedule(input.retry_schedule);
473
+ var cancelAfterAttempts = _cancelAfterAttempts(input.cancel_after_attempts);
474
+ var now = _now();
475
+ var json = JSON.stringify(schedule);
476
+
477
+ var existing = await _getPolicy(slug);
478
+ if (existing) {
479
+ // Re-define is an upsert — the operator dashboard edits the
480
+ // policy in place. archived_at is preserved on a rewrite (a
481
+ // separate archivePolicy surface would clear it; out of
482
+ // scope for v1 — operators clear via a direct DB write or
483
+ // re-define with the same slug after manual UPDATE).
484
+ await query(
485
+ "UPDATE dunning_policies SET retry_schedule_json = ?1, " +
486
+ "cancel_after_attempts = ?2, updated_at = ?3 WHERE slug = ?4",
487
+ [json, cancelAfterAttempts, now, slug],
488
+ );
489
+ } else {
490
+ await query(
491
+ "INSERT INTO dunning_policies " +
492
+ "(slug, retry_schedule_json, cancel_after_attempts, archived_at, created_at, updated_at) " +
493
+ "VALUES (?1, ?2, ?3, NULL, ?4, ?4)",
494
+ [slug, json, cancelAfterAttempts, now],
495
+ );
496
+ }
497
+ return await _getPolicy(slug);
498
+ },
499
+
500
+ enrollInvoice: async function (input) {
501
+ if (!input || typeof input !== "object") {
502
+ throw new TypeError("dunning.enrollInvoice: input object required");
503
+ }
504
+ var invoiceId = _uuid(input.invoice_id, "invoice_id");
505
+ var slug = _slug(input.policy_slug);
506
+
507
+ var policy = await _getPolicy(slug);
508
+ if (!policy) {
509
+ var nfErr = new Error("dunning.enrollInvoice: policy " + slug + " not found");
510
+ nfErr.code = "POLICY_NOT_FOUND";
511
+ throw nfErr;
512
+ }
513
+ if (policy.archived_at != null) {
514
+ var aErr = new Error("dunning.enrollInvoice: policy " + slug + " is archived");
515
+ aErr.code = "POLICY_ARCHIVED";
516
+ throw aErr;
517
+ }
518
+
519
+ // Idempotency: if an enrollment already exists for this
520
+ // invoice, return it. Webhook redelivery of
521
+ // `invoice.payment_failed` flows through here repeatedly; the
522
+ // UNIQUE (invoice_id) constraint would refuse the second
523
+ // INSERT, this short-circuit gives the caller a clean replay.
524
+ var existing = await _getEnrollment(invoiceId);
525
+ if (existing) return existing;
526
+
527
+ var schedule = _parseSchedule(policy.retry_schedule_json);
528
+ var now = _now();
529
+ var planned = _planNextStep(schedule, 0, now);
530
+ // `planned` is non-null because _retrySchedule enforces
531
+ // schedule.length >= 1; defensive nullcheck for the corrupt-
532
+ // row path.
533
+ var nextAt = planned ? planned.next_action_at : null;
534
+
535
+ var id = _b().uuid.v7();
536
+ await query(
537
+ "INSERT INTO dunning_enrollments " +
538
+ "(id, invoice_id, policy_slug, status, next_action_at, attempt_count, " +
539
+ " last_action, last_action_at, ended_at, end_reason, created_at) " +
540
+ "VALUES (?1, ?2, ?3, 'active', ?4, 0, NULL, NULL, NULL, NULL, ?5)",
541
+ [id, invoiceId, slug, nextAt, now],
542
+ );
543
+ return await _refetchEnrollment(id);
544
+ },
545
+
546
+ tickDunning: async function (input) {
547
+ if (!input || typeof input !== "object") {
548
+ throw new TypeError("dunning.tickDunning: input object required");
549
+ }
550
+ var now = _epochMs(input.now, "now");
551
+ var limit = _limit(input.limit);
552
+
553
+ // Snapshot the due-set up front. Each advance runs a small
554
+ // burst of writes; pulling the row list first lets the
555
+ // scheduler process a stable working set per tick.
556
+ var due = await query(
557
+ "SELECT * FROM dunning_enrollments " +
558
+ "WHERE status = 'active' AND next_action_at IS NOT NULL AND next_action_at <= ?1 " +
559
+ "ORDER BY next_action_at ASC, id ASC LIMIT ?2",
560
+ [now, limit],
561
+ );
562
+
563
+ var advanced = [];
564
+ for (var i = 0; i < due.rows.length; i += 1) {
565
+ var enrollment = due.rows[i];
566
+ var policy = await _getPolicy(enrollment.policy_slug);
567
+ if (!policy) {
568
+ // Policy went missing between enrollment + tick — close
569
+ // the row as abandoned so it doesn't spin forever.
570
+ await query(
571
+ "UPDATE dunning_enrollments SET status = 'abandoned', next_action_at = NULL, " +
572
+ "ended_at = ?1, end_reason = 'policy_missing' WHERE id = ?2",
573
+ [now, enrollment.id],
574
+ );
575
+ advanced.push(await _refetchEnrollment(enrollment.id));
576
+ continue;
577
+ }
578
+ var schedule = _parseSchedule(policy.retry_schedule_json);
579
+ var refreshed = await _advance(enrollment, schedule, Number(policy.cancel_after_attempts), now);
580
+ advanced.push(refreshed);
581
+ }
582
+ return advanced;
583
+ },
584
+
585
+ statusForInvoice: async function (invoiceId) {
586
+ invoiceId = _uuid(invoiceId, "invoice_id");
587
+ return await _getEnrollment(invoiceId);
588
+ },
589
+
590
+ historyForInvoice: async function (invoiceId) {
591
+ invoiceId = _uuid(invoiceId, "invoice_id");
592
+ var enrollment = await _getEnrollment(invoiceId);
593
+ if (!enrollment) return [];
594
+ var r = await query(
595
+ "SELECT * FROM dunning_events WHERE enrollment_id = ?1 " +
596
+ "ORDER BY occurred_at ASC, id ASC",
597
+ [enrollment.id],
598
+ );
599
+ return r.rows;
600
+ },
601
+
602
+ unsetEnrollment: async function (input) {
603
+ if (!input || typeof input !== "object") {
604
+ throw new TypeError("dunning.unsetEnrollment: input object required");
605
+ }
606
+ var invoiceId = _uuid(input.invoice_id, "invoice_id");
607
+ var reason = _reason(input.reason);
608
+
609
+ var enrollment = await _getEnrollment(invoiceId);
610
+ if (!enrollment) {
611
+ var nfErr = new Error("dunning.unsetEnrollment: invoice " + invoiceId + " has no enrollment");
612
+ nfErr.code = "ENROLLMENT_NOT_FOUND";
613
+ throw nfErr;
614
+ }
615
+ if (enrollment.status !== "active") {
616
+ // Already terminal — no-op idempotent return so webhook
617
+ // redelivery of "paid out of band" doesn't fail.
618
+ return enrollment;
619
+ }
620
+
621
+ // The reason string drives the close-state: anything that
622
+ // signals the invoice resolved successfully → recovered;
623
+ // everything else → abandoned. Operators wire "paid",
624
+ // "paid_out_of_band", "processor_recovered" to recovery; a
625
+ // generic operator-initiated stop (refund, write-off) flows
626
+ // to abandoned. The regex is intentionally narrow so a typo
627
+ // doesn't accidentally count as a recovery.
628
+ var recoveredRe = /^(paid|paid_out_of_band|processor_recovered|recovered)$/i;
629
+ var nextStatus = recoveredRe.test(reason) ? "recovered" : "abandoned";
630
+
631
+ var now = _now();
632
+ await query(
633
+ "UPDATE dunning_enrollments SET status = ?1, next_action_at = NULL, " +
634
+ "ended_at = ?2, end_reason = ?3 WHERE id = ?4",
635
+ [nextStatus, now, reason, enrollment.id],
636
+ );
637
+ return await _refetchEnrollment(enrollment.id);
638
+ },
639
+
640
+ metricsForPolicy: async function (input) {
641
+ if (!input || typeof input !== "object") {
642
+ throw new TypeError("dunning.metricsForPolicy: input object required");
643
+ }
644
+ var slug = _slug(input.slug);
645
+ var from = _epochMs(input.from, "from");
646
+ var to = _epochMs(input.to, "to");
647
+ if (from > to) {
648
+ throw new TypeError("dunning.metricsForPolicy: from must be <= to");
649
+ }
650
+
651
+ var r = await query(
652
+ "SELECT status, COUNT(*) AS n FROM dunning_enrollments " +
653
+ "WHERE policy_slug = ?1 AND created_at >= ?2 AND created_at <= ?3 " +
654
+ "GROUP BY status",
655
+ [slug, from, to],
656
+ );
657
+ var counts = { active: 0, recovered: 0, cancelled: 0, abandoned: 0 };
658
+ for (var i = 0; i < r.rows.length; i += 1) {
659
+ var row = r.rows[i];
660
+ counts[row.status] = Number(row.n);
661
+ }
662
+ var total = counts.active + counts.recovered + counts.cancelled + counts.abandoned;
663
+ // Recovery + cancellation rate denominators: total enrolled
664
+ // in the window. Returned as floats in [0, 1] — render-tier
665
+ // formatting (percent, locale) is the caller's concern. A
666
+ // zero-total window returns 0 rates (no enrollments → no
667
+ // recovery to measure); the caller checks `total_enrolled`
668
+ // to distinguish "no data" from "all failed."
669
+ var recoveryRate = total === 0 ? 0 : counts.recovered / total;
670
+ var cancellationRate = total === 0 ? 0 : counts.cancelled / total;
671
+
672
+ return {
673
+ policy_slug: slug,
674
+ from: from,
675
+ to: to,
676
+ total_enrolled: total,
677
+ active: counts.active,
678
+ recovered: counts.recovered,
679
+ cancelled: counts.cancelled,
680
+ abandoned: counts.abandoned,
681
+ recovery_rate: recoveryRate,
682
+ cancellation_rate: cancellationRate,
683
+ };
684
+ },
685
+ };
686
+ }
687
+
688
+ module.exports = {
689
+ create: create,
690
+ ACTIONS: ACTIONS.slice(),
691
+ STATUSES: STATUSES.slice(),
692
+ OUTCOMES: OUTCOMES.slice(),
693
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
694
+ MAX_REASON_LEN: MAX_REASON_LEN,
695
+ MAX_DELAY_HOURS: MAX_DELAY_HOURS,
696
+ MAX_SCHEDULE_STEPS: MAX_SCHEDULE_STEPS,
697
+ MAX_CANCEL_ATTEMPTS: MAX_CANCEL_ATTEMPTS,
698
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
699
+ DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
700
+ };