@porulle/adapter-woocommerce 0.10.8 → 0.13.0

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/dist/index.js CHANGED
@@ -1,5 +1,89 @@
1
1
  import { defineChannelConnector, Err, Ok } from "@porulle/core";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
+ const PORULLE_META_PREFIX = "porulle_";
4
+ const WOO_BATCH_LIMIT = 100;
5
+ const catalogRequestOptions = { retryableClientErrors: true };
6
+ const wooProductNativeFields = new Set([
7
+ "name",
8
+ "slug",
9
+ "type",
10
+ "status",
11
+ "featured",
12
+ "catalog_visibility",
13
+ "description",
14
+ "short_description",
15
+ "sku",
16
+ "regular_price",
17
+ "sale_price",
18
+ "date_on_sale_from",
19
+ "date_on_sale_to",
20
+ "virtual",
21
+ "downloadable",
22
+ "downloads",
23
+ "download_limit",
24
+ "download_expiry",
25
+ "external_url",
26
+ "button_text",
27
+ "tax_status",
28
+ "tax_class",
29
+ "manage_stock",
30
+ "stock_quantity",
31
+ "backorders",
32
+ "sold_individually",
33
+ "weight",
34
+ "dimensions",
35
+ "shipping_class",
36
+ "reviews_allowed",
37
+ "upsell_ids",
38
+ "cross_sell_ids",
39
+ "parent_id",
40
+ "purchase_note",
41
+ "menu_order",
42
+ "images",
43
+ ]);
44
+ const wooVariationNativeFields = new Set([
45
+ "description",
46
+ "sku",
47
+ "regular_price",
48
+ "sale_price",
49
+ "date_on_sale_from",
50
+ "date_on_sale_to",
51
+ "status",
52
+ "virtual",
53
+ "downloadable",
54
+ "downloads",
55
+ "download_limit",
56
+ "download_expiry",
57
+ "tax_status",
58
+ "tax_class",
59
+ "manage_stock",
60
+ "stock_quantity",
61
+ "backorders",
62
+ "weight",
63
+ "dimensions",
64
+ "shipping_class",
65
+ "shipping_class_id",
66
+ "image",
67
+ "menu_order",
68
+ ]);
69
+ const zeroDecimalCurrencies = new Set([
70
+ "BIF",
71
+ "CLP",
72
+ "DJF",
73
+ "GNF",
74
+ "ISK",
75
+ "JPY",
76
+ "KMF",
77
+ "KRW",
78
+ "PYG",
79
+ "RWF",
80
+ "UGX",
81
+ "VND",
82
+ "VUV",
83
+ "XAF",
84
+ "XOF",
85
+ "XPF",
86
+ ]);
3
87
  function buildWooUrl(base, path, key, secret, page, cursor) {
4
88
  const url = new URL(path, base.replace(/\/$/, "/"));
5
89
  url.searchParams.set("consumer_key", key);
@@ -10,23 +94,460 @@ function buildWooUrl(base, path, key, secret, page, cursor) {
10
94
  url.searchParams.set("modified_after", cursor);
11
95
  return url.toString();
12
96
  }
13
- function parseMoney(value) {
14
- if (!value)
15
- return 0;
16
- const parsed = Number.parseFloat(value);
17
- return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
97
+ function normalizeCurrency(value) {
98
+ if (typeof value !== "string" || value.trim() === "")
99
+ return undefined;
100
+ return value.trim().toUpperCase();
101
+ }
102
+ function parseMoney(value, currency) {
103
+ if (value == null || value.trim() === "")
104
+ return undefined;
105
+ const parsed = Number(value);
106
+ if (!Number.isFinite(parsed))
107
+ return undefined;
108
+ const exponent = zeroDecimalCurrencies.has(currency) ? 0 : 2;
109
+ return Math.round(parsed * (10 ** exponent));
110
+ }
111
+ function pricesForVariation(variation, currency) {
112
+ if (!currency)
113
+ return undefined;
114
+ const amount = parseMoney(variation.price, currency);
115
+ return amount === undefined ? undefined : [{ currency, amount }];
116
+ }
117
+ function catalogStatus(value) {
118
+ if (value === "publish")
119
+ return "active";
120
+ if (value === "draft" || value === "private")
121
+ return "draft";
122
+ return undefined;
123
+ }
124
+ function asRecord(value) {
125
+ return typeof value === "object" && value !== null ? value : undefined;
126
+ }
127
+ function settingCurrency(data) {
128
+ if (Array.isArray(data)) {
129
+ for (const entry of data) {
130
+ const setting = asRecord(entry);
131
+ if (setting?.id === "woocommerce_currency")
132
+ return normalizeCurrency(setting.value);
133
+ }
134
+ }
135
+ const object = asRecord(data);
136
+ if (!object)
137
+ return undefined;
138
+ const direct = normalizeCurrency(object.woocommerce_currency);
139
+ if (direct)
140
+ return direct;
141
+ const systemStatus = asRecord(object.system_status);
142
+ return normalizeCurrency(systemStatus?.woocommerce_currency);
18
143
  }
19
- async function request(fetchImpl, url, init) {
144
+ async function fetchWooCurrency(fetchImpl, url) {
145
+ const result = await request(fetchImpl, url);
146
+ return result.ok ? settingCurrency(result.value.data) : undefined;
147
+ }
148
+ async function fetchProductVariations(fetchImpl, storeDomain, auth, productId, modifiedAfter) {
149
+ const variations = [];
150
+ let page = 1;
151
+ while (true) {
152
+ const result = await request(fetchImpl, buildWooUrl(storeDomain, `/wp-json/wc/v3/products/${encodeURIComponent(productId)}/variations`, auth.key, auth.secret, page, modifiedAfter));
153
+ if (!result.ok)
154
+ return result;
155
+ variations.push(...result.value.data);
156
+ const totalPages = Number.parseInt(result.value.response.headers.get("x-wp-totalpages") ?? "1", 10);
157
+ if (!Number.isFinite(totalPages) || page >= totalPages)
158
+ break;
159
+ page += 1;
160
+ }
161
+ return Ok(variations);
162
+ }
163
+ function variationFromReference(reference) {
164
+ return typeof reference === "object" && reference !== null ? reference : { id: reference };
165
+ }
166
+ function mergeProductVariations(references, details) {
167
+ const detailById = new Map(details.map((variation) => [String(variation.id), variation]));
168
+ const referencedIds = new Set();
169
+ const merged = references.map((reference) => {
170
+ const fallback = variationFromReference(reference);
171
+ const id = String(fallback.id);
172
+ referencedIds.add(id);
173
+ return detailById.get(id) ?? fallback;
174
+ });
175
+ return [...merged, ...details.filter((variation) => !referencedIds.has(String(variation.id)))];
176
+ }
177
+ function isRetriableStatus(status, options) {
178
+ return status !== undefined && (status >= 500 || (options?.retryableClientErrors === true && (status === 408 || status === 429)));
179
+ }
180
+ async function request(fetchImpl, url, init, options) {
20
181
  try {
21
182
  const response = await fetchImpl(url, { ...init, headers: { accept: "application/json", ...(init?.headers ?? {}) } });
22
183
  if (!response.ok)
23
- return Err({ code: "WOO_API_FAILED", message: `WooCommerce request failed (${response.status}) for ${url}.`, retriable: response.status >= 500 });
184
+ return Err({ code: "WOO_API_FAILED", message: `WooCommerce request failed (${response.status}) for ${url}.`, retriable: isRetriableStatus(response.status, options) });
24
185
  return Ok({ data: await response.json(), response });
25
186
  }
26
187
  catch (error) {
27
188
  return Err({ code: "WOO_API_FAILED", message: error instanceof Error ? error.message : "WooCommerce request failed.", retriable: true });
28
189
  }
29
190
  }
191
+ function connectorError(error) {
192
+ return {
193
+ code: error.code,
194
+ message: error.message,
195
+ ...(error.retriable !== undefined ? { retriable: error.retriable } : {}),
196
+ };
197
+ }
198
+ function batchErrorMessage(error) {
199
+ const record = asRecord(error);
200
+ return typeof record?.message === "string" && record.message.trim() !== ""
201
+ ? record.message
202
+ : typeof error === "string" && error.trim() !== ""
203
+ ? error
204
+ : "WooCommerce batch update failed.";
205
+ }
206
+ function batchErrorStatus(error) {
207
+ const record = asRecord(error);
208
+ const data = asRecord(record?.data);
209
+ return typeof data?.status === "number" ? data.status : undefined;
210
+ }
211
+ function batchItemError(error) {
212
+ return {
213
+ code: "WOO_API_FAILED",
214
+ message: batchErrorMessage(error),
215
+ retriable: isRetriableStatus(batchErrorStatus(error), { retryableClientErrors: true }),
216
+ };
217
+ }
218
+ function catalogPushError(code, message) {
219
+ return Err({ code, message, retriable: false });
220
+ }
221
+ function remoteKey(field) {
222
+ if (typeof field.remoteKey !== "string" || field.remoteKey.trim() === "") {
223
+ return catalogPushError("WOO_REMOTE_KEY_REQUIRED", `WooCommerce remoteKey is required for catalog field "${field.fieldPath}".`);
224
+ }
225
+ return Ok(field.remoteKey.trim());
226
+ }
227
+ function porulleMetaKey(key) {
228
+ const normalized = key.trim();
229
+ return normalized.startsWith(PORULLE_META_PREFIX) ? normalized : `${PORULLE_META_PREFIX}${normalized}`;
230
+ }
231
+ function tagValues(value) {
232
+ const values = Array.isArray(value) ? value : [value];
233
+ return values.flatMap((entry) => {
234
+ if (typeof entry === "string" && entry.trim() !== "")
235
+ return [entry.trim()];
236
+ if (typeof entry === "number" && Number.isFinite(entry))
237
+ return [String(entry)];
238
+ const object = asRecord(entry);
239
+ return typeof object?.name === "string" && object.name.trim() !== "" ? [object.name.trim()] : [];
240
+ });
241
+ }
242
+ function filterableTerms(value) {
243
+ const values = Array.isArray(value) ? value : [value];
244
+ return [...new Set(values.flatMap((entry) => {
245
+ if (typeof entry === "string" && entry.trim() !== "")
246
+ return [entry.trim()];
247
+ if (typeof entry === "number" && Number.isFinite(entry))
248
+ return [String(entry)];
249
+ return [];
250
+ }))];
251
+ }
252
+ function addNativeField(body, locales, field, key) {
253
+ const currentLocale = locales.get(key);
254
+ if (!Object.prototype.hasOwnProperty.call(body, key) || (field.locale === "en" && currentLocale !== "en")) {
255
+ body[key] = field.value;
256
+ locales.set(key, field.locale);
257
+ }
258
+ }
259
+ function addMetaField(metaData, locales, field, key) {
260
+ const currentLocale = locales.get(key);
261
+ const existingIndex = metaData.findIndex((entry) => entry.key === key);
262
+ if (existingIndex === -1 || (field.locale === "en" && currentLocale !== "en")) {
263
+ const entry = { key, value: field.value };
264
+ if (existingIndex === -1)
265
+ metaData.push(entry);
266
+ else
267
+ metaData[existingIndex] = entry;
268
+ locales.set(key, field.locale);
269
+ }
270
+ }
271
+ function appendCatalogFields(fields, nativeFields, body, filterableFields, metaData, tags) {
272
+ const locales = new Map();
273
+ const metaLocales = new Map();
274
+ let hasTagField = false;
275
+ for (const field of fields) {
276
+ if (field.intent === "filterable") {
277
+ const key = remoteKey(field);
278
+ if (!key.ok)
279
+ return key;
280
+ filterableFields.push(field);
281
+ continue;
282
+ }
283
+ if (field.intent === "tag") {
284
+ hasTagField = true;
285
+ for (const tag of tagValues(field.value))
286
+ tags.add(tag);
287
+ continue;
288
+ }
289
+ const key = remoteKey(field);
290
+ if (!key.ok)
291
+ return key;
292
+ if (nativeFields.has(key.value)) {
293
+ addNativeField(body, locales, field, key.value);
294
+ }
295
+ else {
296
+ addMetaField(metaData, metaLocales, field, porulleMetaKey(key.value));
297
+ }
298
+ }
299
+ return Ok({ hasTagField });
300
+ }
301
+ function wooImages(images) {
302
+ return images.map((image, index) => ({
303
+ ...(image.externalId?.trim() ? { id: batchId(image.externalId.trim()) } : { src: image.url }),
304
+ ...(image.alt !== undefined ? { alt: image.alt } : {}),
305
+ position: image.sortOrder ?? index,
306
+ }));
307
+ }
308
+ function mergeWooImages(current, incoming) {
309
+ const incomingById = new Map();
310
+ for (const image of incoming) {
311
+ if (typeof image.id === "number" || typeof image.id === "string")
312
+ incomingById.set(String(image.id), image);
313
+ }
314
+ const currentIds = new Set(current.map((image) => String(image.id)));
315
+ const merged = current.map((image, index) => {
316
+ const replacement = incomingById.get(String(image.id));
317
+ return replacement ?? {
318
+ id: image.id,
319
+ ...(image.alt !== undefined ? { alt: image.alt } : {}),
320
+ position: image.position ?? index,
321
+ };
322
+ });
323
+ const appendedIds = new Set();
324
+ for (const image of incoming) {
325
+ if (typeof image.id === "number" || typeof image.id === "string") {
326
+ const id = String(image.id);
327
+ if (currentIds.has(id) || appendedIds.has(id))
328
+ continue;
329
+ appendedIds.add(id);
330
+ }
331
+ merged.push(image);
332
+ }
333
+ return merged;
334
+ }
335
+ function batchId(externalId) {
336
+ return /^\d+$/.test(externalId) ? Number(externalId) : externalId;
337
+ }
338
+ function buildCatalogPlan(item) {
339
+ const productBody = {};
340
+ const productFilterableFields = [];
341
+ const productMetaData = [];
342
+ const productTags = new Set();
343
+ const productFields = appendCatalogFields(item.fields, wooProductNativeFields, productBody, productFilterableFields, productMetaData, productTags);
344
+ if (!productFields.ok)
345
+ return productFields;
346
+ if (productMetaData.length > 0)
347
+ productBody.meta_data = productMetaData;
348
+ if (productFields.value.hasTagField)
349
+ productBody.tags = [...productTags].map((name) => ({ name }));
350
+ if (item.images !== undefined)
351
+ productBody.images = wooImages(item.images);
352
+ const variants = [];
353
+ for (const variant of item.variants ?? []) {
354
+ const variantBody = {};
355
+ const filterableFields = [];
356
+ const metaData = [];
357
+ const tags = new Set();
358
+ const variantFields = appendCatalogFields(variant.fields, wooVariationNativeFields, variantBody, filterableFields, metaData, tags);
359
+ if (!variantFields.ok)
360
+ return variantFields;
361
+ if (filterableFields.length > 0) {
362
+ return catalogPushError("WOO_VARIANT_FILTERABLE_UNSUPPORTED", `WooCommerce filterable fields must be assigned on product "${item.externalId}", not variation "${variant.externalId}".`);
363
+ }
364
+ if (variantFields.value.hasTagField) {
365
+ return catalogPushError("WOO_VARIANT_TAG_UNSUPPORTED", `WooCommerce tags must be assigned on product "${item.externalId}", not variation "${variant.externalId}".`);
366
+ }
367
+ if (metaData.length > 0)
368
+ variantBody.meta_data = metaData;
369
+ variants.push({ variant, body: variantBody });
370
+ }
371
+ return Ok({ productBody, filterableFields: productFilterableFields, variants });
372
+ }
373
+ function queryUrl(storeDomain, path, key, secret, params) {
374
+ const url = new URL(buildWooUrl(storeDomain, path, key, secret, 1));
375
+ for (const [name, value] of Object.entries(params))
376
+ url.searchParams.set(name, value);
377
+ return url.toString();
378
+ }
379
+ function taxonomyParts(remoteName) {
380
+ const withoutPrefix = remoteName.trim().toLowerCase().replace(/^pa[_-]/, "");
381
+ const base = withoutPrefix.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
382
+ return {
383
+ base,
384
+ taxonomy: `pa_${base}`,
385
+ name: withoutPrefix.replace(/[-_]+/g, " ").trim(),
386
+ };
387
+ }
388
+ async function ensureGlobalAttribute(fetchImpl, store, auth, remoteName) {
389
+ const parts = taxonomyParts(remoteName);
390
+ const existing = await request(fetchImpl, queryUrl(store.storeDomain, "/wp-json/wc/v3/products/attributes", auth.key, auth.secret, { search: parts.name }), undefined, catalogRequestOptions);
391
+ if (!existing.ok)
392
+ return Err(connectorError(existing.error));
393
+ const found = existing.value.data.find((attribute) => {
394
+ const slug = attribute.slug?.toLowerCase();
395
+ return slug === parts.taxonomy || slug === parts.base || attribute.name.toLowerCase() === parts.name;
396
+ });
397
+ if (found)
398
+ return Ok(found);
399
+ const created = await request(fetchImpl, buildWooUrl(store.storeDomain, "/wp-json/wc/v3/products/attributes", auth.key, auth.secret, 1), {
400
+ method: "POST",
401
+ headers: { "content-type": "application/json" },
402
+ body: JSON.stringify({ name: parts.name, slug: parts.base }),
403
+ }, catalogRequestOptions);
404
+ return created.ok ? Ok(created.value.data) : Err(connectorError(created.error));
405
+ }
406
+ async function ensureTerm(fetchImpl, store, auth, attributeId, termName) {
407
+ const path = `/wp-json/wc/v3/products/attributes/${encodeURIComponent(String(attributeId))}/terms`;
408
+ const existing = await request(fetchImpl, queryUrl(store.storeDomain, path, auth.key, auth.secret, { search: termName }), undefined, catalogRequestOptions);
409
+ if (!existing.ok)
410
+ return Err(connectorError(existing.error));
411
+ const found = existing.value.data.find((term) => term.name.toLowerCase() === termName.toLowerCase());
412
+ if (found)
413
+ return Ok(found);
414
+ const created = await request(fetchImpl, buildWooUrl(store.storeDomain, path, auth.key, auth.secret, 1), {
415
+ method: "POST",
416
+ headers: { "content-type": "application/json" },
417
+ body: JSON.stringify({ name: termName }),
418
+ }, catalogRequestOptions);
419
+ return created.ok ? Ok(created.value.data) : Err(connectorError(created.error));
420
+ }
421
+ async function mergedFilterableAttributes(fetchImpl, store, auth, productId, fields, attributeCache, termCache) {
422
+ const termsByRemoteName = new Map();
423
+ for (const field of fields) {
424
+ const key = remoteKey(field);
425
+ if (!key.ok)
426
+ return key;
427
+ const terms = termsByRemoteName.get(key.value) ?? new Set();
428
+ for (const term of filterableTerms(field.value))
429
+ terms.add(term);
430
+ termsByRemoteName.set(key.value, terms);
431
+ }
432
+ const assignments = [];
433
+ for (const [remoteName, termNames] of termsByRemoteName) {
434
+ const parts = taxonomyParts(remoteName);
435
+ const attributeKey = `${store.storeDomain}|${parts.taxonomy}`;
436
+ let attributePromise = attributeCache.get(attributeKey);
437
+ if (!attributePromise) {
438
+ attributePromise = ensureGlobalAttribute(fetchImpl, store, auth, remoteName);
439
+ attributeCache.set(attributeKey, attributePromise);
440
+ }
441
+ const attribute = await attributePromise;
442
+ if (!attribute.ok) {
443
+ attributeCache.delete(attributeKey);
444
+ return attribute;
445
+ }
446
+ const terms = [];
447
+ for (const termName of termNames) {
448
+ const termKey = `${store.storeDomain}|${String(attribute.value.id)}|${termName.toLowerCase()}`;
449
+ let termPromise = termCache.get(termKey);
450
+ if (!termPromise) {
451
+ termPromise = ensureTerm(fetchImpl, store, auth, attribute.value.id, termName);
452
+ termCache.set(termKey, termPromise);
453
+ }
454
+ const term = await termPromise;
455
+ if (!term.ok) {
456
+ termCache.delete(termKey);
457
+ return term;
458
+ }
459
+ terms.push(term.value.name);
460
+ }
461
+ assignments.push({ attribute: attribute.value, terms });
462
+ }
463
+ const current = await request(fetchImpl, buildWooUrl(store.storeDomain, `/wp-json/wc/v3/products/${encodeURIComponent(productId)}`, auth.key, auth.secret, 1), undefined, catalogRequestOptions);
464
+ if (!current.ok)
465
+ return Err(connectorError(current.error));
466
+ const merged = (current.value.data.attributes ?? []).map((attribute) => ({
467
+ ...attribute,
468
+ ...(attribute.options ? { options: [...attribute.options] } : {}),
469
+ }));
470
+ for (const assignment of assignments) {
471
+ const existing = merged.find((attribute) => attribute.id !== undefined && String(attribute.id) === String(assignment.attribute.id));
472
+ if (existing) {
473
+ existing.visible = existing.visible ?? true;
474
+ existing.variation = existing.variation ?? false;
475
+ existing.options = [...new Set([...(existing.options ?? []), ...assignment.terms])];
476
+ }
477
+ else {
478
+ merged.push({
479
+ id: assignment.attribute.id,
480
+ name: assignment.attribute.name,
481
+ visible: true,
482
+ variation: false,
483
+ options: assignment.terms,
484
+ });
485
+ }
486
+ }
487
+ return Ok(merged);
488
+ }
489
+ async function updateProduct(fetchImpl, store, auth, item, plan, attributeCache, termCache) {
490
+ const body = { ...plan.productBody };
491
+ if (Array.isArray(body.images)) {
492
+ const current = await request(fetchImpl, buildWooUrl(store.storeDomain, `/wp-json/wc/v3/products/${encodeURIComponent(item.externalId)}`, auth.key, auth.secret, 1), undefined, catalogRequestOptions);
493
+ if (!current.ok)
494
+ return Err(connectorError(current.error));
495
+ body.images = mergeWooImages(current.value.data.images ?? [], body.images);
496
+ }
497
+ if (plan.filterableFields.length > 0) {
498
+ const attributes = await mergedFilterableAttributes(fetchImpl, store, auth, item.externalId, plan.filterableFields, attributeCache, termCache);
499
+ if (!attributes.ok)
500
+ return attributes;
501
+ body.attributes = attributes.value;
502
+ }
503
+ if (Object.keys(body).length === 0)
504
+ return Ok(undefined);
505
+ const result = await request(fetchImpl, buildWooUrl(store.storeDomain, `/wp-json/wc/v3/products/${encodeURIComponent(item.externalId)}`, auth.key, auth.secret, 1), {
506
+ method: "PUT",
507
+ headers: { "content-type": "application/json" },
508
+ body: JSON.stringify(body),
509
+ }, catalogRequestOptions);
510
+ return result.ok ? Ok(undefined) : Err(connectorError(result.error));
511
+ }
512
+ async function updateVariant(fetchImpl, store, auth, productId, variant, body) {
513
+ if (Object.keys(body).length === 0)
514
+ return Ok(undefined);
515
+ const result = await request(fetchImpl, buildWooUrl(store.storeDomain, `/wp-json/wc/v3/products/${encodeURIComponent(productId)}/variations/${encodeURIComponent(variant.externalId)}`, auth.key, auth.secret, 1), {
516
+ method: "PUT",
517
+ headers: { "content-type": "application/json" },
518
+ body: JSON.stringify(body),
519
+ }, catalogRequestOptions);
520
+ return result.ok ? Ok(undefined) : Err(connectorError(result.error));
521
+ }
522
+ async function pushCatalogItem(fetchImpl, store, auth, item, plan, attributeCache, termCache) {
523
+ const product = await updateProduct(fetchImpl, store, auth, item, plan, attributeCache, termCache);
524
+ if (!product.ok)
525
+ return product;
526
+ for (const variant of plan.variants) {
527
+ const updated = await updateVariant(fetchImpl, store, auth, item.externalId, variant.variant, variant.body);
528
+ if (!updated.ok)
529
+ return updated;
530
+ }
531
+ return Ok(undefined);
532
+ }
533
+ async function pushCatalogBatch(fetchImpl, store, auth, entries) {
534
+ const update = entries.map(({ item, plan }) => ({ id: batchId(item.externalId), ...plan.productBody }));
535
+ const result = await request(fetchImpl, buildWooUrl(store.storeDomain, "/wp-json/wc/v3/products/batch", auth.key, auth.secret, 1), {
536
+ method: "POST",
537
+ headers: { "content-type": "application/json" },
538
+ body: JSON.stringify({ update }),
539
+ }, { retryableClientErrors: true });
540
+ if (!result.ok)
541
+ return Err(connectorError(result.error));
542
+ const responseById = new Map((result.value.data.update ?? []).map((entry) => [String(entry.id), entry]));
543
+ return Ok(entries.map((entry) => {
544
+ const responseEntry = responseById.get(String(batchId(entry.item.externalId)));
545
+ if (!responseEntry) {
546
+ return { index: entry.index, error: { code: "WOO_API_FAILED", message: "WooCommerce batch response omitted this product.", retriable: false } };
547
+ }
548
+ return responseEntry.error === undefined ? { index: entry.index } : { index: entry.index, error: batchItemError(responseEntry.error) };
549
+ }));
550
+ }
30
551
  function wooStatus(status) {
31
552
  if (status === "completed")
32
553
  return { status: "fulfilled" };
@@ -64,9 +585,12 @@ function storeUrl(domain) {
64
585
  }
65
586
  export function wooConnector(options = {}) {
66
587
  const fetchImpl = options.fetchImpl ?? fetch;
588
+ const currencyCache = new Map();
589
+ const attributeCache = new Map();
590
+ const termCache = new Map();
67
591
  return defineChannelConnector({
68
592
  providerId: "woocommerce",
69
- capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
593
+ capabilities: { importCatalog: true, importInventory: true, pushOrder: true, pushCatalog: true, receiveWebhooks: true },
70
594
  buildAuthUrl(params) {
71
595
  const store = storeUrl(params.storeDomain);
72
596
  if (!store)
@@ -120,22 +644,65 @@ export function wooConnector(options = {}) {
120
644
  const parsedPage = isPage && pagePart ? Number.parseInt(pagePart, 10) : 1;
121
645
  const page = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
122
646
  const modifiedAfter = afterParts.length > 0 ? afterParts.join("|") : (!isPage ? cursor : undefined);
647
+ const currencyKey = store.storeDomain.replace(/\/$/, "");
648
+ let currencyPromise = currencyCache.get(currencyKey);
649
+ if (!currencyPromise) {
650
+ currencyPromise = fetchWooCurrency(fetchImpl, buildWooUrl(store.storeDomain, "/wp-json/wc/v3/settings/general", auth.key, auth.secret, 1));
651
+ currencyCache.set(currencyKey, currencyPromise);
652
+ }
653
+ const currency = await currencyPromise;
123
654
  const result = await request(fetchImpl, buildWooUrl(store.storeDomain, "/wp-json/wc/v3/products", auth.key, auth.secret, page, modifiedAfter));
124
655
  if (!result.ok)
125
656
  return result;
126
657
  const totalPages = Number.parseInt(result.value.response.headers.get("x-wp-totalpages") ?? "1", 10);
127
658
  const nextCursor = page < totalPages ? (modifiedAfter ? `${page + 1}|${modifiedAfter}` : String(page + 1)) : null;
128
- return Ok({ items: result.value.data.map((product) => ({
659
+ const items = [];
660
+ for (const product of result.value.data) {
661
+ const references = product.variations ?? [];
662
+ const details = references.length > 0
663
+ ? await fetchProductVariations(fetchImpl, store.storeDomain, auth, String(product.id), modifiedAfter)
664
+ : Ok([]);
665
+ if (!details.ok)
666
+ return details;
667
+ const variants = mergeProductVariations(references, details.value).map((variant) => {
668
+ const optionValues = Object.fromEntries((variant.attributes ?? []).flatMap((attribute) => (attribute.option != null && attribute.option !== "" ? [[attribute.name, attribute.option]] : [])));
669
+ const prices = pricesForVariation(variant, currency);
670
+ return {
671
+ externalId: String(variant.id),
672
+ ...(variant.sku ? { sku: variant.sku } : {}),
673
+ ...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
674
+ ...(prices ? { prices } : {}),
675
+ };
676
+ });
677
+ const options = product.attributes?.filter((attribute) => attribute.variation === true).map((attribute, index) => ({
678
+ name: attribute.name,
679
+ displayName: attribute.name,
680
+ ...(attribute.position != null ? { sortOrder: attribute.position } : { sortOrder: index }),
681
+ values: (attribute.options ?? []).map((value, valueIndex) => ({ value, displayValue: value, sortOrder: valueIndex })),
682
+ }));
683
+ const status = catalogStatus(product.status);
684
+ items.push({
129
685
  externalId: String(product.id),
130
686
  slug: product.slug ?? String(product.id),
131
687
  title: product.name,
132
- ...(product.description ? { description: product.description } : {}),
133
- variants: (product.variations ?? []).map((variant) => ({
134
- externalId: String(variant.id),
135
- ...(variant.sku ? { sku: variant.sku } : {}),
136
- metadata: { price: parseMoney(variant.price) },
137
- })),
138
- })), nextCursor });
688
+ attributes: [{ locale: "en", title: product.name, ...(product.description != null ? { description: product.description } : {}) }],
689
+ variants,
690
+ ...(product.images ? {
691
+ images: product.images.map((image, index) => ({
692
+ externalId: String(image.id),
693
+ url: image.src,
694
+ ...(image.alt != null ? { alt: image.alt } : {}),
695
+ role: index === 0 ? "primary" : "gallery",
696
+ ...(image.position != null ? { sortOrder: image.position } : {}),
697
+ })),
698
+ } : {}),
699
+ ...(options ? { options } : {}),
700
+ ...(product.tags ? { tags: product.tags.flatMap((tag) => tag.slug ? [tag.slug] : []) } : {}),
701
+ ...(product.categories ? { categories: product.categories.flatMap((category) => category.slug ? [category.slug] : []) } : {}),
702
+ ...(status ? { status } : {}),
703
+ });
704
+ }
705
+ return Ok({ items, nextCursor });
139
706
  },
140
707
  async fetchInventory(store, ids) {
141
708
  const auth = credentials(store);
@@ -165,6 +732,60 @@ export function wooConnector(options = {}) {
165
732
  const id = String(result.value.data.id);
166
733
  return Ok({ remoteOrderId: id, remoteUrl: `${store.storeDomain.replace(/\/$/, "")}/wp-admin/post.php?post=${id}&action=edit` });
167
734
  },
735
+ async pushCatalog(store, items, opts) {
736
+ if (store.provider !== "woocommerce") {
737
+ return Err({ code: "WOO_INVALID_STORE_PROVIDER", message: "WooCommerce catalog pushes require a WooCommerce store.", retriable: false });
738
+ }
739
+ const auth = credentials(store);
740
+ if (!auth)
741
+ return Err({ code: "WOO_CREDENTIALS_REQUIRED", message: "WooCommerce consumerKey and consumerSecret are required.", retriable: false });
742
+ const outcomes = items.map((item) => ({ externalId: item.externalId, ok: true }));
743
+ const planned = [];
744
+ for (const [index, item] of items.entries()) {
745
+ const plan = buildCatalogPlan(item);
746
+ if (!plan.ok) {
747
+ outcomes[index] = { externalId: item.externalId, ok: false, error: plan.error };
748
+ }
749
+ else {
750
+ planned.push({ index, item, plan: plan.value });
751
+ }
752
+ }
753
+ if (opts?.dryRun)
754
+ return Ok({ outcomes });
755
+ const batchable = planned.filter(({ plan }) => plan.filterableFields.length === 0 && plan.variants.length === 0 && !Object.prototype.hasOwnProperty.call(plan.productBody, "images") && Object.keys(plan.productBody).length > 0);
756
+ const batched = new Set();
757
+ if (batchable.length > 1) {
758
+ for (let offset = 0; offset < batchable.length; offset += WOO_BATCH_LIMIT) {
759
+ const chunk = batchable.slice(offset, offset + WOO_BATCH_LIMIT);
760
+ const batchResult = await pushCatalogBatch(fetchImpl, store, auth, chunk);
761
+ for (const entry of chunk) {
762
+ batched.add(entry.index);
763
+ }
764
+ if (!batchResult.ok) {
765
+ for (const entry of chunk)
766
+ outcomes[entry.index] = { externalId: entry.item.externalId, ok: false, error: batchResult.error };
767
+ }
768
+ else {
769
+ const entryByIndex = new Map(chunk.map((entry) => [entry.index, entry.item]));
770
+ for (const entry of batchResult.value) {
771
+ if (entry.error) {
772
+ const item = entryByIndex.get(entry.index);
773
+ if (item)
774
+ outcomes[entry.index] = { externalId: item.externalId, ok: false, error: entry.error };
775
+ }
776
+ }
777
+ }
778
+ }
779
+ }
780
+ for (const entry of planned) {
781
+ if (batched.has(entry.index))
782
+ continue;
783
+ const pushed = await pushCatalogItem(fetchImpl, store, auth, entry.item, entry.plan, attributeCache, termCache);
784
+ if (!pushed.ok)
785
+ outcomes[entry.index] = { externalId: entry.item.externalId, ok: false, error: pushed.error };
786
+ }
787
+ return Ok({ outcomes });
788
+ },
168
789
  async fetchOrderStatus(store, remoteId) {
169
790
  const auth = credentials(store);
170
791
  if (!auth)