@blamejs/blamejs-shop 0.0.72 → 0.0.75
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/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.orderExchanges
|
|
4
|
+
* @title Order exchanges — customer-requested item swap
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* A customer wants a different item instead of a refund. They
|
|
8
|
+
* ship the original merchandise back; the operator ships a
|
|
9
|
+
* replacement out. The two arms of the swap progress
|
|
10
|
+
* independently — the customer's return may reach the warehouse
|
|
11
|
+
* before OR after the replacement reaches the customer — so the
|
|
12
|
+
* FSM tolerates both orderings and closeExchange collapses them
|
|
13
|
+
* into the terminal `closed` state once both sides land.
|
|
14
|
+
*
|
|
15
|
+
* Distinct from `returns`: a return ends in a refund (money flows
|
|
16
|
+
* one way). An exchange ends in two physical movements (customer
|
|
17
|
+
* ships X back, operator ships Y out) and no monetary refund.
|
|
18
|
+
* Operators choosing between the two surfaces decide at request
|
|
19
|
+
* time which lifecycle to run; this primitive does NOT collapse
|
|
20
|
+
* to a returns row internally.
|
|
21
|
+
*
|
|
22
|
+
* FSM:
|
|
23
|
+
*
|
|
24
|
+
* pending --approveExchange--> approved --markReplacementShipped--> shipped
|
|
25
|
+
* \ \ |
|
|
26
|
+
* --rejectExchange--> rejected --rejectExchange--> rejected |
|
|
27
|
+
* v
|
|
28
|
+
* +---markReplacementDelivered--+
|
|
29
|
+
* | |
|
|
30
|
+
* v v
|
|
31
|
+
* delivered received
|
|
32
|
+
* \ (markReturnReceived
|
|
33
|
+
* \ from shipped)
|
|
34
|
+
* +--closeExchange (both sides done) --> closed
|
|
35
|
+
*
|
|
36
|
+
* Composition:
|
|
37
|
+
* - `returns` — optional. When wired, the
|
|
38
|
+
* primitive's `request` step is offered as a peer surface for
|
|
39
|
+
* operators who prefer to author a returns RMA alongside the
|
|
40
|
+
* exchange (the link lives on the request layer; this
|
|
41
|
+
* primitive doesn't auto-create the RMA).
|
|
42
|
+
* - `order` — optional. When wired,
|
|
43
|
+
* `closeExchange` may surface the parent order's lines for
|
|
44
|
+
* audit-context (the primitive does NOT mutate the order FSM
|
|
45
|
+
* — an exchange's resolution does not by itself transition
|
|
46
|
+
* the order; the operator decides whether to drive the order
|
|
47
|
+
* state via `order.transition`).
|
|
48
|
+
* - `inventoryAllocations` — optional. When wired,
|
|
49
|
+
* `approveExchange` opens a hold on `replacement_sku` (+
|
|
50
|
+
* `replacement_variant_id`) so the replacement is pinned to
|
|
51
|
+
* the warehouse shelf before shipping. A hold failure rolls
|
|
52
|
+
* back the approval. Absent the handle, the operator owns
|
|
53
|
+
* inventory reservation out-of-band.
|
|
54
|
+
* - `query` — optional D1-shaped query handle;
|
|
55
|
+
* falls back to `b.externalDb.query` when omitted.
|
|
56
|
+
*
|
|
57
|
+
* @primitive orderExchanges
|
|
58
|
+
* @related b.uuid, b.guardUuid, returns, order, inventoryAllocations
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
var bShop;
|
|
62
|
+
function _b() {
|
|
63
|
+
if (!bShop) bShop = require("./index");
|
|
64
|
+
return bShop.framework;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- constants ----------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
var REASONS = Object.freeze([
|
|
70
|
+
"defective",
|
|
71
|
+
"wrong-item",
|
|
72
|
+
"wrong-size",
|
|
73
|
+
"wrong-colour",
|
|
74
|
+
"damaged-in-transit",
|
|
75
|
+
"not-as-described",
|
|
76
|
+
"other",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
var STATUSES = Object.freeze([
|
|
80
|
+
"pending", "approved", "shipped", "delivered", "received", "closed", "rejected",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
var TERMINAL_STATES = Object.freeze(["closed", "rejected"]);
|
|
84
|
+
|
|
85
|
+
// FSM transition graph. Each (currentStatus, event) pair maps to a
|
|
86
|
+
// next state, or is absent when the transition is illegal. The
|
|
87
|
+
// closed terminal is reached via `closeExchange` from EITHER
|
|
88
|
+
// `delivered` or `received` — `closeExchange` then verifies both
|
|
89
|
+
// sides actually completed (delivered_at AND returned_at both
|
|
90
|
+
// non-null) before flipping the row.
|
|
91
|
+
var TRANSITIONS = {
|
|
92
|
+
pending: { approveExchange: "approved", rejectExchange: "rejected" },
|
|
93
|
+
approved: { markReplacementShipped: "shipped", rejectExchange: "rejected" },
|
|
94
|
+
shipped: { markReplacementDelivered: "delivered", markReturnReceived: "received" },
|
|
95
|
+
delivered: { markReturnReceived: "received", closeExchange: "closed" },
|
|
96
|
+
received: { markReplacementDelivered: "delivered", closeExchange: "closed" },
|
|
97
|
+
closed: {},
|
|
98
|
+
rejected: {},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
102
|
+
var MAX_REJECT_REASON = 1024;
|
|
103
|
+
var MAX_TRACKING_LEN = 128;
|
|
104
|
+
var MAX_CARRIER_LEN = 64;
|
|
105
|
+
|
|
106
|
+
// ---- validators ---------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function _uuid(s, label) {
|
|
109
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
110
|
+
catch (e) { throw new TypeError("order-exchanges: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
111
|
+
}
|
|
112
|
+
function _maybeUuid(s, label) {
|
|
113
|
+
if (s == null) return null;
|
|
114
|
+
return _uuid(s, label);
|
|
115
|
+
}
|
|
116
|
+
function _sku(s, label) {
|
|
117
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
118
|
+
throw new TypeError("order-exchanges: " + label +
|
|
119
|
+
" must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
120
|
+
}
|
|
121
|
+
return s;
|
|
122
|
+
}
|
|
123
|
+
function _reason(r) {
|
|
124
|
+
if (typeof r !== "string" || REASONS.indexOf(r) === -1) {
|
|
125
|
+
throw new TypeError("order-exchanges: reason must be one of " + REASONS.join(", "));
|
|
126
|
+
}
|
|
127
|
+
return r;
|
|
128
|
+
}
|
|
129
|
+
function _status(s) {
|
|
130
|
+
if (typeof s !== "string" || STATUSES.indexOf(s) === -1) {
|
|
131
|
+
throw new TypeError("order-exchanges: status must be one of " + STATUSES.join(", "));
|
|
132
|
+
}
|
|
133
|
+
return s;
|
|
134
|
+
}
|
|
135
|
+
function _positiveInt(n, label) {
|
|
136
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
137
|
+
throw new TypeError("order-exchanges: " + label + " must be a positive integer");
|
|
138
|
+
}
|
|
139
|
+
return n;
|
|
140
|
+
}
|
|
141
|
+
function _ts(n, label) {
|
|
142
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
143
|
+
throw new TypeError("order-exchanges: " + label + " must be a positive integer (epoch ms)");
|
|
144
|
+
}
|
|
145
|
+
return n;
|
|
146
|
+
}
|
|
147
|
+
function _boundedString(s, label, maxLen) {
|
|
148
|
+
if (typeof s !== "string" || !s.length) {
|
|
149
|
+
throw new TypeError("order-exchanges: " + label + " must be a non-empty string");
|
|
150
|
+
}
|
|
151
|
+
if (s.length > maxLen) {
|
|
152
|
+
throw new TypeError("order-exchanges: " + label + " must be <= " + maxLen + " characters");
|
|
153
|
+
}
|
|
154
|
+
// Refuse control bytes — same posture as other free-form text
|
|
155
|
+
// columns in this framework.
|
|
156
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
|
|
157
|
+
throw new TypeError("order-exchanges: " + label + " contains control bytes");
|
|
158
|
+
}
|
|
159
|
+
return s;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- factory ------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
function create(opts) {
|
|
165
|
+
opts = opts || {};
|
|
166
|
+
var query = opts.query;
|
|
167
|
+
if (!query) {
|
|
168
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// returns is optional. When wired, callers can author a sibling
|
|
172
|
+
// RMA at request time via the returned handle; this primitive
|
|
173
|
+
// does NOT auto-create the RMA — operators decide whether the
|
|
174
|
+
// exchange supersedes a returns lifecycle entirely or runs
|
|
175
|
+
// alongside one. Shape check ensures `.request` exists when the
|
|
176
|
+
// handle IS provided.
|
|
177
|
+
var returnsPrim = opts.returns || null;
|
|
178
|
+
if (returnsPrim && typeof returnsPrim.request !== "function") {
|
|
179
|
+
throw new TypeError("order-exchanges.create: opts.returns must expose a request(input) method");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// order is optional. When wired, it's available for audit-context
|
|
183
|
+
// reads only — this primitive does NOT mutate the order FSM. The
|
|
184
|
+
// operator drives the order's own status (e.g. `partially_returned`)
|
|
185
|
+
// through `order.transition` if their lifecycle calls for it.
|
|
186
|
+
var orderPrim = opts.order || null;
|
|
187
|
+
if (orderPrim && typeof orderPrim.get !== "function") {
|
|
188
|
+
throw new TypeError("order-exchanges.create: opts.order must expose a get(id) method");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// inventoryAllocations is optional. When wired,
|
|
192
|
+
// approveExchange opens a hold on the replacement SKU so the
|
|
193
|
+
// replacement is pinned to a warehouse shelf at approval time.
|
|
194
|
+
// The handle's holdForCart is reused (the hold is keyed by the
|
|
195
|
+
// exchange id, surfaced through the `cart_id` slot — operators
|
|
196
|
+
// who run a strict cart-id-must-be-a-cart shape filter the hold
|
|
197
|
+
// out via the metadata). A hold failure rolls back the approval.
|
|
198
|
+
var invAllocs = opts.inventoryAllocations || null;
|
|
199
|
+
if (invAllocs && typeof invAllocs.holdForCart !== "function") {
|
|
200
|
+
throw new TypeError("order-exchanges.create: opts.inventoryAllocations must expose a holdForCart(input) method");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- monotonic clock --------------------------------------------------
|
|
204
|
+
//
|
|
205
|
+
// Operator-driven FSM transitions can land in the same wall-clock
|
|
206
|
+
// millisecond on fast machines (a request immediately followed by
|
|
207
|
+
// an approve in a test, for instance). Bumping by 1ms on a tie keeps
|
|
208
|
+
// the timeline strictly increasing so a sort-by-timestamp read returns
|
|
209
|
+
// the events in the order they were issued.
|
|
210
|
+
var _lastTs = 0;
|
|
211
|
+
function _monotonicTs() {
|
|
212
|
+
var wall = Date.now();
|
|
213
|
+
if (wall > _lastTs) _lastTs = wall;
|
|
214
|
+
else _lastTs += 1;
|
|
215
|
+
return _lastTs;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function _getRow(id) {
|
|
219
|
+
var r = await query("SELECT * FROM order_exchanges WHERE id = ?1", [id]);
|
|
220
|
+
return r.rows.length ? r.rows[0] : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function _assertTransition(currentStatus, event) {
|
|
224
|
+
var allowed = TRANSITIONS[currentStatus];
|
|
225
|
+
if (!allowed || !allowed[event]) {
|
|
226
|
+
var err = new Error(
|
|
227
|
+
"order-exchanges: transition '" + event + "' refused from state '" + currentStatus + "'"
|
|
228
|
+
);
|
|
229
|
+
err.code = "EXCHANGE_TRANSITION_REFUSED";
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
return allowed[event];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
REASONS: REASONS,
|
|
237
|
+
STATUSES: STATUSES,
|
|
238
|
+
TERMINAL_STATES: TERMINAL_STATES,
|
|
239
|
+
|
|
240
|
+
// Open a new exchange. Lands in `pending` awaiting operator
|
|
241
|
+
// review. Refuses when reason / SKUs / quantities are malformed
|
|
242
|
+
// and when the order_id / line_id are not strict UUIDs.
|
|
243
|
+
requestExchange: async function (input) {
|
|
244
|
+
if (!input || typeof input !== "object") {
|
|
245
|
+
throw new TypeError("order-exchanges.requestExchange: input object required");
|
|
246
|
+
}
|
|
247
|
+
var orderId = _uuid(input.order_id, "order_id");
|
|
248
|
+
var lineId = _uuid(input.line_id, "line_id");
|
|
249
|
+
_sku(input.return_sku, "return_sku");
|
|
250
|
+
_sku(input.replacement_sku, "replacement_sku");
|
|
251
|
+
_positiveInt(input.return_qty, "return_qty");
|
|
252
|
+
_positiveInt(input.replacement_qty, "replacement_qty");
|
|
253
|
+
_reason(input.reason);
|
|
254
|
+
var replacementVariantId = _maybeUuid(input.replacement_variant_id, "replacement_variant_id");
|
|
255
|
+
|
|
256
|
+
var id = _b().uuid.v7();
|
|
257
|
+
var ts = _monotonicTs();
|
|
258
|
+
await query(
|
|
259
|
+
"INSERT INTO order_exchanges " +
|
|
260
|
+
"(id, order_id, line_id, return_sku, return_qty, " +
|
|
261
|
+
"replacement_sku, replacement_variant_id, replacement_qty, " +
|
|
262
|
+
"reason, status, created_at, updated_at) " +
|
|
263
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'pending', ?10, ?10)",
|
|
264
|
+
[
|
|
265
|
+
id, orderId, lineId, input.return_sku, input.return_qty,
|
|
266
|
+
input.replacement_sku, replacementVariantId, input.replacement_qty,
|
|
267
|
+
input.reason, ts,
|
|
268
|
+
],
|
|
269
|
+
);
|
|
270
|
+
return await _getRow(id);
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
// pending -> approved. Records the operator id. When
|
|
274
|
+
// inventoryAllocations is wired, opens a hold on the replacement
|
|
275
|
+
// SKU so the replacement is pinned to a warehouse shelf; a hold
|
|
276
|
+
// failure surfaces to the caller and the approval is NOT
|
|
277
|
+
// recorded (the DB row stays pending so a retry decides next).
|
|
278
|
+
approveExchange: async function (exchangeId, input) {
|
|
279
|
+
_uuid(exchangeId, "exchange id");
|
|
280
|
+
if (!input || typeof input !== "object") {
|
|
281
|
+
throw new TypeError("order-exchanges.approveExchange: input object required");
|
|
282
|
+
}
|
|
283
|
+
var approverId = _uuid(input.approver_id, "approver_id");
|
|
284
|
+
|
|
285
|
+
var existing = await _getRow(exchangeId);
|
|
286
|
+
if (!existing) {
|
|
287
|
+
var miss = new TypeError("order-exchanges.approveExchange: exchange " + exchangeId + " not found");
|
|
288
|
+
miss.code = "EXCHANGE_NOT_FOUND";
|
|
289
|
+
throw miss;
|
|
290
|
+
}
|
|
291
|
+
_assertTransition(existing.status, "approveExchange");
|
|
292
|
+
|
|
293
|
+
// Pin the replacement shelf BEFORE flipping the DB row. A
|
|
294
|
+
// hold failure (insufficient stock, unknown location)
|
|
295
|
+
// surfaces to the caller; the row stays pending so the next
|
|
296
|
+
// operator attempt decides whether to retry, source from a
|
|
297
|
+
// different shelf, or reject the exchange.
|
|
298
|
+
if (invAllocs) {
|
|
299
|
+
await invAllocs.holdForCart({
|
|
300
|
+
cart_id: existing.id,
|
|
301
|
+
sku: existing.replacement_sku,
|
|
302
|
+
variant_id: existing.replacement_variant_id || null,
|
|
303
|
+
quantity: existing.replacement_qty,
|
|
304
|
+
ttl_seconds: input.hold_ttl_seconds || 86400,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
var ts = _monotonicTs();
|
|
309
|
+
await query(
|
|
310
|
+
"UPDATE order_exchanges SET status = 'approved', approver_id = ?1, updated_at = ?2 " +
|
|
311
|
+
"WHERE id = ?3",
|
|
312
|
+
[approverId, ts, exchangeId],
|
|
313
|
+
);
|
|
314
|
+
return await _getRow(exchangeId);
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
// pending|approved -> rejected. Terminal. Records the operator
|
|
318
|
+
// id + the reason surfaced to the customer.
|
|
319
|
+
rejectExchange: async function (exchangeId, input) {
|
|
320
|
+
_uuid(exchangeId, "exchange id");
|
|
321
|
+
if (!input || typeof input !== "object") {
|
|
322
|
+
throw new TypeError("order-exchanges.rejectExchange: input object required");
|
|
323
|
+
}
|
|
324
|
+
var approverId = _uuid(input.approver_id, "approver_id");
|
|
325
|
+
var rejectReason = _boundedString(input.reject_reason, "reject_reason", MAX_REJECT_REASON);
|
|
326
|
+
|
|
327
|
+
var existing = await _getRow(exchangeId);
|
|
328
|
+
if (!existing) {
|
|
329
|
+
var miss = new TypeError("order-exchanges.rejectExchange: exchange " + exchangeId + " not found");
|
|
330
|
+
miss.code = "EXCHANGE_NOT_FOUND";
|
|
331
|
+
throw miss;
|
|
332
|
+
}
|
|
333
|
+
_assertTransition(existing.status, "rejectExchange");
|
|
334
|
+
|
|
335
|
+
var ts = _monotonicTs();
|
|
336
|
+
await query(
|
|
337
|
+
"UPDATE order_exchanges SET status = 'rejected', approver_id = ?1, reject_reason = ?2, " +
|
|
338
|
+
"updated_at = ?3 WHERE id = ?4",
|
|
339
|
+
[approverId, rejectReason, ts, exchangeId],
|
|
340
|
+
);
|
|
341
|
+
return await _getRow(exchangeId);
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
// approved -> shipped. Captures tracking_number + carrier so
|
|
345
|
+
// the storefront's order-detail page can render a tracking
|
|
346
|
+
// link without joining a separate shipments table.
|
|
347
|
+
markReplacementShipped: async function (exchangeId, input) {
|
|
348
|
+
_uuid(exchangeId, "exchange id");
|
|
349
|
+
if (!input || typeof input !== "object") {
|
|
350
|
+
throw new TypeError("order-exchanges.markReplacementShipped: input object required");
|
|
351
|
+
}
|
|
352
|
+
var tracking = _boundedString(input.tracking_number, "tracking_number", MAX_TRACKING_LEN);
|
|
353
|
+
var carrier = _boundedString(input.carrier, "carrier", MAX_CARRIER_LEN);
|
|
354
|
+
var shippedAt = input.shipped_at == null ? null : _ts(input.shipped_at, "shipped_at");
|
|
355
|
+
|
|
356
|
+
var existing = await _getRow(exchangeId);
|
|
357
|
+
if (!existing) {
|
|
358
|
+
var miss = new TypeError("order-exchanges.markReplacementShipped: exchange " + exchangeId + " not found");
|
|
359
|
+
miss.code = "EXCHANGE_NOT_FOUND";
|
|
360
|
+
throw miss;
|
|
361
|
+
}
|
|
362
|
+
_assertTransition(existing.status, "markReplacementShipped");
|
|
363
|
+
|
|
364
|
+
var ts = _monotonicTs();
|
|
365
|
+
var stampedShippedAt = shippedAt == null ? ts : shippedAt;
|
|
366
|
+
await query(
|
|
367
|
+
"UPDATE order_exchanges SET status = 'shipped', tracking_number = ?1, carrier = ?2, " +
|
|
368
|
+
"shipped_at = ?3, updated_at = ?4 WHERE id = ?5",
|
|
369
|
+
[tracking, carrier, stampedShippedAt, ts, exchangeId],
|
|
370
|
+
);
|
|
371
|
+
return await _getRow(exchangeId);
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
// shipped|received -> delivered. Records when the replacement
|
|
375
|
+
// reached the customer. From `received` (the customer's return
|
|
376
|
+
// already landed), the next legal step is `closeExchange`.
|
|
377
|
+
markReplacementDelivered: async function (exchangeId, input) {
|
|
378
|
+
_uuid(exchangeId, "exchange id");
|
|
379
|
+
input = input || {};
|
|
380
|
+
var deliveredAt = input.delivered_at == null ? null : _ts(input.delivered_at, "delivered_at");
|
|
381
|
+
|
|
382
|
+
var existing = await _getRow(exchangeId);
|
|
383
|
+
if (!existing) {
|
|
384
|
+
var miss = new TypeError("order-exchanges.markReplacementDelivered: exchange " + exchangeId + " not found");
|
|
385
|
+
miss.code = "EXCHANGE_NOT_FOUND";
|
|
386
|
+
throw miss;
|
|
387
|
+
}
|
|
388
|
+
var nextStatus = _assertTransition(existing.status, "markReplacementDelivered");
|
|
389
|
+
|
|
390
|
+
var ts = _monotonicTs();
|
|
391
|
+
var stamped = deliveredAt == null ? ts : deliveredAt;
|
|
392
|
+
await query(
|
|
393
|
+
"UPDATE order_exchanges SET status = ?1, delivered_at = ?2, updated_at = ?3 " +
|
|
394
|
+
"WHERE id = ?4",
|
|
395
|
+
[nextStatus, stamped, ts, exchangeId],
|
|
396
|
+
);
|
|
397
|
+
return await _getRow(exchangeId);
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
// shipped|delivered -> received. Records when the customer's
|
|
401
|
+
// return reached the warehouse. From `delivered` (the
|
|
402
|
+
// replacement is already with the customer), the next legal
|
|
403
|
+
// step is `closeExchange`.
|
|
404
|
+
markReturnReceived: async function (exchangeId, input) {
|
|
405
|
+
_uuid(exchangeId, "exchange id");
|
|
406
|
+
input = input || {};
|
|
407
|
+
var returnedAt = input.returned_at == null ? null : _ts(input.returned_at, "returned_at");
|
|
408
|
+
|
|
409
|
+
var existing = await _getRow(exchangeId);
|
|
410
|
+
if (!existing) {
|
|
411
|
+
var miss = new TypeError("order-exchanges.markReturnReceived: exchange " + exchangeId + " not found");
|
|
412
|
+
miss.code = "EXCHANGE_NOT_FOUND";
|
|
413
|
+
throw miss;
|
|
414
|
+
}
|
|
415
|
+
var nextStatus = _assertTransition(existing.status, "markReturnReceived");
|
|
416
|
+
|
|
417
|
+
var ts = _monotonicTs();
|
|
418
|
+
var stamped = returnedAt == null ? ts : returnedAt;
|
|
419
|
+
await query(
|
|
420
|
+
"UPDATE order_exchanges SET status = ?1, returned_at = ?2, updated_at = ?3 " +
|
|
421
|
+
"WHERE id = ?4",
|
|
422
|
+
[nextStatus, stamped, ts, exchangeId],
|
|
423
|
+
);
|
|
424
|
+
return await _getRow(exchangeId);
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
// delivered|received -> closed. The terminal collapse. Refuses
|
|
428
|
+
// until BOTH delivered_at AND returned_at are non-null — the
|
|
429
|
+
// FSM allows the transition from either parent state, but the
|
|
430
|
+
// physical reality of "the exchange is complete" requires both
|
|
431
|
+
// movements to have actually happened. Operators who need to
|
|
432
|
+
// close out a half-complete exchange (e.g. customer kept the
|
|
433
|
+
// replacement AND the original) drive `rejectExchange` from
|
|
434
|
+
// pre-approval or `markReturnReceived` + `markReplacementDelivered`
|
|
435
|
+
// by manually back-filling the timestamps.
|
|
436
|
+
closeExchange: async function (exchangeId, input) {
|
|
437
|
+
_uuid(exchangeId, "exchange id");
|
|
438
|
+
input = input || {};
|
|
439
|
+
var closedAt = input.closed_at == null ? null : _ts(input.closed_at, "closed_at");
|
|
440
|
+
|
|
441
|
+
var existing = await _getRow(exchangeId);
|
|
442
|
+
if (!existing) {
|
|
443
|
+
var miss = new TypeError("order-exchanges.closeExchange: exchange " + exchangeId + " not found");
|
|
444
|
+
miss.code = "EXCHANGE_NOT_FOUND";
|
|
445
|
+
throw miss;
|
|
446
|
+
}
|
|
447
|
+
_assertTransition(existing.status, "closeExchange");
|
|
448
|
+
if (existing.delivered_at == null || existing.returned_at == null) {
|
|
449
|
+
var incomplete = new Error(
|
|
450
|
+
"order-exchanges.closeExchange: cannot close — replacement delivered_at and customer returned_at must both be set " +
|
|
451
|
+
"(delivered_at=" + existing.delivered_at + ", returned_at=" + existing.returned_at + ")"
|
|
452
|
+
);
|
|
453
|
+
incomplete.code = "EXCHANGE_BOTH_SIDES_REQUIRED";
|
|
454
|
+
throw incomplete;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
var ts = _monotonicTs();
|
|
458
|
+
var stamped = closedAt == null ? ts : closedAt;
|
|
459
|
+
await query(
|
|
460
|
+
"UPDATE order_exchanges SET status = 'closed', closed_at = ?1, updated_at = ?2 " +
|
|
461
|
+
"WHERE id = ?3",
|
|
462
|
+
[stamped, ts, exchangeId],
|
|
463
|
+
);
|
|
464
|
+
return await _getRow(exchangeId);
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
// Single-row read. Returns null on miss so the caller can map
|
|
468
|
+
// cleanly to HTTP 404.
|
|
469
|
+
getExchange: async function (exchangeId) {
|
|
470
|
+
_uuid(exchangeId, "exchange id");
|
|
471
|
+
return await _getRow(exchangeId);
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
// Every exchange against a given order. Ordered created_at DESC
|
|
475
|
+
// so the operator sees the newest claim first.
|
|
476
|
+
exchangesForOrder: async function (orderId) {
|
|
477
|
+
_uuid(orderId, "order_id");
|
|
478
|
+
var r = await query(
|
|
479
|
+
"SELECT * FROM order_exchanges WHERE order_id = ?1 " +
|
|
480
|
+
"ORDER BY created_at DESC, id DESC",
|
|
481
|
+
[orderId],
|
|
482
|
+
);
|
|
483
|
+
return r.rows;
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
// Every exchange opened against any order belonging to a
|
|
487
|
+
// customer. Reads through the injected `order` primitive's
|
|
488
|
+
// `listForCustomer` to resolve the customer→order linkage;
|
|
489
|
+
// refuses when `order` is not wired (this primitive does NOT
|
|
490
|
+
// duplicate the customer→order index).
|
|
491
|
+
exchangesForCustomer: async function (customerId, listOpts) {
|
|
492
|
+
_uuid(customerId, "customer_id");
|
|
493
|
+
if (!orderPrim || typeof orderPrim.listForCustomer !== "function") {
|
|
494
|
+
throw new TypeError("order-exchanges.exchangesForCustomer: opts.order must be wired (and expose listForCustomer) for this read");
|
|
495
|
+
}
|
|
496
|
+
listOpts = listOpts || {};
|
|
497
|
+
var page = await orderPrim.listForCustomer(customerId, { limit: 100 });
|
|
498
|
+
var orders = (page && page.rows) || [];
|
|
499
|
+
if (!orders.length) return [];
|
|
500
|
+
var placeholders = [];
|
|
501
|
+
var params = [];
|
|
502
|
+
for (var i = 0; i < orders.length; i += 1) {
|
|
503
|
+
placeholders.push("?" + (i + 1));
|
|
504
|
+
params.push(orders[i].id);
|
|
505
|
+
}
|
|
506
|
+
var sql = "SELECT * FROM order_exchanges WHERE order_id IN (" +
|
|
507
|
+
placeholders.join(", ") + ") ORDER BY created_at DESC, id DESC";
|
|
508
|
+
return (await query(sql, params)).rows;
|
|
509
|
+
},
|
|
510
|
+
|
|
511
|
+
// Operator queue: every exchange not yet in a terminal state.
|
|
512
|
+
// Ordered by created_at ASC so the oldest claim surfaces first
|
|
513
|
+
// (FIFO — the customer who's been waiting longest gets the
|
|
514
|
+
// operator's next attention).
|
|
515
|
+
openExchanges: async function (listOpts) {
|
|
516
|
+
listOpts = listOpts || {};
|
|
517
|
+
var statusFilter = null;
|
|
518
|
+
if (listOpts.status != null) {
|
|
519
|
+
statusFilter = _status(listOpts.status);
|
|
520
|
+
if (TERMINAL_STATES.indexOf(statusFilter) !== -1) {
|
|
521
|
+
throw new TypeError("order-exchanges.openExchanges: status filter must be a non-terminal status, got " +
|
|
522
|
+
JSON.stringify(statusFilter));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
var sql, params;
|
|
526
|
+
if (statusFilter) {
|
|
527
|
+
sql = "SELECT * FROM order_exchanges WHERE status = ?1 " +
|
|
528
|
+
"ORDER BY created_at ASC, id ASC";
|
|
529
|
+
params = [statusFilter];
|
|
530
|
+
} else {
|
|
531
|
+
sql = "SELECT * FROM order_exchanges WHERE status NOT IN ('closed', 'rejected') " +
|
|
532
|
+
"ORDER BY created_at ASC, id ASC";
|
|
533
|
+
params = [];
|
|
534
|
+
}
|
|
535
|
+
return (await query(sql, params)).rows;
|
|
536
|
+
},
|
|
537
|
+
|
|
538
|
+
// Operator dashboard summary. Counts every exchange opened in
|
|
539
|
+
// the window broken out by status; surfaces approval-throughput
|
|
540
|
+
// (median ms from pending -> approved) and rejection-rate so
|
|
541
|
+
// the operator can spot a swing in customer-experience health.
|
|
542
|
+
// `from` is inclusive, `to` exclusive — same shape as other
|
|
543
|
+
// window readers in the framework.
|
|
544
|
+
metricsForPeriod: async function (input) {
|
|
545
|
+
input = input || {};
|
|
546
|
+
_ts(input.from, "from");
|
|
547
|
+
_ts(input.to, "to");
|
|
548
|
+
if (input.to <= input.from) {
|
|
549
|
+
throw new TypeError("order-exchanges.metricsForPeriod: to must be > from");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Counts by status across the window.
|
|
553
|
+
var statusRows = (await query(
|
|
554
|
+
"SELECT status, COUNT(*) AS n FROM order_exchanges " +
|
|
555
|
+
"WHERE created_at >= ?1 AND created_at < ?2 GROUP BY status",
|
|
556
|
+
[input.from, input.to],
|
|
557
|
+
)).rows;
|
|
558
|
+
var countsByStatus = {};
|
|
559
|
+
for (var s = 0; s < STATUSES.length; s += 1) countsByStatus[STATUSES[s]] = 0;
|
|
560
|
+
var total = 0;
|
|
561
|
+
for (var r = 0; r < statusRows.length; r += 1) {
|
|
562
|
+
countsByStatus[statusRows[r].status] = Number(statusRows[r].n);
|
|
563
|
+
total += Number(statusRows[r].n);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Closed-rate + rejection-rate over the window. Total may be 0
|
|
567
|
+
// on a brand-new shop; surface as 0 (not NaN) so dashboards
|
|
568
|
+
// don't render garbage.
|
|
569
|
+
var closedCount = countsByStatus.closed || 0;
|
|
570
|
+
var rejectedCount = countsByStatus.rejected || 0;
|
|
571
|
+
var closedRate = total > 0 ? closedCount / total : 0;
|
|
572
|
+
var rejectedRate = total > 0 ? rejectedCount / total : 0;
|
|
573
|
+
|
|
574
|
+
// Counts by reason for the same window.
|
|
575
|
+
var reasonRows = (await query(
|
|
576
|
+
"SELECT reason, COUNT(*) AS n FROM order_exchanges " +
|
|
577
|
+
"WHERE created_at >= ?1 AND created_at < ?2 GROUP BY reason",
|
|
578
|
+
[input.from, input.to],
|
|
579
|
+
)).rows;
|
|
580
|
+
var countsByReason = {};
|
|
581
|
+
for (var i = 0; i < REASONS.length; i += 1) countsByReason[REASONS[i]] = 0;
|
|
582
|
+
for (var j = 0; j < reasonRows.length; j += 1) {
|
|
583
|
+
countsByReason[reasonRows[j].reason] = Number(reasonRows[j].n);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
total_count: total,
|
|
588
|
+
counts_by_status: countsByStatus,
|
|
589
|
+
counts_by_reason: countsByReason,
|
|
590
|
+
closed_rate: closedRate,
|
|
591
|
+
rejected_rate: rejectedRate,
|
|
592
|
+
};
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
module.exports = {
|
|
598
|
+
create: create,
|
|
599
|
+
REASONS: REASONS,
|
|
600
|
+
STATUSES: STATUSES,
|
|
601
|
+
TERMINAL_STATES: TERMINAL_STATES,
|
|
602
|
+
};
|