@blamejs/blamejs-shop 0.0.66 → 0.0.72

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/click-and-collect.js +711 -0
  5. package/lib/clickstream.js +713 -0
  6. package/lib/customer-activity.js +862 -0
  7. package/lib/customer-notes.js +712 -0
  8. package/lib/customer-risk-profile.js +593 -0
  9. package/lib/customer-surveys.js +1012 -0
  10. package/lib/damage-photos.js +473 -0
  11. package/lib/dropship-forwarding.js +645 -0
  12. package/lib/email-templates.js +817 -0
  13. package/lib/index.js +36 -0
  14. package/lib/inventory-allocations.js +559 -0
  15. package/lib/inventory-writeoffs.js +636 -0
  16. package/lib/knowledge-base.js +1104 -0
  17. package/lib/locale-router.js +1077 -0
  18. package/lib/loyalty-earn-rules.js +786 -0
  19. package/lib/operator-roles.js +768 -0
  20. package/lib/order-escalation.js +951 -0
  21. package/lib/order-ratings.js +495 -0
  22. package/lib/order-tags.js +944 -0
  23. package/lib/packing-slips.js +810 -0
  24. package/lib/pixel-events.js +995 -0
  25. package/lib/print-queue.js +681 -0
  26. package/lib/product-qa.js +749 -0
  27. package/lib/promo-bundles.js +835 -0
  28. package/lib/push-notifications.js +937 -0
  29. package/lib/refund-automation.js +853 -0
  30. package/lib/reorder-reminders.js +798 -0
  31. package/lib/robots-config.js +753 -0
  32. package/lib/seller-signup.js +1052 -0
  33. package/lib/sitemap-generator.js +717 -0
  34. package/lib/split-shipments.js +7 -1
  35. package/lib/subscription-gifts.js +710 -0
  36. package/lib/tax-cert-renewals.js +632 -0
  37. package/lib/tier-benefits.js +776 -0
  38. package/lib/vendor/MANIFEST.json +2 -2
  39. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  40. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  41. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  42. package/lib/vendor/blamejs/package.json +1 -1
  43. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  44. package/lib/wishlist-alerts.js +842 -0
  45. package/lib/wishlist-sharing.js +718 -0
  46. package/package.json +1 -1
