@porulle/adapter-shopify 0.65.0 → 0.66.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,154 +1,71 @@
1
- import { defineChannelConnector, Err, Ok, toMinorUnits } from "@porulle/core";
2
1
  import { createHmac, timingSafeEqual } from "node:crypto";
3
- import { pushCatalog as executePushCatalog, } from "./push-catalog.js";
4
- export { PORULLE_METAFIELD_NAMESPACE, PUSH_CATALOG_SCOPE, SHOPIFY_NATIVE_PRODUCT_FIELDS, SHOPIFY_NATIVE_VARIANT_FIELDS, shopifyGrantedScopes, shopifyPushCatalogEnabled, shopifyWriteProductsScopeMissingError, } from "./push-catalog.js";
5
- export const REQUIRED_SCOPES = [
6
- "read_products",
7
- "read_inventory",
8
- "read_orders",
9
- "write_orders",
10
- "read_fulfillments",
11
- "write_products",
12
- ];
13
- function normalizeCurrency(value) {
14
- if (typeof value !== "string" || value.trim() === "")
15
- return undefined;
16
- return value.trim().toUpperCase();
2
+ import { defineChannelConnector, Err, Ok } from "@porulle/core";
3
+ import { z } from "zod";
4
+ import { readCatalogItems, readCatalogPage } from "./catalog.js";
5
+ import { shopifyGid, shopifyGraphql } from "./graphql.js";
6
+ import { REQUIRED_SCOPES, buildAuthorizeUrl, exchangeCallback, normalizeShopDomain, parseShopifyCredentials, refreshIfExpiring, validShopDomain, } from "./oauth.js";
7
+ export { SHOPIFY_API_VERSION } from "./graphql.js";
8
+ export { CATALOG_ITEMS_QUERY, CATALOG_PAGE_QUERY, VARIANTS_PAGE_QUERY } from "./catalog.js";
9
+ export { REQUIRED_SCOPES, normalizeShopDomain, parseShopifyCredentials } from "./oauth.js";
10
+ export const INVENTORY_QUERY = `query PorulleInventoryPage($after: String) {
11
+ productVariants(first: 250, after: $after, sortKey: ID) { pageInfo { hasNextPage endCursor } nodes { legacyResourceId inventoryQuantity } }
12
+ }`;
13
+ export const VARIANT_INVENTORY_QUERY = `query PorulleVariantInventory($ids: [ID!]!) {
14
+ nodes(ids: $ids) { ... on ProductVariant { legacyResourceId inventoryQuantity } }
15
+ }`;
16
+ export const STORE_PROFILE_QUERY = `query PorulleStoreProfile {
17
+ shop { name currencyCode myshopifyDomain primaryDomain { host } }
18
+ }`;
19
+ export const ORDER_CREATE_MUTATION = `mutation PorulleOrderCreate($order: OrderCreateOrderInput!, $options: OrderCreateOptionsInput) {
20
+ orderCreate(order: $order, options: $options) { order { legacyResourceId } userErrors { field message } }
21
+ }`;
22
+ export const ORDER_BY_SOURCE_QUERY = `query PorulleOrderBySource($query: String!) {
23
+ orders(first: 1, query: $query) { nodes { legacyResourceId } }
24
+ }`;
25
+ export const ORDER_STATUS_QUERY = `query PorulleOrderStatus($id: ID!) {
26
+ order(id: $id) { cancelledAt displayFinancialStatus displayFulfillmentStatus }
27
+ }`;
28
+ const inventoryLevelSchema = z.object({ legacyResourceId: z.string(), inventoryQuantity: z.number().nullable() });
29
+ const inventoryPageSchema = z.object({
30
+ productVariants: z.object({ pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() }), nodes: z.array(inventoryLevelSchema) }),
31
+ });
32
+ const variantInventorySchema = z.object({ nodes: z.array(z.union([inventoryLevelSchema, z.object({}).strict(), z.null()])) });
33
+ const storeProfileSchema = z.object({
34
+ shop: z.object({ name: z.string(), currencyCode: z.string(), myshopifyDomain: z.string(), primaryDomain: z.object({ host: z.string() }) }),
35
+ });
36
+ const orderCreateSchema = z.object({
37
+ orderCreate: z.object({
38
+ order: z.object({ legacyResourceId: z.string() }).nullable(),
39
+ userErrors: z.array(z.object({ field: z.array(z.string()).nullable(), message: z.string() })),
40
+ }),
41
+ });
42
+ const orderBySourceSchema = z.object({ orders: z.object({ nodes: z.array(z.object({ legacyResourceId: z.string() })) }) });
43
+ const orderStatusSchema = z.object({
44
+ order: z.object({ cancelledAt: z.string().nullable(), displayFinancialStatus: z.string().nullable(), displayFulfillmentStatus: z.string() }).nullable(),
45
+ });
46
+ const SOURCE_NAME = "porulle";
47
+ /** `nodes` answers null for a deleted id and `{}` for an id of another type; neither is a level. */
48
+ function isVariantLevel(node) {
49
+ return node !== null && typeof node.legacyResourceId === "string";
17
50
  }
