@porulle/adapter-woocommerce 0.10.8 → 0.11.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,23 @@
1
1
  import { defineChannelConnector, Err, Ok } from "@porulle/core";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
+ const zeroDecimalCurrencies = new Set([
4
+ "BIF",
5
+ "CLP",
6
+ "DJF",
7
+ "GNF",
8
+ "ISK",
9
+ "JPY",
10
+ "KMF",
11
+ "KRW",
12
+ "PYG",
13
+ "RWF",
14
+ "UGX",
15
+ "VND",
16
+ "VUV",
17
+ "XAF",
18
+ "XOF",
19
+ "XPF",
20
+ ]);
3
21
  function buildWooUrl(base, path, key, secret, page, cursor) {
4
22
  const url = new URL(path, base.replace(/\/$/, "/"));
5
23
  url.searchParams.set("consumer_key", key);
@@ -10,11 +28,85 @@ function buildWooUrl(base, path, key, secret, page, cursor) {
10
28
  url.searchParams.set("modified_after", cursor);
11
29
  return url.toString();
12
30
  }
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;
31
+ function normalizeCurrency(value) {
32
+ if (typeof value !== "string" || value.trim() === "")
33
+ return undefined;
34
+ return value.trim().toUpperCase();
35
+ }
36
+ function parseMoney(value, currency) {
37
+ if (value == null || value.trim() === "")
38
+ return undefined;
39
+ const parsed = Number(value);
40
+ if (!Number.isFinite(parsed))
41
+ return undefined;
42
+ const exponent = zeroDecimalCurrencies.has(currency) ? 0 : 2;
43
+ return Math.round(parsed * (10 ** exponent));
44
+ }
45
+ function pricesForVariation(variation, currency) {
46
+ if (!currency)
47
+ return undefined;
48
+ const amount = parseMoney(variation.price, currency);
49
+ return amount === undefined ? undefined : [{ currency, amount }];
50
+ }
51
+ function catalogStatus(value) {
52
+ if (value === "publish")
53
+ return "active";
54
+ if (value === "draft" || value === "private")
55
+ return "draft";
56
+ return undefined;
57
+ }
58
+ function asRecord(value) {
59
+ return typeof value === "object" && value !== null ? value : undefined;
60
+ }
61
+ function settingCurrency(data) {
62
+ if (Array.isArray(data)) {
63
+ for (const entry of data) {
64
+ const setting = asRecord(entry);
65
+ if (setting?.id === "woocommerce_currency")
66
+ return normalizeCurrency(setting.value);
67
+ }
68
+ }
69
+ const object = asRecord(data);
70
+ if (!object)
71
+ return undefined;
72
+ const direct = normalizeCurrency(object.woocommerce_currency);
73
+ if (direct)
74
+ return direct;
75
+ const systemStatus = asRecord(object.system_status);
76
+ return normalizeCurrency(systemStatus?.woocommerce_currency);
77
+ }
78
+ async function fetchWooCurrency(fetchImpl, url) {
79
+ const result = await request(fetchImpl, url);
80
+ return result.ok ? settingCurrency(result.value.data) : undefined;
81
+ }
82
+ async function fetchProductVariations(fetchImpl, storeDomain, auth, productId, modifiedAfter) {
83
+ const variations = [];
84
+ let page = 1;
85
+ while (true) {
86
+ const result = await request(fetchImpl, buildWooUrl(storeDomain, `/wp-json/wc/v3/products/${encodeURIComponent(productId)}/variations`, auth.key, auth.secret, page, modifiedAfter));
87
+ if (!result.ok)
88
+ return result;
89
+ variations.push(...result.value.data);
90
+ const totalPages = Number.parseInt(result.value.response.headers.get("x-wp-totalpages") ?? "1", 10);
91
+ if (!Number.isFinite(totalPages) || page >= totalPages)
92
+ break;
93
+ page += 1;
94
+ }
95
+ return Ok(variations);
96
+ }
97
+ function variationFromReference(reference) {
98
+ return typeof reference === "object" && reference !== null ? reference : { id: reference };
99
+ }
100
+ function mergeProductVariations(references, details) {
101
+ const detailById = new Map(details.map((variation) => [String(variation.id), variation]));
102
+ const referencedIds = new Set();
103
+ const merged = references.map((reference) => {
104
+ const fallback = variationFromReference(reference);
105
+ const id = String(fallback.id);
106
+ referencedIds.add(id);
107
+ return detailById.get(id) ?? fallback;
108
+ });
109
+ return [...merged, ...details.filter((variation) => !referencedIds.has(String(variation.id)))];
18
110
  }
19
111
  async function request(fetchImpl, url, init) {
20
112
  try {
@@ -64,6 +156,7 @@ function storeUrl(domain) {
64
156
  }
65
157
  export function wooConnector(options = {}) {
66
158
  const fetchImpl = options.fetchImpl ?? fetch;
159
+ const currencyCache = new Map();
67
160
  return defineChannelConnector({
68
161
  providerId: "woocommerce",
69
162
  capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
@@ -120,22 +213,65 @@ export function wooConnector(options = {}) {
120
213
  const parsedPage = isPage && pagePart ? Number.parseInt(pagePart, 10) : 1;
121
214
  const page = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
122
215
  const modifiedAfter = afterParts.length > 0 ? afterParts.join("|") : (!isPage ? cursor : undefined);
216
+ const currencyKey = store.storeDomain.replace(/\/$/, "");
217
+ let currencyPromise = currencyCache.get(currencyKey);
218
+ if (!currencyPromise) {
219
+ currencyPromise = fetchWooCurrency(fetchImpl, buildWooUrl(store.storeDomain, "/wp-json/wc/v3/settings/general", auth.key, auth.secret, 1));
220
+ currencyCache.set(currencyKey, currencyPromise);
221
+ }
222
+ const currency = await currencyPromise;
123
223
  const result = await request(fetchImpl, buildWooUrl(store.storeDomain, "/wp-json/wc/v3/products", auth.key, auth.secret, page, modifiedAfter));
124
224
  if (!result.ok)
125
225
  return result;
126
226
  const totalPages = Number.parseInt(result.value.response.headers.get("x-wp-totalpages") ?? "1", 10);
127
227
  const nextCursor = page < totalPages ? (modifiedAfter ? `${page + 1}|${modifiedAfter}` : String(page + 1)) : null;
128
- return Ok({ items: result.value.data.map((product) => ({
228
+ const items = [];
229
+ for (const product of result.value.data) {
230
+ const references = product.variations ?? [];
231
+ const details = references.length > 0
232
+ ? await fetchProductVariations(fetchImpl, store.storeDomain, auth, String(product.id), modifiedAfter)
233
+ : Ok([]);
234
+ if (!details.ok)
235
+ return details;
236
+ const variants = mergeProductVariations(references, details.value).map((variant) => {
237
+ const optionValues = Object.fromEntries((variant.attributes ?? []).flatMap((attribute) => (attribute.option != null && attribute.option !== "" ? [[attribute.name, attribute.option]] : [])));
238
+ const prices = pricesForVariation(variant, currency);
239
+ return {
240
+ externalId: String(variant.id),
241
+ ...(variant.sku ? { sku: variant.sku } : {}),
242
+ ...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
243
+ ...(prices ? { prices } : {}),
244
+ };
245
+ });
246
+ const options = product.attributes?.filter((attribute) => attribute.variation === true).map((attribute, index) => ({
247
+ name: attribute.name,
248
+ displayName: attribute.name,
249
+ ...(attribute.position != null ? { sortOrder: attribute.position } : { sortOrder: index }),
250
+ values: (attribute.options ?? []).map((value, valueIndex) => ({ value, displayValue: value, sortOrder: valueIndex })),
251
+ }));
252
+ const status = catalogStatus(product.status);
253
+ items.push({
129
254
  externalId: String(product.id),
130
255
  slug: product.slug ?? String(product.id),
131
256
  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 });
257
+ attributes: [{ locale: "en", title: product.name, ...(product.description != null ? { description: product.description } : {}) }],
258
+ variants,
259
+ ...(product.images ? {
260
+ images: product.images.map((image, index) => ({
261
+ externalId: String(image.id),
262
+ url: image.src,
263
+ ...(image.alt != null ? { alt: image.alt } : {}),
264
+ role: index === 0 ? "primary" : "gallery",
265
+ ...(image.position != null ? { sortOrder: image.position } : {}),
266
+ })),
267
+ } : {}),
268
+ ...(options ? { options } : {}),
269
+ ...(product.tags ? { tags: product.tags.flatMap((tag) => tag.slug ? [tag.slug] : []) } : {}),
270
+ ...(product.categories ? { categories: product.categories.flatMap((category) => category.slug ? [category.slug] : []) } : {}),
271
+ ...(status ? { status } : {}),
272
+ });
273
+ }
274
+ return Ok({ items, nextCursor });
139
275
  },
140
276
  async fetchInventory(store, ids) {
141
277
  const auth = credentials(store);