@porulle/adapter-shopify 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/adapter-shopify",
3
- "version": "0.10.8",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,7 +10,7 @@
10
10
  }
11
11
  },
12
12
  "dependencies": {
13
- "@porulle/core": "0.10.8"
13
+ "@porulle/core": "0.13.0"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^24.5.2",
package/src/index.ts CHANGED
@@ -2,14 +2,30 @@ 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,
9
+ ChannelPushCatalogItem,
10
+ ChannelPushCatalogResult,
8
11
  ChannelStore,
9
12
  ChannelOrderSlice,
10
13
  ChannelOrderStatus,
11
14
  Result,
12
15
  } from "@porulle/core";
16
+ import {
17
+ pushCatalog as executePushCatalog,
18
+ } from "./push-catalog.js";
19
+
20
+ export {
21
+ PORULLE_METAFIELD_NAMESPACE,
22
+ PUSH_CATALOG_SCOPE,
23
+ SHOPIFY_NATIVE_PRODUCT_FIELDS,
24
+ SHOPIFY_NATIVE_VARIANT_FIELDS,
25
+ shopifyGrantedScopes,
26
+ shopifyPushCatalogEnabled,
27
+ shopifyWriteProductsScopeMissingError,
28
+ } from "./push-catalog.js";
13
29
 