@@ -0,0 +1,853 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.refundAutomation
4
+ * @title Refund-automation primitive — auto-refund eligibility +
5
+ * execution rules. Composes `refundPolicy` (eligibility),
6
+ * `returns` (RMA), and `payment` (refund call) without
7
+ * owning their responsibilities.
8
+ *
9
+ * @intro
10
+ * `refundPolicy` (migration 0090) answers the upstream question
11
+ * "is a refund permitted at all?" — operator-authored rules that
12
+ * decide whether a customer-initiated refund request qualifies
13
+ * under the storefront's published policy. This primitive answers
14
+ * the narrower follow-on question: "is this specific refund
15
+ * request safe to issue WITHOUT operator review?"
16
+ *
17
+ * When a refund request meets every auto-eligibility gate —
18
+ * inside the per-request amount cap, under the per-customer
19
+ * annual cap, optionally low-risk per the injected
20
+ * `customerRiskProfile` handle, reason and currency in the
21
+ * operator-authored sets — the primitive issues the refund
22
+ * through the composed `payment.refund` call without operator
23
+ * intervention. Otherwise the request falls through to
24
+ * `manual_review` and waits for a human.
25
+ *
26
+ * Composition:
27
+ *
28
+ * var ra = bShop.refundAutomation.create({
29
+ * query: q,
30
+ * refundPolicy: rp, // optional — upstream eligibility
31
+ * returns: rmas, // optional — RMA cross-check
32
+ * payment: pay, // optional — for executeAutoRefund
33
+ * customerRiskProfile: risk, // optional — required if any rule
34
+ * // has requires_low_risk
35
+ * });
36
+ *
37
+ * await ra.defineAutoRule({
38
+ * slug: "small-customer-requests",
39
+ * max_amount_minor: 5000, // $50.00 USD
40
+ * max_refunds_per_customer_year: 3,
41
+ * requires_low_risk: true,
42
+ * eligible_reasons: ["requested_by_customer"],
43
+ * currency_in_set: ["USD"],
44
+ * });
45
+ *
46
+ * var verdict = await ra.evaluateForRefundRequest({
47
+ * order_id: orderId,
48
+ * customer_id: customerId,
49
+ * request_amount_minor: 1500,
50
+ * reason: "requested_by_customer",
51
+ * });
52
+ * // -> { eligible: true, applied_rule: "small-customer-requests",
53
+ * // max_refund_minor: 5000, requires_manual_review: false,
54
+ * // reasons: ["rule_matched"] }
55
+ *
56
+ * if (verdict.eligible) {
57
+ * await ra.executeAutoRefund({
58
+ * order_id: orderId,
59
+ * customer_id: customerId,
60
+ * amount_minor: 1500,
61
+ * reason: "requested_by_customer",
62
+ * });
63
+ * } else if (verdict.requires_manual_review) {
64
+ * // surface in the operator review queue; later resolved via
65
+ * await ra.markManualOverride({
66
+ * order_id: orderId,
67
+ * decision: "auto_approved", // or "declined"
68
+ * operator_id: opId,
69
+ * reason: "approved by manager — long-standing customer",
70
+ * });
71
+ * }
72
+ *
73
+ * Currency: the rule's `currency_in_set` is operator-asserted; the
74
+ * primitive doesn't peek at the order's actual billing currency
75
+ * (that crosses into the `payment` primitive's domain). Callers
76
+ * pre-filter rules at evaluation time by passing requests whose
77
+ * currency is already known — typically the storefront layer
78
+ * resolves the order's currency before opening the refund flow.
79
+ * When `currency_in_set` is empty the rule applies to any
80
+ * currency.
81
+ *
82
+ * Surface:
83
+ * - defineAutoRule({ slug, max_amount_minor,
84
+ * max_refunds_per_customer_year,
85
+ * requires_low_risk, eligible_reasons,
86
+ * currency_in_set, priority? })
87
+ * - evaluateForRefundRequest({ order_id, customer_id,
88
+ * request_amount_minor, reason,
89
+ * currency? }) -> verdict
90
+ * - executeAutoRefund({ order_id, customer_id, amount_minor,
91
+ * reason, payment_intent? })
92
+ * - markManualOverride({ order_id, decision, operator_id, reason })
93
+ * - metricsForRule({ slug, from, to })
94
+ * - listRules({ active_only?, limit? })
95
+ * - updateRule(slug, patch)
96
+ * - archiveRule(slug)
97
+ *
98
+ * Storage:
99
+ * - refund_automation_rules + refund_automation_decisions
100
+ * (migration 0143_refund_automation.sql).
101
+ *
102
+ * Monotonic clock: a per-factory monotonic timestamp ensures that
103
+ * two decisions written against the same order in the same
104
+ * millisecond carry strictly-increasing `decided_at` values. The
105
+ * `(order_id, decided_at DESC)` index then returns the latest
106
+ * decision unambiguously.
107
+ *
108
+ * @primitive refundAutomation
109
+ * @related refundPolicy, returns, payment, customerRiskProfile,
110
+ * operatorAuditLog
111
+ */
112
+
113
+ // ---- constants ----------------------------------------------------------
114
+
115
+ var MAX_SLUG_LEN = 80;
116
+ var MAX_REASON_LEN = 280;
117
+ var MAX_OPERATOR_ID_LEN = 128;
118
+ var MAX_EXTERNAL_ID_LEN = 128;
119
+ var MAX_AMOUNT_MINOR = 1000000000; // $10,000,000.00 — sane upper cap
120
+ var MAX_REFUNDS_PER_YEAR_CAP = 100000;
121
+ var MAX_PRIORITY = 1000000;
122
+ var MAX_LIST_LIMIT = 200;
123
+ var MAX_REASONS_PER_RULE = 32;
124
+ var MAX_CURRENCIES_PER_RULE = 32;
125
+ var MS_PER_YEAR = 365 * 24 * 60 * 60 * 1000;
126
+
127
+ // Slug shape matches coupon-stacking / refund-policy convention.
128
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
129
+ // Reason strings are operator-authored or processor-supplied. Keep
130
+ // loose enough to admit Stripe's documented set (lowercase + under-
131
+ // scores) plus customer-facing prose; refuse control bytes outright.
132
+ var REASON_RE = /^[A-Za-z0-9][A-Za-z0-9 _.,'\-]{0,279}$/;
133
+ // Currency code shape — three uppercase letters (ISO 4217 alpha).
134
+ var CURRENCY_RE = /^[A-Z]{3}$/;
135
+ // Operator id matches the customer / operator handle shape used by
136
+ // other admin-side primitives; alnum + hyphen / underscore / dot.
137
+ var OPERATOR_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
138
+
139
+ var DECISIONS = Object.freeze(["auto_approved", "manual_review", "declined"]);
140
+
141
+ var ALLOWED_PATCH_COLUMNS = Object.freeze([
142
+ "max_amount_minor",
143
+ "max_refunds_per_customer_year",
144
+ "requires_low_risk",
145
+ "eligible_reasons",
146
+ "currency_in_set",
147
+ "priority",
148
+ "active",
149
+ ]);
150
+
151
+ var bShop;
152
+ function _b() {
153
+ if (!bShop) bShop = require("./index");
154
+ return bShop.framework;
155
+ }
156
+
157
+ // ---- validators ---------------------------------------------------------
158
+
159
+ function _slug(s) {
160
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
161
+ throw new TypeError("refundAutomation: slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
162
+ }
163
+ return s;
164
+ }
165
+
166
+ function _amountMinor(n, label) {
167
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > MAX_AMOUNT_MINOR) {
168
+ throw new TypeError("refundAutomation: " + label + " must be a positive integer in [1, " + MAX_AMOUNT_MINOR + "]");
169
+ }
170
+ return n;
171
+ }
172
+
173
+ function _maxRefundsPerYear(n) {
174
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > MAX_REFUNDS_PER_YEAR_CAP) {
175
+ throw new TypeError("refundAutomation: max_refunds_per_customer_year must be an integer in [1, " + MAX_REFUNDS_PER_YEAR_CAP + "]");
176
+ }
177
+ return n;
178
+ }
179
+
180
+ function _bool(v, label) {
181
+ if (typeof v !== "boolean") {
182
+ throw new TypeError("refundAutomation: " + label + " must be a boolean");
183
+ }
184
+ return v;
185
+ }
186
+
187
+ function _priority(n) {
188
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0 || n > MAX_PRIORITY) {
189
+ throw new TypeError("refundAutomation: priority must be an integer in [0, " + MAX_PRIORITY + "]");
190
+ }
191
+ return n;
192
+ }
193
+
194
+ function _reasonEntry(s) {
195
+ if (typeof s !== "string" || !REASON_RE.test(s)) {
196
+ throw new TypeError("refundAutomation: eligible_reasons entries must match /^[A-Za-z0-9][A-Za-z0-9 _.,'\\-]*$/ (<= " + MAX_REASON_LEN + " chars)");
197
+ }
198
+ return s;
199
+ }
200
+
201
+ function _eligibleReasons(arr) {
202
+ if (arr == null) return [];
203
+ if (!Array.isArray(arr)) {
204
+ throw new TypeError("refundAutomation: eligible_reasons must be an array of strings (empty array means 'any reason')");
205
+ }
206
+ if (arr.length > MAX_REASONS_PER_RULE) {
207
+ throw new TypeError("refundAutomation: eligible_reasons length " + arr.length + " exceeds cap " + MAX_REASONS_PER_RULE);
208
+ }
209
+ var seen = Object.create(null);
210
+ var out = [];
211
+ for (var i = 0; i < arr.length; i += 1) {
212
+ var r = _reasonEntry(arr[i]);
213
+ if (seen[r]) {
214
+ throw new TypeError("refundAutomation: eligible_reasons contains duplicate " + JSON.stringify(r));
215
+ }
216
+ seen[r] = true;
217
+ out.push(r);
218
+ }
219
+ return out;
220
+ }
221
+
222
+ function _currencyEntry(s) {
223
+ if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
224
+ throw new TypeError("refundAutomation: currency_in_set entries must be 3-letter ISO-4217 alpha codes (e.g. 'USD', 'EUR')");
225
+ }
226
+ return s;
227
+ }
228
+
229
+ function _currencyInSet(arr) {
230
+ if (arr == null) return [];
231
+ if (!Array.isArray(arr)) {
232
+ throw new TypeError("refundAutomation: currency_in_set must be an array of ISO-4217 codes (empty array means 'any currency')");
233
+ }
234
+ if (arr.length > MAX_CURRENCIES_PER_RULE) {
235
+ throw new TypeError("refundAutomation: currency_in_set length " + arr.length + " exceeds cap " + MAX_CURRENCIES_PER_RULE);
236
+ }
237
+ var seen = Object.create(null);
238
+ var out = [];
239
+ for (var i = 0; i < arr.length; i += 1) {
240
+ var c = _currencyEntry(arr[i]);
241
+ if (seen[c]) {
242
+ throw new TypeError("refundAutomation: currency_in_set contains duplicate " + JSON.stringify(c));
243
+ }
244
+ seen[c] = true;
245
+ out.push(c);
246
+ }
247
+ return out;
248
+ }
249
+
250
+ function _reasonValue(s, label) {
251
+ if (typeof s !== "string" || !REASON_RE.test(s)) {
252
+ throw new TypeError("refundAutomation: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9 _.,'\\-]*$/ (<= " + MAX_REASON_LEN + " chars)");
253
+ }
254
+ return s;
255
+ }
256
+
257
+ function _orderId(s) {
258
+ if (typeof s !== "string" || !s.length || s.length > MAX_EXTERNAL_ID_LEN) {
259
+ throw new TypeError("refundAutomation: order_id must be a non-empty string <= " + MAX_EXTERNAL_ID_LEN + " chars");
260
+ }
261
+ if (/[\x00-\x1f\x7f]/.test(s)) {
262
+ throw new TypeError("refundAutomation: order_id must not contain control bytes");
263
+ }
264
+ return s;
265
+ }
266
+
267
+ function _customerId(s) {
268
+ if (typeof s !== "string" || !s.length || s.length > MAX_EXTERNAL_ID_LEN) {
269
+ throw new TypeError("refundAutomation: customer_id must be a non-empty string <= " + MAX_EXTERNAL_ID_LEN + " chars");
270
+ }
271
+ if (/[\x00-\x1f\x7f]/.test(s)) {
272
+ throw new TypeError("refundAutomation: customer_id must not contain control bytes");
273
+ }
274
+ return s;
275
+ }
276
+
277
+ function _operatorId(s) {
278
+ if (typeof s !== "string" || !OPERATOR_ID_RE.test(s)) {
279
+ throw new TypeError("refundAutomation: operator_id must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_OPERATOR_ID_LEN + " chars)");
280
+ }
281
+ return s;
282
+ }
283
+
284
+ function _decision(s) {
285
+ if (typeof s !== "string" || DECISIONS.indexOf(s) === -1) {
286
+ throw new TypeError("refundAutomation: decision must be one of " + DECISIONS.join(", "));
287
+ }
288
+ return s;
289
+ }
290
+
291
+ function _epochMs(n, label) {
292
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
293
+ throw new TypeError("refundAutomation: " + label + " must be a non-negative integer epoch-ms");
294
+ }
295
+ return n;
296
+ }
297
+
298
+ function _currencyOpt(s) {
299
+ if (s == null) return null;
300
+ return _currencyEntry(s);
301
+ }
302
+
303
+ function _now() { return Date.now(); }
304
+
305
+ // ---- row hydration ------------------------------------------------------
306
+
307
+ function _safeParseArray(s) {
308
+ if (s == null) return [];
309
+ try {
310
+ var parsed = JSON.parse(s);
311
+ if (Array.isArray(parsed)) return parsed;
312
+ return [];
313
+ } catch (_e) {
314
+ return [];
315
+ }
316
+ }
317
+
318
+ function _hydrateRule(r) {
319
+ if (!r) return null;
320
+ return {
321
+ slug: r.slug,
322
+ max_amount_minor: Number(r.max_amount_minor),
323
+ max_refunds_per_customer_year: Number(r.max_refunds_per_customer_year),
324
+ requires_low_risk: r.requires_low_risk === 1 || r.requires_low_risk === true,
325
+ eligible_reasons: _safeParseArray(r.eligible_reasons_json),
326
+ currency_in_set: _safeParseArray(r.currency_in_set_json),
327
+ priority: Number(r.priority),
328
+ active: r.active === 1 || r.active === true,
329
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
330
+ created_at: Number(r.created_at),
331
+ updated_at: Number(r.updated_at),
332
+ };
333
+ }
334
+
335
+ // ---- factory ------------------------------------------------------------
336
+
337
+ function create(opts) {
338
+ opts = opts || {};
339
+ var query = opts.query;
340
+ if (!query) {
341
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
342
+ }
343
+ // Optional composed handles. All four are stubbable; the primitive
344
+ // composes them when present and refuses gracefully when a rule
345
+ // requires a capability the operator hasn't wired (e.g.
346
+ // requires_low_risk without an injected customerRiskProfile).
347
+ var refundPolicyHandle = opts.refundPolicy || null;
348
+ var returnsHandle = opts.returns || null;
349
+ var paymentHandle = opts.payment || null;
350
+ var riskProfileHandle = opts.customerRiskProfile || null;
351
+
352
+ // Per-factory monotonic clock. Two decisions written against the
353
+ // same order in the same wall-clock millisecond would otherwise
354
+ // tie on `decided_at` and make the
355
+ // `(order_id, decided_at DESC)` index ambiguous. Forward-leap when
356
+ // the wall clock outpaces the counter; otherwise bump by 1ms so
357
+ // the sequence is strictly increasing per primitive instance.
358
+ var _lastEventTs = 0;
359
+ function _monotonicTs() {
360
+ var wall = _now();
361
+ if (wall > _lastEventTs) _lastEventTs = wall;
362
+ else _lastEventTs += 1;
363
+ return _lastEventTs;
364
+ }
365
+
366
+ // ---- defineAutoRule -----------------------------------------------
367
+
368
+ async function defineAutoRule(input) {
369
+ if (!input || typeof input !== "object") {
370
+ throw new TypeError("refundAutomation.defineAutoRule: input object required");
371
+ }
372
+ var slug = _slug(input.slug);
373
+ var maxAmt = _amountMinor(input.max_amount_minor, "max_amount_minor");
374
+ var maxPerYear = _maxRefundsPerYear(input.max_refunds_per_customer_year);
375
+ var requiresLowRisk = _bool(input.requires_low_risk, "requires_low_risk");
376
+ var reasons = _eligibleReasons(input.eligible_reasons);
377
+ var currencies = _currencyInSet(input.currency_in_set);
378
+ var priority = input.priority == null ? 0 : _priority(input.priority);
379
+
380
+ // Refuse redefine — same posture as refundPolicy / coupon-stacking.
381
+ var existing = (await query(
382
+ "SELECT slug FROM refund_automation_rules WHERE slug = ?1 LIMIT 1",
383
+ [slug],
384
+ )).rows[0];
385
+ if (existing) {
386
+ throw new TypeError("refundAutomation.defineAutoRule: slug " + JSON.stringify(slug) + " already exists - use updateRule");
387
+ }
388
+
389
+ var ts = _monotonicTs();
390
+ await query(
391
+ "INSERT INTO refund_automation_rules (slug, max_amount_minor, max_refunds_per_customer_year, " +
392
+ "requires_low_risk, eligible_reasons_json, currency_in_set_json, priority, active, " +
393
+ "archived_at, created_at, updated_at) " +
394
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1, NULL, ?8, ?8)",
395
+ [
396
+ slug,
397
+ maxAmt,
398
+ maxPerYear,
399
+ requiresLowRisk ? 1 : 0,
400
+ JSON.stringify(reasons),
401
+ JSON.stringify(currencies),
402
+ priority,
403
+ ts,
404
+ ],
405
+ );
406
+ return await _getRule(slug);
407
+ }
408
+
409
+ // ---- getRule / listRules / updateRule / archiveRule ---------------
410
+
411
+ async function _getRule(slug) {
412
+ var r = (await query(
413
+ "SELECT * FROM refund_automation_rules WHERE slug = ?1 LIMIT 1",
414
+ [slug],
415
+ )).rows[0];
416
+ return _hydrateRule(r);
417
+ }
418
+
419
+ async function listRules(listOpts) {
420
+ listOpts = listOpts || {};
421
+ var activeOnly = false;
422
+ if (listOpts.active_only != null) {
423
+ if (typeof listOpts.active_only !== "boolean") {
424
+ throw new TypeError("refundAutomation.listRules: active_only must be a boolean");
425
+ }
426
+ activeOnly = listOpts.active_only;
427
+ }
428
+ var limit = listOpts.limit == null ? 50 : listOpts.limit;
429
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
430
+ throw new TypeError("refundAutomation.listRules: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
431
+ }
432
+ var sql;
433
+ if (activeOnly) {
434
+ sql = "SELECT * FROM refund_automation_rules WHERE active = 1 AND archived_at IS NULL " +
435
+ "ORDER BY priority DESC, created_at ASC, slug ASC LIMIT ?1";
436
+ } else {
437
+ sql = "SELECT * FROM refund_automation_rules " +
438
+ "ORDER BY priority DESC, created_at ASC, slug ASC LIMIT ?1";
439
+ }
440
+ var rows = (await query(sql, [limit])).rows;
441
+ var out = [];
442
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRule(rows[i]));
443
+ return out;
444
+ }
445
+
446
+ async function updateRule(slug, patch) {
447
+ _slug(slug);
448
+ if (!patch || typeof patch !== "object") {
449
+ throw new TypeError("refundAutomation.updateRule: patch object required");
450
+ }
451
+ var keys = Object.keys(patch);
452
+ if (!keys.length) {
453
+ throw new TypeError("refundAutomation.updateRule: patch must include at least one column");
454
+ }
455
+ var current = await _getRule(slug);
456
+ if (!current) {
457
+ throw new TypeError("refundAutomation.updateRule: slug " + JSON.stringify(slug) + " not found");
458
+ }
459
+ var sets = [];
460
+ var params = [];
461
+ var idx = 1;
462
+ for (var i = 0; i < keys.length; i += 1) {
463
+ var col = keys[i];
464
+ if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
465
+ throw new TypeError("refundAutomation.updateRule: unsupported column " + JSON.stringify(col));
466
+ }
467
+ if (col === "max_amount_minor") {
468
+ sets.push("max_amount_minor = ?" + idx);
469
+ params.push(_amountMinor(patch[col], "max_amount_minor"));
470
+ } else if (col === "max_refunds_per_customer_year") {
471
+ sets.push("max_refunds_per_customer_year = ?" + idx);
472
+ params.push(_maxRefundsPerYear(patch[col]));
473
+ } else if (col === "requires_low_risk") {
474
+ sets.push("requires_low_risk = ?" + idx);
475
+ params.push(_bool(patch[col], "requires_low_risk") ? 1 : 0);
476
+ } else if (col === "eligible_reasons") {
477
+ sets.push("eligible_reasons_json = ?" + idx);
478
+ params.push(JSON.stringify(_eligibleReasons(patch[col])));
479
+ } else if (col === "currency_in_set") {
480
+ sets.push("currency_in_set_json = ?" + idx);
481
+ params.push(JSON.stringify(_currencyInSet(patch[col])));
482
+ } else if (col === "priority") {
483
+ sets.push("priority = ?" + idx);
484
+ params.push(_priority(patch[col]));
485
+ } else /* active */ {
486
+ sets.push("active = ?" + idx);
487
+ params.push(_bool(patch[col], "active") ? 1 : 0);
488
+ }
489
+ idx += 1;
490
+ }
491
+ sets.push("updated_at = ?" + idx);
492
+ params.push(_monotonicTs());
493
+ idx += 1;
494
+ params.push(slug);
495
+ var r = await query(
496
+ "UPDATE refund_automation_rules SET " + sets.join(", ") + " WHERE slug = ?" + idx,
497
+ params,
498
+ );
499
+ if (Number(r.rowCount || 0) === 0) {
500
+ throw new TypeError("refundAutomation.updateRule: slug " + JSON.stringify(slug) + " not found");
501
+ }
502
+ return await _getRule(slug);
503
+ }
504
+
505
+ async function archiveRule(slug) {
506
+ _slug(slug);
507
+ var ts = _monotonicTs();
508
+ var r = await query(
509
+ "UPDATE refund_automation_rules SET archived_at = ?1, active = 0, updated_at = ?1 " +
510
+ "WHERE slug = ?2 AND archived_at IS NULL",
511
+ [ts, slug],
512
+ );
513
+ if (Number(r.rowCount || 0) === 0) {
514
+ var existing = await _getRule(slug);
515
+ if (!existing) {
516
+ throw new TypeError("refundAutomation.archiveRule: slug " + JSON.stringify(slug) + " not found");
517
+ }
518
+ // Already archived — return existing row idempotently.
519
+ return existing;
520
+ }
521
+ return await _getRule(slug);
522
+ }
523
+
524
+ // ---- evaluateForRefundRequest -------------------------------------
525
+ //
526
+ // Walks active, non-archived rules in (priority DESC, created_at
527
+ // ASC, slug ASC) order. The first rule whose every gate accepts
528
+ // governs the verdict; falling out of every rule's gates surfaces
529
+ // as `manual_review` (not `declined` — the primitive's job is to
530
+ // route, not refuse: a human decides whether the operator wants
531
+ // to issue the refund anyway).
532
+ async function evaluateForRefundRequest(input) {
533
+ if (!input || typeof input !== "object") {
534
+ throw new TypeError("refundAutomation.evaluateForRefundRequest: input object required");
535
+ }
536
+ // order_id is validated for shape only — evaluate is a pure
537
+ // read against rules + the customer's prior-decision count. The
538
+ // order_id makes it into the audit row at executeAutoRefund /
539
+ // markManualOverride time.
540
+ _orderId(input.order_id);
541
+ var customerId = _customerId(input.customer_id);
542
+ var requestAmt = _amountMinor(input.request_amount_minor, "request_amount_minor");
543
+ var reason = _reasonValue(input.reason, "reason");
544
+ var currency = _currencyOpt(input.currency);
545
+
546
+ var rows = (await query(
547
+ "SELECT * FROM refund_automation_rules WHERE active = 1 AND archived_at IS NULL " +
548
+ "ORDER BY priority DESC, created_at ASC, slug ASC",
549
+ [],
550
+ )).rows;
551
+
552
+ if (rows.length === 0) {
553
+ return {
554
+ eligible: false,
555
+ applied_rule: null,
556
+ max_refund_minor: 0,
557
+ requires_manual_review: true,
558
+ reasons: ["no_rule_matched"],
559
+ };
560
+ }
561
+
562
+ var firstCandidate = null;
563
+ var firstReasons = null;
564
+
565
+ for (var i = 0; i < rows.length; i += 1) {
566
+ var rule = _hydrateRule(rows[i]);
567
+ var reasons = [];
568
+
569
+ // Per-request amount cap.
570
+ if (requestAmt > rule.max_amount_minor) {
571
+ reasons.push("amount_exceeds_rule_cap");
572
+ }
573
+ // Reason gate. Empty set means "any reason qualifies."
574
+ if (rule.eligible_reasons.length > 0 && rule.eligible_reasons.indexOf(reason) === -1) {
575
+ reasons.push("reason_not_eligible");
576
+ }
577
+ // Currency gate. Empty set means "any currency." A request
578
+ // without a currency argument can't satisfy a currency-gated
579
+ // rule.
580
+ if (rule.currency_in_set.length > 0) {
581
+ if (currency == null) {
582
+ reasons.push("currency_missing");
583
+ } else if (rule.currency_in_set.indexOf(currency) === -1) {
584
+ reasons.push("currency_not_eligible");
585
+ }
586
+ }
587
+ // Per-customer annual cap. Counts prior auto_approved
588
+ // decisions for this customer within the trailing 365 days.
589
+ // The cap is "would this request push the count past the
590
+ // cap?" — a customer at max_refunds-1 still qualifies.
591
+ var since = _now() - MS_PER_YEAR;
592
+ var prior = (await query(
593
+ "SELECT COUNT(*) AS n FROM refund_automation_decisions " +
594
+ "WHERE customer_id = ?1 AND decision = 'auto_approved' AND decided_at >= ?2",
595
+ [customerId, since],
596
+ )).rows[0];
597
+ var priorCount = prior == null ? 0 : Number(prior.n || 0);
598
+ if (priorCount >= rule.max_refunds_per_customer_year) {
599
+ reasons.push("customer_year_cap_exceeded");
600
+ }
601
+ // Low-risk gate. When the rule requires it, the injected
602
+ // customerRiskProfile handle must report a low band. Absent
603
+ // the handle the gate refuses (missing signal is a manual-
604
+ // review signal, not a free pass).
605
+ if (rule.requires_low_risk) {
606
+ if (!riskProfileHandle || typeof riskProfileHandle.bandFor !== "function") {
607
+ reasons.push("risk_profile_unavailable");
608
+ } else {
609
+ var band;
610
+ try {
611
+ band = await riskProfileHandle.bandFor(customerId);
612
+ } catch (_e) {
613
+ band = null;
614
+ }
615
+ if (band !== "low") {
616
+ reasons.push("customer_not_low_risk");
617
+ }
618
+ }
619
+ }
620
+
621
+ if (reasons.length === 0) {
622
+ return {
623
+ eligible: true,
624
+ applied_rule: rule.slug,
625
+ max_refund_minor: rule.max_amount_minor,
626
+ requires_manual_review: false,
627
+ reasons: ["rule_matched"],
628
+ };
629
+ }
630
+
631
+ if (firstCandidate == null) {
632
+ firstCandidate = rule;
633
+ firstReasons = reasons;
634
+ }
635
+ // Otherwise: a lower-priority rule may still accept; keep walking.
636
+ }
637
+
638
+ return {
639
+ eligible: false,
640
+ applied_rule: firstCandidate ? firstCandidate.slug : null,
641
+ max_refund_minor: 0,
642
+ requires_manual_review: true,
643
+ reasons: firstReasons || ["no_rule_matched"],
644
+ };
645
+ }
646
+
647
+ // ---- executeAutoRefund --------------------------------------------
648
+ //
649
+ // Re-evaluates eligibility (a stale verdict the caller is holding
650
+ // from minutes ago might no longer apply if the customer just
651
+ // crossed the annual cap), writes the auto_approved audit row,
652
+ // and composes payment.refund when a handle is wired. The audit
653
+ // row writes BEFORE the payment call so a payment-call failure
654
+ // doesn't lose the operator-visible decision; payment failures
655
+ // surface as a thrown error to the caller, who is responsible
656
+ // for unwinding via markManualOverride or operator escalation.
657
+ async function executeAutoRefund(input) {
658
+ if (!input || typeof input !== "object") {
659
+ throw new TypeError("refundAutomation.executeAutoRefund: input object required");
660
+ }
661
+ var orderId = _orderId(input.order_id);
662
+ var customerId = _customerId(input.customer_id);
663
+ var amount = _amountMinor(input.amount_minor, "amount_minor");
664
+ var reason = _reasonValue(input.reason, "reason");
665
+ var paymentIntent = null;
666
+ if (input.payment_intent != null) {
667
+ if (typeof input.payment_intent !== "string" || !input.payment_intent.length) {
668
+ throw new TypeError("refundAutomation.executeAutoRefund: payment_intent must be a non-empty string when provided");
669
+ }
670
+ paymentIntent = input.payment_intent;
671
+ }
672
+
673
+ var verdict = await evaluateForRefundRequest({
674
+ order_id: orderId,
675
+ customer_id: customerId,
676
+ request_amount_minor: amount,
677
+ reason: reason,
678
+ currency: input.currency,
679
+ });
680
+ if (!verdict.eligible) {
681
+ throw new TypeError("refundAutomation.executeAutoRefund: request did not pass auto-eligibility — reasons: " +
682
+ verdict.reasons.join(", "));
683
+ }
684
+
685
+ var id = _b().uuid.v7();
686
+ var ts = _monotonicTs();
687
+ await query(
688
+ "INSERT INTO refund_automation_decisions (id, order_id, customer_id, applied_rule, decision, " +
689
+ "amount_minor, reason, decided_at) VALUES (?1, ?2, ?3, ?4, 'auto_approved', ?5, ?6, ?7)",
690
+ [id, orderId, customerId, verdict.applied_rule, amount, reason, ts],
691
+ );
692
+
693
+ // Compose payment.refund when wired. The handle's exact shape
694
+ // mirrors the framework's payment primitive (`{ payment_intent,
695
+ // amount_minor, reason, metadata }`). Absent a handle the
696
+ // primitive still records the decision so the operator can
697
+ // drive the actual refund out-of-band.
698
+ var paymentResult = null;
699
+ if (paymentHandle && typeof paymentHandle.refund === "function") {
700
+ var refundInput = {
701
+ amount_minor: amount,
702
+ reason: reason,
703
+ metadata: { order_id: orderId, customer_id: customerId, applied_rule: verdict.applied_rule },
704
+ };
705
+ if (paymentIntent != null) refundInput.payment_intent = paymentIntent;
706
+ paymentResult = await paymentHandle.refund(refundInput);
707
+ }
708
+
709
+ return {
710
+ id: id,
711
+ order_id: orderId,
712
+ customer_id: customerId,
713
+ applied_rule: verdict.applied_rule,
714
+ decision: "auto_approved",
715
+ amount_minor: amount,
716
+ reason: reason,
717
+ decided_at: ts,
718
+ payment_result: paymentResult,
719
+ };
720
+ }
721
+
722
+ // ---- markManualOverride -------------------------------------------
723
+ //
724
+ // Records the operator's decision on a request that fell through to
725
+ // manual review. Writes a fresh decision row keyed by order_id so
726
+ // the dashboard sees the latest verdict. The `applied_rule` column
727
+ // is left NULL — the manual override didn't apply a rule.
728
+ async function markManualOverride(input) {
729
+ if (!input || typeof input !== "object") {
730
+ throw new TypeError("refundAutomation.markManualOverride: input object required");
731
+ }
732
+ var orderId = _orderId(input.order_id);
733
+ var decision = _decision(input.decision);
734
+ var operatorId = _operatorId(input.operator_id);
735
+ var reason = _reasonValue(input.reason, "reason");
736
+
737
+ // Look up the most-recent decision for this order to pull the
738
+ // customer_id forward. The manual override is keyed by order;
739
+ // the customer is whoever the request was originally filed for.
740
+ var prior = (await query(
741
+ "SELECT customer_id FROM refund_automation_decisions " +
742
+ "WHERE order_id = ?1 ORDER BY decided_at DESC LIMIT 1",
743
+ [orderId],
744
+ )).rows[0];
745
+ if (!prior) {
746
+ throw new TypeError("refundAutomation.markManualOverride: order_id " + JSON.stringify(orderId) +
747
+ " has no prior decision row — call evaluateForRefundRequest first so the request is recorded");
748
+ }
749
+ var customerId = prior.customer_id;
750
+
751
+ var id = _b().uuid.v7();
752
+ var ts = _monotonicTs();
753
+ // The reason column carries `<operator_id>: <reason>` so the
754
+ // audit log shows who approved / declined without a separate
755
+ // operator_id column on the decisions table (that level of
756
+ // structured operator-id auditing lives in operator_audit_events).
757
+ var auditedReason = operatorId + ": " + reason;
758
+ if (auditedReason.length > MAX_REASON_LEN) {
759
+ auditedReason = auditedReason.slice(0, MAX_REASON_LEN);
760
+ }
761
+ await query(
762
+ "INSERT INTO refund_automation_decisions (id, order_id, customer_id, applied_rule, decision, " +
763
+ "amount_minor, reason, decided_at) VALUES (?1, ?2, ?3, NULL, ?4, NULL, ?5, ?6)",
764
+ [id, orderId, customerId, decision, auditedReason, ts],
765
+ );
766
+ return {
767
+ id: id,
768
+ order_id: orderId,
769
+ customer_id: customerId,
770
+ applied_rule: null,
771
+ decision: decision,
772
+ amount_minor: null,
773
+ reason: auditedReason,
774
+ decided_at: ts,
775
+ };
776
+ }
777
+
778
+ // ---- metricsForRule -----------------------------------------------
779
+ //
780
+ // Aggregates over the [from, to] window. Returns counts by
781
+ // decision bucket + total refunded amount for auto_approved rows.
782
+ async function metricsForRule(input) {
783
+ if (!input || typeof input !== "object") {
784
+ throw new TypeError("refundAutomation.metricsForRule: input object required");
785
+ }
786
+ var slug = _slug(input.slug);
787
+ var from = _epochMs(input.from, "from");
788
+ var to = _epochMs(input.to, "to");
789
+ if (from > to) {
790
+ throw new TypeError("refundAutomation.metricsForRule: from must be <= to");
791
+ }
792
+ var rows = (await query(
793
+ "SELECT decision, amount_minor FROM refund_automation_decisions " +
794
+ "WHERE applied_rule = ?1 AND decided_at >= ?2 AND decided_at <= ?3",
795
+ [slug, from, to],
796
+ )).rows;
797
+ var autoCount = 0;
798
+ var manualCount = 0;
799
+ var declinedCount = 0;
800
+ var totalRefundedMinor = 0;
801
+ for (var i = 0; i < rows.length; i += 1) {
802
+ var r = rows[i];
803
+ if (r.decision === "auto_approved") {
804
+ autoCount += 1;
805
+ if (r.amount_minor != null) totalRefundedMinor += Number(r.amount_minor);
806
+ } else if (r.decision === "manual_review") {
807
+ manualCount += 1;
808
+ } else if (r.decision === "declined") {
809
+ declinedCount += 1;
810
+ }
811
+ }
812
+ return {
813
+ slug: slug,
814
+ from: from,
815
+ to: to,
816
+ auto_approved_count: autoCount,
817
+ manual_review_count: manualCount,
818
+ declined_count: declinedCount,
819
+ total_refunded_minor: totalRefundedMinor,
820
+ };
821
+ }
822
+
823
+ // Internal cross-references so the unused-handle defensive reads
824
+ // are explicit. refundPolicy + returns aren't currently composed in
825
+ // the v1 surface — they're held for forward compatibility (a future
826
+ // gate will compose refundPolicy.evaluate as an upstream eligibility
827
+ // pre-check, and returns will let us cross-check that an RMA
828
+ // actually exists before issuing the refund). Keeping the handles
829
+ // in the factory keeps the operator-facing wiring stable across
830
+ // those additions.
831
+ void refundPolicyHandle;
832
+ void returnsHandle;
833
+
834
+ return {
835
+ defineAutoRule: defineAutoRule,
836
+ evaluateForRefundRequest: evaluateForRefundRequest,
837
+ executeAutoRefund: executeAutoRefund,
838
+ markManualOverride: markManualOverride,
839
+ metricsForRule: metricsForRule,
840
+ listRules: listRules,
841
+ updateRule: updateRule,
842
+ archiveRule: archiveRule,
843
+ };
844
+ }
845
+
846
+ module.exports = {
847
+ create: create,
848
+ DECISIONS: DECISIONS,
849
+ ALLOWED_PATCH_COLUMNS: ALLOWED_PATCH_COLUMNS,
850
+ MAX_AMOUNT_MINOR: MAX_AMOUNT_MINOR,
851
+ MAX_REASONS_PER_RULE: MAX_REASONS_PER_RULE,
852
+ MAX_CURRENCIES_PER_RULE: MAX_CURRENCIES_PER_RULE,
853
+ };