@blamejs/blamejs-shop 0.0.53 → 0.0.56
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 +6 -0
- package/lib/addresses.js +430 -0
- package/lib/analytics.js +400 -0
- package/lib/cart-abandonment.js +664 -0
- package/lib/currency-display.js +432 -0
- package/lib/email-suppressions.js +579 -0
- package/lib/email.js +264 -0
- package/lib/index.js +14 -0
- package/lib/inventory-receive.js +494 -0
- package/lib/loyalty.js +496 -0
- package/lib/newsletter.js +176 -12
- package/lib/notifications.js +474 -0
- package/lib/order-tracking.js +456 -0
- package/lib/payment.js +193 -13
- package/lib/referrals.js +649 -0
- package/lib/returns.js +627 -0
- package/lib/reviews.js +412 -0
- package/lib/search-suggestions.js +528 -0
- package/lib/tax-exempt.js +519 -0
- package/lib/tax.js +391 -3
- package/lib/vendor/MANIFEST.json +1 -1
- package/lib/webhooks.js +293 -16
- package/lib/wishlist.js +269 -0
- package/package.json +1 -1
package/lib/returns.js
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.returns
|
|
4
|
+
* @title Returns primitive — RMA workflow for post-purchase claims
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* A return_authorizations row tracks one customer-initiated claim
|
|
8
|
+
* against an `orders` row. The lifecycle is a five-state machine
|
|
9
|
+
* walked here at the application tier (the order primitive owns
|
|
10
|
+
* the b.fsm-driven order lifecycle; an RMA is a sibling record
|
|
11
|
+
* keyed by `order_id`, not a state on the order itself):
|
|
12
|
+
*
|
|
13
|
+
* pending ─request───► approved ─markReceived───► received
|
|
14
|
+
* └────► rejected (terminal) │
|
|
15
|
+
* approved ────────────────────► rejected │
|
|
16
|
+
* ▼
|
|
17
|
+
* refund → refunded
|
|
18
|
+
*
|
|
19
|
+
* `rma_code` is an operator-readable identifier (`RMA-YYMMDD-AAAAA`)
|
|
20
|
+
* surfaced to the customer on the return-label email. The
|
|
21
|
+
* `AAAAA` segment is 5 characters from the ambiguity-free alphabet
|
|
22
|
+
* `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (no 0/O/I/1 — same alphabet
|
|
23
|
+
* gift-card codes use, for the same reason). Code generation
|
|
24
|
+
* uses `b.crypto.generateBytes` (SHAKE256 over OS-RNG) so the
|
|
25
|
+
* draw stays unguessable even if the OS RNG has a transient
|
|
26
|
+
* weakness; the unique index on `rma_code` surfaces the rare
|
|
27
|
+
* collision as an INSERT failure the request layer retries.
|
|
28
|
+
*
|
|
29
|
+
* `listForCustomer` paginates the customer's RMA history with an
|
|
30
|
+
* HMAC-tagged cursor (`b.pagination.encodeCursor`) so an
|
|
31
|
+
* operator-controlled secret prevents cursor forgery / replay
|
|
32
|
+
* across deployments. The cursor matches the shape used by
|
|
33
|
+
* order.listForCustomer for consistency.
|
|
34
|
+
*
|
|
35
|
+
* Composition:
|
|
36
|
+
* var ret = bShop.returns.create({ query: q });
|
|
37
|
+
* var { id, rma_code } = await ret.request({
|
|
38
|
+
* order_id: o.id, reason: "defective",
|
|
39
|
+
* lines: [{ sku: "WIDGET-1", qty: 1 }],
|
|
40
|
+
* });
|
|
41
|
+
* await ret.approve(id, { refund_amount_minor: 4999 });
|
|
42
|
+
* await ret.markReceived(id);
|
|
43
|
+
* await ret.refund(id);
|
|
44
|
+
*
|
|
45
|
+
* @related b.crypto.generateBytes, b.pagination.encodeCursor, b.uuid.v7
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
var bShop;
|
|
49
|
+
function _b() {
|
|
50
|
+
if (!bShop) bShop = require("./index");
|
|
51
|
+
return bShop.framework;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- constants ----------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
var REASONS = [
|
|
57
|
+
"defective",
|
|
58
|
+
"wrong-item",
|
|
59
|
+
"not-as-described",
|
|
60
|
+
"no-longer-needed",
|
|
61
|
+
"damaged-in-transit",
|
|
62
|
+
"other",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
var STATUSES = ["pending", "approved", "received", "refunded", "rejected"];
|
|
66
|
+
|
|
67
|
+
// Same ambiguity-free alphabet as gift-card codes: 32 glyphs, no
|
|
68
|
+
// 0/O/I/1. 256 % 32 === 0 so each random byte modulo-32 lands on a
|
|
69
|
+
// uniform draw (no rejection sampling needed).
|
|
70
|
+
var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
71
|
+
var CODE_LEN = 5;
|
|
72
|
+
// RMA-YYMMDD-AAAAA — date prefix groups codes per day for operator
|
|
73
|
+
// recognition; the 5-char tail (32^5 ≈ 33.5M) keeps collisions
|
|
74
|
+
// vanishingly rare per day.
|
|
75
|
+
var CODE_FULL_RE = /^RMA-\d{6}-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{5}$/;
|
|
76
|
+
|
|
77
|
+
var MAX_NOTE_LEN = 4096;
|
|
78
|
+
var MAX_DETAIL_LEN = 1024;
|
|
79
|
+
var MAX_LIST_LIMIT = 100;
|
|
80
|
+
var DEFAULT_LIST_LIMIT = 20;
|
|
81
|
+
|
|
82
|
+
// Pagination orderKey — must match the shape on the wire so a
|
|
83
|
+
// tampered cursor (different orderKey) is rejected by
|
|
84
|
+
// decodeCursor's HMAC-tagged state check.
|
|
85
|
+
var RMA_ORDER_KEY = ["created_at:desc", "id:desc"];
|
|
86
|
+
|
|
87
|
+
// State transition graph. Encoded as a plain map so the validator
|
|
88
|
+
// reads straight; no b.fsm dependency because the surface is small
|
|
89
|
+
// (5 states, 4 events) and per-event setters need to touch
|
|
90
|
+
// different timestamp columns anyway.
|
|
91
|
+
var TRANSITIONS = {
|
|
92
|
+
pending: { approve: "approved", reject: "rejected" },
|
|
93
|
+
approved: { markReceived: "received", reject: "rejected" },
|
|
94
|
+
received: { refund: "refunded" },
|
|
95
|
+
refunded: {},
|
|
96
|
+
rejected: {},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
var TERMINAL_STATES = Object.freeze(["refunded", "rejected"]);
|
|
100
|
+
|
|
101
|
+
// ---- validators ---------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function _uuid(s, label) {
|
|
104
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
105
|
+
catch (e) { throw new TypeError("returns: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function _reason(r) {
|
|
109
|
+
if (typeof r !== "string" || REASONS.indexOf(r) === -1) {
|
|
110
|
+
throw new TypeError("returns: reason must be one of " + REASONS.join(", "));
|
|
111
|
+
}
|
|
112
|
+
return r;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function _status(s) {
|
|
116
|
+
if (typeof s !== "string" || STATUSES.indexOf(s) === -1) {
|
|
117
|
+
throw new TypeError("returns: status must be one of " + STATUSES.join(", "));
|
|
118
|
+
}
|
|
119
|
+
return s;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function _positiveInt(n, label) {
|
|
123
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
|
|
124
|
+
throw new TypeError("returns: " + label + " must be a positive integer");
|
|
125
|
+
}
|
|
126
|
+
return n;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _nonNegInt(n, label) {
|
|
130
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
131
|
+
throw new TypeError("returns: " + label + " must be a non-negative integer (minor units)");
|
|
132
|
+
}
|
|
133
|
+
return n;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _currency(c) {
|
|
137
|
+
if (typeof c !== "string" || !/^[A-Z]{3}$/.test(c)) {
|
|
138
|
+
throw new TypeError("returns: currency must be 3-letter uppercase ISO 4217");
|
|
139
|
+
}
|
|
140
|
+
return c;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function _boundedString(s, label, maxLen) {
|
|
144
|
+
if (s == null) return "";
|
|
145
|
+
if (typeof s !== "string") {
|
|
146
|
+
throw new TypeError("returns: " + label + " must be a string");
|
|
147
|
+
}
|
|
148
|
+
if (s.length > maxLen) {
|
|
149
|
+
throw new TypeError("returns: " + label + " must be <= " + maxLen + " characters");
|
|
150
|
+
}
|
|
151
|
+
// Refuse control bytes — same posture as b.guardEmail's strict
|
|
152
|
+
// profile. Newlines + tabs are allowed since operator notes are
|
|
153
|
+
// multi-line free-form.
|
|
154
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
|
|
155
|
+
throw new TypeError("returns: " + label + " contains control bytes");
|
|
156
|
+
}
|
|
157
|
+
return s;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _sku(s) {
|
|
161
|
+
if (typeof s !== "string" || !s.length || s.length > 128) {
|
|
162
|
+
throw new TypeError("returns: line sku must be a non-empty string <= 128 chars");
|
|
163
|
+
}
|
|
164
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
165
|
+
throw new TypeError("returns: line sku contains control bytes");
|
|
166
|
+
}
|
|
167
|
+
return s;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _epochOrNull(ts, label) {
|
|
171
|
+
if (ts == null) return null;
|
|
172
|
+
if (typeof ts !== "number" || !Number.isInteger(ts) || ts <= 0) {
|
|
173
|
+
throw new TypeError("returns: " + label + " must be a positive integer epoch-ms or null");
|
|
174
|
+
}
|
|
175
|
+
return ts;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _now() { return Date.now(); }
|
|
179
|
+
|
|
180
|
+
// ---- rma_code generation ------------------------------------------------
|
|
181
|
+
|
|
182
|
+
// YYMMDD prefix from epoch-ms, UTC. Stable per-day grouping so
|
|
183
|
+
// operators triaging a support ticket can spot same-day cohorts.
|
|
184
|
+
function _datePrefix(epochMs) {
|
|
185
|
+
var d = new Date(epochMs);
|
|
186
|
+
var yy = String(d.getUTCFullYear() % 100).padStart(2, "0");
|
|
187
|
+
var mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
188
|
+
var dd = String(d.getUTCDate()).padStart(2, "0");
|
|
189
|
+
return yy + mm + dd;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function _randomTail() {
|
|
193
|
+
var buf = _b().crypto.generateBytes(CODE_LEN);
|
|
194
|
+
var out = "";
|
|
195
|
+
for (var i = 0; i < CODE_LEN; i += 1) {
|
|
196
|
+
out += CODE_ALPHABET.charAt(buf[i] & 31);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function _generateCode(epochMs) {
|
|
202
|
+
return "RMA-" + _datePrefix(epochMs) + "-" + _randomTail();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _canonicalCode(input) {
|
|
206
|
+
if (typeof input !== "string" || !input.length) {
|
|
207
|
+
throw new TypeError("returns: rma_code must be a non-empty string");
|
|
208
|
+
}
|
|
209
|
+
var up = input.toUpperCase().trim();
|
|
210
|
+
if (!CODE_FULL_RE.test(up)) {
|
|
211
|
+
throw new TypeError("returns: rma_code must match RMA-YYMMDD-AAAAA");
|
|
212
|
+
}
|
|
213
|
+
return up;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- factory ------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
function create(opts) {
|
|
219
|
+
opts = opts || {};
|
|
220
|
+
var query = opts.query;
|
|
221
|
+
if (!query) {
|
|
222
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
223
|
+
}
|
|
224
|
+
// Pagination cursors for listForCustomer are HMAC-tagged via
|
|
225
|
+
// b.pagination so an operator can't hand-craft one to skip past a
|
|
226
|
+
// hidden RMA or replay across deployments. Same shape as
|
|
227
|
+
// order.listForCustomer.
|
|
228
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
229
|
+
if (process.env.NODE_ENV === "production") {
|
|
230
|
+
throw new Error("returns.create: opts.cursorSecret is required in production");
|
|
231
|
+
}
|
|
232
|
+
opts.cursorSecret = "returns-cursor-secret-dev-only";
|
|
233
|
+
}
|
|
234
|
+
var cursorSecret = opts.cursorSecret;
|
|
235
|
+
|
|
236
|
+
// Hydrate a full RMA + its lines. Internal helper — `get` and
|
|
237
|
+
// `byCode` both share this so the wire shape stays consistent.
|
|
238
|
+
async function _hydrate(row) {
|
|
239
|
+
if (!row) return null;
|
|
240
|
+
var lines = (await query(
|
|
241
|
+
"SELECT * FROM return_lines WHERE rma_id = ?1 ORDER BY id ASC",
|
|
242
|
+
[row.id],
|
|
243
|
+
)).rows;
|
|
244
|
+
row.lines = lines;
|
|
245
|
+
return row;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
REASONS: REASONS,
|
|
250
|
+
STATUSES: STATUSES,
|
|
251
|
+
TERMINAL_STATES: TERMINAL_STATES,
|
|
252
|
+
CODE_ALPHABET: CODE_ALPHABET,
|
|
253
|
+
|
|
254
|
+
request: async function (input) {
|
|
255
|
+
if (!input || typeof input !== "object") {
|
|
256
|
+
throw new TypeError("returns.request: input object required");
|
|
257
|
+
}
|
|
258
|
+
_uuid(input.order_id, "order_id");
|
|
259
|
+
if (input.customer_id != null) _uuid(input.customer_id, "customer_id");
|
|
260
|
+
_reason(input.reason);
|
|
261
|
+
var reasonDetail = _boundedString(input.reason_detail, "reason_detail", MAX_DETAIL_LEN);
|
|
262
|
+
var customerNotes = _boundedString(input.customer_notes, "customer_notes", MAX_NOTE_LEN);
|
|
263
|
+
if (!Array.isArray(input.lines) || input.lines.length === 0) {
|
|
264
|
+
throw new TypeError("returns.request: lines must be a non-empty array");
|
|
265
|
+
}
|
|
266
|
+
for (var li = 0; li < input.lines.length; li += 1) {
|
|
267
|
+
var l = input.lines[li];
|
|
268
|
+
if (!l || typeof l !== "object") {
|
|
269
|
+
throw new TypeError("returns.request: lines[" + li + "] must be an object");
|
|
270
|
+
}
|
|
271
|
+
_sku(l.sku);
|
|
272
|
+
_positiveInt(l.qty, "lines[" + li + "].qty");
|
|
273
|
+
if (l.order_line_id != null) _uuid(l.order_line_id, "lines[" + li + "].order_line_id");
|
|
274
|
+
if (l.reason != null && typeof l.reason !== "string") {
|
|
275
|
+
throw new TypeError("returns.request: lines[" + li + "].reason must be a string");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// INSERT-with-retry on rma_code collision. The 32^5 ≈ 33.5M
|
|
280
|
+
// per-day tail space makes collisions vanishingly rare, but
|
|
281
|
+
// the UNIQUE index will surface one as an SQLITE_CONSTRAINT
|
|
282
|
+
// we recover from by re-drawing the tail. Cap retries so a
|
|
283
|
+
// pathological RNG failure (or a clock stuck on one ms across
|
|
284
|
+
// a busy day) doesn't loop forever.
|
|
285
|
+
var id = _b().uuid.v7();
|
|
286
|
+
var ts = _now();
|
|
287
|
+
var code = null;
|
|
288
|
+
var maxAttempts = 5;
|
|
289
|
+
for (var attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
290
|
+
var candidate = _generateCode(ts);
|
|
291
|
+
try {
|
|
292
|
+
await query(
|
|
293
|
+
"INSERT INTO return_authorizations " +
|
|
294
|
+
"(id, order_id, customer_id, rma_code, reason, reason_detail, status, " +
|
|
295
|
+
"refund_amount_minor, refund_currency, customer_notes, operator_notes, " +
|
|
296
|
+
"created_at, updated_at) " +
|
|
297
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', 0, 'USD', ?7, '', ?8, ?8)",
|
|
298
|
+
[
|
|
299
|
+
id, input.order_id, input.customer_id || null, candidate,
|
|
300
|
+
input.reason, reasonDetail, customerNotes, ts,
|
|
301
|
+
],
|
|
302
|
+
);
|
|
303
|
+
code = candidate;
|
|
304
|
+
break;
|
|
305
|
+
} catch (e) {
|
|
306
|
+
var msg = (e && e.message) || "";
|
|
307
|
+
if (/UNIQUE|constraint/i.test(msg) && attempt < maxAttempts - 1) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
throw e;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (code == null) {
|
|
314
|
+
throw new Error("returns.request: failed to generate unique rma_code after " + maxAttempts + " attempts");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (var i = 0; i < input.lines.length; i += 1) {
|
|
318
|
+
var ln = input.lines[i];
|
|
319
|
+
await query(
|
|
320
|
+
"INSERT INTO return_lines (id, rma_id, order_line_id, sku, qty, reason, " +
|
|
321
|
+
"amount_minor, amount_currency) " +
|
|
322
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, 'USD')",
|
|
323
|
+
[
|
|
324
|
+
_b().uuid.v7(), id, ln.order_line_id || null,
|
|
325
|
+
ln.sku, ln.qty, ln.reason || null,
|
|
326
|
+
],
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return { id: id, rma_code: code, status: "pending" };
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
approve: async function (rmaId, input) {
|
|
334
|
+
_uuid(rmaId, "rma id");
|
|
335
|
+
if (!input || typeof input !== "object") {
|
|
336
|
+
throw new TypeError("returns.approve: input object required");
|
|
337
|
+
}
|
|
338
|
+
_nonNegInt(input.refund_amount_minor, "refund_amount_minor");
|
|
339
|
+
var refundCurrency = input.refund_currency == null ? "USD" : _currency(input.refund_currency);
|
|
340
|
+
var operatorNotes = _boundedString(input.operator_notes, "operator_notes", MAX_NOTE_LEN);
|
|
341
|
+
|
|
342
|
+
var current = await this._currentStatus(rmaId);
|
|
343
|
+
_assertTransition(current, "approve");
|
|
344
|
+
|
|
345
|
+
var ts = _now();
|
|
346
|
+
await query(
|
|
347
|
+
"UPDATE return_authorizations SET status = 'approved', " +
|
|
348
|
+
"refund_amount_minor = ?1, refund_currency = ?2, approved_at = ?3, " +
|
|
349
|
+
"operator_notes = CASE WHEN ?4 = '' THEN operator_notes ELSE ?4 END, " +
|
|
350
|
+
"updated_at = ?3 WHERE id = ?5",
|
|
351
|
+
[input.refund_amount_minor, refundCurrency, ts, operatorNotes, rmaId],
|
|
352
|
+
);
|
|
353
|
+
return await this.get(rmaId);
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
markReceived: async function (rmaId, input) {
|
|
357
|
+
_uuid(rmaId, "rma id");
|
|
358
|
+
input = input || {};
|
|
359
|
+
var receivedAt = _epochOrNull(input.received_at, "received_at");
|
|
360
|
+
var operatorNotes = _boundedString(input.operator_notes, "operator_notes", MAX_NOTE_LEN);
|
|
361
|
+
|
|
362
|
+
var current = await this._currentStatus(rmaId);
|
|
363
|
+
_assertTransition(current, "markReceived");
|
|
364
|
+
|
|
365
|
+
var ts = _now();
|
|
366
|
+
var rxAt = receivedAt == null ? ts : receivedAt;
|
|
367
|
+
await query(
|
|
368
|
+
"UPDATE return_authorizations SET status = 'received', " +
|
|
369
|
+
"received_at = ?1, " +
|
|
370
|
+
"operator_notes = CASE WHEN ?2 = '' THEN operator_notes ELSE ?2 END, " +
|
|
371
|
+
"updated_at = ?3 WHERE id = ?4",
|
|
372
|
+
[rxAt, operatorNotes, ts, rmaId],
|
|
373
|
+
);
|
|
374
|
+
return await this.get(rmaId);
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
refund: async function (rmaId, input) {
|
|
378
|
+
_uuid(rmaId, "rma id");
|
|
379
|
+
input = input || {};
|
|
380
|
+
var refundedAt = _epochOrNull(input.refunded_at, "refunded_at");
|
|
381
|
+
var operatorNotes = _boundedString(input.operator_notes, "operator_notes", MAX_NOTE_LEN);
|
|
382
|
+
|
|
383
|
+
var current = await this._currentStatus(rmaId);
|
|
384
|
+
_assertTransition(current, "refund");
|
|
385
|
+
|
|
386
|
+
var ts = _now();
|
|
387
|
+
var rfAt = refundedAt == null ? ts : refundedAt;
|
|
388
|
+
await query(
|
|
389
|
+
"UPDATE return_authorizations SET status = 'refunded', " +
|
|
390
|
+
"refunded_at = ?1, " +
|
|
391
|
+
"operator_notes = CASE WHEN ?2 = '' THEN operator_notes ELSE ?2 END, " +
|
|
392
|
+
"updated_at = ?3 WHERE id = ?4",
|
|
393
|
+
[rfAt, operatorNotes, ts, rmaId],
|
|
394
|
+
);
|
|
395
|
+
return await this.get(rmaId);
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
reject: async function (rmaId, input) {
|
|
399
|
+
_uuid(rmaId, "rma id");
|
|
400
|
+
if (!input || typeof input !== "object") {
|
|
401
|
+
throw new TypeError("returns.reject: input object required");
|
|
402
|
+
}
|
|
403
|
+
if (typeof input.rejected_reason !== "string" || !input.rejected_reason.length) {
|
|
404
|
+
throw new TypeError("returns.reject: rejected_reason must be a non-empty string");
|
|
405
|
+
}
|
|
406
|
+
var rejectedReason = _boundedString(input.rejected_reason, "rejected_reason", MAX_DETAIL_LEN);
|
|
407
|
+
var operatorNotes = _boundedString(input.operator_notes, "operator_notes", MAX_NOTE_LEN);
|
|
408
|
+
|
|
409
|
+
var current = await this._currentStatus(rmaId);
|
|
410
|
+
_assertTransition(current, "reject");
|
|
411
|
+
|
|
412
|
+
var ts = _now();
|
|
413
|
+
await query(
|
|
414
|
+
"UPDATE return_authorizations SET status = 'rejected', " +
|
|
415
|
+
"rejected_at = ?1, rejected_reason = ?2, " +
|
|
416
|
+
"operator_notes = CASE WHEN ?3 = '' THEN operator_notes ELSE ?3 END, " +
|
|
417
|
+
"updated_at = ?1 WHERE id = ?4",
|
|
418
|
+
[ts, rejectedReason, operatorNotes, rmaId],
|
|
419
|
+
);
|
|
420
|
+
return await this.get(rmaId);
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
get: async function (rmaId) {
|
|
424
|
+
_uuid(rmaId, "rma id");
|
|
425
|
+
var r = await query(
|
|
426
|
+
"SELECT * FROM return_authorizations WHERE id = ?1",
|
|
427
|
+
[rmaId],
|
|
428
|
+
);
|
|
429
|
+
return await _hydrate(r.rows[0] || null);
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
byCode: async function (rmaCode) {
|
|
433
|
+
var canonical = _canonicalCode(rmaCode);
|
|
434
|
+
var r = await query(
|
|
435
|
+
"SELECT * FROM return_authorizations WHERE rma_code = ?1",
|
|
436
|
+
[canonical],
|
|
437
|
+
);
|
|
438
|
+
return await _hydrate(r.rows[0] || null);
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
listForOrder: async function (orderId) {
|
|
442
|
+
_uuid(orderId, "order_id");
|
|
443
|
+
var r = await query(
|
|
444
|
+
"SELECT * FROM return_authorizations WHERE order_id = ?1 " +
|
|
445
|
+
"ORDER BY created_at DESC, id DESC",
|
|
446
|
+
[orderId],
|
|
447
|
+
);
|
|
448
|
+
var rows = r.rows;
|
|
449
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
450
|
+
await _hydrate(rows[i]);
|
|
451
|
+
}
|
|
452
|
+
return rows;
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
listForCustomer: async function (customerId, listOpts) {
|
|
456
|
+
_uuid(customerId, "customer_id");
|
|
457
|
+
listOpts = listOpts || {};
|
|
458
|
+
var limit = listOpts.limit == null ? DEFAULT_LIST_LIMIT : listOpts.limit;
|
|
459
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
|
|
460
|
+
throw new TypeError("returns.listForCustomer: limit must be 1..." + MAX_LIST_LIMIT);
|
|
461
|
+
}
|
|
462
|
+
var statusFilter = null;
|
|
463
|
+
if (listOpts.status != null) {
|
|
464
|
+
statusFilter = _status(listOpts.status);
|
|
465
|
+
}
|
|
466
|
+
var cursorVals = null;
|
|
467
|
+
if (listOpts.cursor != null) {
|
|
468
|
+
if (typeof listOpts.cursor !== "string") {
|
|
469
|
+
throw new TypeError("returns.listForCustomer: cursor must be an opaque string or null");
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
|
|
473
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(RMA_ORDER_KEY)) {
|
|
474
|
+
throw new TypeError("returns.listForCustomer: cursor orderKey mismatch");
|
|
475
|
+
}
|
|
476
|
+
cursorVals = state.vals;
|
|
477
|
+
} catch (e) {
|
|
478
|
+
if (e instanceof TypeError) throw e;
|
|
479
|
+
throw new TypeError("returns.listForCustomer: cursor — " + (e && e.message || "malformed"));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
var sql, params;
|
|
484
|
+
if (cursorVals && statusFilter) {
|
|
485
|
+
sql = "SELECT * FROM return_authorizations WHERE customer_id = ?1 AND status = ?2 AND " +
|
|
486
|
+
"(created_at < ?3 OR (created_at = ?3 AND id < ?4)) " +
|
|
487
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?5";
|
|
488
|
+
params = [customerId, statusFilter, cursorVals[0], cursorVals[1], limit];
|
|
489
|
+
} else if (cursorVals) {
|
|
490
|
+
sql = "SELECT * FROM return_authorizations WHERE customer_id = ?1 AND " +
|
|
491
|
+
"(created_at < ?2 OR (created_at = ?2 AND id < ?3)) " +
|
|
492
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?4";
|
|
493
|
+
params = [customerId, cursorVals[0], cursorVals[1], limit];
|
|
494
|
+
} else if (statusFilter) {
|
|
495
|
+
sql = "SELECT * FROM return_authorizations WHERE customer_id = ?1 AND status = ?2 " +
|
|
496
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?3";
|
|
497
|
+
params = [customerId, statusFilter, limit];
|
|
498
|
+
} else {
|
|
499
|
+
sql = "SELECT * FROM return_authorizations WHERE customer_id = ?1 " +
|
|
500
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?2";
|
|
501
|
+
params = [customerId, limit];
|
|
502
|
+
}
|
|
503
|
+
var rows = (await query(sql, params)).rows;
|
|
504
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
505
|
+
await _hydrate(rows[i]);
|
|
506
|
+
}
|
|
507
|
+
var last = rows[rows.length - 1];
|
|
508
|
+
var next = null;
|
|
509
|
+
if (last && rows.length === limit) {
|
|
510
|
+
next = _b().pagination.encodeCursor({
|
|
511
|
+
orderKey: RMA_ORDER_KEY,
|
|
512
|
+
vals: [last.created_at, last.id],
|
|
513
|
+
forward: true,
|
|
514
|
+
}, cursorSecret);
|
|
515
|
+
}
|
|
516
|
+
return { rows: rows, next_cursor: next };
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
summaryForOperator: async function (input) {
|
|
520
|
+
input = input || {};
|
|
521
|
+
var from = _epochOrNull(input.from, "from");
|
|
522
|
+
var to = _epochOrNull(input.to, "to");
|
|
523
|
+
var statusFilter = null;
|
|
524
|
+
if (input.status != null) statusFilter = _status(input.status);
|
|
525
|
+
|
|
526
|
+
var where = [];
|
|
527
|
+
var params = [];
|
|
528
|
+
var idx = 1;
|
|
529
|
+
if (from != null) { where.push("created_at >= ?" + idx); params.push(from); idx += 1; }
|
|
530
|
+
if (to != null) { where.push("created_at < ?" + idx); params.push(to); idx += 1; }
|
|
531
|
+
if (statusFilter) { where.push("status = ?" + idx); params.push(statusFilter); idx += 1; }
|
|
532
|
+
var whereSql = where.length ? (" WHERE " + where.join(" AND ")) : "";
|
|
533
|
+
|
|
534
|
+
// counts_by_status holds every status seen in the window, even
|
|
535
|
+
// when an explicit status filter is supplied (the filter just
|
|
536
|
+
// narrows the population).
|
|
537
|
+
var statusRows = (await query(
|
|
538
|
+
"SELECT status, COUNT(*) AS n FROM return_authorizations" + whereSql +
|
|
539
|
+
" GROUP BY status",
|
|
540
|
+
params,
|
|
541
|
+
)).rows;
|
|
542
|
+
|
|
543
|
+
var countsByStatus = {};
|
|
544
|
+
for (var s = 0; s < STATUSES.length; s += 1) countsByStatus[STATUSES[s]] = 0;
|
|
545
|
+
var total = 0;
|
|
546
|
+
for (var r = 0; r < statusRows.length; r += 1) {
|
|
547
|
+
var row = statusRows[r];
|
|
548
|
+
countsByStatus[row.status] = Number(row.n);
|
|
549
|
+
total += Number(row.n);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Refund value totals — sum only over RMAs that actually
|
|
553
|
+
// refunded (or are approved-and-pending-refund) so the
|
|
554
|
+
// operator sees committed-payout vs pending-payout. Currency
|
|
555
|
+
// mix is reported per-bucket so a multi-currency shop doesn't
|
|
556
|
+
// collapse to a meaningless single number.
|
|
557
|
+
var refundedRows = (await query(
|
|
558
|
+
"SELECT refund_currency, SUM(refund_amount_minor) AS total FROM return_authorizations" +
|
|
559
|
+
(whereSql ? whereSql + " AND status = 'refunded'" : " WHERE status = 'refunded'") +
|
|
560
|
+
" GROUP BY refund_currency",
|
|
561
|
+
params,
|
|
562
|
+
)).rows;
|
|
563
|
+
var pendingRows = (await query(
|
|
564
|
+
"SELECT refund_currency, SUM(refund_amount_minor) AS total FROM return_authorizations" +
|
|
565
|
+
(whereSql ? whereSql + " AND status IN ('approved','received')" : " WHERE status IN ('approved','received')") +
|
|
566
|
+
" GROUP BY refund_currency",
|
|
567
|
+
params,
|
|
568
|
+
)).rows;
|
|
569
|
+
|
|
570
|
+
function _byCurrency(rows) {
|
|
571
|
+
var out = {};
|
|
572
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
573
|
+
out[rows[i].refund_currency] = Number(rows[i].total) || 0;
|
|
574
|
+
}
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
total_count: total,
|
|
580
|
+
counts_by_status: countsByStatus,
|
|
581
|
+
refunded_by_currency: _byCurrency(refundedRows),
|
|
582
|
+
pending_by_currency: _byCurrency(pendingRows),
|
|
583
|
+
};
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
// Internal helper — exported (underscored) for test introspection.
|
|
587
|
+
// Returns the current status row or throws if the RMA doesn't
|
|
588
|
+
// exist. Used by every transition method so the not-found and
|
|
589
|
+
// illegal-transition refusals stay shape-consistent.
|
|
590
|
+
_currentStatus: async function (rmaId) {
|
|
591
|
+
var r = await query(
|
|
592
|
+
"SELECT status FROM return_authorizations WHERE id = ?1",
|
|
593
|
+
[rmaId],
|
|
594
|
+
);
|
|
595
|
+
if (!r.rows.length) {
|
|
596
|
+
var miss = new TypeError("returns: rma " + rmaId + " not found");
|
|
597
|
+
miss.code = "RMA_NOT_FOUND";
|
|
598
|
+
throw miss;
|
|
599
|
+
}
|
|
600
|
+
return r.rows[0].status;
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Refuse transitions outside the FSM graph. Surfaced as a typed
|
|
606
|
+
// error with `.code = 'RMA_TRANSITION_REFUSED'` so callers can
|
|
607
|
+
// distinguish "you tried to refund a pending RMA" from "you passed
|
|
608
|
+
// a bad UUID".
|
|
609
|
+
function _assertTransition(currentStatus, event) {
|
|
610
|
+
var allowed = TRANSITIONS[currentStatus];
|
|
611
|
+
if (!allowed || !allowed[event]) {
|
|
612
|
+
var err = new Error(
|
|
613
|
+
"returns: transition '" + event + "' refused from state '" + currentStatus + "'"
|
|
614
|
+
);
|
|
615
|
+
err.code = "RMA_TRANSITION_REFUSED";
|
|
616
|
+
throw err;
|
|
617
|
+
}
|
|
618
|
+
return allowed[event];
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
module.exports = {
|
|
622
|
+
create: create,
|
|
623
|
+
REASONS: REASONS,
|
|
624
|
+
STATUSES: STATUSES,
|
|
625
|
+
TERMINAL_STATES: TERMINAL_STATES,
|
|
626
|
+
CODE_ALPHABET: CODE_ALPHABET,
|
|
627
|
+
};
|