@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/CHANGELOG.md +4 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/customer-import.js +590 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +21 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +951 -0
- package/lib/stock-transfers.js +777 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/storefront-forms.js +884 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
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
|
+
};
|