@blamejs/blamejs-shop 0.0.61 → 0.0.64

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/lib/vendors.js ADDED
@@ -0,0 +1,797 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.vendors
4
+ * @title Vendors primitive — multi-vendor marketplace registry +
5
+ * per-vendor catalog assignment + payout commission ledger
6
+ *
7
+ * @intro
8
+ * A vendor is a supplier / brand the operator drop-ships from.
9
+ * Distinct from `affiliates`: an affiliate is a referrer paid for
10
+ * driving traffic; a vendor actually ships the goods. The operator
11
+ * registers each vendor with a stable `slug` handle, contact info,
12
+ * a payout rule, and a `commission_split_bps` share of every order
13
+ * the vendor's SKUs appear on. Catalog SKUs are assigned to exactly
14
+ * one vendor via `assignSku`; the join table's UNIQUE on `sku`
15
+ * refuses a second vendor claiming the same SKU until the first
16
+ * `unassignSku`s it.
17
+ *
18
+ * FSM:
19
+ *
20
+ * active <-> paused (pauseVendor / reinstateVendor)
21
+ * active|paused -> archived (archiveVendor — terminal)
22
+ *
23
+ * Commission math:
24
+ *
25
+ * commission_minor = floor(gross_minor * commission_split_bps / 10000)
26
+ *
27
+ * `recordCommission({ vendor_slug, order_id, gross_minor, currency })`
28
+ * writes a pending commission row at order-completion time.
29
+ * `payoutsDue({ as_of })` aggregates outstanding pending commissions
30
+ * per vendor for the finance pipeline to drain. The finance side
31
+ * stamps the payout reference + status transition via the
32
+ * commission row's lifecycle (out of scope for this primitive's
33
+ * v1 surface — that's a downstream operator-driven settlement;
34
+ * the ledger row is the audit grain).
35
+ *
36
+ * Composes:
37
+ * - `b.crypto.namespaceHash` — contact-email hashing under the
38
+ * "vendor-contact-email" namespace so
39
+ * the raw address never lands on disk
40
+ * - `b.guardEmail` — strict-profile validate + sanitize
41
+ * - `b.uuid.v7` — commission row PK (monotonic
42
+ * lexicographic so audit queries sort
43
+ * cleanly without an extra index)
44
+ *
45
+ * Surface:
46
+ * registerVendor({ slug, name, contact_email, contact_phone?,
47
+ * address?, payout_method, payout_address,
48
+ * commission_split_bps, status })
49
+ * getVendor(slug) / vendorBySlug(slug)
50
+ * listVendors({ status? })
51
+ * updateVendor(slug, patch)
52
+ * pauseVendor(slug) / reinstateVendor(slug) / archiveVendor(slug)
53
+ * assignSku({ vendor_slug, sku }) /
54
+ * unassignSku({ vendor_slug, sku })
55
+ * vendorForSku(sku) / skusForVendor(vendor_slug)
56
+ * recordCommission({ vendor_slug, order_id, gross_minor, currency,
57
+ * occurred_at? })
58
+ * payoutsDue({ as_of })
59
+ *
60
+ * Storage: `migrations-d1/0084_vendors.sql` — three tables,
61
+ * `vendors` + `vendor_skus` + `vendor_commissions`. ON DELETE
62
+ * CASCADE drops the join + ledger rows when the vendor row is hard-
63
+ * deleted (the primitive only soft-deletes via `archiveVendor`;
64
+ * hard delete is an operator-side migration concern).
65
+ *
66
+ * @primitive vendors
67
+ * @related b.crypto, b.guardEmail, b.uuid, shop.affiliates
68
+ */
69
+
70
+ var MAX_NAME_LEN = 200;
71
+ var MAX_PAYOUT_ADDRESS_LEN = 512;
72
+ var MAX_ADDRESS_JSON_LEN = 4096;
73
+ var MAX_PHONE_LEN = 32;
74
+ var MAX_SLUG_LEN = 64;
75
+ var MAX_SKU_LEN = 128;
76
+
77
+ var EMAIL_NAMESPACE = "vendor-contact-email";
78
+
79
+ var PAYOUT_METHODS = [
80
+ "paypal", "bank_transfer", "stripe_connect", "gift_card", "store_credit",
81
+ ];
82
+ var VENDOR_STATUSES = ["active", "paused", "archived"];
83
+ var COMMISSION_STATUSES = ["pending", "paid", "voided"];
84
+
85
+ var BPS_DENOMINATOR = 10000;
86
+ var MAX_BPS = 10000;
87
+ var MAX_AMOUNT_MINOR = 100000000000; // 1e11 — sanity cap on a
88
+ // single gross_minor /
89
+ // commission_minor row.
90
+
91
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
92
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
93
+ var PHONE_RE = /^\+?[1-9]\d{1,14}$/;
94
+
95
+ // Control bytes + zero-width / direction-override family. The name +
96
+ // payout_address render in operator dashboards; embedded control /
97
+ // direction-override bytes are a slipping-class for header injection
98
+ // + visual-spoofing attacks downstream. Spelled with \u-escapes so
99
+ // ESLint's no-irregular-whitespace stays happy.
100
+ var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
101
+ var ZERO_WIDTH_RE = new RegExp(
102
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
103
+ );
104
+
105
+ // Mutable columns for `updateVendor(slug, patch)`. Slug is immutable
106
+ // — it's the public handle + the join-table key; mutating it would
107
+ // orphan vendor_skus + vendor_commissions rows. Contact email is
108
+ // immutable — changing it would orphan the email-hash audit trail.
109
+ // Status transitions land via pauseVendor / reinstateVendor /
110
+ // archiveVendor (each one stamps its own timestamp column).
111
+ // The patch keys are the operator-facing names, NOT raw SQL column
112
+ // names — `address` is the hydrated object, mapped to the `address_json`
113
+ // column at the write site. The friendlier surface keeps the operator
114
+ // console + the primitive's hydrated read-shape symmetric.
115
+ var ALLOWED_UPDATE_COLUMNS = Object.freeze([
116
+ "name", "contact_phone", "address", "payout_method",
117
+ "payout_address", "commission_split_bps",
118
+ ]);
119
+
120
+ // Lazy framework handle — matches the pattern used by every other
121
+ // shop primitive; avoids the require cycle that would arise from
122
+ // importing `./index` at module-eval time.
123
+ var bShop;
124
+ function _b() {
125
+ if (!bShop) bShop = require("./index");
126
+ return bShop.framework;
127
+ }
128
+
129
+ // ---- validators ---------------------------------------------------------
130
+
131
+ function _slug(s, label) {
132
+ if (typeof s !== "string" || !s.length) {
133
+ throw new TypeError("vendors: " + label + " must be a non-empty string");
134
+ }
135
+ if (s.length > MAX_SLUG_LEN) {
136
+ throw new TypeError("vendors: " + label + " must be <= " + MAX_SLUG_LEN + " characters");
137
+ }
138
+ if (!SLUG_RE.test(s)) {
139
+ throw new TypeError("vendors: " + label + " must be lowercase alnum + dash, no leading/trailing dash");
140
+ }
141
+ return s;
142
+ }
143
+
144
+ function _sku(s) {
145
+ if (typeof s !== "string" || !s.length) {
146
+ throw new TypeError("vendors: sku must be a non-empty string");
147
+ }
148
+ if (s.length > MAX_SKU_LEN) {
149
+ throw new TypeError("vendors: sku must be <= " + MAX_SKU_LEN + " characters");
150
+ }
151
+ if (!SKU_RE.test(s)) {
152
+ throw new TypeError("vendors: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/");
153
+ }
154
+ return s;
155
+ }
156
+
157
+ function _name(s) {
158
+ if (typeof s !== "string") {
159
+ throw new TypeError("vendors: name must be a string");
160
+ }
161
+ var trimmed = s.trim();
162
+ if (!trimmed.length) {
163
+ throw new TypeError("vendors: name must be non-empty after trim");
164
+ }
165
+ if (s.length > MAX_NAME_LEN) {
166
+ throw new TypeError("vendors: name must be <= " + MAX_NAME_LEN + " characters");
167
+ }
168
+ if (CONTROL_BYTE_STRICT_RE.test(s)) {
169
+ throw new TypeError("vendors: name contains control bytes");
170
+ }
171
+ if (ZERO_WIDTH_RE.test(s)) {
172
+ throw new TypeError("vendors: name contains zero-width / direction-override bytes");
173
+ }
174
+ return s;
175
+ }
176
+
177
+ function _payoutMethod(s) {
178
+ if (typeof s !== "string" || PAYOUT_METHODS.indexOf(s) === -1) {
179
+ throw new TypeError("vendors: payout_method must be one of " + PAYOUT_METHODS.join(", "));
180
+ }
181
+ return s;
182
+ }
183
+
184
+ function _payoutAddress(s) {
185
+ if (typeof s !== "string") {
186
+ throw new TypeError("vendors: payout_address must be a string");
187
+ }
188
+ var trimmed = s.trim();
189
+ if (!trimmed.length) {
190
+ throw new TypeError("vendors: payout_address must be non-empty after trim");
191
+ }
192
+ if (s.length > MAX_PAYOUT_ADDRESS_LEN) {
193
+ throw new TypeError("vendors: payout_address must be <= " + MAX_PAYOUT_ADDRESS_LEN + " characters");
194
+ }
195
+ if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
196
+ throw new TypeError("vendors: payout_address contains control / zero-width bytes");
197
+ }
198
+ return s;
199
+ }
200
+
201
+ function _phone(value) {
202
+ if (value == null || value === "") return null;
203
+ if (typeof value !== "string") {
204
+ throw new TypeError("vendors: contact_phone must be a string or null");
205
+ }
206
+ var trimmed = value.trim();
207
+ if (!trimmed.length) return null;
208
+ if (trimmed.length > MAX_PHONE_LEN) {
209
+ throw new TypeError("vendors: contact_phone must be <= " + MAX_PHONE_LEN + " characters");
210
+ }
211
+ if (!PHONE_RE.test(trimmed)) {
212
+ throw new TypeError("vendors: contact_phone must match E.164-ish shape (^\\+?[1-9]\\d{1,14}$)");
213
+ }
214
+ return trimmed;
215
+ }
216
+
217
+ function _addressJson(value) {
218
+ if (value == null) return null;
219
+ if (typeof value !== "object" || Array.isArray(value)) {
220
+ throw new TypeError("vendors: address must be a plain object or null");
221
+ }
222
+ var encoded;
223
+ try { encoded = JSON.stringify(value); }
224
+ catch (_e) {
225
+ throw new TypeError("vendors: address must be JSON-serialisable");
226
+ }
227
+ if (encoded.length > MAX_ADDRESS_JSON_LEN) {
228
+ throw new TypeError("vendors: address JSON must be <= " + MAX_ADDRESS_JSON_LEN + " characters serialised");
229
+ }
230
+ if (CONTROL_BYTE_STRICT_RE.test(encoded) || ZERO_WIDTH_RE.test(encoded)) {
231
+ throw new TypeError("vendors: address contains control / zero-width bytes");
232
+ }
233
+ return encoded;
234
+ }
235
+
236
+ function _commissionSplit(n) {
237
+ if (!Number.isInteger(n) || n < 0 || n > MAX_BPS) {
238
+ throw new TypeError("vendors: commission_split_bps must be an integer 0.." + MAX_BPS);
239
+ }
240
+ return n;
241
+ }
242
+
243
+ function _vendorStatus(s) {
244
+ if (typeof s !== "string" || VENDOR_STATUSES.indexOf(s) === -1) {
245
+ throw new TypeError("vendors: status must be one of " + VENDOR_STATUSES.join(", "));
246
+ }
247
+ return s;
248
+ }
249
+
250
+ function _grossMinor(n) {
251
+ if (!Number.isInteger(n) || n < 0) {
252
+ throw new TypeError("vendors: gross_minor must be a non-negative integer");
253
+ }
254
+ if (n > MAX_AMOUNT_MINOR) {
255
+ throw new TypeError("vendors: gross_minor must be <= " + MAX_AMOUNT_MINOR);
256
+ }
257
+ return n;
258
+ }
259
+
260
+ function _currency(s) {
261
+ if (typeof s !== "string" || !/^[A-Z]{3}$/.test(s)) {
262
+ throw new TypeError("vendors: currency must be a 3-letter uppercase ISO-4217 code");
263
+ }
264
+ return s;
265
+ }
266
+
267
+ function _orderId(s) {
268
+ if (typeof s !== "string" || !s.length) {
269
+ throw new TypeError("vendors: order_id must be a non-empty string");
270
+ }
271
+ if (s.length > 256) {
272
+ throw new TypeError("vendors: order_id must be <= 256 characters");
273
+ }
274
+ if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
275
+ throw new TypeError("vendors: order_id contains control / zero-width bytes");
276
+ }
277
+ return s;
278
+ }
279
+
280
+ function _normalizeEmail(input) {
281
+ if (typeof input !== "string" || !input.length) {
282
+ throw new TypeError("vendors: contact_email must be a non-empty string");
283
+ }
284
+ var guardEmail = _b().guardEmail;
285
+ var report;
286
+ try {
287
+ report = guardEmail.validate(input, { profile: "strict" });
288
+ } catch (e) {
289
+ throw new TypeError("vendors: contact_email — " + (e && e.message || "invalid email"));
290
+ }
291
+ if (!report || report.ok === false) {
292
+ var first = (report && report.issues && report.issues[0]) || {};
293
+ throw new TypeError("vendors: contact_email — " + (first.snippet || first.ruleId || "refused at strict profile"));
294
+ }
295
+ var canonical;
296
+ try {
297
+ canonical = guardEmail.sanitize(input, { profile: "strict" });
298
+ } catch (e2) {
299
+ throw new TypeError("vendors: contact_email — " + (e2 && e2.message || "refused"));
300
+ }
301
+ return canonical.trim().toLowerCase();
302
+ }
303
+
304
+ function _now() { return Date.now(); }
305
+
306
+ // ---- commission math ----------------------------------------------------
307
+
308
+ function _computeCommissionMinor(grossMinor, splitBps) {
309
+ // Floor division on integers keeps the rounding consistent across
310
+ // platforms; the operator absorbs the sub-cent dust (alternative is
311
+ // rounding up, which lets a vendor over-claim by splitting orders).
312
+ return Math.floor((grossMinor * splitBps) / BPS_DENOMINATOR);
313
+ }
314
+
315
+ // ---- row hydration ------------------------------------------------------
316
+
317
+ function _hydrateVendor(row) {
318
+ if (!row) return null;
319
+ return {
320
+ slug: row.slug,
321
+ name: row.name,
322
+ contact_email_hash: row.contact_email_hash,
323
+ contact_email_normalised: row.contact_email_normalised,
324
+ contact_phone: row.contact_phone == null ? null : row.contact_phone,
325
+ address: row.address_json == null ? null : JSON.parse(row.address_json),
326
+ payout_method: row.payout_method,
327
+ payout_address: row.payout_address,
328
+ commission_split_bps: Number(row.commission_split_bps),
329
+ status: row.status,
330
+ paused_at: row.paused_at == null ? null : Number(row.paused_at),
331
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
332
+ created_at: Number(row.created_at),
333
+ updated_at: Number(row.updated_at),
334
+ };
335
+ }
336
+
337
+ // ---- factory ------------------------------------------------------------
338
+
339
+ function create(opts) {
340
+ opts = opts || {};
341
+ var query = opts.query;
342
+ if (!query) {
343
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
344
+ }
345
+
346
+ function _hashEmail(canonicalEmail) {
347
+ return _b().crypto.namespaceHash(EMAIL_NAMESPACE, canonicalEmail);
348
+ }
349
+
350
+ async function _getVendorRaw(slug) {
351
+ var r = await query("SELECT * FROM vendors WHERE slug = ?1", [slug]);
352
+ return r.rows[0] || null;
353
+ }
354
+
355
+ async function _getCommissionRaw(id) {
356
+ var r = await query("SELECT * FROM vendor_commissions WHERE id = ?1", [id]);
357
+ return r.rows[0] || null;
358
+ }
359
+
360
+ return {
361
+ EMAIL_NAMESPACE: EMAIL_NAMESPACE,
362
+ PAYOUT_METHODS: PAYOUT_METHODS.slice(),
363
+ VENDOR_STATUSES: VENDOR_STATUSES.slice(),
364
+ COMMISSION_STATUSES: COMMISSION_STATUSES.slice(),
365
+ MAX_NAME_LEN: MAX_NAME_LEN,
366
+ MAX_PAYOUT_ADDRESS_LEN: MAX_PAYOUT_ADDRESS_LEN,
367
+ MAX_ADDRESS_JSON_LEN: MAX_ADDRESS_JSON_LEN,
368
+ MAX_PHONE_LEN: MAX_PHONE_LEN,
369
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
370
+ MAX_SKU_LEN: MAX_SKU_LEN,
371
+ BPS_DENOMINATOR: BPS_DENOMINATOR,
372
+ MAX_BPS: MAX_BPS,
373
+
374
+ registerVendor: async function (input) {
375
+ if (!input || typeof input !== "object") {
376
+ throw new TypeError("vendors.registerVendor: input object required");
377
+ }
378
+ var slug = _slug(input.slug, "slug");
379
+ var name = _name(input.name);
380
+ var emailNorm = _normalizeEmail(input.contact_email);
381
+ var emailHash = _hashEmail(emailNorm);
382
+ var phone = _phone(input.contact_phone);
383
+ var addressJson = _addressJson(input.address);
384
+ var payoutMethod = _payoutMethod(input.payout_method);
385
+ var payoutAddr = _payoutAddress(input.payout_address);
386
+ var splitBps = _commissionSplit(input.commission_split_bps);
387
+ var status = _vendorStatus(input.status);
388
+ if (status === "archived") {
389
+ // Refuse opening a vendor in the terminal state — archive
390
+ // is the operator-driven soft-delete, never a registration
391
+ // outcome. Operators register active|paused and transition
392
+ // later via archiveVendor.
393
+ throw new TypeError("vendors.registerVendor: status must be 'active' or 'paused' at registration");
394
+ }
395
+
396
+ // Slug uniqueness — PRIMARY KEY enforces it at SQL too, but
397
+ // surfacing the refusal as a typed error is friendlier than
398
+ // letting SQLITE_CONSTRAINT leak.
399
+ var existing = await _getVendorRaw(slug);
400
+ if (existing) {
401
+ var dupe = new Error("vendors.registerVendor: slug " + JSON.stringify(slug) + " already registered");
402
+ dupe.code = "VENDOR_SLUG_TAKEN";
403
+ throw dupe;
404
+ }
405
+
406
+ var ts = _now();
407
+ await query(
408
+ "INSERT INTO vendors " +
409
+ "(slug, name, contact_email_hash, contact_email_normalised, " +
410
+ " contact_phone, address_json, payout_method, payout_address, " +
411
+ " commission_split_bps, status, paused_at, archived_at, " +
412
+ " created_at, updated_at) " +
413
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL, ?12, ?12)",
414
+ [
415
+ slug, name, emailHash, emailNorm, phone, addressJson,
416
+ payoutMethod, payoutAddr, splitBps, status,
417
+ status === "paused" ? ts : null, ts,
418
+ ],
419
+ );
420
+ return _hydrateVendor(await _getVendorRaw(slug));
421
+ },
422
+
423
+ getVendor: async function (slug) {
424
+ _slug(slug, "slug");
425
+ return _hydrateVendor(await _getVendorRaw(slug));
426
+ },
427
+
428
+ // Alias — operator vocabulary keeps both names; the slug IS the
429
+ // identifier so there's no other lookup path to disambiguate
430
+ // against. `getVendor` and `vendorBySlug` resolve identically.
431
+ vendorBySlug: async function (slug) {
432
+ _slug(slug, "slug");
433
+ return _hydrateVendor(await _getVendorRaw(slug));
434
+ },
435
+
436
+ listVendors: async function (listOpts) {
437
+ listOpts = listOpts || {};
438
+ var sql, params;
439
+ if (listOpts.status != null) {
440
+ var status = _vendorStatus(listOpts.status);
441
+ sql = "SELECT * FROM vendors WHERE status = ?1 ORDER BY created_at DESC, slug ASC";
442
+ params = [status];
443
+ } else {
444
+ sql = "SELECT * FROM vendors ORDER BY created_at DESC, slug ASC";
445
+ params = [];
446
+ }
447
+ var r = await query(sql, params);
448
+ return r.rows.map(_hydrateVendor);
449
+ },
450
+
451
+ // Patch-style update — only ALLOWED_UPDATE_COLUMNS can be set.
452
+ // Slug + contact_email are immutable post-registration (changing
453
+ // the slug would orphan vendor_skus + vendor_commissions; changing
454
+ // the email would orphan the email-hash audit trail). Archived
455
+ // vendors refuse mutation — the terminal state preserves the row
456
+ // as-shipped for audit; operators register a successor.
457
+ updateVendor: async function (slug, patch) {
458
+ _slug(slug, "slug");
459
+ if (!patch || typeof patch !== "object") {
460
+ throw new TypeError("vendors.updateVendor: patch object required");
461
+ }
462
+ var keys = Object.keys(patch);
463
+ if (!keys.length) {
464
+ throw new TypeError("vendors.updateVendor: patch must contain at least one column");
465
+ }
466
+ for (var i = 0; i < keys.length; i += 1) {
467
+ if (ALLOWED_UPDATE_COLUMNS.indexOf(keys[i]) === -1) {
468
+ throw new TypeError("vendors.updateVendor: column '" + keys[i] + "' not updatable");
469
+ }
470
+ }
471
+
472
+ var current = await _getVendorRaw(slug);
473
+ if (!current) return null;
474
+ if (current.status === "archived") {
475
+ var refused = new Error("vendors.updateVendor: refused — vendor is archived");
476
+ refused.code = "VENDOR_ARCHIVED";
477
+ throw refused;
478
+ }
479
+
480
+ var sets = [];
481
+ var params = [];
482
+ var idx = 1;
483
+ function _set(col, val) {
484
+ sets.push(col + " = ?" + idx);
485
+ params.push(val);
486
+ idx += 1;
487
+ }
488
+ if (patch.name != null) _set("name", _name(patch.name));
489
+ if (Object.prototype.hasOwnProperty.call(patch, "contact_phone")) _set("contact_phone", _phone(patch.contact_phone));
490
+ if (Object.prototype.hasOwnProperty.call(patch, "address")) _set("address_json", _addressJson(patch.address));
491
+ if (patch.payout_method != null) _set("payout_method", _payoutMethod(patch.payout_method));
492
+ if (patch.payout_address != null) _set("payout_address", _payoutAddress(patch.payout_address));
493
+ if (patch.commission_split_bps != null) _set("commission_split_bps", _commissionSplit(patch.commission_split_bps));
494
+
495
+ var ts = _now();
496
+ _set("updated_at", ts);
497
+ params.push(slug);
498
+ var sql = "UPDATE vendors SET " + sets.join(", ") + " WHERE slug = ?" + idx;
499
+ await query(sql, params);
500
+ return _hydrateVendor(await _getVendorRaw(slug));
501
+ },
502
+
503
+ // FSM: active -> paused. Refuses if already paused (idempotency
504
+ // hazard — second call would overwrite paused_at and lose the
505
+ // original pause timestamp) or archived (terminal).
506
+ pauseVendor: async function (slug) {
507
+ _slug(slug, "slug");
508
+ var current = await _getVendorRaw(slug);
509
+ if (!current) return null;
510
+ if (current.status === "paused") {
511
+ var already = new Error("vendors.pauseVendor: refused — vendor is already paused");
512
+ already.code = "VENDOR_TRANSITION_REFUSED";
513
+ throw already;
514
+ }
515
+ if (current.status === "archived") {
516
+ var arch = new Error("vendors.pauseVendor: refused — vendor is archived");
517
+ arch.code = "VENDOR_TRANSITION_REFUSED";
518
+ throw arch;
519
+ }
520
+ var ts = _now();
521
+ await query(
522
+ "UPDATE vendors SET status = 'paused', paused_at = ?1, updated_at = ?1 WHERE slug = ?2",
523
+ [ts, slug],
524
+ );
525
+ return _hydrateVendor(await _getVendorRaw(slug));
526
+ },
527
+
528
+ // FSM: paused -> active. Refuses if active (no-op) or archived
529
+ // (terminal). Clears paused_at so the audit trail records the
530
+ // most recent pause window only — operator can rebuild full
531
+ // history from the operator-audit-log primitive if needed.
532
+ reinstateVendor: async function (slug) {
533
+ _slug(slug, "slug");
534
+ var current = await _getVendorRaw(slug);
535
+ if (!current) return null;
536
+ if (current.status === "active") {
537
+ var already = new Error("vendors.reinstateVendor: refused — vendor is already active");
538
+ already.code = "VENDOR_TRANSITION_REFUSED";
539
+ throw already;
540
+ }
541
+ if (current.status === "archived") {
542
+ var arch = new Error("vendors.reinstateVendor: refused — vendor is archived");
543
+ arch.code = "VENDOR_TRANSITION_REFUSED";
544
+ throw arch;
545
+ }
546
+ var ts = _now();
547
+ await query(
548
+ "UPDATE vendors SET status = 'active', paused_at = NULL, updated_at = ?1 WHERE slug = ?2",
549
+ [ts, slug],
550
+ );
551
+ return _hydrateVendor(await _getVendorRaw(slug));
552
+ },
553
+
554
+ // FSM: active|paused -> archived (terminal). Refuses if already
555
+ // archived. Historical vendor_skus + vendor_commissions rows are
556
+ // preserved — the FK constraint cascades only on hard-delete,
557
+ // which this primitive never performs.
558
+ archiveVendor: async function (slug) {
559
+ _slug(slug, "slug");
560
+ var current = await _getVendorRaw(slug);
561
+ if (!current) return null;
562
+ if (current.status === "archived") {
563
+ var already = new Error("vendors.archiveVendor: refused — vendor is already archived");
564
+ already.code = "VENDOR_TRANSITION_REFUSED";
565
+ throw already;
566
+ }
567
+ var ts = _now();
568
+ await query(
569
+ "UPDATE vendors SET status = 'archived', archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
570
+ [ts, slug],
571
+ );
572
+ return _hydrateVendor(await _getVendorRaw(slug));
573
+ },
574
+
575
+ // Assign a SKU to a vendor. The join table's UNIQUE on `sku`
576
+ // enforces single-vendor ownership — a second `assignSku` for the
577
+ // same SKU (whether to the same or a different vendor) refuses.
578
+ // Operators must `unassignSku` first. Archived vendors refuse new
579
+ // assignments; paused vendors are allowed (a pause is a temporary
580
+ // halt, not an inventory unhook — the operator may be onboarding
581
+ // SKUs while KYC clears).
582
+ assignSku: async function (input) {
583
+ if (!input || typeof input !== "object") {
584
+ throw new TypeError("vendors.assignSku: input object required");
585
+ }
586
+ var vendorSlug = _slug(input.vendor_slug, "vendor_slug");
587
+ var sku = _sku(input.sku);
588
+
589
+ var vendor = await _getVendorRaw(vendorSlug);
590
+ if (!vendor) {
591
+ var miss = new Error("vendors.assignSku: vendor not found");
592
+ miss.code = "VENDOR_NOT_FOUND";
593
+ throw miss;
594
+ }
595
+ if (vendor.status === "archived") {
596
+ var arch = new Error("vendors.assignSku: refused — vendor is archived");
597
+ arch.code = "VENDOR_ARCHIVED";
598
+ throw arch;
599
+ }
600
+
601
+ // Surface the UNIQUE refusal as a typed error rather than
602
+ // leaking SQLITE_CONSTRAINT. The check-then-insert is racy on
603
+ // a real D1; the SQL UNIQUE is the actual enforcement and the
604
+ // caller catches both code paths via the typed error below.
605
+ var existing = await query(
606
+ "SELECT vendor_slug FROM vendor_skus WHERE sku = ?1", [sku],
607
+ );
608
+ if (existing.rows.length) {
609
+ var taken = new Error(
610
+ "vendors.assignSku: sku " + JSON.stringify(sku) +
611
+ " is already assigned to vendor " + JSON.stringify(existing.rows[0].vendor_slug)
612
+ );
613
+ taken.code = "VENDOR_SKU_TAKEN";
614
+ throw taken;
615
+ }
616
+
617
+ var ts = _now();
618
+ try {
619
+ await query(
620
+ "INSERT INTO vendor_skus (vendor_slug, sku, assigned_at) VALUES (?1, ?2, ?3)",
621
+ [vendorSlug, sku, ts],
622
+ );
623
+ } catch (e) {
624
+ if (e && e.message && e.message.indexOf("UNIQUE") !== -1) {
625
+ var raced = new Error("vendors.assignSku: sku " + JSON.stringify(sku) + " is already assigned");
626
+ raced.code = "VENDOR_SKU_TAKEN";
627
+ throw raced;
628
+ }
629
+ throw e;
630
+ }
631
+ return {
632
+ vendor_slug: vendorSlug,
633
+ sku: sku,
634
+ assigned_at: ts,
635
+ };
636
+ },
637
+
638
+ // Remove the (vendor, sku) assignment. Returns true when a row
639
+ // was removed; false when no such assignment existed (idempotent
640
+ // — unassigning twice is a no-op rather than a refusal). The
641
+ // vendor_slug guard ensures an operator can't unassign someone
642
+ // else's SKU by SKU alone — the call must match the owning
643
+ // vendor.
644
+ unassignSku: async function (input) {
645
+ if (!input || typeof input !== "object") {
646
+ throw new TypeError("vendors.unassignSku: input object required");
647
+ }
648
+ var vendorSlug = _slug(input.vendor_slug, "vendor_slug");
649
+ var sku = _sku(input.sku);
650
+ var r = await query(
651
+ "DELETE FROM vendor_skus WHERE vendor_slug = ?1 AND sku = ?2",
652
+ [vendorSlug, sku],
653
+ );
654
+ return Number(r.rowCount || 0) > 0;
655
+ },
656
+
657
+ // Lookup which vendor (if any) owns a given SKU. Returns the
658
+ // hydrated vendor row, or null when the SKU is unassigned. The
659
+ // join hits the UNIQUE index on `sku` so this is O(log n).
660
+ vendorForSku: async function (sku) {
661
+ _sku(sku);
662
+ var r = await query(
663
+ "SELECT v.* FROM vendor_skus s JOIN vendors v ON v.slug = s.vendor_slug WHERE s.sku = ?1",
664
+ [sku],
665
+ );
666
+ return _hydrateVendor(r.rows[0]);
667
+ },
668
+
669
+ // List every SKU assigned to a vendor, newest assignment first.
670
+ // Returns the raw `{ sku, assigned_at }` rows rather than
671
+ // joining the catalog — the operator console will hydrate
672
+ // product titles on its own page.
673
+ skusForVendor: async function (vendorSlug) {
674
+ _slug(vendorSlug, "vendor_slug");
675
+ var r = await query(
676
+ "SELECT sku, assigned_at FROM vendor_skus WHERE vendor_slug = ?1 " +
677
+ "ORDER BY assigned_at DESC, sku ASC",
678
+ [vendorSlug],
679
+ );
680
+ return r.rows.map(function (row) {
681
+ return { sku: row.sku, assigned_at: Number(row.assigned_at) };
682
+ });
683
+ },
684
+
685
+ // Write a commission row at order-completion time. The
686
+ // commission_minor is computed at write time from the vendor's
687
+ // *current* commission_split_bps; the row preserves the resolved
688
+ // amount so a later operator-side rate change doesn't
689
+ // retroactively alter historical payouts. Idempotent on
690
+ // (vendor_slug, order_id) — a second call returns the existing
691
+ // row's snapshot. Archived vendors refuse new commission writes
692
+ // — the historical ledger is closed.
693
+ recordCommission: async function (input) {
694
+ if (!input || typeof input !== "object") {
695
+ throw new TypeError("vendors.recordCommission: input object required");
696
+ }
697
+ var vendorSlug = _slug(input.vendor_slug, "vendor_slug");
698
+ var orderId = _orderId(input.order_id);
699
+ var grossMinor = _grossMinor(input.gross_minor);
700
+ var currency = _currency(input.currency);
701
+ var occurredAt;
702
+ if (input.occurred_at != null) {
703
+ if (!Number.isInteger(input.occurred_at) || input.occurred_at < 0) {
704
+ throw new TypeError("vendors.recordCommission: occurred_at must be a non-negative integer (ms epoch)");
705
+ }
706
+ occurredAt = input.occurred_at;
707
+ } else {
708
+ occurredAt = _now();
709
+ }
710
+
711
+ var vendor = await _getVendorRaw(vendorSlug);
712
+ if (!vendor) {
713
+ var miss = new Error("vendors.recordCommission: vendor not found");
714
+ miss.code = "VENDOR_NOT_FOUND";
715
+ throw miss;
716
+ }
717
+ if (vendor.status === "archived") {
718
+ var arch = new Error("vendors.recordCommission: refused — vendor is archived");
719
+ arch.code = "VENDOR_ARCHIVED";
720
+ throw arch;
721
+ }
722
+
723
+ // Idempotency on (vendor_slug, order_id) — surfaced via the
724
+ // UNIQUE index. A retried checkout finalisation lands on the
725
+ // same row.
726
+ var dupe = await query(
727
+ "SELECT * FROM vendor_commissions WHERE vendor_slug = ?1 AND order_id = ?2",
728
+ [vendorSlug, orderId],
729
+ );
730
+ if (dupe.rows.length) {
731
+ return dupe.rows[0];
732
+ }
733
+
734
+ var commissionMinor = _computeCommissionMinor(
735
+ grossMinor, Number(vendor.commission_split_bps)
736
+ );
737
+
738
+ var id = _b().uuid.v7();
739
+ await query(
740
+ "INSERT INTO vendor_commissions " +
741
+ "(id, vendor_slug, order_id, gross_minor, commission_minor, " +
742
+ " currency, status, occurred_at, paid_at, payout_reference) " +
743
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, NULL, NULL)",
744
+ [id, vendorSlug, orderId, grossMinor, commissionMinor, currency, occurredAt],
745
+ );
746
+ return await _getCommissionRaw(id);
747
+ },
748
+
749
+ // Operator finance dashboard — which vendors are owed as of
750
+ // `as_of`. Returns one row per vendor whose sum-of-pending
751
+ // commissions (occurred_at <= as_of) is > 0, with `pending_minor`
752
+ // total + commission count. Sorted by pending_minor DESC so the
753
+ // largest payout lands at the top. Archived vendors still appear
754
+ // if they have outstanding pending rows — the operator must drain
755
+ // the ledger before considering the relationship fully closed.
756
+ payoutsDue: async function (input) {
757
+ if (!input || typeof input !== "object") {
758
+ throw new TypeError("vendors.payoutsDue: input object required");
759
+ }
760
+ if (!Number.isInteger(input.as_of) || input.as_of < 0) {
761
+ throw new TypeError("vendors.payoutsDue: as_of must be a non-negative integer (ms epoch)");
762
+ }
763
+ var r = await query(
764
+ "SELECT vendor_slug, SUM(commission_minor) AS pending_minor, COUNT(*) AS commission_count " +
765
+ "FROM vendor_commissions " +
766
+ "WHERE status = 'pending' AND occurred_at <= ?1 " +
767
+ "GROUP BY vendor_slug " +
768
+ "HAVING SUM(commission_minor) > 0 " +
769
+ "ORDER BY pending_minor DESC, vendor_slug ASC",
770
+ [input.as_of],
771
+ );
772
+ return r.rows.map(function (row) {
773
+ return {
774
+ vendor_slug: row.vendor_slug,
775
+ pending_minor: Number(row.pending_minor || 0),
776
+ commission_count: Number(row.commission_count || 0),
777
+ };
778
+ });
779
+ },
780
+ };
781
+ }
782
+
783
+ module.exports = {
784
+ create: create,
785
+ EMAIL_NAMESPACE: EMAIL_NAMESPACE,
786
+ PAYOUT_METHODS: PAYOUT_METHODS.slice(),
787
+ VENDOR_STATUSES: VENDOR_STATUSES.slice(),
788
+ COMMISSION_STATUSES: COMMISSION_STATUSES.slice(),
789
+ MAX_NAME_LEN: MAX_NAME_LEN,
790
+ MAX_PAYOUT_ADDRESS_LEN: MAX_PAYOUT_ADDRESS_LEN,
791
+ MAX_ADDRESS_JSON_LEN: MAX_ADDRESS_JSON_LEN,
792
+ MAX_PHONE_LEN: MAX_PHONE_LEN,
793
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
794
+ MAX_SKU_LEN: MAX_SKU_LEN,
795
+ BPS_DENOMINATOR: BPS_DENOMINATOR,
796
+ MAX_BPS: MAX_BPS,
797
+ };