18
- function pricesForVariant(variant, currency) {
19
- if (!currency)
20
- return undefined;
21
- const amount = toMinorUnits(variant.price, currency);
22
- if (amount === undefined)
23
- return undefined;
24
- const compareAtAmount = toMinorUnits(variant.compare_at_price, currency);
25
- return [{
26
- currency,
27
- amount,
28
- ...(compareAtAmount !== undefined && compareAtAmount !== amount ? { compareAtAmount } : {}),
29
- }];
51
+ /** Stock is never negative here: an oversold variant reads as none available. */
52
+ function level(node) {
53
+ return { externalId: node.legacyResourceId, available: Math.max(0, node.inventoryQuantity ?? 0) };
30
54
  }
31
- /**
32
- * Shopify carries a variant's weight twice: `grams`, which it normalises itself,
33
- * and `weight` with a `weight_unit`. Prefer `grams`; fall back to converting
34
- * `weight` only when `grams` is absent or zero.
35
- *
36
- * Returns `undefined` — never `0` — when no weight is known, so the caller omits
37
- * the key entirely. A written `0` is indistinguishable from a genuinely
38
- * weightless item and would defeat a downstream default-parcel substitution.
39
- *
40
- * An unrecognised `weight_unit` is refused rather than assumed to be grams:
41
- * reading "lbs" (a spelling Shopify does not use, but a proxy might) as grams
42
- * under-prices a parcel by a factor of 453.
43
- */
44
- function weightGramsForVariant(variant) {
45
- const { grams, weight, weight_unit: weightUnit } = variant;
46
- if (typeof grams === "number" && Number.isFinite(grams) && grams > 0) {
47
- return Math.round(grams);
48
- }
49
- if (typeof weight === "number" && Number.isFinite(weight) && weight > 0) {
50
- switch (weightUnit ?? "g") {
51
- case "g":
52
- return Math.round(weight);
53
- case "kg":
54
- return Math.round(weight * 1000);
55
- case "oz":
56
- return Math.round(weight * 28.349523125);
57
- case "lb":
58
- return Math.round(weight * 453.59237);
59
- default:
60
- return undefined;
61
- }
62
- }
63
- return undefined;
64
- }
65
- function catalogStatus(value) {
66
- return value === "draft" || value === "active" || value === "archived" ? value : undefined;
67
- }
68
- function slugify(value) {
69
- return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
70
- }
71
- async function fetchShopCurrency(fetchImpl, url, accessToken) {
72
- const result = await request(fetchImpl, url, accessToken);
73
- return result.ok ? normalizeCurrency(result.value.data.shop?.currency) : undefined;
74
- }
75
- /** `baseUrl` when given, else the store's own origin. The single place that choice is made. */
76
- function shopOrigin(storeDomain, baseUrl) {
77
- if (baseUrl !== undefined && baseUrl !== "")
78
- return baseUrl.replace(/\/$/, "");
79
- return `https://${storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
80
- }
81
- function apiBase(store, version, baseUrl) {
82
- return `${shopOrigin(store.storeDomain, baseUrl)}/admin/api/${version}`;
83
- }
84
- function shopifyOAuthStartUrl(appUrl, storeDomain) {
85
- try {
86
- const url = new URL("/api/channels/oauth/shopify/start", appUrl);
87
- url.searchParams.set("shop", storeDomain);
88
- return url.toString();
89
- }
90
- catch {
91
- return undefined;
92
- }
93
- }
94
- /**
95
- * The `rel="next"` target of a REST response's `Link` header, or null on the last page.
96
- *
97
- * RFC 8288 permits the relation as a quoted string OR a bare token, and this reads both. It read
98
- * only `rel="next"` once, and the asymmetry is the argument rather than the likelihood: a reader
99
- * that accepts only the quoted form and meets `rel=next` does not throw — it finds no next link,
100
- * ends the walk, and reports a SUCCESSFUL walk of a partial list. Silent truncation. Accepting both
101
- * cannot make a malformed header parse as a valid one, so the permissive direction has no cost.
102
- */
103
- function nextPageUrl(response) {
104
- const link = response.headers.get("link") ?? "";
105
- return link.match(/<([^>]+)>;\s*rel=(?:"next"|next)(?:\s*(?:,|$))/)?.[1] ?? null;
106
- }
107
- /** The `{ variant: { id, inventory_quantity } }` a `variants/{id}.json` answers, read without a cast. */
108
- function variantInventoryOf(body) {
109
- if (typeof body !== "object" || body === null || !("variant" in body))
110
- return undefined;
111
- const variant = body.variant;
112
- if (typeof variant !== "object" || variant === null || !("id" in variant))
113
- return undefined;
114
- const id = variant.id;
115
- if (typeof id !== "number" && typeof id !== "string")
116
- return undefined;
117
- const quantity = "inventory_quantity" in variant ? variant.inventory_quantity : null;
118
- return { id, inventory_quantity: typeof quantity === "number" ? quantity : null };
119
- }
120
- /** Up to this many variant ids are read one `variants/{id}.json` each (the order-time check);
121
- * more are answered from one walk of the catalogue. */
122
- const PER_VARIANT_INVENTORY_READS = 25;
123
- async function request(fetchImpl, url, accessToken, init) {
124
- try {
125
- const response = await fetchImpl(url, {
126
- ...init,
127
- headers: { accept: "application/json", ...(init?.headers ?? {}), "x-shopify-access-token": accessToken },
128
- });
129
- if (!response.ok)
130
- return Err({ code: "SHOPIFY_API_FAILED", message: `Shopify API request failed (${response.status}) for ${url}.`, retriable: response.status >= 500 });
131
- return Ok({ data: await response.json(), response });
132
- }
133
- catch (error) {
134
- return Err({ code: "SHOPIFY_API_FAILED", message: error instanceof Error ? error.message : "Shopify API request failed.", retriable: true });
135
- }
55
+ function money(minor, currency) {
56
+ return { shopMoney: { amount: (minor / 100).toFixed(2), currencyCode: currency } };
136
57
  }
137
- function shopifyStatus(order) {
138
- if (order.cancelled_at)
58
+ function orderStatus(order) {
59
+ if (order.cancelledAt)
139
60
  return { status: "cancelled" };
140
- if (order.fulfillment_status === "fulfilled")
61
+ if (order.displayFulfillmentStatus === "FULFILLED")
141
62
  return { status: "fulfilled" };
142
- if (order.financial_status === "paid" || order.financial_status === "partially_paid")
63
+ if (order.displayFinancialStatus === "PAID" || order.displayFinancialStatus === "PARTIALLY_PAID")
143
64
  return { status: "confirmed" };
144
- if (order.financial_status === "refunded" || order.financial_status === "voided")
65
+ if (order.displayFinancialStatus === "REFUNDED" || order.displayFinancialStatus === "VOIDED")
145
66
  return { status: "failed" };
146
67
  return { status: "pending" };
147
68
  }
148
- function credentials(store) {
149
- const accessToken = store.credentials.accessToken;
150
- return typeof accessToken === "string" && accessToken.length > 0 ? accessToken : undefined;
151
- }
152
69
  function validBase64Hmac(secret, body, signature) {
153
70
  if (!signature)
154
71
  return false;
@@ -156,347 +73,217 @@ function validBase64Hmac(secret, body, signature) {
156
73
  const actual = Buffer.from(signature, "base64");
157
74
  return actual.length === expected.length && timingSafeEqual(actual, expected);
158
75
  }
159
- function validShopDomain(value) {
160
- return /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.myshopify\.com$/i.test(value);
161
- }
162
- function oauthHmacMessage(searchParams) {
163
- return [...searchParams.entries()]
164
- .filter(([key]) => key !== "hmac")
165
- .sort(([left], [right]) => left.localeCompare(right))
166
- .map(([key, value]) => `${key}=${value}`)
167
- .join("&");
168
- }
169
- function validOAuthHmac(searchParams, secret) {
170
- const provided = searchParams.get("hmac");
171
- if (!provided || !/^[a-f0-9]+$/i.test(provided))
172
- return false;
173
- const expected = createHmac("sha256", secret).update(oauthHmacMessage(searchParams)).digest();
174
- const actual = Buffer.from(provided, "hex");
175
- return actual.length === expected.length && timingSafeEqual(actual, expected);
176
- }
177
- function oauthError(code, message) {
178
- return Err({ code, message, retriable: false });
179
- }
180
- export function shopifyReauthorizeUrl(options, params) {
181
- return shopifyConnector(options).buildAuthUrl({
182
- ...params,
183
- scopes: params.scopes ?? [],
184
- });
185
- }
186
- export function shopifyConnector(options = {}) {
76
+ const credentialsRequired = { code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "The store holds no Shopify access token; it must be reconnected.", retriable: false };
77
+ export function shopifyConnector(options) {
187
78
  const fetchImpl = options.fetchImpl ?? fetch;
188
- /** One `products.json` page of variant stock (`inventory_quantity`, all locations summed), keyed by variant id. */
189
- const inventoryPage = async (store, cursor) => {
190
- const token = credentials(store);
191
- if (!token)
192
- return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
193
- const url = cursor ?? `${apiBase(store, version, options.baseUrl)}/products.json?limit=250&fields=id,variants`;
194
- const result = await request(fetchImpl, url, token);
195
- if (!result.ok)
196
- return result;
197
- const levels = result.value.data.products.flatMap((product) => (product.variants ?? [])
198
- .map((variant) => ({ externalId: String(variant.id), available: Math.max(0, variant.inventory_quantity ?? 0) })));
199
- return Ok({ levels, nextCursor: nextPageUrl(result.value.response) });
79
+ const origin = (shopDomain) => options.shopOrigin?.(shopDomain) ?? `https://${shopDomain}`;
80
+ const target = (store) => {
81
+ const credentials = parseShopifyCredentials(store.credentials);
82
+ return credentials ? { fetchImpl, origin: origin(store.storeDomain), accessToken: credentials.accessToken } : undefined;
200
83
  };
201
- const version = options.apiVersion ?? "2024-10";
202
- const currencyCache = new Map();
203
84
  return defineChannelConnector({
204
85
  providerId: "shopify",
205
- capabilities: { importCatalog: true, importInventory: true, pushOrder: true, pushCatalog: true, receiveWebhooks: true },
86
+ capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
87
+ normalizeStoreDomain: normalizeShopDomain,
206
88
  buildAuthUrl(params) {
207
- if (!options.clientId || !options.clientSecret || !options.appUrl) {
208
- return oauthError("SHOPIFY_OAUTH_NOT_CONFIGURED", "Shopify OAuth requires clientId, clientSecret, and appUrl.");
209
- }
210
- const shopDomain = params.storeDomain.toLowerCase();
211
- if (!validShopDomain(shopDomain))
212
- return oauthError("SHOPIFY_INVALID_STORE_DOMAIN", "Shopify storeDomain must be a *.myshopify.com domain.");
213
- const scopes = [...new Set([...REQUIRED_SCOPES, ...(options.scopes ?? []), ...params.scopes])];
214
- const url = new URL(`${shopOrigin(shopDomain, options.baseUrl)}/admin/oauth/authorize`);
215
- url.searchParams.set("client_id", options.clientId);
216
- url.searchParams.set("scope", scopes.join(","));
217
- url.searchParams.set("redirect_uri", params.redirectUri);
218
- url.searchParams.set("state", params.state);
219
- return Ok(url.toString());
89
+ const shop = params.storeDomain.toLowerCase();
90
+ if (!validShopDomain(shop))
91
+ return Err({ code: "SHOPIFY_INVALID_STORE_DOMAIN", message: "A Shopify store is named by its *.myshopify.com domain.", retriable: false });
92
+ return Ok(buildAuthorizeUrl({ origin: origin(shop), clientId: options.clientId, scopes: REQUIRED_SCOPES, redirectUri: params.redirectUri, state: params.state }));
220
93
  },
221
94
  async completeAuth(request, ctx) {
222
- if (!options.clientId || !options.clientSecret || !options.appUrl) {
223
- return oauthError("SHOPIFY_OAUTH_NOT_CONFIGURED", "Shopify OAuth requires clientId, clientSecret, and appUrl.");
224
- }
225
- const url = new URL(request.url);
226
- const shopDomain = ctx.storeDomain.toLowerCase();
227
- const callbackShop = url.searchParams.get("shop")?.toLowerCase();
228
- if (!validShopDomain(shopDomain) || callbackShop !== shopDomain) {
229
- return oauthError("SHOPIFY_INVALID_STORE_DOMAIN", "Shopify storeDomain must be a *.myshopify.com domain.");
230
- }
231
- if (!validOAuthHmac(url.searchParams, options.clientSecret)) {
232
- return oauthError("SHOPIFY_INVALID_OAUTH_HMAC", "Shopify OAuth callback HMAC is invalid.");
233
- }
234
- const timestamp = Number(url.searchParams.get("timestamp"));
235
- if (!Number.isInteger(timestamp) || Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) {
236
- return oauthError("SHOPIFY_STALE_OAUTH_CALLBACK", "Shopify OAuth callback timestamp is stale.");
237
- }
238
- const code = url.searchParams.get("code");
239
- if (!code)
240
- return oauthError("SHOPIFY_OAUTH_CODE_REQUIRED", "Shopify OAuth callback code is required.");
241
- try {
242
- const response = await fetchImpl(`${shopOrigin(shopDomain, options.baseUrl)}/admin/oauth/access_token`, {
243
- method: "POST",
244
- headers: { accept: "application/json", "content-type": "application/json" },
245
- body: JSON.stringify({ client_id: options.clientId, client_secret: options.clientSecret, code }),
246
- });
247
- if (!response.ok)
248
- return oauthError("SHOPIFY_TOKEN_EXCHANGE_FAILED", `Shopify token exchange failed (${response.status}).`);
249
- const body = await response.json();
250
- if (typeof body.access_token !== "string" || !body.access_token)
251
- return oauthError("SHOPIFY_TOKEN_INVALID", "Shopify token exchange did not return an access token.");
252
- const grantedScopes = typeof body.scope === "string"
253
- ? body.scope.split(",").map((scope) => scope.trim()).filter(Boolean)
254
- : [];
255
- return Ok({ credentials: { accessToken: body.access_token, grantedScopes }, storeDomain: shopDomain });
256
- }
257
- catch (error) {
258
- return Err({ code: "SHOPIFY_TOKEN_EXCHANGE_FAILED", message: error instanceof Error ? error.message : "Shopify token exchange failed.", retriable: true });
259
- }
95
+ const shop = ctx.storeDomain.toLowerCase();
96
+ const credentials = await exchangeCallback({
97
+ fetchImpl,
98
+ origin: origin(shop),
99
+ clientId: options.clientId,
100
+ clientSecret: options.clientSecret,
101
+ callbackUrl: new URL(request.url),
102
+ expectedShop: shop,
103
+ requiredScopes: REQUIRED_SCOPES,
104
+ now: Date.now(),
105
+ });
106
+ if (!credentials.ok)
107
+ return credentials;
108
+ return Ok({ credentials: { ...credentials.value }, storeDomain: shop });
109
+ },
110
+ async liveCredentials(store) {
111
+ const credentials = parseShopifyCredentials(store.credentials);
112
+ if (!credentials)
113
+ return Err(credentialsRequired);
114
+ const refreshed = await refreshIfExpiring({ fetchImpl, origin: origin(store.storeDomain), clientId: options.clientId, clientSecret: options.clientSecret, credentials, now: Date.now() });
115
+ if (!refreshed.ok)
116
+ return refreshed;
117
+ return Ok(refreshed.value === null ? null : { ...refreshed.value });
118
+ },
119
+ async fetchStoreProfile(store) {
120
+ const shop = target(store);
121
+ if (!shop)
122
+ return Err(credentialsRequired);
123
+ const profile = await shopifyGraphql(shop, STORE_PROFILE_QUERY, {}, storeProfileSchema);
124
+ if (!profile.ok)
125
+ return profile;
126
+ const { name, currencyCode, myshopifyDomain, primaryDomain } = profile.value.shop;
127
+ // Both hosts serve the shop's own storefront, and Shopify attests to both by answering this
128
+ // query for the token the merchant granted: no separate proof of ownership is needed.
129
+ return Ok({ name, currency: currencyCode, storefrontHosts: [...new Set([myshopifyDomain.toLowerCase(), primaryDomain.host.toLowerCase()])] });
260
130
  },
261
131
  async importCatalog(store, cursor) {
262
- const token = credentials(store);
263
- if (!token)
264
- return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
265
- const currencyUrl = `${apiBase(store, version, options.baseUrl)}/shop.json`;
266
- const currencyKey = apiBase(store, version, options.baseUrl);
267
- let currencyPromise = currencyCache.get(currencyKey);
268
- if (!currencyPromise) {
269
- currencyPromise = fetchShopCurrency(fetchImpl, currencyUrl, token);
270
- currencyCache.set(currencyKey, currencyPromise);
271
- }
272
- const currency = await currencyPromise;
273
- const url = cursor ?? `${apiBase(store, version, options.baseUrl)}/products.json?limit=250`;
274
- const result = await request(fetchImpl, url, token);
275
- if (!result.ok)
276
- return result;
277
- const next = nextPageUrl(result.value.response);
278
- return Ok({
279
- items: result.value.data.products.map((product) => {
280
- const options = product.options?.map((option, index) => ({
281
- name: option.name,
282
- displayName: option.name,
283
- ...(option.position != null ? { sortOrder: option.position } : { sortOrder: index }),
284
- values: (option.values ?? []).map((value, valueIndex) => ({ value, displayValue: value, sortOrder: valueIndex })),
285
- }));
286
- const variants = (product.variants ?? []).map((variant) => {
287
- const selectors = [variant.option1, variant.option2, variant.option3];
288
- const optionValues = Object.fromEntries((product.options ?? []).slice(0, 3).flatMap((option, index) => {
289
- const value = selectors[index];
290
- return value != null && value !== "" ? [[option.name, value]] : [];
291
- }));
292
- const prices = pricesForVariant(variant, currency);
293
- const weightGrams = weightGramsForVariant(variant);
294
- return {
295
- externalId: String(variant.id),
296
- ...(variant.sku ? { sku: variant.sku } : {}),
297
- ...(variant.barcode ? { barcode: variant.barcode } : {}),
298
- ...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
299
- ...(prices ? { prices } : {}),
300
- // The inventory item id lets a stock webhook, which names only the item, find this variant.
301
- ...(weightGrams !== undefined || (variant.inventory_item_id !== undefined && variant.inventory_item_id !== null)
302
- ? { metadata: {
303
- ...(weightGrams !== undefined ? { weightGrams } : {}),
304
- ...(variant.inventory_item_id !== undefined && variant.inventory_item_id !== null ? { inventoryItemId: String(variant.inventory_item_id) } : {}),
305
- } }
306
- : {}),
307
- };
308
- });
309
- const category = product.product_type ? slugify(product.product_type) : "";
310
- const status = catalogStatus(product.status);
311
- return {
312
- externalId: String(product.id),
313
- slug: product.handle ?? String(product.id),
314
- title: product.title,
315
- attributes: [{ locale: "en", title: product.title, ...(product.body_html != null ? { description: product.body_html } : {}) }],
316
- variants,
317
- ...(product.images ? {
318
- images: product.images.map((image, index) => ({
319
- externalId: String(image.id),
320
- url: image.src,
321
- ...(image.alt != null ? { alt: image.alt } : {}),
322
- role: index === 0 ? "primary" : "gallery",
323
- ...(image.position != null ? { sortOrder: image.position } : {}),
324
- ...(image.variant_ids != null ? { variantExternalIds: image.variant_ids.map(String) } : {}),
325
- })),
326
- } : {}),
327
- ...(options ? { options } : {}),
328
- ...(product.tags != null ? { tags: product.tags.split(",").map((tag) => tag.trim()).filter(Boolean) } : {}),
329
- ...(product.vendor ? { brand: product.vendor } : {}),
330
- ...(category ? { categories: [category] } : {}),
331
- ...(status ? { status } : {}),
332
- };
333
- }),
334
- nextCursor: next,
335
- });
132
+ const shop = target(store);
133
+ if (!shop)
134
+ return Err(credentialsRequired);
135
+ return readCatalogPage(shop, cursor);
136
+ },
137
+ async fetchCatalogItems(store, externalIds) {
138
+ const shop = target(store);
139
+ if (!shop)
140
+ return Err(credentialsRequired);
141
+ return readCatalogItems(shop, externalIds);
336
142
  },
337
143
  /**
338
- * Stock per VARIANT, keyed by the variant id every connector call site matches on.
339
- *
340
- * Read from the variant's `inventory_quantity`, never from `inventory_levels.json`: that
341
- * endpoint requires `inventory_item_ids` or `location_ids`, takes at most 50 ids, and keys
342
- * levels by INVENTORY ITEM id, which is not the variant id — so it answered a real store
343
- * nothing the connector could match, and the sim (whose mock was laxer) one 250-row page.
344
- * https://shopify.dev/docs/api/admin-rest/latest/resources/inventorylevel
345
- *
346
- * `inventory_quantity` sums ALL locations, so a store with a non-selling location overstates
347
- * sellable stock; per-location stock would need InventoryLevel by location. Negative stock
348
- * (oversold) reads as 0, as it is stored.
349
- *
350
- * A few ids (the order-time check) cost one `variants/{id}.json` each; a variant Shopify no
351
- * longer has is omitted, which the caller treats as unconfirmed. More ids, or none (a full
352
- * sync), are answered from one walk of `products.json`, every page: ceil(products / 250).
144
+ * Stock per VARIANT (the id every connector call site matches on), from `inventoryQuantity`:
145
+ * available summed over every location, so a store with a non-selling location overstates
146
+ * sellable stock. A few ids (the order-time check) are one `nodes` read; a variant Shopify no
147
+ * longer has is omitted, which the caller treats as unconfirmed. More or none walk every page.
353
148
  */
354
149
  async fetchInventory(store, ids) {
355
- const token = credentials(store);
356
- if (!token)
357
- return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
358
- const base = apiBase(store, version, options.baseUrl);
359
- const level = (variant) => ({ externalId: String(variant.id), available: Math.max(0, variant.inventory_quantity ?? 0) });
360
- if (ids !== undefined && ids.length <= PER_VARIANT_INVENTORY_READS) {
361
- const levels = [];
362
- for (const id of ids) {
363
- const url = `${base}/variants/${encodeURIComponent(id)}.json`;
364
- let response;
365
- try {
366
- response = await fetchImpl(url, { headers: { accept: "application/json", "x-shopify-access-token": token } });
367
- }
368
- catch (error) {
369
- return Err({ code: "SHOPIFY_API_FAILED", message: error instanceof Error ? error.message : "Shopify API request failed.", retriable: true });
370
- }
371
- // Gone upstream: omitted, so the caller refuses the line as unconfirmed rather than failing
372
- // every other line of the order with it.
373
- if (response.status === 404)
374
- continue;
375
- if (!response.ok)
376
- return Err({ code: "SHOPIFY_API_FAILED", message: `Shopify API request failed (${response.status}) for ${url}.`, retriable: response.status >= 500 });
377
- const variant = variantInventoryOf(await response.json());
378
- if (variant === undefined)
379
- return Err({ code: "SHOPIFY_API_FAILED", message: `Shopify answered no variant for ${url}.` });
380
- levels.push(level(variant));
381
- }
382
- return Ok(levels);
150
+ const shop = target(store);
151
+ if (!shop)
152
+ return Err(credentialsRequired);
153
+ if (ids !== undefined && ids.length <= 250) {
154
+ if (ids.length === 0)
155
+ return Ok([]);
156
+ const read = await shopifyGraphql(shop, VARIANT_INVENTORY_QUERY, { ids: ids.map((id) => shopifyGid("ProductVariant", id)) }, variantInventorySchema);
157
+ if (!read.ok)
158
+ return read;
159
+ return Ok(read.value.nodes.filter(isVariantLevel).map(level));
383
160
  }
384
161
  const wanted = ids === undefined ? undefined : new Set(ids);
385
162
  const levels = [];
386
163
  let cursor = null;
387
- const seen = new Set();
388
164
  do {
389
- const page = await inventoryPage(store, cursor);
165
+ const page = await inventoryPage(shop, cursor);
390
166
  if (!page.ok)
391
167
  return page;
392
168
  levels.push(...page.value.levels.filter((entry) => wanted === undefined || wanted.has(entry.externalId)));
393
169
  cursor = page.value.nextCursor;
394
- if (cursor !== null && seen.has(cursor))
395
- return Err({ code: "SHOPIFY_API_FAILED", message: "Shopify pagination repeated a page." });
396
- if (cursor !== null)
397
- seen.add(cursor);
398
170
  } while (cursor !== null);
399
171
  return Ok(levels);
400
172
  },
401
- /** One `products.json` page (250 products) of variant stock; the cursor is Shopify's next-page URL. */
402
- fetchInventoryPage: (store, cursor) => inventoryPage(store, cursor),
403
- async pushCatalog(store, items, opts) {
404
- const oauthStartUrl = options.appUrl ? shopifyOAuthStartUrl(options.appUrl, store.storeDomain) : undefined;
405
- return executePushCatalog({
406
- fetchImpl,
407
- apiBase: (target) => apiBase(target, version),
408
- credentials,
409
- }, store, items, {
410
- ...(opts?.dryRun === true ? { dryRun: true } : {}),
411
- ...(oauthStartUrl ? { reauthorizeUrl: oauthStartUrl } : {}),
412
- });
173
+ async fetchInventoryPage(store, cursor) {
174
+ const shop = target(store);
175
+ if (!shop)
176
+ return Err(credentialsRequired);
177
+ return inventoryPage(shop, cursor);
413
178
  },
179
+ /**
180
+ * Creates the paid order in the store. The platform's order id rides as `sourceIdentifier`, and
181
+ * an order already carrying it is answered instead of created again: a retry after a lost
182
+ * response must not put a second paid order in front of the merchant.
183
+ */
414
184
  async pushOrder(store, slice) {
415
- const token = credentials(store);
416
- if (!token)
417
- return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
418
- const [firstName, ...lastParts] = slice.customer.name.trim().split(/\s+/);
419
- const result = await request(fetchImpl, `${apiBase(store, version, options.baseUrl)}/orders.json`, token, {
420
- method: "POST",
421
- headers: { "content-type": "application/json", "idempotency-key": `porulle:${slice.orderId}` },
422
- body: JSON.stringify({ order: {
423
- financial_status: "paid",
424
- line_items: slice.lines.map((line) => ({ variant_id: line.externalVariantId, quantity: line.quantity, price: line.unitPrice / 100 })),
425
- customer: { email: slice.customer.email, first_name: firstName ?? "", last_name: lastParts.join(" ") },
426
- shipping_address: slice.customer.shippingAddress,
427
- transactions: [{ kind: "sale", status: "success", amount: slice.grandTotal / 100 }],
428
- } }),
429
- });
430
- if (!result.ok)
431
- return result;
432
- const id = String(result.value.data.order.id);
433
- return Ok({ remoteOrderId: id, remoteUrl: `${apiBase(store, version, options.baseUrl)}/orders/${id}.json` });
185
+ const shop = target(store);
186
+ if (!shop)
187
+ return Err(credentialsRequired);
188
+ const existing = await shopifyGraphql(shop, ORDER_BY_SOURCE_QUERY, { query: `source_identifier:${JSON.stringify(slice.orderId)}` }, orderBySourceSchema);
189
+ if (!existing.ok)
190
+ return existing;
191
+ const already = existing.value.orders.nodes[0];
192
+ if (already)
193
+ return Ok({ remoteOrderId: already.legacyResourceId });
194
+ const [firstName, ...rest] = slice.customer.name.trim().split(/\s+/);
195
+ const address = slice.customer.shippingAddress;
196
+ // Shopify takes a province as its CODE. A region the shopper typed as a name is kept for the
197
+ // courier on the second address line rather than sent where Shopify would reject it.
198
+ const provinceCode = address.region !== undefined && /^[A-Z0-9]{1,3}$/.test(address.region) ? address.region : undefined;
199
+ const regionText = address.region !== undefined && provinceCode === undefined ? address.region : undefined;
200
+ const address2 = [address.line2, regionText].filter((part) => part !== undefined && part !== "").join(", ");
201
+ const shippingAddress = {
202
+ firstName: address.firstName,
203
+ lastName: address.lastName,
204
+ address1: address.line1,
205
+ ...(address2 ? { address2 } : {}),
206
+ city: address.city,
207
+ ...(provinceCode ? { provinceCode } : {}),
208
+ ...(address.postalCode ? { zip: address.postalCode } : {}),
209
+ countryCode: address.countryCode,
210
+ ...(address.phone ? { phone: address.phone } : {}),
211
+ };
212
+ const created = await shopifyGraphql(shop, ORDER_CREATE_MUTATION, {
213
+ order: {
214
+ sourceName: SOURCE_NAME,
215
+ sourceIdentifier: slice.orderId,
216
+ currency: slice.currency,
217
+ email: slice.customer.email,
218
+ financialStatus: "PAID",
219
+ customer: { toUpsert: { email: slice.customer.email, firstName: firstName ?? "", lastName: rest.join(" ") } },
220
+ shippingAddress,
221
+ lineItems: slice.lines.map((line) => ({
222
+ variantId: shopifyGid("ProductVariant", line.externalVariantId),
223
+ quantity: line.quantity,
224
+ priceSet: money(line.unitPrice, slice.currency),
225
+ })),
226
+ transactions: [{ kind: "SALE", status: "SUCCESS", gateway: SOURCE_NAME, amountSet: money(slice.grandTotal, slice.currency) }],
227
+ },
228
+ options: { inventoryBehaviour: "DECREMENT_OBEYING_POLICY", sendReceipt: false, sendFulfillmentReceipt: false },
229
+ }, orderCreateSchema);
230
+ if (!created.ok)
231
+ return created;
232
+ const { order, userErrors } = created.value.orderCreate;
233
+ if (userErrors.length > 0 || !order) {
234
+ return Err({ code: "SHOPIFY_ORDER_REJECTED", message: `Shopify refused the order: ${userErrors.map((error) => error.message).join("; ") || "no order returned"}.`, retriable: false });
235
+ }
236
+ return Ok({ remoteOrderId: order.legacyResourceId });
434
237
  },
435
238
  async fetchOrderStatus(store, remoteId) {
436
- const token = credentials(store);
437
- if (!token)
438
- return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
439
- const result = await request(fetchImpl, `${apiBase(store, version, options.baseUrl)}/orders/${encodeURIComponent(remoteId)}.json`, token);
440
- return result.ok ? Ok(shopifyStatus(result.value.data.order)) : result;
441
- },
442
- async verifyWebhook(_store, request) {
443
- const body = await request.text();
444
- // Shopify signs every webhook for an app with the app CLIENT SECRET — there is no
445
- // per-store/per-subscription secret (unlike WooCommerce). Verify against clientSecret.
446
- if (!options.clientSecret) {
447
- return Err({ code: "SHOPIFY_CLIENT_SECRET_MISSING", message: "Shopify clientSecret is required to verify webhooks." });
448
- }
449
- if (!validBase64Hmac(options.clientSecret, body, request.headers.get("x-shopify-hmac-sha256"))) {
450
- return Err({ code: "INVALID_WEBHOOK_SIGNATURE", message: "Invalid Shopify webhook signature." });
451
- }
452
- try {
453
- const data = JSON.parse(body);
454
- const id = request.headers.get("x-shopify-event-id");
455
- const type = request.headers.get("x-shopify-topic");
456
- if (!id || !type)
457
- return Err({ code: "INVALID_WEBHOOK", message: "Shopify webhook headers are incomplete." });
458
- return Ok({ id, type, data });
459
- }
460
- catch {
461
- return Err({ code: "INVALID_WEBHOOK", message: "Shopify webhook body must be valid JSON." });
462
- }
239
+ const shop = target(store);
240
+ if (!shop)
241
+ return Err(credentialsRequired);
242
+ const read = await shopifyGraphql(shop, ORDER_STATUS_QUERY, { id: `gid://shopify/Order/${remoteId}` }, orderStatusSchema);
243
+ if (!read.ok)
244
+ return read;
245
+ if (!read.value.order)
246
+ return Err({ code: "SHOPIFY_ORDER_NOT_FOUND", message: `Shopify has no order ${remoteId}.`, retriable: false });
247
+ return Ok(orderStatus(read.value.order));
463
248
  },
249
+ /**
250
+ * Every Shopify delivery — catalogue, stock, orders, uninstall and the mandatory compliance topics —
251
+ * arrives at ONE app-level address declared in `shopify.app.toml`, signed with the app's client
252
+ * secret (Shopify has no per-store webhook secret). The shop is named by a header, and a delivery
253
+ * is identified by `X-Shopify-Webhook-Id`: `X-Shopify-Event-Id` is shared by every delivery one
254
+ * merchant action produces, so deduplicating on it would drop a second topic as a "duplicate".
255
+ */
464
256
  async verifyAppWebhook(request) {
465
- if (!options.clientSecret) {
466
- return Err({ code: "SHOPIFY_CLIENT_SECRET_MISSING", message: "Shopify clientSecret is required to verify app webhooks.", retriable: false });
467
- }
468
257
  const body = await request.text();
469
258
  if (!validBase64Hmac(options.clientSecret, body, request.headers.get("x-shopify-hmac-sha256"))) {
470
- return Err({ code: "INVALID_APP_WEBHOOK_SIGNATURE", message: "Invalid Shopify app webhook signature.", retriable: false });
259
+ return Err({ code: "INVALID_APP_WEBHOOK_SIGNATURE", message: "Invalid Shopify webhook signature.", retriable: false });
471
260
  }
261
+ const topic = request.headers.get("x-shopify-topic");
262
+ const shopDomain = request.headers.get("x-shopify-shop-domain")?.toLowerCase();
263
+ const id = request.headers.get("x-shopify-webhook-id");
264
+ if (!topic || !shopDomain || !id)
265
+ return Err({ code: "INVALID_APP_WEBHOOK", message: "Shopify webhook headers are incomplete.", retriable: false });
266
+ let data;
472
267
  try {
473
- const data = JSON.parse(body);
474
- const payload = data;
475
- const topic = request.headers.get("x-shopify-topic");
476
- const shopDomain = typeof payload.shop_domain === "string" ? payload.shop_domain : "";
477
- if (!topic)
478
- return Err({ code: "INVALID_APP_WEBHOOK", message: "Shopify app webhook topic header is missing.", retriable: false });
479
- return Ok({ topic, shopDomain, data });
268
+ data = JSON.parse(body);
480
269
  }
481
270
  catch {
482
- return Err({ code: "INVALID_APP_WEBHOOK", message: "Shopify app webhook body must be valid JSON.", retriable: false });
271
+ return Err({ code: "INVALID_APP_WEBHOOK", message: "Shopify webhook body must be valid JSON.", retriable: false });
483
272
  }
273
+ return Ok({ id, topic, shopDomain, data });
484
274
  },
485
- async registerWebhooks(store, topics, callbackUrl) {
486
- const token = credentials(store);
487
- if (!token)
488
- return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
489
- for (const topic of topics) {
490
- const result = await request(fetchImpl, `${apiBase(store, version, options.baseUrl)}/webhooks.json`, token, {
491
- method: "POST",
492
- headers: { "content-type": "application/json" },
493
- body: JSON.stringify({ webhook: { topic, address: callbackUrl, format: "json" } }),
494
- });
495
- if (!result.ok)
496
- return result;
497
- }
498
- return Ok({ registered: topics.length });
275
+ async refundExecute() {
276
+ return Err({ code: "NOT_IMPLEMENTED", message: "Refunds are issued by the platform, not executed in the Shopify store." });
499
277
  },
500
- async refundExecute() { return Err({ code: "NOT_IMPLEMENTED", message: "Shopify refund execution is not implemented in this slice." }); },
501
278
  });
279
+ async function inventoryPage(shop, cursor) {
280
+ const page = await shopifyGraphql(shop, INVENTORY_QUERY, { after: cursor }, inventoryPageSchema);
281
+ if (!page.ok)
282
+ return page;
283
+ const { pageInfo, nodes } = page.value.productVariants;
284
+ if (pageInfo.hasNextPage && (!pageInfo.endCursor || pageInfo.endCursor === cursor)) {
285
+ return Err({ code: "SHOPIFY_PAGINATION_STUCK", message: "Shopify answered an inventory page that does not advance.", retriable: false });
286
+ }
287
+ return Ok({ levels: nodes.map(level), nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null });
288
+ }
502
289
  }