@blamejs/blamejs-shop 0.0.56 → 0.0.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/backorder.js +452 -0
- package/lib/bundles.js +587 -0
- package/lib/fraud-screen.js +808 -0
- package/lib/index.js +10 -0
- package/lib/inventory-locations.js +774 -0
- package/lib/order-export.js +724 -0
- package/lib/order-notes.js +563 -0
- package/lib/payment-methods.js +522 -0
- package/lib/print-on-demand.js +709 -0
- package/lib/save-for-later.js +667 -0
- package/lib/variants.js +726 -0
- package/package.json +1 -1
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.printOnDemand
|
|
4
|
+
* @title Print-on-demand primitive — supplier-agnostic binding +
|
|
5
|
+
* per-order forward-to-supplier fulfillment record
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Operators selling print-on-demand goods (T-shirts, posters,
|
|
9
|
+
* books) bind each storefront SKU to a third-party supplier's
|
|
10
|
+
* blank product + variant + artwork. The supplier (Printful /
|
|
11
|
+
* Printify / Gelato / Lulu / Gooten / Cloudprinter, plus a
|
|
12
|
+
* `custom` escape hatch) prints and ships the physical good;
|
|
13
|
+
* the shop's role is to capture the order and forward the
|
|
14
|
+
* relevant lines to the right supplier.
|
|
15
|
+
*
|
|
16
|
+
* This primitive is the binding store + the forward record. The
|
|
17
|
+
* actual HTTP call to the supplier lives in the operator's
|
|
18
|
+
* worker — each supplier has its own auth shape (API key in a
|
|
19
|
+
* header / OAuth2 / HMAC-signed request) and endpoint surface,
|
|
20
|
+
* and pinning a wire-format choice into the framework would
|
|
21
|
+
* shut out the operator whose preferred supplier isn't yet
|
|
22
|
+
* wired. The worker reads `pendingFulfillments`, calls the
|
|
23
|
+
* supplier, then calls back into this primitive to record the
|
|
24
|
+
* submission / shipment / failure.
|
|
25
|
+
*
|
|
26
|
+
* Composition:
|
|
27
|
+
* - b.uuid.v7 — fulfillment row PKs
|
|
28
|
+
* - b.guardUuid — strict UUID validation on read verbs
|
|
29
|
+
* - b.fsm — pod_fulfillment status FSM (pending →
|
|
30
|
+
* submitted → shipped, or → failed / cancelled)
|
|
31
|
+
* - b.audit — FSM emits transition events under "fsm"
|
|
32
|
+
* - b.safeSql — column allowlist for updateBinding patch
|
|
33
|
+
* - catalog.variants.bySku — bindSku refuses if the SKU doesn't
|
|
34
|
+
* resolve to a real variant
|
|
35
|
+
*
|
|
36
|
+
* The shop never holds inventory for these SKUs. `costForOrder`
|
|
37
|
+
* reports the operator's wholesale cost (used for margin
|
|
38
|
+
* reporting); storefront prices come from `pricing`.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
var bShop;
|
|
42
|
+
function _b() {
|
|
43
|
+
if (!bShop) bShop = require("./index");
|
|
44
|
+
return bShop.framework;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- constants ----------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
var SUPPLIERS = Object.freeze([
|
|
50
|
+
"printful", "printify", "gelato", "lulu",
|
|
51
|
+
"gooten", "cloudprinter", "custom",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
var FULFILLMENT_STATUSES = Object.freeze([
|
|
55
|
+
"pending", "submitted", "shipped", "failed", "cancelled",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// Columns the operator may patch via updateBinding. `sku` is the PK
|
|
59
|
+
// (rebind via unbind + bind, not via patch). `created_at` is
|
|
60
|
+
// immutable; `updated_at` is set by the primitive on every patch.
|
|
61
|
+
var ALLOWED_BINDING_COLUMNS = Object.freeze([
|
|
62
|
+
"supplier",
|
|
63
|
+
"supplier_product_id",
|
|
64
|
+
"supplier_variant_id",
|
|
65
|
+
"artwork_url",
|
|
66
|
+
"position_json",
|
|
67
|
+
"colorway",
|
|
68
|
+
"cost_minor",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
72
|
+
var MAX_URL_LEN = 2048;
|
|
73
|
+
var MAX_ID_LEN = 256;
|
|
74
|
+
var MAX_COLORWAY_LEN = 64;
|
|
75
|
+
var MAX_TRACKING_LEN = 128;
|
|
76
|
+
var MAX_CARRIER_LEN = 64;
|
|
77
|
+
var MAX_ERROR_LEN = 4000;
|
|
78
|
+
var MAX_REASON_LEN = 4000;
|
|
79
|
+
var MAX_LIST_LIMIT = 200;
|
|
80
|
+
|
|
81
|
+
var BINDING_ORDER_KEY = ["sku:asc"];
|
|
82
|
+
var FULFILLMENT_PENDING_ORDER_KEY = ["created_at:asc", "id:asc"];
|
|
83
|
+
|
|
84
|
+
// ---- FSM definition -----------------------------------------------------
|
|
85
|
+
|
|
86
|
+
var _podFsm = null;
|
|
87
|
+
function _getPodFsm() {
|
|
88
|
+
if (_podFsm) return _podFsm;
|
|
89
|
+
// b.fsm emits audit events under the 'fsm' namespace — register
|
|
90
|
+
// idempotently so the audit sink keeps the events instead of
|
|
91
|
+
// dropping them with a noisy warning.
|
|
92
|
+
try { _b().audit.registerNamespace("fsm"); } catch (_e) { /* idempotent; ignore */ }
|
|
93
|
+
_podFsm = _b().fsm.define({
|
|
94
|
+
name: "pod_fulfillment",
|
|
95
|
+
initial: "pending",
|
|
96
|
+
states: {
|
|
97
|
+
pending: {},
|
|
98
|
+
submitted: {},
|
|
99
|
+
shipped: {},
|
|
100
|
+
failed: {},
|
|
101
|
+
cancelled: {},
|
|
102
|
+
},
|
|
103
|
+
transitions: [
|
|
104
|
+
{ from: "pending", to: "submitted", on: "submit" },
|
|
105
|
+
{ from: "submitted", to: "shipped", on: "ship" },
|
|
106
|
+
{ from: "pending", to: "failed", on: "fail" },
|
|
107
|
+
{ from: "submitted", to: "failed", on: "fail" },
|
|
108
|
+
{ from: "pending", to: "cancelled", on: "cancel" },
|
|
109
|
+
{ from: "submitted", to: "cancelled", on: "cancel" },
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
return _podFsm;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---- validators ---------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
function _uuid(s, label) {
|
|
118
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
119
|
+
catch (e) { throw new TypeError("print-on-demand: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
120
|
+
}
|
|
121
|
+
function _sku(s) {
|
|
122
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
123
|
+
throw new TypeError("print-on-demand: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, <= 128 chars)");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function _supplier(s) {
|
|
127
|
+
if (typeof s !== "string" || SUPPLIERS.indexOf(s) === -1) {
|
|
128
|
+
throw new TypeError("print-on-demand: supplier must be one of " + SUPPLIERS.join(", "));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function _shortText(s, label, max) {
|
|
132
|
+
if (typeof s !== "string" || !s.length || s.length > max) {
|
|
133
|
+
throw new TypeError("print-on-demand: " + label + " must be a non-empty string <= " + max + " chars");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function _optShortText(s, label, max) {
|
|
137
|
+
if (s == null) return null;
|
|
138
|
+
if (typeof s !== "string" || s.length > max) {
|
|
139
|
+
throw new TypeError("print-on-demand: " + label + " must be a string <= " + max + " chars when provided");
|
|
140
|
+
}
|
|
141
|
+
return s;
|
|
142
|
+
}
|
|
143
|
+
function _url(s, label) {
|
|
144
|
+
// Shape-tier validation: non-empty, reasonable length, no control
|
|
145
|
+
// bytes. The supplier downloads the artwork — we don't fetch it
|
|
146
|
+
// here, so a full HEAD-and-mime-sniff is out of scope. The
|
|
147
|
+
// operator is responsible for handing in a URL the supplier can
|
|
148
|
+
// actually reach (signed R2 / S3 / etc.).
|
|
149
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_URL_LEN) {
|
|
150
|
+
throw new TypeError("print-on-demand: " + label + " must be a non-empty URL string <= " + MAX_URL_LEN + " chars");
|
|
151
|
+
}
|
|
152
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
153
|
+
throw new TypeError("print-on-demand: " + label + " must not contain control characters");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function _position(p, label) {
|
|
157
|
+
// Position is an opaque JSON blob — different suppliers want
|
|
158
|
+
// different shapes. The documented shape is {x, y, w, h, dpi};
|
|
159
|
+
// each field, when present, must be a finite non-negative number.
|
|
160
|
+
// Operators handing in supplier-specific extras (rotation, anchor)
|
|
161
|
+
// see them round-trip without inspection.
|
|
162
|
+
if (p == null) return {};
|
|
163
|
+
if (typeof p !== "object" || Array.isArray(p)) {
|
|
164
|
+
throw new TypeError("print-on-demand: " + label + " must be an object when provided");
|
|
165
|
+
}
|
|
166
|
+
var keys = ["x", "y", "w", "h", "dpi"];
|
|
167
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
168
|
+
var k = keys[i];
|
|
169
|
+
if (Object.prototype.hasOwnProperty.call(p, k)) {
|
|
170
|
+
var v = p[k];
|
|
171
|
+
if (typeof v !== "number" || !isFinite(v) || v < 0) {
|
|
172
|
+
throw new TypeError("print-on-demand: " + label + "." + k + " must be a non-negative finite number");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return p;
|
|
177
|
+
}
|
|
178
|
+
function _colorway(c) {
|
|
179
|
+
if (c == null) return null;
|
|
180
|
+
if (typeof c !== "string" || !c.length || c.length > MAX_COLORWAY_LEN) {
|
|
181
|
+
throw new TypeError("print-on-demand: colorway must be a non-empty string <= " + MAX_COLORWAY_LEN + " chars when provided");
|
|
182
|
+
}
|
|
183
|
+
return c;
|
|
184
|
+
}
|
|
185
|
+
function _costMinor(n) {
|
|
186
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
187
|
+
throw new TypeError("print-on-demand: cost_minor must be a non-negative integer (minor units)");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function _positiveInt(n, label) {
|
|
191
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
192
|
+
throw new TypeError("print-on-demand: " + label + " must be a positive integer");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function _shipTo(s) {
|
|
196
|
+
if (!s || typeof s !== "object" || Array.isArray(s)) {
|
|
197
|
+
throw new TypeError("print-on-demand: shipping_address must be an object");
|
|
198
|
+
}
|
|
199
|
+
if (typeof s.country !== "string" || !/^[A-Z]{2}$/.test(s.country)) {
|
|
200
|
+
throw new TypeError("print-on-demand: shipping_address.country must be a 2-letter ISO 3166-1 code (uppercase)");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function _limit(n, label) {
|
|
204
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
205
|
+
throw new TypeError("print-on-demand: " + label + " must be an integer in 1..." + MAX_LIST_LIMIT);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function _now() { return Date.now(); }
|
|
210
|
+
|
|
211
|
+
// ---- factory ------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
function create(opts) {
|
|
214
|
+
opts = opts || {};
|
|
215
|
+
if (!opts.catalog || !opts.catalog.variants || typeof opts.catalog.variants.bySku !== "function") {
|
|
216
|
+
throw new TypeError("print-on-demand.create: opts.catalog with variants.bySku(sku) required");
|
|
217
|
+
}
|
|
218
|
+
var catalog = opts.catalog;
|
|
219
|
+
var query = opts.query;
|
|
220
|
+
if (!query) {
|
|
221
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
222
|
+
}
|
|
223
|
+
// Pagination cursors are HMAC-tagged via b.pagination so an
|
|
224
|
+
// operator can't hand-craft one to skip past a binding or replay
|
|
225
|
+
// across orderKey changes. The secret must be stable for the
|
|
226
|
+
// deployment; rotating it invalidates outstanding cursors
|
|
227
|
+
// (acceptable). Tests inject a fixed dev string; production must
|
|
228
|
+
// supply an explicit secret.
|
|
229
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
230
|
+
if (process.env.NODE_ENV === "production") {
|
|
231
|
+
throw new Error("print-on-demand.create: opts.cursorSecret is required in production");
|
|
232
|
+
}
|
|
233
|
+
opts.cursorSecret = "print-on-demand-cursor-secret-dev-only";
|
|
234
|
+
}
|
|
235
|
+
var cursorSecret = opts.cursorSecret;
|
|
236
|
+
|
|
237
|
+
// Validate the line array shape (used by costForOrder +
|
|
238
|
+
// forwardOrder). Returns the normalized lines + a flag for any
|
|
239
|
+
// line whose SKU has no binding (the caller decides what to do).
|
|
240
|
+
function _normalizeLines(lines) {
|
|
241
|
+
if (!Array.isArray(lines) || lines.length === 0) {
|
|
242
|
+
throw new TypeError("print-on-demand: lines must be a non-empty array");
|
|
243
|
+
}
|
|
244
|
+
var out = [];
|
|
245
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
246
|
+
var l = lines[i];
|
|
247
|
+
if (!l || typeof l !== "object") {
|
|
248
|
+
throw new TypeError("print-on-demand: lines[" + i + "] must be an object");
|
|
249
|
+
}
|
|
250
|
+
_sku(l.sku);
|
|
251
|
+
_positiveInt(l.qty, "lines[" + i + "].qty");
|
|
252
|
+
out.push({ sku: l.sku, qty: l.qty });
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function _getBindingRow(sku) {
|
|
258
|
+
var r = await query("SELECT * FROM pod_bindings WHERE sku = ?1", [sku]);
|
|
259
|
+
if (!r.rows.length) return null;
|
|
260
|
+
var row = r.rows[0];
|
|
261
|
+
row.position = row.position_json ? JSON.parse(row.position_json) : {};
|
|
262
|
+
return row;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function _getFulfillmentRow(id) {
|
|
266
|
+
var r = await query("SELECT * FROM pod_fulfillments WHERE id = ?1", [id]);
|
|
267
|
+
if (!r.rows.length) return null;
|
|
268
|
+
var row = r.rows[0];
|
|
269
|
+
row.lines = row.lines_json ? JSON.parse(row.lines_json) : [];
|
|
270
|
+
row.shipping = row.shipping_json ? JSON.parse(row.shipping_json) : {};
|
|
271
|
+
return row;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Replay the FSM against the row's current status and dispatch
|
|
275
|
+
// the event. Surfaces b.fsm refusal as a typed error with the
|
|
276
|
+
// original cause attached so a worker can distinguish "wrong
|
|
277
|
+
// state" from "unknown row" without string-sniffing.
|
|
278
|
+
async function _transitionFulfillment(fulfillmentId, event, patch) {
|
|
279
|
+
var current = await _getFulfillmentRow(fulfillmentId);
|
|
280
|
+
if (!current) {
|
|
281
|
+
throw new TypeError("print-on-demand: fulfillment " + fulfillmentId + " not found");
|
|
282
|
+
}
|
|
283
|
+
var fsm = _getPodFsm();
|
|
284
|
+
var instance = fsm.restore({
|
|
285
|
+
state: current.status,
|
|
286
|
+
history: [],
|
|
287
|
+
context: {},
|
|
288
|
+
});
|
|
289
|
+
try {
|
|
290
|
+
await instance.transition(event, null);
|
|
291
|
+
} catch (e) {
|
|
292
|
+
var err = new Error("print-on-demand: fulfillment transition refused — " + (e && e.message || e));
|
|
293
|
+
err.code = (e && e.code) || "POD_FULFILLMENT_TRANSITION_REFUSED";
|
|
294
|
+
err.cause = e;
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
var ts = _now();
|
|
298
|
+
var sets = ["status = ?1", "updated_at = ?2"];
|
|
299
|
+
var params = [instance.state, ts];
|
|
300
|
+
var p = 3;
|
|
301
|
+
var keys = Object.keys(patch || {});
|
|
302
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
303
|
+
var k = keys[i];
|
|
304
|
+
// Each transition writes a known fixed set of columns; the
|
|
305
|
+
// patch keys come from the primitive itself, never from
|
|
306
|
+
// operator-controlled input. safeSql.assertOneOf is the
|
|
307
|
+
// belt-and-braces gate so a future patch can't slip in an
|
|
308
|
+
// operator-controlled column name.
|
|
309
|
+
_b().safeSql.assertOneOf(k, [
|
|
310
|
+
"supplier_order_id", "tracking_number", "carrier",
|
|
311
|
+
"error", "submitted_at", "shipped_at",
|
|
312
|
+
]);
|
|
313
|
+
sets.push(k + " = ?" + p);
|
|
314
|
+
params.push(patch[k]);
|
|
315
|
+
p += 1;
|
|
316
|
+
}
|
|
317
|
+
params.push(fulfillmentId);
|
|
318
|
+
await query(
|
|
319
|
+
"UPDATE pod_fulfillments SET " + sets.join(", ") + " WHERE id = ?" + p,
|
|
320
|
+
params,
|
|
321
|
+
);
|
|
322
|
+
return await _getFulfillmentRow(fulfillmentId);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
SUPPLIERS: SUPPLIERS,
|
|
327
|
+
FULFILLMENT_STATUSES: FULFILLMENT_STATUSES,
|
|
328
|
+
ALLOWED_BINDING_COLUMNS: ALLOWED_BINDING_COLUMNS,
|
|
329
|
+
|
|
330
|
+
bindSku: async function (input) {
|
|
331
|
+
if (!input || typeof input !== "object") {
|
|
332
|
+
throw new TypeError("print-on-demand.bindSku: input object required");
|
|
333
|
+
}
|
|
334
|
+
_sku(input.sku);
|
|
335
|
+
_supplier(input.supplier);
|
|
336
|
+
_shortText(input.supplier_product_id, "supplier_product_id", MAX_ID_LEN);
|
|
337
|
+
_shortText(input.supplier_variant_id, "supplier_variant_id", MAX_ID_LEN);
|
|
338
|
+
_url(input.artwork_url, "artwork_url");
|
|
339
|
+
var position = _position(input.position, "position");
|
|
340
|
+
var colorway = _colorway(input.colorway);
|
|
341
|
+
var costMinor = input.cost_minor == null ? 0 : input.cost_minor;
|
|
342
|
+
_costMinor(costMinor);
|
|
343
|
+
|
|
344
|
+
// Refuse if the SKU doesn't exist in the catalog. The bindSku
|
|
345
|
+
// verb is the operator's commit point — if the catalog row
|
|
346
|
+
// got renamed / deleted between SKU draft and bind, we want
|
|
347
|
+
// the refusal here rather than a dangling binding that fires
|
|
348
|
+
// on a non-existent storefront SKU.
|
|
349
|
+
var variant = await catalog.variants.bySku(input.sku);
|
|
350
|
+
if (!variant) {
|
|
351
|
+
throw new TypeError("print-on-demand.bindSku: sku " + JSON.stringify(input.sku) + " not found in catalog");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Refuse rebind via bindSku — use unbindSku + bindSku, or
|
|
355
|
+
// updateBinding. This keeps the create path single-purpose
|
|
356
|
+
// and surfaces the "switching supplier" choice explicitly to
|
|
357
|
+
// the operator.
|
|
358
|
+
var existing = await _getBindingRow(input.sku);
|
|
359
|
+
if (existing) {
|
|
360
|
+
throw new TypeError("print-on-demand.bindSku: sku " + JSON.stringify(input.sku) + " already bound — use updateBinding or unbindSku first");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
var ts = _now();
|
|
364
|
+
await query(
|
|
365
|
+
"INSERT INTO pod_bindings (sku, supplier, supplier_product_id, supplier_variant_id, " +
|
|
366
|
+
"artwork_url, position_json, colorway, cost_minor, created_at, updated_at) " +
|
|
367
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)",
|
|
368
|
+
[
|
|
369
|
+
input.sku, input.supplier, input.supplier_product_id,
|
|
370
|
+
input.supplier_variant_id, input.artwork_url,
|
|
371
|
+
JSON.stringify(position), colorway, costMinor, ts,
|
|
372
|
+
],
|
|
373
|
+
);
|
|
374
|
+
return await _getBindingRow(input.sku);
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
unbindSku: async function (sku) {
|
|
378
|
+
_sku(sku);
|
|
379
|
+
var r = await query("DELETE FROM pod_bindings WHERE sku = ?1", [sku]);
|
|
380
|
+
return r.rowCount > 0;
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
getBinding: async function (sku) {
|
|
384
|
+
_sku(sku);
|
|
385
|
+
return await _getBindingRow(sku);
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
// Paginated list of bindings, ordered by sku ASC (alphabetical;
|
|
389
|
+
// the admin-list-of-skus shape). Optional `supplier` filter
|
|
390
|
+
// narrows to one integration. Cursor is HMAC-tagged.
|
|
391
|
+
listBindings: async function (listOpts) {
|
|
392
|
+
listOpts = listOpts || {};
|
|
393
|
+
var supplier = null;
|
|
394
|
+
if (listOpts.supplier != null) {
|
|
395
|
+
_supplier(listOpts.supplier);
|
|
396
|
+
supplier = listOpts.supplier;
|
|
397
|
+
}
|
|
398
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
399
|
+
_limit(limit, "limit");
|
|
400
|
+
|
|
401
|
+
var cursorVals = null;
|
|
402
|
+
if (listOpts.cursor != null) {
|
|
403
|
+
if (typeof listOpts.cursor !== "string") {
|
|
404
|
+
throw new TypeError("print-on-demand.listBindings: cursor must be an opaque string or null");
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
|
|
408
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(BINDING_ORDER_KEY)) {
|
|
409
|
+
throw new TypeError("print-on-demand.listBindings: cursor orderKey mismatch");
|
|
410
|
+
}
|
|
411
|
+
cursorVals = state.vals;
|
|
412
|
+
} catch (e) {
|
|
413
|
+
if (e instanceof TypeError) throw e;
|
|
414
|
+
throw new TypeError("print-on-demand.listBindings: cursor — " + (e && e.message || "malformed"));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
var sql, params;
|
|
419
|
+
if (supplier && cursorVals) {
|
|
420
|
+
sql = "SELECT * FROM pod_bindings WHERE supplier = ?1 AND sku > ?2 " +
|
|
421
|
+
"ORDER BY sku ASC LIMIT ?3";
|
|
422
|
+
params = [supplier, cursorVals[0], limit];
|
|
423
|
+
} else if (supplier) {
|
|
424
|
+
sql = "SELECT * FROM pod_bindings WHERE supplier = ?1 " +
|
|
425
|
+
"ORDER BY sku ASC LIMIT ?2";
|
|
426
|
+
params = [supplier, limit];
|
|
427
|
+
} else if (cursorVals) {
|
|
428
|
+
sql = "SELECT * FROM pod_bindings WHERE sku > ?1 " +
|
|
429
|
+
"ORDER BY sku ASC LIMIT ?2";
|
|
430
|
+
params = [cursorVals[0], limit];
|
|
431
|
+
} else {
|
|
432
|
+
sql = "SELECT * FROM pod_bindings ORDER BY sku ASC LIMIT ?1";
|
|
433
|
+
params = [limit];
|
|
434
|
+
}
|
|
435
|
+
var rows = (await query(sql, params)).rows;
|
|
436
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
437
|
+
rows[i].position = rows[i].position_json ? JSON.parse(rows[i].position_json) : {};
|
|
438
|
+
}
|
|
439
|
+
var last = rows[rows.length - 1];
|
|
440
|
+
var next = null;
|
|
441
|
+
if (last && rows.length === limit) {
|
|
442
|
+
next = _b().pagination.encodeCursor({
|
|
443
|
+
orderKey: BINDING_ORDER_KEY,
|
|
444
|
+
vals: [last.sku],
|
|
445
|
+
forward: true,
|
|
446
|
+
}, cursorSecret);
|
|
447
|
+
}
|
|
448
|
+
return { rows: rows, next_cursor: next };
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
updateBinding: async function (sku, patch) {
|
|
452
|
+
_sku(sku);
|
|
453
|
+
if (!patch || typeof patch !== "object") {
|
|
454
|
+
throw new TypeError("print-on-demand.updateBinding: patch object required");
|
|
455
|
+
}
|
|
456
|
+
var existing = await _getBindingRow(sku);
|
|
457
|
+
if (!existing) {
|
|
458
|
+
throw new TypeError("print-on-demand.updateBinding: sku " + JSON.stringify(sku) + " not bound");
|
|
459
|
+
}
|
|
460
|
+
var sets = [];
|
|
461
|
+
var params = [];
|
|
462
|
+
var i = 1;
|
|
463
|
+
var keys = Object.keys(patch);
|
|
464
|
+
for (var k = 0; k < keys.length; k += 1) {
|
|
465
|
+
var col = keys[k];
|
|
466
|
+
// safeSql.assertOneOf is the structural gate; the
|
|
467
|
+
// per-column validators below enforce the value shape.
|
|
468
|
+
_b().safeSql.assertOneOf(col, ALLOWED_BINDING_COLUMNS);
|
|
469
|
+
var val = patch[col];
|
|
470
|
+
if (col === "supplier") { _supplier(val); }
|
|
471
|
+
else if (col === "supplier_product_id") { _shortText(val, "supplier_product_id", MAX_ID_LEN); }
|
|
472
|
+
else if (col === "supplier_variant_id") { _shortText(val, "supplier_variant_id", MAX_ID_LEN); }
|
|
473
|
+
else if (col === "artwork_url") { _url(val, "artwork_url"); }
|
|
474
|
+
else if (col === "position_json") { val = JSON.stringify(_position(val, "position")); }
|
|
475
|
+
else if (col === "colorway") { val = _colorway(val); }
|
|
476
|
+
else if (col === "cost_minor") { _costMinor(val); }
|
|
477
|
+
sets.push(col + " = ?" + i);
|
|
478
|
+
params.push(val);
|
|
479
|
+
i += 1;
|
|
480
|
+
}
|
|
481
|
+
if (!sets.length) {
|
|
482
|
+
throw new TypeError("print-on-demand.updateBinding: patch must contain at least one allowed column (" + ALLOWED_BINDING_COLUMNS.join(", ") + ")");
|
|
483
|
+
}
|
|
484
|
+
sets.push("updated_at = ?" + i);
|
|
485
|
+
params.push(_now());
|
|
486
|
+
i += 1;
|
|
487
|
+
params.push(sku);
|
|
488
|
+
await query(
|
|
489
|
+
"UPDATE pod_bindings SET " + sets.join(", ") + " WHERE sku = ?" + i,
|
|
490
|
+
params,
|
|
491
|
+
);
|
|
492
|
+
return await _getBindingRow(sku);
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
// Aggregate the operator's wholesale cost across the lines on
|
|
496
|
+
// an order. SKUs without a binding contribute 0 cost and are
|
|
497
|
+
// reported on `unbound_skus` so the caller can surface the
|
|
498
|
+
// gap. Returns `{ total_cost_minor, by_supplier, unbound_skus }`.
|
|
499
|
+
costForOrder: async function (orderLines) {
|
|
500
|
+
var lines = _normalizeLines(orderLines);
|
|
501
|
+
var totalCost = 0;
|
|
502
|
+
var bySupplier = {};
|
|
503
|
+
var unbound = [];
|
|
504
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
505
|
+
var l = lines[i];
|
|
506
|
+
var b = await _getBindingRow(l.sku);
|
|
507
|
+
if (!b) {
|
|
508
|
+
unbound.push(l.sku);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
var lineCost = b.cost_minor * l.qty;
|
|
512
|
+
totalCost += lineCost;
|
|
513
|
+
if (!bySupplier[b.supplier]) {
|
|
514
|
+
bySupplier[b.supplier] = { cost_minor: 0, line_count: 0 };
|
|
515
|
+
}
|
|
516
|
+
bySupplier[b.supplier].cost_minor += lineCost;
|
|
517
|
+
bySupplier[b.supplier].line_count += 1;
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
total_cost_minor: totalCost,
|
|
521
|
+
by_supplier: bySupplier,
|
|
522
|
+
unbound_skus: unbound,
|
|
523
|
+
};
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
// Forward an order's POD lines to the supplier. Writes ONE
|
|
527
|
+
// pod_fulfillments row per supplier present on the order, in
|
|
528
|
+
// `pending` status. Returns the list of row ids the worker
|
|
529
|
+
// should pick up. Lines whose SKU has no binding are returned
|
|
530
|
+
// on `unbound_skus` and do NOT create a fulfillment row (the
|
|
531
|
+
// operator's pre-flight is expected to refuse the checkout in
|
|
532
|
+
// that case; this primitive is defensive in case it slips
|
|
533
|
+
// through).
|
|
534
|
+
forwardOrder: async function (input) {
|
|
535
|
+
if (!input || typeof input !== "object") {
|
|
536
|
+
throw new TypeError("print-on-demand.forwardOrder: input object required");
|
|
537
|
+
}
|
|
538
|
+
_uuid(input.order_id, "order_id");
|
|
539
|
+
_shipTo(input.shipping_address);
|
|
540
|
+
var lines = _normalizeLines(input.lines);
|
|
541
|
+
|
|
542
|
+
// Bucket lines per supplier so each supplier gets one
|
|
543
|
+
// fulfillment row (with all its lines bundled). Unbound SKUs
|
|
544
|
+
// are surfaced separately.
|
|
545
|
+
var perSupplier = {};
|
|
546
|
+
var unbound = [];
|
|
547
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
548
|
+
var l = lines[i];
|
|
549
|
+
var b = await _getBindingRow(l.sku);
|
|
550
|
+
if (!b) { unbound.push(l.sku); continue; }
|
|
551
|
+
if (!perSupplier[b.supplier]) perSupplier[b.supplier] = [];
|
|
552
|
+
perSupplier[b.supplier].push({
|
|
553
|
+
sku: l.sku,
|
|
554
|
+
qty: l.qty,
|
|
555
|
+
supplier_product_id: b.supplier_product_id,
|
|
556
|
+
supplier_variant_id: b.supplier_variant_id,
|
|
557
|
+
artwork_url: b.artwork_url,
|
|
558
|
+
position: b.position,
|
|
559
|
+
colorway: b.colorway,
|
|
560
|
+
cost_minor: b.cost_minor,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
var ts = _now();
|
|
565
|
+
var fulfillmentIds = [];
|
|
566
|
+
var suppliers = Object.keys(perSupplier);
|
|
567
|
+
for (var s = 0; s < suppliers.length; s += 1) {
|
|
568
|
+
var supplier = suppliers[s];
|
|
569
|
+
var fid = _b().uuid.v7();
|
|
570
|
+
await query(
|
|
571
|
+
"INSERT INTO pod_fulfillments (id, order_id, supplier, status, lines_json, " +
|
|
572
|
+
"shipping_json, created_at, updated_at) " +
|
|
573
|
+
"VALUES (?1, ?2, ?3, 'pending', ?4, ?5, ?6, ?6)",
|
|
574
|
+
[
|
|
575
|
+
fid, input.order_id, supplier,
|
|
576
|
+
JSON.stringify(perSupplier[supplier]),
|
|
577
|
+
JSON.stringify(input.shipping_address),
|
|
578
|
+
ts,
|
|
579
|
+
],
|
|
580
|
+
);
|
|
581
|
+
fulfillmentIds.push(fid);
|
|
582
|
+
}
|
|
583
|
+
return {
|
|
584
|
+
fulfillment_ids: fulfillmentIds,
|
|
585
|
+
unbound_skus: unbound,
|
|
586
|
+
};
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
markFulfillmentSubmitted: async function (input) {
|
|
590
|
+
if (!input || typeof input !== "object") {
|
|
591
|
+
throw new TypeError("print-on-demand.markFulfillmentSubmitted: input object required");
|
|
592
|
+
}
|
|
593
|
+
_uuid(input.pod_fulfillment_id, "pod_fulfillment_id");
|
|
594
|
+
_shortText(input.supplier_order_id, "supplier_order_id", MAX_ID_LEN);
|
|
595
|
+
return await _transitionFulfillment(input.pod_fulfillment_id, "submit", {
|
|
596
|
+
supplier_order_id: input.supplier_order_id,
|
|
597
|
+
submitted_at: _now(),
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
markFulfillmentShipped: async function (input) {
|
|
602
|
+
if (!input || typeof input !== "object") {
|
|
603
|
+
throw new TypeError("print-on-demand.markFulfillmentShipped: input object required");
|
|
604
|
+
}
|
|
605
|
+
_uuid(input.pod_fulfillment_id, "pod_fulfillment_id");
|
|
606
|
+
_shortText(input.tracking_number, "tracking_number", MAX_TRACKING_LEN);
|
|
607
|
+
_shortText(input.carrier, "carrier", MAX_CARRIER_LEN);
|
|
608
|
+
return await _transitionFulfillment(input.pod_fulfillment_id, "ship", {
|
|
609
|
+
tracking_number: input.tracking_number,
|
|
610
|
+
carrier: input.carrier,
|
|
611
|
+
shipped_at: _now(),
|
|
612
|
+
});
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
markFulfillmentFailed: async function (input) {
|
|
616
|
+
if (!input || typeof input !== "object") {
|
|
617
|
+
throw new TypeError("print-on-demand.markFulfillmentFailed: input object required");
|
|
618
|
+
}
|
|
619
|
+
_uuid(input.pod_fulfillment_id, "pod_fulfillment_id");
|
|
620
|
+
_shortText(input.error, "error", MAX_ERROR_LEN);
|
|
621
|
+
return await _transitionFulfillment(input.pod_fulfillment_id, "fail", {
|
|
622
|
+
error: input.error,
|
|
623
|
+
});
|
|
624
|
+
},
|
|
625
|
+
|
|
626
|
+
markFulfillmentCancelled: async function (input) {
|
|
627
|
+
if (!input || typeof input !== "object") {
|
|
628
|
+
throw new TypeError("print-on-demand.markFulfillmentCancelled: input object required");
|
|
629
|
+
}
|
|
630
|
+
_uuid(input.pod_fulfillment_id, "pod_fulfillment_id");
|
|
631
|
+
var reason = _optShortText(input.reason, "reason", MAX_REASON_LEN);
|
|
632
|
+
var patch = {};
|
|
633
|
+
// Cancellation reason lands in the `error` column so the
|
|
634
|
+
// operator-facing single field carries the "why is this
|
|
635
|
+
// terminal" answer regardless of whether it terminated via
|
|
636
|
+
// fail or cancel. Operators that want a richer audit trail
|
|
637
|
+
// compose on top.
|
|
638
|
+
if (reason != null) patch.error = "[cancelled] " + reason;
|
|
639
|
+
return await _transitionFulfillment(input.pod_fulfillment_id, "cancel", patch);
|
|
640
|
+
},
|
|
641
|
+
|
|
642
|
+
getFulfillment: async function (id) {
|
|
643
|
+
_uuid(id, "pod_fulfillment_id");
|
|
644
|
+
return await _getFulfillmentRow(id);
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
// All fulfillments for one order, ordered by created_at ASC
|
|
648
|
+
// (the order the supplier rows were written). Operators
|
|
649
|
+
// rendering an order detail page get the supplier-by-supplier
|
|
650
|
+
// breakdown in the order the forward fired.
|
|
651
|
+
fulfillmentsForOrder: async function (orderId) {
|
|
652
|
+
_uuid(orderId, "order_id");
|
|
653
|
+
var rows = (await query(
|
|
654
|
+
"SELECT * FROM pod_fulfillments WHERE order_id = ?1 ORDER BY created_at ASC, id ASC",
|
|
655
|
+
[orderId],
|
|
656
|
+
)).rows;
|
|
657
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
658
|
+
rows[i].lines = rows[i].lines_json ? JSON.parse(rows[i].lines_json) : [];
|
|
659
|
+
rows[i].shipping = rows[i].shipping_json ? JSON.parse(rows[i].shipping_json) : {};
|
|
660
|
+
}
|
|
661
|
+
return rows;
|
|
662
|
+
},
|
|
663
|
+
|
|
664
|
+
// Worker queue drain: pending rows in FIFO order so the
|
|
665
|
+
// longest-waiting fulfillment goes first. Optional supplier
|
|
666
|
+
// filter narrows to one integration's worker.
|
|
667
|
+
pendingFulfillments: async function (listOpts) {
|
|
668
|
+
listOpts = listOpts || {};
|
|
669
|
+
var supplier = null;
|
|
670
|
+
if (listOpts.supplier != null) {
|
|
671
|
+
_supplier(listOpts.supplier);
|
|
672
|
+
supplier = listOpts.supplier;
|
|
673
|
+
}
|
|
674
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
675
|
+
_limit(limit, "limit");
|
|
676
|
+
var sql, params;
|
|
677
|
+
if (supplier) {
|
|
678
|
+
sql = "SELECT * FROM pod_fulfillments WHERE status = 'pending' AND supplier = ?1 " +
|
|
679
|
+
"ORDER BY created_at ASC, id ASC LIMIT ?2";
|
|
680
|
+
params = [supplier, limit];
|
|
681
|
+
} else {
|
|
682
|
+
sql = "SELECT * FROM pod_fulfillments WHERE status = 'pending' " +
|
|
683
|
+
"ORDER BY created_at ASC, id ASC LIMIT ?1";
|
|
684
|
+
params = [limit];
|
|
685
|
+
}
|
|
686
|
+
var rows = (await query(sql, params)).rows;
|
|
687
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
688
|
+
rows[i].lines = rows[i].lines_json ? JSON.parse(rows[i].lines_json) : [];
|
|
689
|
+
rows[i].shipping = rows[i].shipping_json ? JSON.parse(rows[i].shipping_json) : {};
|
|
690
|
+
}
|
|
691
|
+
// Worker queue drain doesn't paginate by cursor — the worker
|
|
692
|
+
// re-runs the query after handling the batch. The
|
|
693
|
+
// FULFILLMENT_PENDING_ORDER_KEY constant documents the
|
|
694
|
+
// ordering for callers that need it (see exports).
|
|
695
|
+
void FULFILLMENT_PENDING_ORDER_KEY;
|
|
696
|
+
return rows;
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
module.exports = {
|
|
702
|
+
create: create,
|
|
703
|
+
SUPPLIERS: SUPPLIERS,
|
|
704
|
+
FULFILLMENT_STATUSES: FULFILLMENT_STATUSES,
|
|
705
|
+
ALLOWED_BINDING_COLUMNS: ALLOWED_BINDING_COLUMNS,
|
|
706
|
+
// Exposed so the test suite can assert the FSM shape without
|
|
707
|
+
// re-deriving the definition from the transitions table.
|
|
708
|
+
_getPodFsm: _getPodFsm,
|
|
709
|
+
};
|