@blamejs/blamejs-shop 0.0.61 → 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 +2 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +10 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/sms-dispatcher.js +7 -1
- package/lib/stock-transfers.js +777 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.refundPolicy
|
|
4
|
+
* @title Refund-policy primitive — operator-authored eligibility rules
|
|
5
|
+
* that decide whether a refund request qualifies BEFORE the RMA
|
|
6
|
+
* workflow opens.
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* A refund-policy row answers one question:
|
|
10
|
+
*
|
|
11
|
+
* "A customer is requesting a refund on order O. Given the order's
|
|
12
|
+
* lines, the customer's status, the calendar gap between purchase
|
|
13
|
+
* and request, and whether the customer presented a receipt —
|
|
14
|
+
* does one of my active policies say yes, and if so what shape of
|
|
15
|
+
* refund is on the table?"
|
|
16
|
+
*
|
|
17
|
+
* Distinct from `returns` (which owns the RMA finite-state machine
|
|
18
|
+
* once a refund has been accepted as eligible) and from `payment`
|
|
19
|
+
* (which executes the money movement once an RMA reaches `refund`).
|
|
20
|
+
* The refund-policy primitive is the upstream gate that says yes or
|
|
21
|
+
* no, and — when yes — names the policy that governs, the kind of
|
|
22
|
+
* refund permitted, the cap, and the restocking fee.
|
|
23
|
+
*
|
|
24
|
+
* Surface:
|
|
25
|
+
*
|
|
26
|
+
* - `definePolicy({ slug, title, applies_to, refund_window_days,
|
|
27
|
+
* exclusions?, refund_kind, partial_refund_bps?,
|
|
28
|
+
* restocking_fee_minor?, requires_receipt,
|
|
29
|
+
* customer_status_in?, priority? })`
|
|
30
|
+
* Create a new policy. `applies_to` is one of
|
|
31
|
+
* `all` / `category` / `vendor` / `sku` / `tag`.
|
|
32
|
+
* `refund_kind` is one of `full` / `partial` /
|
|
33
|
+
* `store_credit_only` / `no_refund`. `partial` requires
|
|
34
|
+
* `partial_refund_bps` in [1, 9999]; the other kinds refuse
|
|
35
|
+
* it. `requires_receipt` is a boolean. `exclusions` is an
|
|
36
|
+
* object of optional arrays
|
|
37
|
+
* `{ categories?, vendors?, skus?, tags? }`; entries are
|
|
38
|
+
* tight-format strings.
|
|
39
|
+
*
|
|
40
|
+
* - `evaluate({ order_id, order_total_minor, line_categories,
|
|
41
|
+
* line_vendors, line_skus, line_tags, customer_id?,
|
|
42
|
+
* customer_status?, order_date, request_date,
|
|
43
|
+
* has_receipt })`
|
|
44
|
+
* Walks active policies in (priority DESC, created_at ASC,
|
|
45
|
+
* slug ASC) order. Returns
|
|
46
|
+
* `{ eligible: bool, applied_policy?: slug, refund_kind,
|
|
47
|
+
* max_refund_minor, restocking_fee_minor, reasons: [...] }`.
|
|
48
|
+
* When no policy applies, returns
|
|
49
|
+
* `{ eligible: false, refund_kind: 'no_refund',
|
|
50
|
+
* max_refund_minor: 0, restocking_fee_minor: 0,
|
|
51
|
+
* reasons: ['no_policy_matched'] }` — the conservative
|
|
52
|
+
* default is "we don't refund unless an operator authored a
|
|
53
|
+
* policy that says we do."
|
|
54
|
+
*
|
|
55
|
+
* - `auditEvaluation({ order_id, evaluation, verdict })`
|
|
56
|
+
* Records the evaluation receipt to `refund_policy_audit`.
|
|
57
|
+
* The caller composes `evaluate` + `auditEvaluation` so the
|
|
58
|
+
* primitive doesn't double-write when an integrator wants a
|
|
59
|
+
* dry-run preview. Receipts are append-only by convention.
|
|
60
|
+
*
|
|
61
|
+
* - `getPolicy(slug)` / `listPolicies({ active_only?, limit? })` /
|
|
62
|
+
* `updatePolicy(slug, patch)` / `archivePolicy(slug)` /
|
|
63
|
+
* `listAudit({ order_id, limit? })`.
|
|
64
|
+
*
|
|
65
|
+
* Storage:
|
|
66
|
+
* - `refund_policies` + `refund_policy_audit` (migration
|
|
67
|
+
* `0090_refund_policy.sql`).
|
|
68
|
+
*
|
|
69
|
+
* @primitive refundPolicy
|
|
70
|
+
* @related returns, payment, operatorAuditLog
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
// ---- constants ----------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
var MAX_SLUG_LEN = 80;
|
|
76
|
+
var MAX_TITLE_LEN = 200;
|
|
77
|
+
var MAX_EXCLUSION_ENTRIES = 256;
|
|
78
|
+
var MAX_EXCLUSION_LEN = 128;
|
|
79
|
+
var MAX_STATUS_ENTRIES = 32;
|
|
80
|
+
var MAX_STATUS_LEN = 64;
|
|
81
|
+
var MAX_WINDOW_DAYS = 36500; // ~100 years; refuses absurd values
|
|
82
|
+
var MAX_PRIORITY = 1000000;
|
|
83
|
+
var MAX_LIST_LIMIT = 200;
|
|
84
|
+
var MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
85
|
+
|
|
86
|
+
// Slug shape matches coupon-stacking / promo-banners / customer-segments
|
|
87
|
+
// convention — alnum + hyphen + underscore + dot, leading char alnum,
|
|
88
|
+
// capped length.
|
|
89
|
+
var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
90
|
+
// Exclusion entries are operator-author SKU / category / vendor / tag
|
|
91
|
+
// strings; tight enough to refuse control bytes + whitespace, loose
|
|
92
|
+
// enough to admit the shapes the catalog / order primitives emit.
|
|
93
|
+
var EXCLUSION_RE = /^[A-Za-z0-9][A-Za-z0-9._\-:/]{0,127}$/;
|
|
94
|
+
var STATUS_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
95
|
+
|
|
96
|
+
var APPLIES_TO = Object.freeze(["all", "category", "vendor", "sku", "tag"]);
|
|
97
|
+
var REFUND_KINDS = Object.freeze(["full", "partial", "store_credit_only", "no_refund"]);
|
|
98
|
+
var AUDIT_DECISIONS = Object.freeze(["eligible", "denied", "no_policy"]);
|
|
99
|
+
|
|
100
|
+
var EXCLUSION_AXES = Object.freeze(["categories", "vendors", "skus", "tags"]);
|
|
101
|
+
|
|
102
|
+
var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
103
|
+
"title",
|
|
104
|
+
"applies_to",
|
|
105
|
+
"refund_window_days",
|
|
106
|
+
"exclusions",
|
|
107
|
+
"refund_kind",
|
|
108
|
+
"partial_refund_bps",
|
|
109
|
+
"restocking_fee_minor",
|
|
110
|
+
"requires_receipt",
|
|
111
|
+
"customer_status_in",
|
|
112
|
+
"active",
|
|
113
|
+
"priority",
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
var bShop;
|
|
117
|
+
function _b() {
|
|
118
|
+
if (!bShop) bShop = require("./index");
|
|
119
|
+
return bShop.framework;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- validators ---------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
function _slug(s) {
|
|
125
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
126
|
+
throw new TypeError("refundPolicy: slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
|
|
127
|
+
}
|
|
128
|
+
return s;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _title(s) {
|
|
132
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
|
|
133
|
+
throw new TypeError("refundPolicy: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
|
|
134
|
+
}
|
|
135
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
136
|
+
throw new TypeError("refundPolicy: title must not contain control bytes");
|
|
137
|
+
}
|
|
138
|
+
return s;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function _appliesTo(s) {
|
|
142
|
+
if (typeof s !== "string" || APPLIES_TO.indexOf(s) === -1) {
|
|
143
|
+
throw new TypeError("refundPolicy: applies_to must be one of " + APPLIES_TO.join(", "));
|
|
144
|
+
}
|
|
145
|
+
return s;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function _refundKind(s) {
|
|
149
|
+
if (typeof s !== "string" || REFUND_KINDS.indexOf(s) === -1) {
|
|
150
|
+
throw new TypeError("refundPolicy: refund_kind must be one of " + REFUND_KINDS.join(", "));
|
|
151
|
+
}
|
|
152
|
+
return s;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _windowDays(n) {
|
|
156
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_WINDOW_DAYS) {
|
|
157
|
+
throw new TypeError("refundPolicy: refund_window_days must be an integer in [0, " + MAX_WINDOW_DAYS + "]");
|
|
158
|
+
}
|
|
159
|
+
return n;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _bps(n) {
|
|
163
|
+
if (!Number.isInteger(n) || n < 1 || n > 9999) {
|
|
164
|
+
throw new TypeError("refundPolicy: partial_refund_bps must be an integer in [1, 9999] (use refund_kind 'full' for 100% and 'no_refund' for 0%)");
|
|
165
|
+
}
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _restockingFee(n) {
|
|
170
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
171
|
+
throw new TypeError("refundPolicy: restocking_fee_minor must be a non-negative integer");
|
|
172
|
+
}
|
|
173
|
+
return n;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _bool(v, label) {
|
|
177
|
+
if (typeof v !== "boolean") {
|
|
178
|
+
throw new TypeError("refundPolicy: " + label + " must be a boolean");
|
|
179
|
+
}
|
|
180
|
+
return v;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function _priority(n) {
|
|
184
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_PRIORITY) {
|
|
185
|
+
throw new TypeError("refundPolicy: priority must be an integer in [0, " + MAX_PRIORITY + "]");
|
|
186
|
+
}
|
|
187
|
+
return n;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function _exclusionEntry(s, axisLabel) {
|
|
191
|
+
if (typeof s !== "string" || !EXCLUSION_RE.test(s)) {
|
|
192
|
+
throw new TypeError("refundPolicy: exclusions." + axisLabel + " entries must match /^[A-Za-z0-9][A-Za-z0-9._\\-:/]*$/ (<= " + MAX_EXCLUSION_LEN + " chars)");
|
|
193
|
+
}
|
|
194
|
+
return s;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _exclusionAxis(arr, axisLabel) {
|
|
198
|
+
if (arr == null) return [];
|
|
199
|
+
if (!Array.isArray(arr)) {
|
|
200
|
+
throw new TypeError("refundPolicy: exclusions." + axisLabel + " must be an array of strings");
|
|
201
|
+
}
|
|
202
|
+
if (arr.length > MAX_EXCLUSION_ENTRIES) {
|
|
203
|
+
throw new TypeError("refundPolicy: exclusions." + axisLabel + " length " + arr.length + " exceeds cap " + MAX_EXCLUSION_ENTRIES);
|
|
204
|
+
}
|
|
205
|
+
var seen = Object.create(null);
|
|
206
|
+
var out = [];
|
|
207
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
208
|
+
var v = _exclusionEntry(arr[i], axisLabel);
|
|
209
|
+
if (seen[v]) {
|
|
210
|
+
throw new TypeError("refundPolicy: exclusions." + axisLabel + " contains duplicate " + JSON.stringify(v));
|
|
211
|
+
}
|
|
212
|
+
seen[v] = true;
|
|
213
|
+
out.push(v);
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function _exclusions(v) {
|
|
219
|
+
if (v == null) return { categories: [], vendors: [], skus: [], tags: [] };
|
|
220
|
+
if (typeof v !== "object" || Array.isArray(v)) {
|
|
221
|
+
throw new TypeError("refundPolicy: exclusions must be an object with optional axes " + EXCLUSION_AXES.join(", "));
|
|
222
|
+
}
|
|
223
|
+
var keys = Object.keys(v);
|
|
224
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
225
|
+
if (EXCLUSION_AXES.indexOf(keys[i]) === -1) {
|
|
226
|
+
throw new TypeError("refundPolicy: exclusions unknown axis " + JSON.stringify(keys[i]) +
|
|
227
|
+
" (allowed: " + JSON.stringify(EXCLUSION_AXES) + ")");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
categories: _exclusionAxis(v.categories, "categories"),
|
|
232
|
+
vendors: _exclusionAxis(v.vendors, "vendors"),
|
|
233
|
+
skus: _exclusionAxis(v.skus, "skus"),
|
|
234
|
+
tags: _exclusionAxis(v.tags, "tags"),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function _statusEntry(s) {
|
|
239
|
+
if (typeof s !== "string" || !STATUS_RE.test(s)) {
|
|
240
|
+
throw new TypeError("refundPolicy: customer_status_in entries must match /^[a-z0-9][a-z0-9_-]*$/ (<= " + MAX_STATUS_LEN + " chars)");
|
|
241
|
+
}
|
|
242
|
+
return s;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function _customerStatusIn(v) {
|
|
246
|
+
if (v == null) return [];
|
|
247
|
+
if (!Array.isArray(v)) {
|
|
248
|
+
throw new TypeError("refundPolicy: customer_status_in must be an array of status strings");
|
|
249
|
+
}
|
|
250
|
+
if (v.length > MAX_STATUS_ENTRIES) {
|
|
251
|
+
throw new TypeError("refundPolicy: customer_status_in length " + v.length + " exceeds cap " + MAX_STATUS_ENTRIES);
|
|
252
|
+
}
|
|
253
|
+
var seen = Object.create(null);
|
|
254
|
+
var out = [];
|
|
255
|
+
for (var i = 0; i < v.length; i += 1) {
|
|
256
|
+
var s = _statusEntry(v[i]);
|
|
257
|
+
if (seen[s]) {
|
|
258
|
+
throw new TypeError("refundPolicy: customer_status_in contains duplicate " + JSON.stringify(s));
|
|
259
|
+
}
|
|
260
|
+
seen[s] = true;
|
|
261
|
+
out.push(s);
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Refund-kind / partial_refund_bps cross-check. `partial` requires the
|
|
267
|
+
// bps; every other kind refuses it. Centralised so definePolicy and
|
|
268
|
+
// updatePolicy share the same gate.
|
|
269
|
+
function _crossCheckRefundKind(kind, bps) {
|
|
270
|
+
if (kind === "partial") {
|
|
271
|
+
if (bps == null) {
|
|
272
|
+
throw new TypeError("refundPolicy: refund_kind 'partial' requires partial_refund_bps");
|
|
273
|
+
}
|
|
274
|
+
return _bps(bps);
|
|
275
|
+
}
|
|
276
|
+
if (bps != null) {
|
|
277
|
+
throw new TypeError("refundPolicy: partial_refund_bps is only valid when refund_kind is 'partial'");
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function _now() { return Date.now(); }
|
|
283
|
+
|
|
284
|
+
// ---- row hydration ------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
function _safeParseObject(s, fallback) {
|
|
287
|
+
if (s == null) return fallback;
|
|
288
|
+
try {
|
|
289
|
+
var parsed = JSON.parse(s);
|
|
290
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
291
|
+
return fallback;
|
|
292
|
+
} catch (_e) {
|
|
293
|
+
return fallback;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function _safeParseArray(s) {
|
|
298
|
+
if (s == null) return [];
|
|
299
|
+
try {
|
|
300
|
+
var parsed = JSON.parse(s);
|
|
301
|
+
if (Array.isArray(parsed)) return parsed;
|
|
302
|
+
return [];
|
|
303
|
+
} catch (_e) {
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function _hydrateRow(r) {
|
|
309
|
+
if (!r) return null;
|
|
310
|
+
var excRaw = _safeParseObject(r.exclusions_json, {});
|
|
311
|
+
return {
|
|
312
|
+
slug: r.slug,
|
|
313
|
+
title: r.title,
|
|
314
|
+
applies_to: r.applies_to,
|
|
315
|
+
refund_window_days: Number(r.refund_window_days),
|
|
316
|
+
exclusions: {
|
|
317
|
+
categories: Array.isArray(excRaw.categories) ? excRaw.categories : [],
|
|
318
|
+
vendors: Array.isArray(excRaw.vendors) ? excRaw.vendors : [],
|
|
319
|
+
skus: Array.isArray(excRaw.skus) ? excRaw.skus : [],
|
|
320
|
+
tags: Array.isArray(excRaw.tags) ? excRaw.tags : [],
|
|
321
|
+
},
|
|
322
|
+
refund_kind: r.refund_kind,
|
|
323
|
+
partial_refund_bps: r.partial_refund_bps == null ? null : Number(r.partial_refund_bps),
|
|
324
|
+
restocking_fee_minor: Number(r.restocking_fee_minor),
|
|
325
|
+
requires_receipt: r.requires_receipt === 1 || r.requires_receipt === true,
|
|
326
|
+
customer_status_in: _safeParseArray(r.customer_status_in_json),
|
|
327
|
+
active: r.active === 1 || r.active === true,
|
|
328
|
+
priority: Number(r.priority),
|
|
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
|
+
// ---- evaluate input readers --------------------------------------------
|
|
336
|
+
|
|
337
|
+
function _readStringArray(arr, label) {
|
|
338
|
+
if (arr == null) return [];
|
|
339
|
+
if (!Array.isArray(arr)) {
|
|
340
|
+
throw new TypeError("refundPolicy.evaluate: " + label + " must be an array of strings when provided");
|
|
341
|
+
}
|
|
342
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
343
|
+
if (typeof arr[i] !== "string") {
|
|
344
|
+
throw new TypeError("refundPolicy.evaluate: " + label + "[" + i + "] must be a string");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return arr;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function _readEpoch(n, label) {
|
|
351
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
|
|
352
|
+
throw new TypeError("refundPolicy.evaluate: " + label + " must be a positive integer epoch-ms");
|
|
353
|
+
}
|
|
354
|
+
return n;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Calendar-day delta between two epoch-ms timestamps, computed against
|
|
358
|
+
// UTC midnight boundaries so a request made one second after midnight
|
|
359
|
+
// on day N+W is exactly W days from a purchase made one second before
|
|
360
|
+
// midnight on day N. This matches the operator-intuitive "days
|
|
361
|
+
// since" semantics; a wall-clock millisecond delta would surface
|
|
362
|
+
// boundary surprises around timezone-naive operator inputs.
|
|
363
|
+
function _calendarDaysBetween(fromMs, toMs) {
|
|
364
|
+
var fromMidnight = Math.floor(fromMs / MS_PER_DAY);
|
|
365
|
+
var toMidnight = Math.floor(toMs / MS_PER_DAY);
|
|
366
|
+
return toMidnight - fromMidnight;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---- factory ------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
function create(opts) {
|
|
372
|
+
opts = opts || {};
|
|
373
|
+
var query = opts.query;
|
|
374
|
+
if (!query) {
|
|
375
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---- definePolicy --------------------------------------------------
|
|
379
|
+
|
|
380
|
+
async function definePolicy(input) {
|
|
381
|
+
if (!input || typeof input !== "object") {
|
|
382
|
+
throw new TypeError("refundPolicy.definePolicy: input object required");
|
|
383
|
+
}
|
|
384
|
+
var slug = _slug(input.slug);
|
|
385
|
+
var title = _title(input.title);
|
|
386
|
+
var appliesTo = _appliesTo(input.applies_to);
|
|
387
|
+
var refundWindowDays = _windowDays(input.refund_window_days);
|
|
388
|
+
var exclusions = _exclusions(input.exclusions);
|
|
389
|
+
var refundKind = _refundKind(input.refund_kind);
|
|
390
|
+
var partialBps = _crossCheckRefundKind(refundKind, input.partial_refund_bps);
|
|
391
|
+
var restockingFee = input.restocking_fee_minor == null ? 0 : _restockingFee(input.restocking_fee_minor);
|
|
392
|
+
var requiresReceipt = _bool(input.requires_receipt, "requires_receipt");
|
|
393
|
+
var customerStatusIn = _customerStatusIn(input.customer_status_in);
|
|
394
|
+
var priority = input.priority == null ? 0 : _priority(input.priority);
|
|
395
|
+
|
|
396
|
+
// Refuse redefine — same posture as coupon-stacking. Operators
|
|
397
|
+
// mutate an existing slug through updatePolicy; a blind INSERT
|
|
398
|
+
// would clobber created_at and is rarely what the operator wants.
|
|
399
|
+
var existing = (await query(
|
|
400
|
+
"SELECT slug FROM refund_policies WHERE slug = ?1 LIMIT 1",
|
|
401
|
+
[slug],
|
|
402
|
+
)).rows[0];
|
|
403
|
+
if (existing) {
|
|
404
|
+
throw new TypeError("refundPolicy.definePolicy: slug " + JSON.stringify(slug) + " already exists - use updatePolicy");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
var ts = _now();
|
|
408
|
+
await query(
|
|
409
|
+
"INSERT INTO refund_policies (slug, title, applies_to, refund_window_days, exclusions_json, " +
|
|
410
|
+
"refund_kind, partial_refund_bps, restocking_fee_minor, requires_receipt, " +
|
|
411
|
+
"customer_status_in_json, active, priority, archived_at, created_at, updated_at) " +
|
|
412
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1, ?11, NULL, ?12, ?12)",
|
|
413
|
+
[
|
|
414
|
+
slug,
|
|
415
|
+
title,
|
|
416
|
+
appliesTo,
|
|
417
|
+
refundWindowDays,
|
|
418
|
+
JSON.stringify(exclusions),
|
|
419
|
+
refundKind,
|
|
420
|
+
partialBps,
|
|
421
|
+
restockingFee,
|
|
422
|
+
requiresReceipt ? 1 : 0,
|
|
423
|
+
JSON.stringify(customerStatusIn),
|
|
424
|
+
priority,
|
|
425
|
+
ts,
|
|
426
|
+
],
|
|
427
|
+
);
|
|
428
|
+
return await getPolicy(slug);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ---- getPolicy / listPolicies --------------------------------------
|
|
432
|
+
|
|
433
|
+
async function getPolicy(slug) {
|
|
434
|
+
_slug(slug);
|
|
435
|
+
var r = (await query(
|
|
436
|
+
"SELECT * FROM refund_policies WHERE slug = ?1 LIMIT 1",
|
|
437
|
+
[slug],
|
|
438
|
+
)).rows[0];
|
|
439
|
+
return _hydrateRow(r);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function listPolicies(listOpts) {
|
|
443
|
+
listOpts = listOpts || {};
|
|
444
|
+
var activeOnly = false;
|
|
445
|
+
if (listOpts.active_only != null) {
|
|
446
|
+
if (typeof listOpts.active_only !== "boolean") {
|
|
447
|
+
throw new TypeError("refundPolicy.listPolicies: active_only must be a boolean");
|
|
448
|
+
}
|
|
449
|
+
activeOnly = listOpts.active_only;
|
|
450
|
+
}
|
|
451
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
452
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
|
|
453
|
+
throw new TypeError("refundPolicy.listPolicies: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
454
|
+
}
|
|
455
|
+
var sql, params;
|
|
456
|
+
if (activeOnly) {
|
|
457
|
+
sql = "SELECT * FROM refund_policies WHERE active = 1 AND archived_at IS NULL " +
|
|
458
|
+
"ORDER BY priority DESC, created_at ASC, slug ASC LIMIT ?1";
|
|
459
|
+
params = [limit];
|
|
460
|
+
} else {
|
|
461
|
+
sql = "SELECT * FROM refund_policies " +
|
|
462
|
+
"ORDER BY priority DESC, created_at ASC, slug ASC LIMIT ?1";
|
|
463
|
+
params = [limit];
|
|
464
|
+
}
|
|
465
|
+
var rows = (await query(sql, params)).rows;
|
|
466
|
+
var out = [];
|
|
467
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRow(rows[i]));
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---- updatePolicy --------------------------------------------------
|
|
472
|
+
|
|
473
|
+
async function updatePolicy(slug, patch) {
|
|
474
|
+
_slug(slug);
|
|
475
|
+
if (!patch || typeof patch !== "object") {
|
|
476
|
+
throw new TypeError("refundPolicy.updatePolicy: patch object required");
|
|
477
|
+
}
|
|
478
|
+
var keys = Object.keys(patch);
|
|
479
|
+
if (!keys.length) {
|
|
480
|
+
throw new TypeError("refundPolicy.updatePolicy: patch must include at least one column");
|
|
481
|
+
}
|
|
482
|
+
var current = await getPolicy(slug);
|
|
483
|
+
if (!current) {
|
|
484
|
+
throw new TypeError("refundPolicy.updatePolicy: slug " + JSON.stringify(slug) + " not found");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// refund_kind + partial_refund_bps cross-check requires both
|
|
488
|
+
// post-patch values together. Resolve the prospective end state
|
|
489
|
+
// first so a patch that mutates only one of the two doesn't drift
|
|
490
|
+
// into an inconsistent shape.
|
|
491
|
+
var nextKind = Object.prototype.hasOwnProperty.call(patch, "refund_kind")
|
|
492
|
+
? _refundKind(patch.refund_kind) : current.refund_kind;
|
|
493
|
+
var nextBpsRaw = Object.prototype.hasOwnProperty.call(patch, "partial_refund_bps")
|
|
494
|
+
? patch.partial_refund_bps : current.partial_refund_bps;
|
|
495
|
+
var resolvedBps = _crossCheckRefundKind(nextKind, nextBpsRaw);
|
|
496
|
+
|
|
497
|
+
var sets = [];
|
|
498
|
+
var params = [];
|
|
499
|
+
var idx = 1;
|
|
500
|
+
|
|
501
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
502
|
+
var col = keys[i];
|
|
503
|
+
if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
|
|
504
|
+
throw new TypeError("refundPolicy.updatePolicy: unsupported column " + JSON.stringify(col));
|
|
505
|
+
}
|
|
506
|
+
if (col === "title") {
|
|
507
|
+
sets.push("title = ?" + idx);
|
|
508
|
+
params.push(_title(patch[col]));
|
|
509
|
+
} else if (col === "applies_to") {
|
|
510
|
+
sets.push("applies_to = ?" + idx);
|
|
511
|
+
params.push(_appliesTo(patch[col]));
|
|
512
|
+
} else if (col === "refund_window_days") {
|
|
513
|
+
sets.push("refund_window_days = ?" + idx);
|
|
514
|
+
params.push(_windowDays(patch[col]));
|
|
515
|
+
} else if (col === "exclusions") {
|
|
516
|
+
sets.push("exclusions_json = ?" + idx);
|
|
517
|
+
params.push(JSON.stringify(_exclusions(patch[col])));
|
|
518
|
+
} else if (col === "refund_kind") {
|
|
519
|
+
sets.push("refund_kind = ?" + idx);
|
|
520
|
+
params.push(nextKind);
|
|
521
|
+
} else if (col === "partial_refund_bps") {
|
|
522
|
+
sets.push("partial_refund_bps = ?" + idx);
|
|
523
|
+
params.push(resolvedBps);
|
|
524
|
+
} else if (col === "restocking_fee_minor") {
|
|
525
|
+
sets.push("restocking_fee_minor = ?" + idx);
|
|
526
|
+
params.push(_restockingFee(patch[col]));
|
|
527
|
+
} else if (col === "requires_receipt") {
|
|
528
|
+
sets.push("requires_receipt = ?" + idx);
|
|
529
|
+
params.push(_bool(patch[col], "requires_receipt") ? 1 : 0);
|
|
530
|
+
} else if (col === "customer_status_in") {
|
|
531
|
+
sets.push("customer_status_in_json = ?" + idx);
|
|
532
|
+
params.push(JSON.stringify(_customerStatusIn(patch[col])));
|
|
533
|
+
} else if (col === "active") {
|
|
534
|
+
sets.push("active = ?" + idx);
|
|
535
|
+
params.push(_bool(patch[col], "active") ? 1 : 0);
|
|
536
|
+
} else /* priority */ {
|
|
537
|
+
sets.push("priority = ?" + idx);
|
|
538
|
+
params.push(_priority(patch[col]));
|
|
539
|
+
}
|
|
540
|
+
idx += 1;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// If refund_kind changed but partial_refund_bps wasn't in the patch,
|
|
544
|
+
// the SQL above hasn't reset the bps column to the resolved value
|
|
545
|
+
// — force the write so a kind change from 'partial' to 'full'
|
|
546
|
+
// clears the now-orphaned bps, and a change from 'full' to
|
|
547
|
+
// 'partial' surfaces the explicit-bps requirement.
|
|
548
|
+
var patchHasKind = Object.prototype.hasOwnProperty.call(patch, "refund_kind");
|
|
549
|
+
var patchHasBps = Object.prototype.hasOwnProperty.call(patch, "partial_refund_bps");
|
|
550
|
+
if (patchHasKind && !patchHasBps) {
|
|
551
|
+
sets.push("partial_refund_bps = ?" + idx);
|
|
552
|
+
params.push(resolvedBps);
|
|
553
|
+
idx += 1;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
sets.push("updated_at = ?" + idx);
|
|
557
|
+
params.push(_now());
|
|
558
|
+
idx += 1;
|
|
559
|
+
params.push(slug);
|
|
560
|
+
|
|
561
|
+
var r = await query(
|
|
562
|
+
"UPDATE refund_policies SET " + sets.join(", ") + " WHERE slug = ?" + idx,
|
|
563
|
+
params,
|
|
564
|
+
);
|
|
565
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
566
|
+
throw new TypeError("refundPolicy.updatePolicy: slug " + JSON.stringify(slug) + " not found");
|
|
567
|
+
}
|
|
568
|
+
return await getPolicy(slug);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ---- archivePolicy -------------------------------------------------
|
|
572
|
+
|
|
573
|
+
async function archivePolicy(slug) {
|
|
574
|
+
_slug(slug);
|
|
575
|
+
var ts = _now();
|
|
576
|
+
var r = await query(
|
|
577
|
+
"UPDATE refund_policies SET archived_at = ?1, active = 0, updated_at = ?1 " +
|
|
578
|
+
"WHERE slug = ?2 AND archived_at IS NULL",
|
|
579
|
+
[ts, slug],
|
|
580
|
+
);
|
|
581
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
582
|
+
var existing = await getPolicy(slug);
|
|
583
|
+
if (!existing) {
|
|
584
|
+
throw new TypeError("refundPolicy.archivePolicy: slug " + JSON.stringify(slug) + " not found");
|
|
585
|
+
}
|
|
586
|
+
// Already archived — return existing row idempotently so an
|
|
587
|
+
// "archive sweep" doesn't have to special-case a slug a coworker
|
|
588
|
+
// archived first.
|
|
589
|
+
return existing;
|
|
590
|
+
}
|
|
591
|
+
return await getPolicy(slug);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ---- evaluate ------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
// Walks active, non-archived policies in (priority DESC, created_at
|
|
597
|
+
// ASC, slug ASC) order, applying each policy's preconditions
|
|
598
|
+
// (customer_status_in, refund_window_days, requires_receipt) and
|
|
599
|
+
// scope gate (applies_to + exclusions). The first policy that
|
|
600
|
+
// accepts the request governs the verdict.
|
|
601
|
+
//
|
|
602
|
+
// Returns `{ eligible, applied_policy?, refund_kind,
|
|
603
|
+
// max_refund_minor, restocking_fee_minor, reasons }`.
|
|
604
|
+
//
|
|
605
|
+
// `reasons` always carries at least one entry — successful
|
|
606
|
+
// evaluations report `policy_matched` plus any soft-warnings (e.g.
|
|
607
|
+
// `restocking_fee_applied`); refusals report every gate that
|
|
608
|
+
// rejected the request from the highest-priority candidate (or
|
|
609
|
+
// `no_policy_matched` when no policy was active).
|
|
610
|
+
async function evaluate(input) {
|
|
611
|
+
if (!input || typeof input !== "object") {
|
|
612
|
+
throw new TypeError("refundPolicy.evaluate: input object required");
|
|
613
|
+
}
|
|
614
|
+
if (typeof input.order_id !== "string" || !input.order_id.length) {
|
|
615
|
+
throw new TypeError("refundPolicy.evaluate: order_id must be a non-empty string");
|
|
616
|
+
}
|
|
617
|
+
if (!Number.isInteger(input.order_total_minor) || input.order_total_minor < 0) {
|
|
618
|
+
throw new TypeError("refundPolicy.evaluate: order_total_minor must be a non-negative integer");
|
|
619
|
+
}
|
|
620
|
+
var lineCategories = _readStringArray(input.line_categories, "line_categories");
|
|
621
|
+
var lineVendors = _readStringArray(input.line_vendors, "line_vendors");
|
|
622
|
+
var lineSkus = _readStringArray(input.line_skus, "line_skus");
|
|
623
|
+
var lineTags = _readStringArray(input.line_tags, "line_tags");
|
|
624
|
+
var orderDate = _readEpoch(input.order_date, "order_date");
|
|
625
|
+
var requestDate = _readEpoch(input.request_date, "request_date");
|
|
626
|
+
if (requestDate < orderDate) {
|
|
627
|
+
throw new TypeError("refundPolicy.evaluate: request_date must be >= order_date");
|
|
628
|
+
}
|
|
629
|
+
if (typeof input.has_receipt !== "boolean") {
|
|
630
|
+
throw new TypeError("refundPolicy.evaluate: has_receipt must be a boolean");
|
|
631
|
+
}
|
|
632
|
+
var customerStatus = null;
|
|
633
|
+
if (input.customer_status != null) {
|
|
634
|
+
if (typeof input.customer_status !== "string" || !input.customer_status.length) {
|
|
635
|
+
throw new TypeError("refundPolicy.evaluate: customer_status must be a non-empty string when provided");
|
|
636
|
+
}
|
|
637
|
+
customerStatus = input.customer_status;
|
|
638
|
+
}
|
|
639
|
+
if (input.customer_id != null) {
|
|
640
|
+
if (typeof input.customer_id !== "string" || !input.customer_id.length) {
|
|
641
|
+
throw new TypeError("refundPolicy.evaluate: customer_id must be a non-empty string when provided");
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
var daysSincePurchase = _calendarDaysBetween(orderDate, requestDate);
|
|
646
|
+
|
|
647
|
+
var rows = (await query(
|
|
648
|
+
"SELECT * FROM refund_policies WHERE active = 1 AND archived_at IS NULL " +
|
|
649
|
+
"ORDER BY priority DESC, created_at ASC, slug ASC",
|
|
650
|
+
[],
|
|
651
|
+
)).rows;
|
|
652
|
+
|
|
653
|
+
// No policies active — conservative default.
|
|
654
|
+
if (rows.length === 0) {
|
|
655
|
+
return {
|
|
656
|
+
eligible: false,
|
|
657
|
+
applied_policy: null,
|
|
658
|
+
refund_kind: "no_refund",
|
|
659
|
+
max_refund_minor: 0,
|
|
660
|
+
restocking_fee_minor: 0,
|
|
661
|
+
reasons: ["no_policy_matched"],
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Walk policies. Track the first policy whose `applies_to` scope
|
|
666
|
+
// matches; that policy's refusal reasons are the verdict reasons
|
|
667
|
+
// when no policy accepts. A policy whose scope doesn't match the
|
|
668
|
+
// order at all is silently skipped (it's authored for a
|
|
669
|
+
// different lane).
|
|
670
|
+
var firstCandidate = null;
|
|
671
|
+
var firstReasons = null;
|
|
672
|
+
|
|
673
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
674
|
+
var p = _hydrateRow(rows[i]);
|
|
675
|
+
|
|
676
|
+
// Scope gate. `applies_to: all` matches every order; the other
|
|
677
|
+
// four axes require at least one line whose attribute is NOT
|
|
678
|
+
// in the matching exclusion list (i.e. there's something
|
|
679
|
+
// refundable left after exclusions collapse the line set).
|
|
680
|
+
var scopeOk = false;
|
|
681
|
+
var refundableCount;
|
|
682
|
+
if (p.applies_to === "all") {
|
|
683
|
+
// The "all" lane still respects the four exclusion axes —
|
|
684
|
+
// the operator may author a policy that scopes to "all
|
|
685
|
+
// orders" but excludes a specific category.
|
|
686
|
+
refundableCount = _countRefundableLines(
|
|
687
|
+
lineCategories, lineVendors, lineSkus, lineTags, p.exclusions,
|
|
688
|
+
);
|
|
689
|
+
scopeOk = true; // policy is in scope; exclusions may still drop every line
|
|
690
|
+
} else if (p.applies_to === "category") {
|
|
691
|
+
scopeOk = _anyLineMatchesAxis(lineCategories, p.exclusions.categories) !== "all_excluded"
|
|
692
|
+
&& lineCategories.length > 0;
|
|
693
|
+
refundableCount = _countRefundableLines(
|
|
694
|
+
lineCategories, lineVendors, lineSkus, lineTags, p.exclusions,
|
|
695
|
+
);
|
|
696
|
+
} else if (p.applies_to === "vendor") {
|
|
697
|
+
scopeOk = _anyLineMatchesAxis(lineVendors, p.exclusions.vendors) !== "all_excluded"
|
|
698
|
+
&& lineVendors.length > 0;
|
|
699
|
+
refundableCount = _countRefundableLines(
|
|
700
|
+
lineCategories, lineVendors, lineSkus, lineTags, p.exclusions,
|
|
701
|
+
);
|
|
702
|
+
} else if (p.applies_to === "sku") {
|
|
703
|
+
scopeOk = _anyLineMatchesAxis(lineSkus, p.exclusions.skus) !== "all_excluded"
|
|
704
|
+
&& lineSkus.length > 0;
|
|
705
|
+
refundableCount = _countRefundableLines(
|
|
706
|
+
lineCategories, lineVendors, lineSkus, lineTags, p.exclusions,
|
|
707
|
+
);
|
|
708
|
+
} else /* tag */ {
|
|
709
|
+
scopeOk = _anyLineMatchesAxis(lineTags, p.exclusions.tags) !== "all_excluded"
|
|
710
|
+
&& lineTags.length > 0;
|
|
711
|
+
refundableCount = _countRefundableLines(
|
|
712
|
+
lineCategories, lineVendors, lineSkus, lineTags, p.exclusions,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
if (!scopeOk) {
|
|
716
|
+
// Policy scope doesn't match this order — silently skip; the
|
|
717
|
+
// operator wrote this policy for a different lane.
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
var reasons = [];
|
|
722
|
+
|
|
723
|
+
if (p.customer_status_in.length > 0) {
|
|
724
|
+
if (customerStatus == null || p.customer_status_in.indexOf(customerStatus) === -1) {
|
|
725
|
+
reasons.push("customer_status_mismatch");
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (daysSincePurchase > p.refund_window_days) {
|
|
729
|
+
reasons.push("outside_refund_window");
|
|
730
|
+
}
|
|
731
|
+
if (p.requires_receipt && !input.has_receipt) {
|
|
732
|
+
reasons.push("receipt_required");
|
|
733
|
+
}
|
|
734
|
+
if (refundableCount === 0) {
|
|
735
|
+
reasons.push("all_lines_excluded");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (reasons.length === 0) {
|
|
739
|
+
// Policy accepts. Compute the refund shape.
|
|
740
|
+
var maxRefundMinor;
|
|
741
|
+
if (p.refund_kind === "full" || p.refund_kind === "store_credit_only") {
|
|
742
|
+
maxRefundMinor = input.order_total_minor;
|
|
743
|
+
} else if (p.refund_kind === "partial") {
|
|
744
|
+
// floor(total * bps / 10000) — integer math, no float
|
|
745
|
+
// drift. The bps gate (1..9999) guarantees the result is
|
|
746
|
+
// strictly less than the order total.
|
|
747
|
+
maxRefundMinor = Math.floor((input.order_total_minor * p.partial_refund_bps) / 10000);
|
|
748
|
+
} else /* no_refund */ {
|
|
749
|
+
maxRefundMinor = 0;
|
|
750
|
+
}
|
|
751
|
+
// Restocking fee is deducted from the cap; clamped at zero
|
|
752
|
+
// so a fee larger than the cap surfaces as "no money refunded"
|
|
753
|
+
// rather than a negative number.
|
|
754
|
+
var afterFee = Math.max(0, maxRefundMinor - p.restocking_fee_minor);
|
|
755
|
+
var acceptReasons = ["policy_matched"];
|
|
756
|
+
if (p.refund_kind === "no_refund") {
|
|
757
|
+
// Successful match against a `no_refund` policy is a
|
|
758
|
+
// definitive negative answer; the policy applied, the
|
|
759
|
+
// verdict is "no money." The caller still gets the
|
|
760
|
+
// applied_policy slug so the customer-facing message can
|
|
761
|
+
// cite which rule governs.
|
|
762
|
+
return {
|
|
763
|
+
eligible: false,
|
|
764
|
+
applied_policy: p.slug,
|
|
765
|
+
refund_kind: "no_refund",
|
|
766
|
+
max_refund_minor: 0,
|
|
767
|
+
restocking_fee_minor: 0,
|
|
768
|
+
reasons: ["policy_matched", "refund_kind_no_refund"],
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
if (p.restocking_fee_minor > 0) acceptReasons.push("restocking_fee_applied");
|
|
772
|
+
return {
|
|
773
|
+
eligible: afterFee > 0,
|
|
774
|
+
applied_policy: p.slug,
|
|
775
|
+
refund_kind: p.refund_kind,
|
|
776
|
+
max_refund_minor: afterFee,
|
|
777
|
+
restocking_fee_minor: p.restocking_fee_minor,
|
|
778
|
+
reasons: acceptReasons.concat(afterFee === 0 ? ["restocking_fee_exceeds_refund"] : []),
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (firstCandidate == null) {
|
|
783
|
+
firstCandidate = p;
|
|
784
|
+
firstReasons = reasons;
|
|
785
|
+
}
|
|
786
|
+
// Otherwise: a lower-priority policy might still accept the
|
|
787
|
+
// request; keep walking.
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// No policy accepted. Surface the highest-priority candidate's
|
|
791
|
+
// refusal reasons when one was in scope; otherwise the catch-all
|
|
792
|
+
// `no_policy_matched`.
|
|
793
|
+
if (firstCandidate) {
|
|
794
|
+
return {
|
|
795
|
+
eligible: false,
|
|
796
|
+
applied_policy: firstCandidate.slug,
|
|
797
|
+
refund_kind: "no_refund",
|
|
798
|
+
max_refund_minor: 0,
|
|
799
|
+
restocking_fee_minor: 0,
|
|
800
|
+
reasons: firstReasons,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
eligible: false,
|
|
805
|
+
applied_policy: null,
|
|
806
|
+
refund_kind: "no_refund",
|
|
807
|
+
max_refund_minor: 0,
|
|
808
|
+
restocking_fee_minor: 0,
|
|
809
|
+
reasons: ["no_policy_matched"],
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ---- auditEvaluation ----------------------------------------------
|
|
814
|
+
|
|
815
|
+
// Append-only audit-log write. Caller composes evaluate +
|
|
816
|
+
// auditEvaluation so a dry-run preview doesn't double-write. The
|
|
817
|
+
// `decision` column is the bucketed verdict for operator dashboard
|
|
818
|
+
// filters; the full evaluation payload + result lives in
|
|
819
|
+
// `evaluation_json` so a compliance review reconstructs exactly
|
|
820
|
+
// what the primitive knew at the time.
|
|
821
|
+
async function auditEvaluation(input) {
|
|
822
|
+
if (!input || typeof input !== "object") {
|
|
823
|
+
throw new TypeError("refundPolicy.auditEvaluation: input object required");
|
|
824
|
+
}
|
|
825
|
+
if (typeof input.order_id !== "string" || !input.order_id.length) {
|
|
826
|
+
throw new TypeError("refundPolicy.auditEvaluation: order_id must be a non-empty string");
|
|
827
|
+
}
|
|
828
|
+
if (!input.evaluation || typeof input.evaluation !== "object") {
|
|
829
|
+
throw new TypeError("refundPolicy.auditEvaluation: evaluation object required");
|
|
830
|
+
}
|
|
831
|
+
if (!input.verdict || typeof input.verdict !== "object") {
|
|
832
|
+
throw new TypeError("refundPolicy.auditEvaluation: verdict object required");
|
|
833
|
+
}
|
|
834
|
+
var decision;
|
|
835
|
+
if (input.verdict.eligible === true) decision = "eligible";
|
|
836
|
+
else if (input.verdict.applied_policy) decision = "denied";
|
|
837
|
+
else decision = "no_policy";
|
|
838
|
+
if (AUDIT_DECISIONS.indexOf(decision) === -1) {
|
|
839
|
+
throw new TypeError("refundPolicy.auditEvaluation: derived decision " + JSON.stringify(decision) +
|
|
840
|
+
" not in " + JSON.stringify(AUDIT_DECISIONS));
|
|
841
|
+
}
|
|
842
|
+
var appliedSlug = null;
|
|
843
|
+
if (input.verdict.applied_policy != null) {
|
|
844
|
+
appliedSlug = _slug(input.verdict.applied_policy);
|
|
845
|
+
}
|
|
846
|
+
var id = _b().uuid.v7();
|
|
847
|
+
var ts = _now();
|
|
848
|
+
await query(
|
|
849
|
+
"INSERT INTO refund_policy_audit (id, order_id, evaluation_json, decision, applied_slug, occurred_at) " +
|
|
850
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
851
|
+
[
|
|
852
|
+
id,
|
|
853
|
+
input.order_id,
|
|
854
|
+
JSON.stringify({ evaluation: input.evaluation, verdict: input.verdict }),
|
|
855
|
+
decision,
|
|
856
|
+
appliedSlug,
|
|
857
|
+
ts,
|
|
858
|
+
],
|
|
859
|
+
);
|
|
860
|
+
return { id: id, order_id: input.order_id, decision: decision, applied_slug: appliedSlug, occurred_at: ts };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async function listAudit(listOpts) {
|
|
864
|
+
listOpts = listOpts || {};
|
|
865
|
+
if (typeof listOpts.order_id !== "string" || !listOpts.order_id.length) {
|
|
866
|
+
throw new TypeError("refundPolicy.listAudit: order_id must be a non-empty string");
|
|
867
|
+
}
|
|
868
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
869
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
|
|
870
|
+
throw new TypeError("refundPolicy.listAudit: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
871
|
+
}
|
|
872
|
+
var rows = (await query(
|
|
873
|
+
"SELECT * FROM refund_policy_audit WHERE order_id = ?1 " +
|
|
874
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT ?2",
|
|
875
|
+
[listOpts.order_id, limit],
|
|
876
|
+
)).rows;
|
|
877
|
+
var out = [];
|
|
878
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
879
|
+
var r = rows[i];
|
|
880
|
+
var payload;
|
|
881
|
+
try { payload = JSON.parse(r.evaluation_json); } catch (_e) { payload = null; }
|
|
882
|
+
out.push({
|
|
883
|
+
id: r.id,
|
|
884
|
+
order_id: r.order_id,
|
|
885
|
+
decision: r.decision,
|
|
886
|
+
applied_slug: r.applied_slug == null ? null : r.applied_slug,
|
|
887
|
+
occurred_at: Number(r.occurred_at),
|
|
888
|
+
payload: payload,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
return out;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
definePolicy: definePolicy,
|
|
896
|
+
getPolicy: getPolicy,
|
|
897
|
+
listPolicies: listPolicies,
|
|
898
|
+
updatePolicy: updatePolicy,
|
|
899
|
+
archivePolicy: archivePolicy,
|
|
900
|
+
evaluate: evaluate,
|
|
901
|
+
auditEvaluation: auditEvaluation,
|
|
902
|
+
listAudit: listAudit,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ---- scope helpers ------------------------------------------------------
|
|
907
|
+
|
|
908
|
+
// Returns "all_excluded" when every entry in `axisLines` appears in
|
|
909
|
+
// `excluded`; "some_match" when at least one entry survives; and a
|
|
910
|
+
// trivial pass-through ("no_lines") when `axisLines` is empty. The
|
|
911
|
+
// scope gate uses this to skip a policy whose `applies_to` axis has
|
|
912
|
+
// no overlap with the order at all.
|
|
913
|
+
function _anyLineMatchesAxis(axisLines, excluded) {
|
|
914
|
+
if (!axisLines.length) return "no_lines";
|
|
915
|
+
var excSet = Object.create(null);
|
|
916
|
+
for (var i = 0; i < excluded.length; i += 1) excSet[excluded[i]] = true;
|
|
917
|
+
for (var j = 0; j < axisLines.length; j += 1) {
|
|
918
|
+
if (!excSet[axisLines[j]]) return "some_match";
|
|
919
|
+
}
|
|
920
|
+
return "all_excluded";
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Count lines that survive exclusion across all four axes. A line is
|
|
924
|
+
// represented by its position across the four parallel arrays (the
|
|
925
|
+
// evaluator treats every axis as line-positional — `line_categories[0]`,
|
|
926
|
+
// `line_vendors[0]`, `line_skus[0]`, `line_tags[0]` describe the same
|
|
927
|
+
// line). When the axes are uneven (some lines missing a tag, etc.),
|
|
928
|
+
// the missing slot is treated as "not excluded" — the operator who
|
|
929
|
+
// authors a tag-exclusion list doesn't want a missing-tag line to
|
|
930
|
+
// get accidentally caught by an empty-string match.
|
|
931
|
+
function _countRefundableLines(cats, vendors, skus, tags, exc) {
|
|
932
|
+
var n = Math.max(cats.length, vendors.length, skus.length, tags.length);
|
|
933
|
+
if (n === 0) return 0;
|
|
934
|
+
var catsSet = _toSet(exc.categories);
|
|
935
|
+
var vendorsSet = _toSet(exc.vendors);
|
|
936
|
+
var skusSet = _toSet(exc.skus);
|
|
937
|
+
var tagsSet = _toSet(exc.tags);
|
|
938
|
+
var refundable = 0;
|
|
939
|
+
for (var i = 0; i < n; i += 1) {
|
|
940
|
+
if (cats[i] != null && catsSet[cats[i]]) continue;
|
|
941
|
+
if (vendors[i] != null && vendorsSet[vendors[i]]) continue;
|
|
942
|
+
if (skus[i] != null && skusSet[skus[i]]) continue;
|
|
943
|
+
if (tags[i] != null && tagsSet[tags[i]]) continue;
|
|
944
|
+
refundable += 1;
|
|
945
|
+
}
|
|
946
|
+
return refundable;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function _toSet(arr) {
|
|
950
|
+
var s = Object.create(null);
|
|
951
|
+
for (var i = 0; i < arr.length; i += 1) s[arr[i]] = true;
|
|
952
|
+
return s;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
module.exports = {
|
|
956
|
+
create: create,
|
|
957
|
+
APPLIES_TO: APPLIES_TO,
|
|
958
|
+
REFUND_KINDS: REFUND_KINDS,
|
|
959
|
+
AUDIT_DECISIONS: AUDIT_DECISIONS,
|
|
960
|
+
EXCLUSION_AXES: EXCLUSION_AXES,
|
|
961
|
+
ALLOWED_PATCH_COLUMNS: ALLOWED_PATCH_COLUMNS,
|
|
962
|
+
MAX_EXCLUSION_ENTRIES: MAX_EXCLUSION_ENTRIES,
|
|
963
|
+
MAX_STATUS_ENTRIES: MAX_STATUS_ENTRIES,
|
|
964
|
+
MAX_WINDOW_DAYS: MAX_WINDOW_DAYS,
|
|
965
|
+
};
|