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