@base44/app-plugin-commerce 0.1.4 → 0.1.6

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,717 @@
1
+ /**
2
+ * commerce/seed-store — catalog seeding pipeline.
3
+ *
4
+ * One pipeline serves two callers: the template's demo catalog
5
+ * (`sampleCatalog()`, over sample-data.ts) and a caller-supplied
6
+ * `products`/`coupons`/`tax_rates` payload (`normalizeCatalogPayload()`).
7
+ * Both normalize to the same CatalogSpec, and `seedCatalog()` writes it:
8
+ * get-or-create taxonomy (never duplicated, never rolled back when it
9
+ * pre-existed), create products and their variations, roll each variant
10
+ * parent's price up, then apply term counts in one deferred pass.
11
+ *
12
+ * The caller payload is agent-ergonomic — everything is referenced by display
13
+ * name, and variations are generated from `attributes` when not given:
14
+ *
15
+ * { name, regular_price, sku?, stock_quantity?, images?, categories?, tags?,
16
+ * attributes?: [{ name: "Size", options: ["S","M"] }] | { Size: ["S","M"] },
17
+ * default_options?: { Size: "M" },
18
+ * variations?: [{ options: { Size: "S" }, regular_price?, stock_quantity?, sku?, image? }] }
19
+ */
20
+ import { HttpError } from "../../../shared/commerce/auth.ts";
21
+ import { getSettings } from "../../../shared/commerce/settings.ts";
22
+ import { scanAll } from "../../../shared/commerce/scan.ts";
23
+ import {
24
+ assertUniqueSku,
25
+ bumpCount,
26
+ derivePricing,
27
+ deriveStock,
28
+ ensureUniqueSlug,
29
+ rollUpParent,
30
+ slugify,
31
+ } from "../../../shared/commerce/catalog.ts";
32
+ import {
33
+ SAMPLE_ATTRIBUTE_TERMS,
34
+ SAMPLE_ATTRIBUTES,
35
+ SAMPLE_CATEGORIES,
36
+ SAMPLE_COUPONS,
37
+ SAMPLE_PRODUCTS,
38
+ SAMPLE_TAX_RATES,
39
+ } from "./sample-data.ts";
40
+
41
+ // ── shapes ───────────────────────────────────────────────────────────────────
42
+
43
+ interface NormalizedVariation {
44
+ options: Record<string, string>; // attribute code → option (declared casing)
45
+ fields: Record<string, any>;
46
+ }
47
+
48
+ interface NormalizedProduct {
49
+ fields: Record<string, any>; // entity passthrough, images normalized
50
+ categorySlugs: string[];
51
+ tagNames: string[];
52
+ attributes: Array<{ code: string; options: string[] }>;
53
+ defaultOptions: Record<string, string>;
54
+ variations: NormalizedVariation[];
55
+ }
56
+
57
+ export interface CatalogSpec {
58
+ categories: Array<Record<string, any>>; // full definition records, keyed by slug
59
+ attributes: Array<{ name: string; code: string; order: number }>;
60
+ termsByCode: Record<string, Array<{ name: string; order: number }>>;
61
+ products: NormalizedProduct[];
62
+ coupons: any[];
63
+ tax_rates: any[];
64
+ }
65
+
66
+ /** 400-payload failure carrying every problem at once. Caught in entry.ts. */
67
+ export class CatalogPayloadError extends Error {
68
+ errors: Array<{ path: string; error: string }>;
69
+ constructor(errors: Array<{ path: string; error: string }>) {
70
+ super("Invalid catalog payload — nothing was written.");
71
+ this.errors = errors;
72
+ }
73
+ }
74
+
75
+ // ── limits & allow-lists ─────────────────────────────────────────────────────
76
+
77
+ const MAX_PRODUCTS = 100;
78
+ const MAX_COUPONS = 50;
79
+ const MAX_TAX_RATES = 50;
80
+ const MAX_VARIATIONS_PER_PRODUCT = 50; // also the cartesian-explosion guard
81
+ const MAX_TOTAL_VARIATIONS = 500; // every variation is a sequential create
82
+
83
+ const PRODUCT_KEYS = new Set([
84
+ // spec-only keys
85
+ "categories", "tags", "attributes", "default_options", "variations",
86
+ // entity fields
87
+ "name", "slug", "sku", "status", "regular_price", "sale_price",
88
+ "date_on_sale_from", "date_on_sale_to", "manage_stock", "stock_quantity",
89
+ "backorders", "description", "short_description", "images", "featured",
90
+ "catalog_visibility", "virtual", "downloadable", "downloads",
91
+ "download_limit", "download_expiry", "tax_status", "tax_class", "weight",
92
+ "dimensions", "sold_individually", "low_stock_amount", "shipping_class_id",
93
+ "upsell_ids", "cross_sell_ids", "meta_data",
94
+ ]);
95
+
96
+ const VARIATION_KEYS = new Set([
97
+ "options", "sku", "status", "regular_price", "sale_price",
98
+ "date_on_sale_from", "date_on_sale_to", "stock_quantity", "backorders",
99
+ "image", "description", "weight", "dimensions", "virtual", "downloadable",
100
+ "downloads", "download_limit", "download_expiry",
101
+ ]);
102
+
103
+ const ENUMS: Record<string, string[]> = {
104
+ status: ["draft", "pending", "private", "publish"],
105
+ backorders: ["no", "notify", "yes"],
106
+ catalog_visibility: ["visible", "catalog", "search", "hidden"],
107
+ tax_status: ["taxable", "shipping", "none"],
108
+ discount_type: ["percent", "fixed_cart", "fixed_product"],
109
+ };
110
+
111
+ const PRICE_KEYS = ["regular_price", "sale_price", "stock_quantity", "low_stock_amount", "weight"];
112
+
113
+ // ── normalization (pure — no I/O, throws CatalogPayloadError) ────────────────
114
+
115
+ /**
116
+ * Normalize the caller's catalog keys into a CatalogSpec, or return null when
117
+ * the body carries none. Validates everything in one pass so the caller gets
118
+ * every problem at once, before anything (canaries included) runs.
119
+ */
120
+ export function normalizeCatalogPayload(body: any): CatalogSpec | null {
121
+ const hasCatalog = ["products", "coupons", "tax_rates"].some((k) => body[k] != null);
122
+ if (!hasCatalog) return null;
123
+
124
+ const errors: Array<{ path: string; error: string }> = [];
125
+ const err = (path: string, error: string) => errors.push({ path, error });
126
+
127
+ if (body.with_sample_data) {
128
+ err("with_sample_data", "cannot be combined with products/coupons/tax_rates — seed either the demo catalog or your own, not both");
129
+ }
130
+
131
+ const list = (key: string, cap: number): any[] => {
132
+ const value = body[key];
133
+ if (value == null) return [];
134
+ if (!Array.isArray(value)) {
135
+ err(key, "must be an array");
136
+ return [];
137
+ }
138
+ if (value.length > cap) err(key, `at most ${cap} entries per call (got ${value.length})`);
139
+ return value.slice(0, cap);
140
+ };
141
+
142
+ const products = list("products", MAX_PRODUCTS);
143
+ const coupons = list("coupons", MAX_COUPONS);
144
+ const taxRates = list("tax_rates", MAX_TAX_RATES);
145
+
146
+ // shared definition collectors (deduped across products)
147
+ const categoryBySlug = new Map<string, Record<string, any>>();
148
+ const attributeByCode = new Map<string, { name: string; code: string; order: number }>();
149
+ const termsByCode: Record<string, Array<{ name: string; order: number }>> = {};
150
+ const seenSkus = new Map<string, string>(); // sku → path that used it
151
+ const seenSlugs = new Map<string, string>(); // derived slug (no-sku products) → path
152
+ let totalVariations = 0;
153
+
154
+ const normalized: NormalizedProduct[] = [];
155
+ products.forEach((spec, i) => {
156
+ const p = normalizeProduct(spec, `products[${i}]`, err, {
157
+ categoryBySlug, attributeByCode, termsByCode, seenSkus, seenSlugs,
158
+ });
159
+ if (p) {
160
+ totalVariations += p.variations.length;
161
+ normalized.push(p);
162
+ }
163
+ });
164
+ if (totalVariations > MAX_TOTAL_VARIATIONS) {
165
+ err("products", `at most ${MAX_TOTAL_VARIATIONS} variations across the call (got ${totalVariations}) — split into multiple calls`);
166
+ }
167
+
168
+ coupons.forEach((c, i) => {
169
+ const path = `coupons[${i}]`;
170
+ if (!c || typeof c !== "object") return err(path, "must be an object");
171
+ if (!String(c.code ?? "").trim()) err(`${path}.code`, "is required");
172
+ if (c.discount_type != null && !ENUMS.discount_type.includes(c.discount_type)) {
173
+ err(`${path}.discount_type`, `must be one of: ${ENUMS.discount_type.join(", ")}`);
174
+ }
175
+ if (!Number.isFinite(Number(c.amount)) || Number(c.amount) < 0) err(`${path}.amount`, "must be a non-negative number");
176
+ });
177
+
178
+ taxRates.forEach((r, i) => {
179
+ const path = `tax_rates[${i}]`;
180
+ if (!r || typeof r !== "object") return err(path, "must be an object");
181
+ if (!String(r.country ?? "").trim()) err(`${path}.country`, "is required");
182
+ if (!String(r.name ?? "").trim()) err(`${path}.name`, "is required");
183
+ if (!Number.isFinite(Number(r.rate)) || Number(r.rate) < 0) err(`${path}.rate`, "must be a non-negative number");
184
+ });
185
+
186
+ if (errors.length) throw new CatalogPayloadError(errors);
187
+
188
+ return {
189
+ categories: [...categoryBySlug.values()],
190
+ attributes: [...attributeByCode.values()],
191
+ termsByCode,
192
+ products: normalized,
193
+ coupons,
194
+ tax_rates: taxRates,
195
+ };
196
+ }
197
+
198
+ function normalizeProduct(
199
+ spec: any,
200
+ path: string,
201
+ err: (path: string, error: string) => void,
202
+ shared: {
203
+ categoryBySlug: Map<string, Record<string, any>>;
204
+ attributeByCode: Map<string, { name: string; code: string; order: number }>;
205
+ termsByCode: Record<string, Array<{ name: string; order: number }>>;
206
+ seenSkus: Map<string, string>;
207
+ seenSlugs: Map<string, string>;
208
+ },
209
+ ): NormalizedProduct | null {
210
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
211
+ err(path, "must be an object");
212
+ return null;
213
+ }
214
+ const name = String(spec.name ?? "").trim();
215
+ if (!name) err(`${path}.name`, "is required");
216
+
217
+ for (const key of Object.keys(spec)) {
218
+ if (!PRODUCT_KEYS.has(key)) err(`${path}.${key}`, "unknown key — not a product field the seeder accepts");
219
+ }
220
+ checkEnumsAndNumbers(spec, path, err);
221
+
222
+ // taxonomy references (display names)
223
+ const categorySlugs = strList(spec.categories, `${path}.categories`, err).map((catName) => {
224
+ const slug = slugify(catName);
225
+ if (!shared.categoryBySlug.has(slug)) {
226
+ shared.categoryBySlug.set(slug, { name: catName, slug, menu_order: shared.categoryBySlug.size });
227
+ }
228
+ return slug;
229
+ });
230
+ const tagNames = strList(spec.tags, `${path}.tags`, err);
231
+
232
+ // attribute axes: array form is canonical, map form accepted (insertion order)
233
+ const axes: Array<{ name: string; code: string; options: string[] }> = [];
234
+ if (spec.attributes != null) {
235
+ let entries: Array<[string, any]> = [];
236
+ if (Array.isArray(spec.attributes)) {
237
+ entries = spec.attributes.map((a: any): [string, any] => [String(a?.name ?? ""), a?.options]);
238
+ } else if (typeof spec.attributes === "object") {
239
+ entries = Object.entries(spec.attributes);
240
+ } else {
241
+ err(`${path}.attributes`, "must be [{ name, options }] or { <name>: [options] }");
242
+ }
243
+ entries.forEach(([attrName, options], i) => {
244
+ const aPath = `${path}.attributes[${i}]`;
245
+ const trimmed = String(attrName ?? "").trim();
246
+ if (!trimmed) return err(`${aPath}.name`, "attribute name is required");
247
+ const opts = strList(options, `${aPath}.options`, err);
248
+ if (!opts.length) return err(`${aPath}.options`, "at least one option is required");
249
+ const code = slugify(trimmed);
250
+ if (axes.some((a) => a.code === code)) return err(aPath, `duplicate attribute "${trimmed}" on this product`);
251
+ axes.push({ name: trimmed, code, options: opts });
252
+ if (!shared.attributeByCode.has(code)) {
253
+ shared.attributeByCode.set(code, { name: trimmed, code, order: shared.attributeByCode.size });
254
+ }
255
+ const terms = (shared.termsByCode[code] ??= []);
256
+ for (const o of opts) {
257
+ if (!terms.some((t) => t.name.toLowerCase() === o.toLowerCase())) terms.push({ name: o, order: terms.length });
258
+ }
259
+ });
260
+ }
261
+
262
+ // resolve an options map ({ Size: "S" } — keys by attribute name or code) to code → option
263
+ const resolveOptions = (input: any, oPath: string): Record<string, string> | null => {
264
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
265
+ err(oPath, "must be an object of { <attribute>: <option> }");
266
+ return null;
267
+ }
268
+ const out: Record<string, string> = {};
269
+ for (const [key, raw] of Object.entries(input)) {
270
+ const axis = axes.find((a) => a.code === slugify(key) || a.name.toLowerCase() === String(key).toLowerCase());
271
+ if (!axis) {
272
+ err(`${oPath}.${key}`, `"${key}" is not one of this product's attributes`);
273
+ continue;
274
+ }
275
+ const value = String(raw ?? "").trim();
276
+ const match = axis.options.find((o) => o.toLowerCase() === value.toLowerCase());
277
+ if (!match) {
278
+ err(`${oPath}.${key}`, `"${value}" is not one of the declared ${axis.name} options (${axis.options.join(", ")})`);
279
+ continue;
280
+ }
281
+ out[axis.code] = match;
282
+ }
283
+ return out;
284
+ };
285
+
286
+ const defaultOptions = spec.default_options != null
287
+ ? resolveOptions(spec.default_options, `${path}.default_options`) ?? {}
288
+ : {};
289
+
290
+ // variations: explicit list is authoritative; otherwise the full cartesian product
291
+ const inheritable: Record<string, any> = {};
292
+ for (const key of ["regular_price", "sale_price", "date_on_sale_from", "date_on_sale_to", "backorders"]) {
293
+ if (spec[key] !== undefined) inheritable[key] = spec[key];
294
+ }
295
+ let variations: NormalizedVariation[] = [];
296
+ if (spec.variations != null) {
297
+ if (!axes.length) err(`${path}.variations`, "variations need attributes — declare the product's attributes too");
298
+ if (!Array.isArray(spec.variations)) {
299
+ err(`${path}.variations`, "must be an array");
300
+ } else {
301
+ if (spec.variations.length > MAX_VARIATIONS_PER_PRODUCT) {
302
+ err(`${path}.variations`, `at most ${MAX_VARIATIONS_PER_PRODUCT} variations per product (got ${spec.variations.length})`);
303
+ }
304
+ const seenCombos = new Set<string>();
305
+ spec.variations.slice(0, MAX_VARIATIONS_PER_PRODUCT).forEach((v: any, i: number) => {
306
+ const vPath = `${path}.variations[${i}]`;
307
+ if (!v || typeof v !== "object") return err(vPath, "must be an object");
308
+ for (const key of Object.keys(v)) {
309
+ if (!VARIATION_KEYS.has(key)) err(`${vPath}.${key}`, "unknown key — not a variation field the seeder accepts");
310
+ }
311
+ checkEnumsAndNumbers(v, vPath, err);
312
+ const options = resolveOptions(v.options ?? {}, `${vPath}.options`);
313
+ if (!options) return;
314
+ const missing = axes.filter((a) => !(a.code in options));
315
+ if (missing.length) return err(`${vPath}.options`, `missing ${missing.map((a) => a.name).join(", ")} — every attribute needs a value`);
316
+ const combo = axes.map((a) => `${a.code}:${options[a.code].toLowerCase()}`).join("|");
317
+ if (seenCombos.has(combo)) return err(vPath, "duplicate combination — another variation already covers these options");
318
+ seenCombos.add(combo);
319
+ const { options: _o, ...fields } = v;
320
+ variations.push({ options, fields: { ...inheritable, ...compact(fields) } });
321
+ });
322
+ }
323
+ } else if (axes.length) {
324
+ const combos = axes.reduce((n, a) => n * a.options.length, 1);
325
+ if (combos > MAX_VARIATIONS_PER_PRODUCT) {
326
+ err(`${path}.attributes`, `${combos} auto-generated combinations exceed ${MAX_VARIATIONS_PER_PRODUCT} — pass an explicit variations array with the combinations you stock`);
327
+ } else {
328
+ variations = axes
329
+ .reduce<Array<Record<string, string>>>(
330
+ (acc, a) => acc.flatMap((combo) => a.options.map((o) => ({ ...combo, [a.code]: o }))),
331
+ [{}],
332
+ )
333
+ .map((options) => ({ options, fields: { ...inheritable } }));
334
+ }
335
+ }
336
+
337
+ // intra-payload duplicates
338
+ const sku = spec.sku != null ? String(spec.sku).trim() : "";
339
+ const trackSku = (value: string, sPath: string) => {
340
+ if (!value) return;
341
+ const prev = shared.seenSkus.get(value);
342
+ if (prev) err(sPath, `sku "${value}" already used at ${prev}`);
343
+ else shared.seenSkus.set(value, sPath);
344
+ };
345
+ trackSku(sku, `${path}.sku`);
346
+ variations.forEach((v, i) => trackSku(String(v.fields.sku ?? "").trim(), `${path}.variations[${i}].sku`));
347
+ if (!sku && name) {
348
+ const slug = slugify(String(spec.slug ?? "") || name);
349
+ const prev = shared.seenSlugs.get(slug);
350
+ if (prev) err(`${path}.name`, `"${name}" duplicates ${prev} — give the products distinct names or skus`);
351
+ else shared.seenSlugs.set(slug, path);
352
+ }
353
+
354
+ // entity passthrough fields
355
+ const fields: Record<string, any> = {};
356
+ for (const [key, value] of Object.entries(spec)) {
357
+ if (["categories", "tags", "attributes", "default_options", "variations"].includes(key)) continue;
358
+ if (PRODUCT_KEYS.has(key) && value !== undefined) fields[key] = value;
359
+ }
360
+ if (fields.sku !== undefined) fields.sku = sku;
361
+ fields.images = normalizeImages(spec.images, name, `${path}.images`, err);
362
+
363
+ return {
364
+ fields,
365
+ categorySlugs,
366
+ tagNames,
367
+ attributes: axes.map((a) => ({ code: a.code, options: a.options })),
368
+ defaultOptions,
369
+ variations,
370
+ };
371
+ }
372
+
373
+ function checkEnumsAndNumbers(spec: any, path: string, err: (p: string, e: string) => void): void {
374
+ for (const [key, allowed] of Object.entries(ENUMS)) {
375
+ if (key === "discount_type") continue; // coupon-only
376
+ if (spec[key] != null && !allowed.includes(spec[key])) {
377
+ err(`${path}.${key}`, `must be one of: ${allowed.join(", ")}`);
378
+ }
379
+ }
380
+ for (const key of PRICE_KEYS) {
381
+ if (spec[key] != null && (!Number.isFinite(Number(spec[key])) || Number(spec[key]) < 0)) {
382
+ err(`${path}.${key}`, "must be a non-negative number");
383
+ }
384
+ }
385
+ }
386
+
387
+ /** Coerce a names list ("Shoes" entries, tag names, attribute options) to trimmed strings. */
388
+ function strList(value: any, path: string, err: (p: string, e: string) => void): string[] {
389
+ if (value == null) return [];
390
+ if (!Array.isArray(value)) {
391
+ err(path, "must be an array");
392
+ return [];
393
+ }
394
+ const out: string[] = [];
395
+ value.forEach((entry, i) => {
396
+ const s = String(entry ?? "").trim();
397
+ if (!s) err(`${path}[${i}]`, "must be a non-empty string");
398
+ else if (!out.some((x) => x.toLowerCase() === s.toLowerCase())) out.push(s);
399
+ });
400
+ return out;
401
+ }
402
+
403
+ /** `images` accepts plain URLs or { src, name?, alt? }; normalized to the entity shape. */
404
+ function normalizeImages(value: any, productName: string, path: string, err: (p: string, e: string) => void): any[] {
405
+ if (value == null) return [];
406
+ if (!Array.isArray(value)) {
407
+ err(path, "must be an array of URLs or { src, alt? } objects");
408
+ return [];
409
+ }
410
+ return value.map((entry, position) => {
411
+ const img = typeof entry === "string" ? { src: entry } : entry;
412
+ const src = String(img?.src ?? "").trim();
413
+ if (!src) {
414
+ err(`${path}[${position}]`, "src is required");
415
+ return null;
416
+ }
417
+ return {
418
+ src,
419
+ name: img.name ?? productName,
420
+ alt: img.alt ?? (position === 0 ? productName : `${productName} — view ${position + 1}`),
421
+ position,
422
+ };
423
+ }).filter(Boolean);
424
+ }
425
+
426
+ /** Drop undefined values so entity creates never carry them. */
427
+ function compact(record: Record<string, any>): Record<string, any> {
428
+ return Object.fromEntries(Object.entries(record).filter(([, v]) => v !== undefined));
429
+ }
430
+
431
+ // ── the demo catalog, as a CatalogSpec ───────────────────────────────────────
432
+
433
+ export function sampleCatalog(): CatalogSpec {
434
+ return {
435
+ categories: SAMPLE_CATEGORIES,
436
+ attributes: SAMPLE_ATTRIBUTES,
437
+ termsByCode: SAMPLE_ATTRIBUTE_TERMS,
438
+ products: SAMPLE_PRODUCTS.map((spec) => {
439
+ const { key: _key, categories, attributes, default_attributes, variations, ...fields } = spec;
440
+ return {
441
+ fields: { ...fields, images: fields.images ?? [] },
442
+ categorySlugs: categories ?? [],
443
+ tagNames: [],
444
+ attributes: (attributes ?? []).map((a: any) => ({ code: a.code, options: a.options ?? [] })),
445
+ defaultOptions: Object.fromEntries((default_attributes ?? []).map((d: any) => [d.code, d.option])),
446
+ variations: (variations ?? []).map((v: any) => {
447
+ const { options, ...vFields } = v;
448
+ return { options: { ...options }, fields: compact(vFields) };
449
+ }),
450
+ };
451
+ }),
452
+ coupons: SAMPLE_COUPONS,
453
+ tax_rates: SAMPLE_TAX_RATES,
454
+ };
455
+ }
456
+
457
+ // ── seeding ──────────────────────────────────────────────────────────────────
458
+
459
+ /**
460
+ * Write a CatalogSpec. `skipExisting` is the custom-payload mode: a product
461
+ * whose sku (or, without one, derived slug) already exists is reported as
462
+ * skipped instead of duplicated, so a retrying caller converges. The sample
463
+ * path passes false — it only ever runs against a store with zero products.
464
+ *
465
+ * On mid-write failure every record this call created is deleted (newest
466
+ * first) and the error surfaces as `errorCode`; taxonomy that pre-existed is
467
+ * never touched by the rollback.
468
+ */
469
+ export async function seedCatalog(
470
+ sr: any,
471
+ spec: CatalogSpec,
472
+ opts: { skipExisting: boolean; errorCode: string },
473
+ ): Promise<Record<string, any>> {
474
+ const created: Array<{ entity: string; id: string }> = [];
475
+ const track = async (entity: string, record: Record<string, any>) => {
476
+ const rec = await sr.entities[entity].create(compact(record));
477
+ created.push({ entity, id: rec.id });
478
+ return rec;
479
+ };
480
+ const counts = {
481
+ categories: { created: 0, reused: 0 },
482
+ tags: { created: 0, reused: 0 },
483
+ attributes: { created: 0, reused: 0 },
484
+ terms: { created: 0, reused: 0 },
485
+ };
486
+
487
+ try {
488
+ const inventory = (await getSettings(sr, "inventory")).inventory ?? {};
489
+ const outThreshold = Number(inventory.out_of_stock_threshold ?? 0);
490
+
491
+ // ── decide per product before writing anything ─────────────────────────
492
+ const plan: Array<{ p: NormalizedProduct; skip?: Record<string, any> }> = [];
493
+ for (const p of spec.products) {
494
+ let skip: Record<string, any> | undefined;
495
+ if (opts.skipExisting) {
496
+ const sku = p.fields.sku;
497
+ if (sku) {
498
+ const prods = (await sr.entities["commerce.Product"].filter({ sku }, undefined, 1)) ?? [];
499
+ const hit = prods[0] ?? ((await sr.entities["commerce.ProductVariation"].filter({ sku }, undefined, 1)) ?? [])[0];
500
+ if (hit) skip = { name: p.fields.name, skipped: true, reason: "sku_exists", existing_id: hit.id };
501
+ } else {
502
+ const slug = slugify(p.fields.slug || p.fields.name);
503
+ const hits = (await sr.entities["commerce.Product"].filter({ slug }, undefined, 1)) ?? [];
504
+ if (hits.length) skip = { name: p.fields.name, skipped: true, reason: "slug_exists", existing_id: hits[0].id };
505
+ }
506
+ }
507
+ if (!skip) {
508
+ // explicit variation skus fail as 409 duplicate_sku here, before any write
509
+ for (const v of p.variations) {
510
+ if (v.fields.sku) await assertUniqueSku(sr, String(v.fields.sku), {});
511
+ }
512
+ }
513
+ plan.push({ p, skip });
514
+ }
515
+
516
+ // ── taxonomy (get-or-create; reused records are never rolled back) ─────
517
+ const categoryBySlug: Record<string, any> = {};
518
+ for (const def of spec.categories) {
519
+ const hits = (await sr.entities["commerce.ProductCategory"].filter({ slug: def.slug }, undefined, 1)) ?? [];
520
+ if (hits.length) {
521
+ categoryBySlug[def.slug] = hits[0];
522
+ counts.categories.reused++;
523
+ } else {
524
+ categoryBySlug[def.slug] = await track("commerce.ProductCategory", { description: "", display: "default", menu_order: 0, ...def, count: 0 });
525
+ counts.categories.created++;
526
+ }
527
+ }
528
+
529
+ let allTags: any[] | null = null;
530
+ const tagByLower: Record<string, any> = {};
531
+ const resolveTag = async (tagName: string) => {
532
+ const key = tagName.toLowerCase();
533
+ if (tagByLower[key]) return tagByLower[key];
534
+ allTags ??= await scanAll(sr.entities["commerce.ProductTag"], null, "name");
535
+ let tag = allTags!.find((t: any) => String(t.name ?? "").toLowerCase() === key);
536
+ if (tag) counts.tags.reused++;
537
+ else {
538
+ tag = await track("commerce.ProductTag", { name: tagName, count: 0 });
539
+ counts.tags.created++;
540
+ }
541
+ tagByLower[key] = tag;
542
+ return tag;
543
+ };
544
+
545
+ let allAttributes: any[] | null = null;
546
+ const attributeByCode: Record<string, any> = {};
547
+ const termsByAttrId: Record<string, any[]> = {};
548
+ for (const def of spec.attributes) {
549
+ allAttributes ??= await scanAll(sr.entities["commerce.ProductAttribute"], null, "order");
550
+ let attr = allAttributes!.find((a: any) => a.code === def.code);
551
+ if (attr) counts.attributes.reused++;
552
+ else {
553
+ const maxOrder = allAttributes!.reduce((m: number, a: any) => Math.max(m, Number(a.order ?? 0)), -1);
554
+ attr = await track("commerce.ProductAttribute", { name: def.name, code: def.code, order: maxOrder + 1 });
555
+ allAttributes!.push(attr);
556
+ counts.attributes.created++;
557
+ }
558
+ attributeByCode[def.code] = attr;
559
+
560
+ const terms = (await sr.entities["commerce.ProductAttributeTerm"].filter({ attribute_id: attr.id }, undefined, 500)) ?? [];
561
+ let maxTermOrder = terms.reduce((m: number, t: any) => Math.max(m, Number(t.order ?? 0)), -1);
562
+ for (const term of spec.termsByCode[def.code] ?? []) {
563
+ if (terms.some((t: any) => String(t.name ?? "").toLowerCase() === term.name.toLowerCase())) {
564
+ counts.terms.reused++;
565
+ continue;
566
+ }
567
+ terms.push(await track("commerce.ProductAttributeTerm", { attribute_id: attr.id, name: term.name, order: ++maxTermOrder, count: 0 }));
568
+ counts.terms.created++;
569
+ }
570
+ termsByAttrId[attr.id] = terms;
571
+ }
572
+
573
+ // References are by name, so payload casing must never fork an existing
574
+ // term — the stored casing wins everywhere a name is written.
575
+ const canonicalOption = (code: string, option: string): string => {
576
+ const attr = attributeByCode[code];
577
+ const term = (termsByAttrId[attr?.id] ?? []).find((t: any) => String(t.name ?? "").toLowerCase() === option.toLowerCase());
578
+ return term ? term.name : option;
579
+ };
580
+
581
+ // ── products + variations ──────────────────────────────────────────────
582
+ const results: Array<Record<string, any>> = [];
583
+ const countDeltas = new Map<string, number>(); // "entityid" → +n
584
+ const bumpLater = (entity: string, id: string) => {
585
+ const key = `${entity}${id}`;
586
+ countDeltas.set(key, (countDeltas.get(key) ?? 0) + 1);
587
+ };
588
+ let variationsCreated = 0;
589
+
590
+ for (const { p, skip } of plan) {
591
+ if (skip) {
592
+ results.push(skip);
593
+ continue;
594
+ }
595
+ const record: any = {
596
+ status: "publish",
597
+ tag_ids: [],
598
+ meta_data: [],
599
+ total_sales: 0,
600
+ ...p.fields,
601
+ category_ids: p.categorySlugs.map((slug) => categoryBySlug[slug]?.id).filter(Boolean),
602
+ };
603
+ for (const tagName of p.tagNames) record.tag_ids = [...record.tag_ids, (await resolveTag(tagName)).id];
604
+ record.slug = await ensureUniqueSlug(sr, slugify(record.slug || record.name));
605
+ if (record.manage_stock === undefined && record.stock_quantity != null) record.manage_stock = true;
606
+
607
+ if (p.attributes.length) {
608
+ record.attributes = p.attributes.map((a, position) => ({
609
+ attribute_id: attributeByCode[a.code].id,
610
+ name: attributeByCode[a.code].name,
611
+ position,
612
+ options: a.options.map((o) => canonicalOption(a.code, o)),
613
+ }));
614
+ const defaults = Object.entries(p.defaultOptions);
615
+ if (defaults.length) {
616
+ record.default_attributes = defaults.map(([code, option]) => ({
617
+ attribute_id: attributeByCode[code].id,
618
+ name: attributeByCode[code].name,
619
+ option: canonicalOption(code, option),
620
+ }));
621
+ }
622
+ }
623
+
624
+ derivePricing(record);
625
+ deriveStock(record, outThreshold, !!record.manage_stock);
626
+ const product = await track("commerce.Product", record);
627
+
628
+ const seededVariations: any[] = [];
629
+ for (const v of p.variations) {
630
+ const rec: any = {
631
+ status: "publish",
632
+ ...v.fields,
633
+ product_id: product.id,
634
+ attributes: Object.entries(v.options).map(([code, option]) => ({
635
+ attribute_id: attributeByCode[code].id,
636
+ name: attributeByCode[code].name,
637
+ option: canonicalOption(code, option),
638
+ })),
639
+ };
640
+ if (typeof rec.image === "string") rec.image = { src: rec.image, name: record.name, alt: record.name };
641
+ if (!rec.sku && record.sku) {
642
+ // synthesized skus self-heal on collision instead of failing the seed
643
+ rec.sku = `${record.sku}-${Object.values(v.options).map((o) => String(o).replace(/[^a-z0-9]+/gi, "")).join("-").toUpperCase()}`;
644
+ try {
645
+ await assertUniqueSku(sr, rec.sku, {});
646
+ } catch {
647
+ rec.sku = `${rec.sku}-${crypto.randomUUID().slice(0, 4).toUpperCase()}`;
648
+ }
649
+ }
650
+ // explicit stock is per-variation; none means the pool on the parent
651
+ rec.manage_stock = rec.stock_quantity != null ? "yes" : "parent";
652
+ derivePricing(rec);
653
+ deriveStock(rec, outThreshold, rec.manage_stock === "yes");
654
+ seededVariations.push(await track("commerce.ProductVariation", rec));
655
+ variationsCreated++;
656
+ }
657
+ if (seededVariations.length) await rollUpParent(sr, product, seededVariations);
658
+
659
+ for (const id of record.category_ids) bumpLater("commerce.ProductCategory", id);
660
+ for (const id of record.tag_ids) bumpLater("commerce.ProductTag", id);
661
+ results.push({ name: product.name, id: product.id, slug: product.slug, sku: product.sku ?? "", variation_count: seededVariations.length });
662
+ }
663
+
664
+ // deferred so a mid-creation rollback never leaves counts drifted;
665
+ // a failure from here on is count-only and recount-terms repairs it
666
+ for (const [key, delta] of countDeltas) {
667
+ const [entity, id] = key.split("");
668
+ await bumpCount(sr, entity, id, delta);
669
+ }
670
+
671
+ // ── coupons + tax rates (skip-if-exists) ───────────────────────────────
672
+ const couponCounts = { created: 0, skipped: 0 };
673
+ for (const c of spec.coupons) {
674
+ const code = String(c.code).trim().toLowerCase();
675
+ const hits = (await sr.entities["commerce.Coupon"].filter({ code }, undefined, 1)) ?? [];
676
+ if (hits.length) {
677
+ couponCounts.skipped++;
678
+ continue;
679
+ }
680
+ await track("commerce.Coupon", { discount_type: "fixed_cart", usage_count: 0, used_by: [], ...c, code });
681
+ couponCounts.created++;
682
+ }
683
+
684
+ const taxCounts = { created: 0, skipped: 0 };
685
+ for (const r of spec.tax_rates) {
686
+ const hits = (await sr.entities["commerce.TaxRate"].filter({ country: r.country, name: r.name }, undefined, 5)) ?? [];
687
+ if (hits.some((h: any) => String(h.state ?? "") === String(r.state ?? ""))) {
688
+ taxCounts.skipped++;
689
+ continue;
690
+ }
691
+ await track("commerce.TaxRate", { state: "", postcodes: [], cities: [], priority: 1, compound: false, shipping: true, tax_class: "standard", menu_order: 0, ...r });
692
+ taxCounts.created++;
693
+ }
694
+
695
+ return {
696
+ categories: counts.categories,
697
+ tags: counts.tags,
698
+ attributes: counts.attributes,
699
+ terms: counts.terms,
700
+ products_created: results.filter((r) => !r.skipped).length,
701
+ products_skipped: results.filter((r) => r.skipped).length,
702
+ variations_created: variationsCreated,
703
+ coupons: couponCounts,
704
+ tax_rates: taxCounts,
705
+ products: results,
706
+ };
707
+ } catch (e) {
708
+ // best-effort rollback, newest first
709
+ for (const { entity, id } of created.reverse()) {
710
+ try {
711
+ await sr.entities[entity].delete(id);
712
+ } catch { /* leave orphans; commerce/admin-tools can clean */ }
713
+ }
714
+ if (e instanceof HttpError) throw e;
715
+ throw new HttpError(500, `Catalog seeding failed and was rolled back: ${(e as Error).message}`, opts.errorCode);
716
+ }
717
+ }