@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/catalog.d.ts +198 -0
- package/dist/catalog.js +226 -0
- package/dist/graphql.d.ts +31 -0
- package/dist/graphql.js +106 -0
- package/dist/index.d.ts +19 -33
- package/dist/index.js +230 -443
- package/dist/oauth.d.ts +66 -0
- package/dist/oauth.js +170 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +12 -5
- package/src/catalog.ts +245 -0
- package/src/graphql.ts +128 -0
- package/src/index.ts +232 -513
- package/src/oauth.ts +200 -0
- package/dist/push-catalog.d.ts +0 -18
- package/dist/push-catalog.js +0 -502
- package/src/push-catalog.ts +0 -702
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 {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
138
|
-
if (order.
|
|
58
|
+
function orderStatus(order) {
|
|
59
|
+
if (order.cancelledAt)
|
|
139
60
|
return { status: "cancelled" };
|
|
140
|
-
if (order.
|
|
61
|
+
if (order.displayFulfillmentStatus === "FULFILLED")
|
|
141
62
|
return { status: "fulfilled" };
|
|
142
|
-
if (order.
|
|
63
|
+
if (order.displayFinancialStatus === "PAID" || order.displayFinancialStatus === "PARTIALLY_PAID")
|
|
143
64
|
return { status: "confirmed" };
|
|
144
|
-
if (order.
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
|
|
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,
|
|
86
|
+
capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
|
|
87
|
+
normalizeStoreDomain: normalizeShopDomain,
|
|
206
88
|
buildAuthUrl(params) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
if (!
|
|
240
|
-
return
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
263
|
-
if (!
|
|
264
|
-
return Err(
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
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
|
|
356
|
-
if (!
|
|
357
|
-
return Err(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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(
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
return
|
|
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
|
|
416
|
-
if (!
|
|
417
|
-
return Err(
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
437
|
-
if (!
|
|
438
|
-
return Err(
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
486
|
-
|
|
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
|
}
|