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