14
30
  export interface ShopifyConnectorOptions {
15
31
  fetchImpl?: typeof fetch;
@@ -26,6 +42,7 @@ export const REQUIRED_SCOPES = [
26
42
  "read_orders",
27
43
  "write_orders",
28
44
  "read_fulfillments",
45
+ "write_products",
29
46
  ] as const;
30
47
 
31
48
  type ShopifyProduct = {
@@ -33,19 +50,108 @@ type ShopifyProduct = {
33
50
  title: string;
34
51
  handle?: string;
35
52
  body_html?: string;
36
- variants?: Array<{ id: number | string; sku?: string | null; barcode?: string | null; price?: string | null }>;
53
+ vendor?: string | null;
54
+ product_type?: string | null;
55
+ tags?: string | null;
56
+ status?: string | null;
57
+ images?: Array<{
58
+ id: number | string;
59
+ src: string;
60
+ alt?: string | null;
61
+ position?: number | null;
62
+ variant_ids?: Array<number | string> | null;
63
+ }>;
64
+ options?: Array<{
65
+ name: string;
66
+ position?: number | null;
67
+ values?: string[];
68
+ }>;
69
+ variants?: Array<{
70
+ id: number | string;
71
+ sku?: string | null;
72
+ barcode?: string | null;
73
+ price?: string | null;
74
+ compare_at_price?: string | null;
75
+ option1?: string | null;
76
+ option2?: string | null;
77
+ option3?: string | null;
78
+ }>;
37
79
  };
38
80
 
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;
81
+ const zeroDecimalCurrencies = new Set([
82
+ "BIF",
83
+ "CLP",
84
+ "DJF",
85
+ "GNF",
86
+ "ISK",
87
+ "JPY",
88
+ "KMF",
89
+ "KRW",
90
+ "PYG",
91
+ "RWF",
92
+ "UGX",
93
+ "VND",
94
+ "VUV",
95
+ "XAF",
96
+ "XOF",
97
+ "XPF",
98
+ ]);
99
+
100
+ function normalizeCurrency(value: unknown): string | undefined {
101
+ if (typeof value !== "string" || value.trim() === "") return undefined;
102
+ return value.trim().toUpperCase();
103
+ }
104
+
105
+ function parseMoney(value: string | null | undefined, currency: string): number | undefined {
106
+ if (value == null || value.trim() === "") return undefined;
107
+ const parsed = Number(value);
108
+ if (!Number.isFinite(parsed)) return undefined;
109
+ const exponent = zeroDecimalCurrencies.has(currency) ? 0 : 2;
110
+ return Math.round(parsed * (10 ** exponent));
111
+ }
112
+
113
+ function pricesForVariant(
114
+ variant: NonNullable<ShopifyProduct["variants"]>[number],
115
+ currency: string | undefined,
116
+ ): ChannelCatalogPrice[] | undefined {
117
+ if (!currency) return undefined;
118
+ const amount = parseMoney(variant.price, currency);
119
+ if (amount === undefined) return undefined;
120
+ const compareAtAmount = parseMoney(variant.compare_at_price, currency);
121
+ return [{
122
+ currency,
123
+ amount,
124
+ ...(compareAtAmount !== undefined && compareAtAmount !== amount ? { compareAtAmount } : {}),
125
+ }];
126
+ }
127
+
128
+ function catalogStatus(value: string | null | undefined): "draft" | "active" | "archived" | undefined {
129
+ return value === "draft" || value === "active" || value === "archived" ? value : undefined;
130
+ }
131
+
132
+ function slugify(value: string): string {
133
+ return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
134
+ }
135
+
136
+ async function fetchShopCurrency(fetchImpl: typeof fetch, url: string, accessToken: string): Promise<string | undefined> {
137
+ const result = await request<{ shop?: { currency?: string | null } }>(fetchImpl, url, accessToken);
138
+ return result.ok ? normalizeCurrency(result.value.data.shop?.currency) : undefined;
43
139
  }
44
140
 
45
141
  function apiBase(store: ChannelStore, version: string): string {
46
142
  return `https://${store.storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}/admin/api/${version}`;
47
143
  }
48
144
 
145
+ function shopifyOAuthStartUrl(appUrl: string, storeDomain: string): string | undefined {
146
+ try {
147
+ const url = new URL("/api/channels/oauth/shopify/start", appUrl);
148
+ url.searchParams.set("shop", storeDomain);
149
+ return url.toString();
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
49
155
  async function request<T>(fetchImpl: typeof fetch, url: string, accessToken: string, init?: RequestInit): Promise<Result<{ data: T; response: Response }>> {
50
156
  try {
51
157
  const response = await fetchImpl(url, {
@@ -103,12 +209,29 @@ function oauthError(code: string, message: string): Result<never, ChannelConnect
103
209
  return Err({ code, message, retriable: false });
104
210
  }
105
211
 
212
+ export function shopifyReauthorizeUrl(
213
+ options: ShopifyConnectorOptions,
214
+ params: {
215
+ storeDomain: string;
216
+ state: string;
217
+ redirectUri: string;
218
+ callbackUri: string;
219
+ scopes?: string[];
220
+ },
221
+ ): Result<string, ChannelConnectorError> {
222
+ return shopifyConnector(options).buildAuthUrl!({
223
+ ...params,
224
+ scopes: params.scopes ?? [],
225
+ });
226
+ }
227
+
106
228
  export function shopifyConnector(options: ShopifyConnectorOptions = {}): ChannelConnector {
107
229
  const fetchImpl = options.fetchImpl ?? fetch;
108
230
  const version = options.apiVersion ?? "2024-10";
231
+ const currencyCache = new Map<string, Promise<string | undefined>>();
109
232
  return defineChannelConnector({
110
233
  providerId: "shopify",
111
- capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
234
+ capabilities: { importCatalog: true, importInventory: true, pushOrder: true, pushCatalog: true, receiveWebhooks: true },
112
235
  buildAuthUrl(params) {
113
236
  if (!options.clientId || !options.clientSecret || !options.appUrl) {
114
237
  return oauthError("SHOPIFY_OAUTH_NOT_CONFIGURED", "Shopify OAuth requires clientId, clientSecret, and appUrl.");
@@ -149,9 +272,12 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
149
272
  body: JSON.stringify({ client_id: options.clientId, client_secret: options.clientSecret, code }),
150
273
  });
151
274
  if (!response.ok) return oauthError("SHOPIFY_TOKEN_EXCHANGE_FAILED", `Shopify token exchange failed (${response.status}).`);
152
- const body = await response.json() as { access_token?: unknown };
275
+ const body = await response.json() as { access_token?: unknown; scope?: unknown };
153
276
  if (typeof body.access_token !== "string" || !body.access_token) return oauthError("SHOPIFY_TOKEN_INVALID", "Shopify token exchange did not return an access token.");
154
- return Ok({ credentials: { accessToken: body.access_token }, storeDomain: shopDomain });
277
+ const grantedScopes = typeof body.scope === "string"
278
+ ? body.scope.split(",").map((scope) => scope.trim()).filter(Boolean)
279
+ : [];
280
+ return Ok({ credentials: { accessToken: body.access_token, grantedScopes }, storeDomain: shopDomain });
155
281
  } catch (error) {
156
282
  return Err({ code: "SHOPIFY_TOKEN_EXCHANGE_FAILED", message: error instanceof Error ? error.message : "Shopify token exchange failed.", retriable: true });
157
283
  }
@@ -159,24 +285,67 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
159
285
  async importCatalog(store, cursor): Promise<Result<ChannelCatalogPage>> {
160
286
  const token = credentials(store);
161
287
  if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
288
+ const currencyUrl = `${apiBase(store, version)}/shop.json`;
289
+ const currencyKey = apiBase(store, version);
290
+ let currencyPromise = currencyCache.get(currencyKey);
291
+ if (!currencyPromise) {
292
+ currencyPromise = fetchShopCurrency(fetchImpl, currencyUrl, token);
293
+ currencyCache.set(currencyKey, currencyPromise);
294
+ }
295
+ const currency = await currencyPromise;
162
296
  const url = cursor ?? `${apiBase(store, version)}/products.json?limit=250`;
163
297
  const result = await request<{ products: ShopifyProduct[] }>(fetchImpl, url, token);
164
298
  if (!result.ok) return result;
165
299
  const link = result.value.response.headers.get("link") ?? "";
166
300
  const next = link.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null;
167
301
  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
- })),
302
+ items: result.value.data.products.map((product) => {
303
+ const options = product.options?.map((option, index) => ({
304
+ name: option.name,
305
+ displayName: option.name,
306
+ ...(option.position != null ? { sortOrder: option.position } : { sortOrder: index }),
307
+ values: (option.values ?? []).map((value, valueIndex) => ({ value, displayValue: value, sortOrder: valueIndex })),
308
+ }));
309
+ const variants = (product.variants ?? []).map((variant) => {
310
+ const selectors = [variant.option1, variant.option2, variant.option3];
311
+ const optionValues = Object.fromEntries((product.options ?? []).slice(0, 3).flatMap((option, index) => {
312
+ const value = selectors[index];
313
+ return value != null && value !== "" ? [[option.name, value] as const] : [];
314
+ }));
315
+ const prices = pricesForVariant(variant, currency);
316
+ return {
317
+ externalId: String(variant.id),
318
+ ...(variant.sku ? { sku: variant.sku } : {}),
319
+ ...(variant.barcode ? { barcode: variant.barcode } : {}),
320
+ ...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
321
+ ...(prices ? { prices } : {}),
322
+ };
323
+ });
324
+ const category = product.product_type ? slugify(product.product_type) : "";
325
+ const status = catalogStatus(product.status);
326
+ return {
327
+ externalId: String(product.id),
328
+ slug: product.handle ?? String(product.id),
329
+ title: product.title,
330
+ attributes: [{ locale: "en", title: product.title, ...(product.body_html != null ? { description: product.body_html } : {}) }],
331
+ variants,
332
+ ...(product.images ? {
333
+ images: product.images.map((image, index) => ({
334
+ externalId: String(image.id),
335
+ url: image.src,
336
+ ...(image.alt != null ? { alt: image.alt } : {}),
337
+ role: index === 0 ? "primary" as const : "gallery" as const,
338
+ ...(image.position != null ? { sortOrder: image.position } : {}),
339
+ ...(image.variant_ids != null ? { variantExternalIds: image.variant_ids.map(String) } : {}),
340
+ })),
341
+ } : {}),
342
+ ...(options ? { options } : {}),
343
+ ...(product.tags != null ? { tags: product.tags.split(",").map((tag) => tag.trim()).filter(Boolean) } : {}),
344
+ ...(product.vendor ? { brand: product.vendor } : {}),
345
+ ...(category ? { categories: [category] } : {}),
346
+ ...(status ? { status } : {}),
347
+ };
348
+ }),
180
349
  nextCursor: next,
181
350
  });
182
351
  },
@@ -189,6 +358,17 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
189
358
  if (!result.ok) return result;
190
359
  return Ok(result.value.data.inventory_levels.map((level) => ({ externalId: String(level.inventory_item_id), available: level.available ?? 0 })));
191
360
  },
361
+ async pushCatalog(store: ChannelStore, items: ChannelPushCatalogItem[], opts?: { dryRun?: boolean }): Promise<Result<ChannelPushCatalogResult, ChannelConnectorError>> {
362
+ const oauthStartUrl = options.appUrl ? shopifyOAuthStartUrl(options.appUrl, store.storeDomain) : undefined;
363
+ return executePushCatalog({
364
+ fetchImpl,
365
+ apiBase: (target) => apiBase(target, version),
366
+ credentials,
367
+ }, store, items, {
368
+ ...(opts?.dryRun === true ? { dryRun: true } : {}),
369
+ ...(oauthStartUrl ? { reauthorizeUrl: oauthStartUrl } : {}),
370
+ });
371
+ },
192
372
  async pushOrder(store, slice: ChannelOrderSlice) {
193
373
  const token = credentials(store);
194
374
  if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });