@porulle/adapter-shopify 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/adapter-shopify",
3
- "version": "0.10.8",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,15 +10,15 @@
10
10
  }
11
11
  },
12
12
  "dependencies": {
13
- "@porulle/core": "0.10.8"
13
+ "@porulle/core": "0.11.0"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^24.5.2",
17
17
  "eslint": "^9.39.1",
18
18
  "typescript": "5.9.2",
19
19
  "vitest": "^3.2.4",
20
- "@porulle/typescript-config": "0.1.0",
21
- "@porulle/eslint-config": "0.1.0"
20
+ "@porulle/eslint-config": "0.1.0",
21
+ "@porulle/typescript-config": "0.1.0"
22
22
  },
23
23
  "publishConfig": {
24
24
  "access": "public"
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import { defineChannelConnector, Err, Ok } from "@porulle/core";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  import type {
4
4
  ChannelCatalogPage,
5
+ ChannelCatalogPrice,
5
6
  ChannelConnector,
6
7
  ChannelConnectorError,
7
8
  ChannelInventoryLevel,
@@ -33,13 +34,92 @@ type ShopifyProduct = {
33
34
  title: string;
34
35
  handle?: string;
35
36
  body_html?: string;
36
- variants?: Array<{ id: number | string; sku?: string | null; barcode?: string | null; price?: string | null }>;
37
+ vendor?: string | null;
38
+ product_type?: string | null;
39
+ tags?: string | null;
40
+ status?: string | null;
41
+ images?: Array<{
42
+ id: number | string;
43
+ src: string;
44
+ alt?: string | null;
45
+ position?: number | null;
46
+ variant_ids?: Array<number | string> | null;
47
+ }>;
48
+ options?: Array<{
49
+ name: string;
50
+ position?: number | null;
51
+ values?: string[];
52
+ }>;
53
+ variants?: Array<{
54
+ id: number | string;
55
+ sku?: string | null;
56
+ barcode?: string | null;
57
+ price?: string | null;
58
+ compare_at_price?: string | null;
59
+ option1?: string | null;
60
+ option2?: string | null;
61
+ option3?: string | null;
62
+ }>;
37
63
  };
38
64
 
39
- function parseMoney(value: string | null | undefined): number {
40
- if (!value) return 0;
41
- const parsed = Number.parseFloat(value);
42
- return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
65
+ const zeroDecimalCurrencies = new Set([
66
+ "BIF",
67
+ "CLP",
68
+ "DJF",
69
+ "GNF",
70
+ "ISK",
71
+ "JPY",
72
+ "KMF",
73
+ "KRW",
74
+ "PYG",
75
+ "RWF",
76
+ "UGX",
77
+ "VND",
78
+ "VUV",
79
+ "XAF",
80
+ "XOF",
81
+ "XPF",
82
+ ]);
83
+
84
+ function normalizeCurrency(value: unknown): string | undefined {
85
+ if (typeof value !== "string" || value.trim() === "") return undefined;
86
+ return value.trim().toUpperCase();
87
+ }
88
+
89
+ function parseMoney(value: string | null | undefined, currency: string): number | undefined {
90
+ if (value == null || value.trim() === "") return undefined;
91
+ const parsed = Number(value);
92
+ if (!Number.isFinite(parsed)) return undefined;
93
+ const exponent = zeroDecimalCurrencies.has(currency) ? 0 : 2;
94
+ return Math.round(parsed * (10 ** exponent));
95
+ }
96
+
97
+ function pricesForVariant(
98
+ variant: NonNullable<ShopifyProduct["variants"]>[number],
99
+ currency: string | undefined,
100
+ ): ChannelCatalogPrice[] | undefined {
101
+ if (!currency) return undefined;
102
+ const amount = parseMoney(variant.price, currency);
103
+ if (amount === undefined) return undefined;
104
+ const compareAtAmount = parseMoney(variant.compare_at_price, currency);
105
+ return [{
106
+ currency,
107
+ amount,
108
+ ...(compareAtAmount !== undefined && compareAtAmount !== amount ? { compareAtAmount } : {}),
109
+ }];
110
+ }
111
+
112
+ function catalogStatus(value: string | null | undefined): "draft" | "active" | "archived" | undefined {
113
+ return value === "draft" || value === "active" || value === "archived" ? value : undefined;
114
+ }
115
+
116
+ function slugify(value: string): string {
117
+ return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
118
+ }
119
+
120
+ async function fetchShopCurrency(fetchImpl: typeof fetch, url: string, accessToken: string): Promise<string | undefined> {
121
+ const result = await request<{ shop?: { currency?: string | null } }>(fetchImpl, url, accessToken);
122
+ return result.ok ? normalizeCurrency(result.value.data.shop?.currency) : undefined;
43
123
  }
44
124
 
45
125
  function apiBase(store: ChannelStore, version: string): string {
@@ -106,6 +186,7 @@ function oauthError(code: string, message: string): Result<never, ChannelConnect
106
186
  export function shopifyConnector(options: ShopifyConnectorOptions = {}): ChannelConnector {
107
187
  const fetchImpl = options.fetchImpl ?? fetch;
108
188
  const version = options.apiVersion ?? "2024-10";
189
+ const currencyCache = new Map<string, Promise<string | undefined>>();
109
190
  return defineChannelConnector({
110
191
  providerId: "shopify",
111
192
  capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
@@ -159,24 +240,67 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
159
240
  async importCatalog(store, cursor): Promise<Result<ChannelCatalogPage>> {
160
241
  const token = credentials(store);
161
242
  if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
243
+ const currencyUrl = `${apiBase(store, version)}/shop.json`;
244
+ const currencyKey = apiBase(store, version);
245
+ let currencyPromise = currencyCache.get(currencyKey);
246
+ if (!currencyPromise) {
247
+ currencyPromise = fetchShopCurrency(fetchImpl, currencyUrl, token);
248
+ currencyCache.set(currencyKey, currencyPromise);
249
+ }
250
+ const currency = await currencyPromise;
162
251
  const url = cursor ?? `${apiBase(store, version)}/products.json?limit=250`;
163
252
  const result = await request<{ products: ShopifyProduct[] }>(fetchImpl, url, token);
164
253
  if (!result.ok) return result;
165
254
  const link = result.value.response.headers.get("link") ?? "";
166
255
  const next = link.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null;
167
256
  return Ok({
168
- items: result.value.data.products.map((product) => ({
169
- externalId: String(product.id),
170
- slug: product.handle ?? String(product.id),
171
- title: product.title,
172
- ...(product.body_html ? { description: product.body_html } : {}),
173
- variants: (product.variants ?? []).map((variant) => ({
174
- externalId: String(variant.id),
175
- ...(variant.sku ? { sku: variant.sku } : {}),
176
- ...(variant.barcode ? { barcode: variant.barcode } : {}),
177
- metadata: { price: parseMoney(variant.price) },
178
- })),
179
- })),
257
+ items: result.value.data.products.map((product) => {
258
+ const options = product.options?.map((option, index) => ({
259
+ name: option.name,
260
+ displayName: option.name,
261
+ ...(option.position != null ? { sortOrder: option.position } : { sortOrder: index }),
262
+ values: (option.values ?? []).map((value, valueIndex) => ({ value, displayValue: value, sortOrder: valueIndex })),
263
+ }));
264
+ const variants = (product.variants ?? []).map((variant) => {
265
+ const selectors = [variant.option1, variant.option2, variant.option3];
266
+ const optionValues = Object.fromEntries((product.options ?? []).slice(0, 3).flatMap((option, index) => {
267
+ const value = selectors[index];
268
+ return value != null && value !== "" ? [[option.name, value] as const] : [];
269
+ }));
270
+ const prices = pricesForVariant(variant, currency);
271
+ return {
272
+ externalId: String(variant.id),
273
+ ...(variant.sku ? { sku: variant.sku } : {}),
274
+ ...(variant.barcode ? { barcode: variant.barcode } : {}),
275
+ ...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
276
+ ...(prices ? { prices } : {}),
277
+ };
278
+ });
279
+ const category = product.product_type ? slugify(product.product_type) : "";
280
+ const status = catalogStatus(product.status);
281
+ return {
282
+ externalId: String(product.id),
283
+ slug: product.handle ?? String(product.id),
284
+ title: product.title,
285
+ attributes: [{ locale: "en", title: product.title, ...(product.body_html != null ? { description: product.body_html } : {}) }],
286
+ variants,
287
+ ...(product.images ? {
288
+ images: product.images.map((image, index) => ({
289
+ externalId: String(image.id),
290
+ url: image.src,
291
+ ...(image.alt != null ? { alt: image.alt } : {}),
292
+ role: index === 0 ? "primary" as const : "gallery" as const,
293
+ ...(image.position != null ? { sortOrder: image.position } : {}),
294
+ ...(image.variant_ids != null ? { variantExternalIds: image.variant_ids.map(String) } : {}),
295
+ })),
296
+ } : {}),
297
+ ...(options ? { options } : {}),
298
+ ...(product.tags != null ? { tags: product.tags.split(",").map((tag) => tag.trim()).filter(Boolean) } : {}),
299
+ ...(product.vendor ? { brand: product.vendor } : {}),
300
+ ...(category ? { categories: [category] } : {}),
301
+ ...(status ? { status } : {}),
302
+ };
303
+ }),
180
304
  nextCursor: next,
181
305
  });
182
306
  },