@blamejs/blamejs-shop 0.0.65 → 0.0.70
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 +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- 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
|
+
};
|