@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.
@@ -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
+ };