@porulle/adapter-shopify 0.73.2 → 0.74.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.
@@ -0,0 +1,9 @@
1
+ import type { ChannelConnectorError, ChannelEvent, ChannelWebhookEvent, Result } from "@porulle/core";
2
+ import type { ShopifyGraphqlTarget } from "./graphql.js";
3
+ /**
4
+ * A stock delivery names an INVENTORY ITEM and one location's count. The variant it belongs to and
5
+ * its stock summed over locations are read fresh, so the level applied is the store's, not the payload's.
6
+ */
7
+ export declare const INVENTORY_ITEM_VARIANT_QUERY = "query PorulleInventoryItemVariant($id: ID!) {\n inventoryItem(id: $id) { variant { legacyResourceId inventoryQuantity } }\n}";
8
+ /** What one Shopify delivery means. `shop` is the store's Admin API, for the reads a nudge needs. */
9
+ export declare function decodeShopifyWebhook(shop: ShopifyGraphqlTarget | undefined, event: ChannelWebhookEvent): Promise<Result<ChannelEvent[], ChannelConnectorError>>;
@@ -0,0 +1,131 @@
1
+ import { Err, Ok } from "@porulle/core";
2
+ import { z } from "zod";
3
+ import { shopifyGid, shopifyGraphql } from "./graphql.js";
4
+ /**
5
+ * A stock delivery names an INVENTORY ITEM and one location's count. The variant it belongs to and
6
+ * its stock summed over locations are read fresh, so the level applied is the store's, not the payload's.
7
+ */
8
+ export const INVENTORY_ITEM_VARIANT_QUERY = `query PorulleInventoryItemVariant($id: ID!) {
9
+ inventoryItem(id: $id) { variant { legacyResourceId inventoryQuantity } }
10
+ }`;
11
+ const inventoryItemVariantSchema = z.object({
12
+ inventoryItem: z.object({ variant: z.object({ legacyResourceId: z.string(), inventoryQuantity: z.number().nullable() }).nullable() }).nullable(),
13
+ });
14
+ const id = z.union([z.string(), z.number()]).transform(String);
15
+ const withId = z.object({ id });
16
+ const inventoryLevelPayload = z.object({ inventory_item_id: id });
17
+ const refundPayload = z.object({
18
+ id,
19
+ order_id: id,
20
+ refund_line_items: z.array(z.object({
21
+ quantity: z.number().int(),
22
+ line_item: z.object({ variant_id: id.nullish(), product_id: id.nullish() }),
23
+ })).default([]),
24
+ });
25
+ const fulfilledPayload = z.object({
26
+ id,
27
+ fulfillments: z.array(z.object({
28
+ id,
29
+ status: z.string().nullish(),
30
+ tracking_company: z.string().nullish(),
31
+ tracking_number: z.string().nullish(),
32
+ tracking_url: z.string().nullish(),
33
+ line_items: z.array(z.object({ variant_id: id.nullish(), quantity: z.number().int().positive() })).default([]),
34
+ })).default([]),
35
+ });
36
+ const RETURN_STATUS = {
37
+ "returns/approve": "approved",
38
+ "returns/decline": "declined",
39
+ "returns/close": "closed",
40
+ "returns/cancel": "cancelled",
41
+ "returns/reopen": "approved",
42
+ };
43
+ const COMPLIANCE = {
44
+ "customers/data_request": "customer_data",
45
+ "customers/redact": "customer_redact",
46
+ "shop/redact": "shop_redact",
47
+ };
48
+ function malformed(topic, error) {
49
+ return Err({ code: "SHOPIFY_WEBHOOK_MALFORMED", message: `A Shopify ${topic} delivery did not parse: ${error.message}`, retriable: false });
50
+ }
51
+ /** A parcel the store cancelled or failed is not a parcel. */
52
+ function shipments(fulfillments) {
53
+ return fulfillments
54
+ .filter((parcel) => parcel.status !== "cancelled" && parcel.status !== "error" && parcel.status !== "failure")
55
+ .map((parcel) => ({
56
+ remoteId: parcel.id,
57
+ ...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
58
+ ...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
59
+ ...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
60
+ lines: parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [{ externalVariantId: line.variant_id, quantity: line.quantity }])),
61
+ }));
62
+ }
63
+ /** What one Shopify delivery means. `shop` is the store's Admin API, for the reads a nudge needs. */
64
+ export async function decodeShopifyWebhook(shop, event) {
65
+ const topic = event.type;
66
+ const data = event.data;
67
+ if (topic === "products/create" || topic === "products/update" || topic === "products/delete") {
68
+ const parsed = withId.safeParse(data);
69
+ if (!parsed.success)
70
+ return malformed(topic, parsed.error);
71
+ return Ok([{ kind: topic === "products/delete" ? "product.deleted" : "product.changed", externalIds: [parsed.data.id] }]);
72
+ }
73
+ if (topic === "inventory_levels/update") {
74
+ const parsed = inventoryLevelPayload.safeParse(data);
75
+ if (!parsed.success)
76
+ return malformed(topic, parsed.error);
77
+ if (!shop)
78
+ return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "The store holds no Shopify access token; it must be reconnected.", retriable: false });
79
+ const read = await shopifyGraphql(shop, INVENTORY_ITEM_VARIANT_QUERY, { id: shopifyGid("InventoryItem", parsed.data.inventory_item_id) }, inventoryItemVariantSchema);
80
+ if (!read.ok)
81
+ return read;
82
+ const variant = read.value.inventoryItem?.variant;
83
+ // An item no variant carries (deleted between the delivery and the read) has no level to apply.
84
+ if (!variant)
85
+ return Ok([]);
86
+ return Ok([{ kind: "inventory.changed", levels: [{ externalId: variant.legacyResourceId, available: Math.max(0, variant.inventoryQuantity ?? 0) }] }]);
87
+ }
88
+ if (topic === "orders/cancelled") {
89
+ const parsed = withId.safeParse(data);
90
+ if (!parsed.success)
91
+ return malformed(topic, parsed.error);
92
+ return Ok([{ kind: "order.cancelled", remoteOrderId: parsed.data.id }]);
93
+ }
94
+ if (topic === "orders/fulfilled" || topic === "orders/partially_fulfilled") {
95
+ const parsed = fulfilledPayload.safeParse(data);
96
+ if (!parsed.success)
97
+ return malformed(topic, parsed.error);
98
+ return Ok([{ kind: "order.fulfilled", remoteOrderId: parsed.data.id, partial: topic === "orders/partially_fulfilled", shipments: shipments(parsed.data.fulfillments) }]);
99
+ }
100
+ if (topic === "refunds/create") {
101
+ const parsed = refundPayload.safeParse(data);
102
+ if (!parsed.success)
103
+ return malformed(topic, parsed.error);
104
+ return Ok([{
105
+ kind: "refund.created",
106
+ remoteOrderId: parsed.data.order_id,
107
+ remoteRefundId: parsed.data.id,
108
+ lines: parsed.data.refund_line_items.flatMap((entry) => {
109
+ const externalVariantId = entry.line_item.variant_id ?? entry.line_item.product_id;
110
+ return externalVariantId == null ? [] : [{ externalVariantId, quantity: entry.quantity }];
111
+ }),
112
+ }]);
113
+ }
114
+ const returnStatus = RETURN_STATUS[topic];
115
+ if (returnStatus !== undefined) {
116
+ const parsed = withId.safeParse(data);
117
+ if (!parsed.success)
118
+ return malformed(topic, parsed.error);
119
+ return Ok([{ kind: "return.updated", remoteReturnId: parsed.data.id, status: returnStatus }]);
120
+ }
121
+ if (topic === "app/uninstalled")
122
+ return Ok([{ kind: "connection.revoked" }]);
123
+ const compliance = COMPLIANCE[topic];
124
+ if (compliance !== undefined) {
125
+ const parsed = z.record(z.string(), z.unknown()).safeParse(data);
126
+ if (!parsed.success)
127
+ return malformed(topic, parsed.error);
128
+ return Ok([{ kind: "compliance.request", request: compliance, data: parsed.data }]);
129
+ }
130
+ return Ok([]);
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/adapter-shopify",
3
- "version": "0.73.2",
3
+ "version": "0.74.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "zod": "^4.1.11",
19
- "@porulle/core": "0.73.2"
19
+ "@porulle/core": "0.74.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^24.5.2",
@@ -26,8 +26,8 @@
26
26
  "miniflare": "4.20260722.1",
27
27
  "typescript": "5.9.2",
28
28
  "vitest": "^3.2.4",
29
- "@porulle/typescript-config": "0.1.0",
30
- "@porulle/eslint-config": "0.1.0"
29
+ "@porulle/eslint-config": "0.1.0",
30
+ "@porulle/typescript-config": "0.1.0"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
package/src/graphql.ts CHANGED
@@ -127,6 +127,6 @@ export async function shopifyGraphql<T>(
127
127
  }
128
128
 
129
129
  /** `gid://shopify/<Type>/<id>` for a numeric id; the adapter keys everything by the numeric id. */
130
- export function shopifyGid(type: "Product" | "ProductVariant", id: string): string {
130
+ export function shopifyGid(type: "Product" | "ProductVariant" | "InventoryItem" | "Order", id: string): string {
131
131
  return `gid://shopify/${type}/${id}`;
132
132
  }
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  } from "@porulle/core";
14
14
  import { z } from "zod";
15
15
  import { readCatalogItems, readCatalogPage } from "./catalog.js";
16
+ import { decodeShopifyWebhook } from "./webhooks.js";
16
17
  import { shopifyGid, shopifyGraphql } from "./graphql.js";
17
18
  import type { ShopifyGraphqlTarget } from "./graphql.js";
18
19
  import {
@@ -27,6 +28,7 @@ import {
27
28
 
28
29
  export { SHOPIFY_API_VERSION } from "./graphql.js";
29
30
  export { CATALOG_ITEMS_QUERY, CATALOG_PAGE_QUERY, VARIANTS_PAGE_QUERY } from "./catalog.js";
31
+ export { INVENTORY_ITEM_VARIANT_QUERY } from "./webhooks.js";
30
32
  export { REQUIRED_SCOPES, normalizeShopDomain, parseShopifyCredentials } from "./oauth.js";
31
33
  export type { ShopifyCredentials } from "./oauth.js";
32
34
 
@@ -326,7 +328,9 @@ export function shopifyConnector(options: ShopifyConnectorOptions): ChannelConne
326
328
  }, orderCreateSchema);
327
329
  if (!created.ok) return created;
328
330
  const { order, userErrors } = created.value.orderCreate;
329
- if (userErrors.some((error) => error.code === "INVENTORY_CLAIM_FAILED")) {
331
+ // Live Shopify (2026-10, measured) refuses stock as `INVALID` on `["order","lineItems"]` with "Line items
332
+ // Unable to reserve inventory", not the documented `INVENTORY_CLAIM_FAILED`; both are read as out of stock.
333
+ if (userErrors.some((error) => error.code === "INVENTORY_CLAIM_FAILED" || /unable to reserve inventory/i.test(error.message))) {
330
334
  return Err({ code: CHANNEL_OUT_OF_STOCK, message: `The store does not have the stock: ${userErrors.map((error) => error.message).join("; ")}.`, retriable: false });
331
335
  }
332
336
  if (userErrors.length > 0 || !order) {
@@ -415,6 +419,9 @@ export function shopifyConnector(options: ShopifyConnectorOptions): ChannelConne
415
419
  }
416
420
  return Ok({ id, topic, shopDomain, data });
417
421
  },
422
+ async decodeWebhook(store, event) {
423
+ return decodeShopifyWebhook(target(store), event);
424
+ },
418
425
  async refundExecute() {
419
426
  return Err({ code: "NOT_IMPLEMENTED", message: "Refunds are issued by the platform, not executed in the Shopify store." });
420
427
  },
@@ -0,0 +1,130 @@
1
+ import { Err, Ok } from "@porulle/core";
2
+ import type { ChannelConnectorError, ChannelEvent, ChannelShipment, ChannelWebhookEvent, Result } from "@porulle/core";
3
+ import { z } from "zod";
4
+ import { shopifyGid, shopifyGraphql } from "./graphql.js";
5
+ import type { ShopifyGraphqlTarget } from "./graphql.js";
6
+
7
+ /**
8
+ * A stock delivery names an INVENTORY ITEM and one location's count. The variant it belongs to and
9
+ * its stock summed over locations are read fresh, so the level applied is the store's, not the payload's.
10
+ */
11
+ export const INVENTORY_ITEM_VARIANT_QUERY = `query PorulleInventoryItemVariant($id: ID!) {
12
+ inventoryItem(id: $id) { variant { legacyResourceId inventoryQuantity } }
13
+ }`;
14
+
15
+ const inventoryItemVariantSchema = z.object({
16
+ inventoryItem: z.object({ variant: z.object({ legacyResourceId: z.string(), inventoryQuantity: z.number().nullable() }).nullable() }).nullable(),
17
+ });
18
+
19
+ const id = z.union([z.string(), z.number()]).transform(String);
20
+ const withId = z.object({ id });
21
+ const inventoryLevelPayload = z.object({ inventory_item_id: id });
22
+ const refundPayload = z.object({
23
+ id,
24
+ order_id: id,
25
+ refund_line_items: z.array(z.object({
26
+ quantity: z.number().int(),
27
+ line_item: z.object({ variant_id: id.nullish(), product_id: id.nullish() }),
28
+ })).default([]),
29
+ });
30
+ const fulfilledPayload = z.object({
31
+ id,
32
+ fulfillments: z.array(z.object({
33
+ id,
34
+ status: z.string().nullish(),
35
+ tracking_company: z.string().nullish(),
36
+ tracking_number: z.string().nullish(),
37
+ tracking_url: z.string().nullish(),
38
+ line_items: z.array(z.object({ variant_id: id.nullish(), quantity: z.number().int().positive() })).default([]),
39
+ })).default([]),
40
+ });
41
+
42
+ const RETURN_STATUS: Record<string, Extract<ChannelEvent, { kind: "return.updated" }>["status"]> = {
43
+ "returns/approve": "approved",
44
+ "returns/decline": "declined",
45
+ "returns/close": "closed",
46
+ "returns/cancel": "cancelled",
47
+ "returns/reopen": "approved",
48
+ };
49
+
50
+ const COMPLIANCE: Record<string, Extract<ChannelEvent, { kind: "compliance.request" }>["request"]> = {
51
+ "customers/data_request": "customer_data",
52
+ "customers/redact": "customer_redact",
53
+ "shop/redact": "shop_redact",
54
+ };
55
+
56
+ function malformed(topic: string, error: z.ZodError): Result<never, ChannelConnectorError> {
57
+ return Err({ code: "SHOPIFY_WEBHOOK_MALFORMED", message: `A Shopify ${topic} delivery did not parse: ${error.message}`, retriable: false });
58
+ }
59
+
60
+ /** A parcel the store cancelled or failed is not a parcel. */
61
+ function shipments(fulfillments: z.infer<typeof fulfilledPayload>["fulfillments"]): ChannelShipment[] {
62
+ return fulfillments
63
+ .filter((parcel) => parcel.status !== "cancelled" && parcel.status !== "error" && parcel.status !== "failure")
64
+ .map((parcel) => ({
65
+ remoteId: parcel.id,
66
+ ...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
67
+ ...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
68
+ ...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
69
+ lines: parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [{ externalVariantId: line.variant_id, quantity: line.quantity }])),
70
+ }));
71
+ }
72
+
73
+ /** What one Shopify delivery means. `shop` is the store's Admin API, for the reads a nudge needs. */
74
+ export async function decodeShopifyWebhook(shop: ShopifyGraphqlTarget | undefined, event: ChannelWebhookEvent): Promise<Result<ChannelEvent[], ChannelConnectorError>> {
75
+ const topic = event.type;
76
+ const data = event.data;
77
+ if (topic === "products/create" || topic === "products/update" || topic === "products/delete") {
78
+ const parsed = withId.safeParse(data);
79
+ if (!parsed.success) return malformed(topic, parsed.error);
80
+ return Ok([{ kind: topic === "products/delete" ? "product.deleted" : "product.changed", externalIds: [parsed.data.id] }]);
81
+ }
82
+ if (topic === "inventory_levels/update") {
83
+ const parsed = inventoryLevelPayload.safeParse(data);
84
+ if (!parsed.success) return malformed(topic, parsed.error);
85
+ if (!shop) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "The store holds no Shopify access token; it must be reconnected.", retriable: false });
86
+ const read = await shopifyGraphql(shop, INVENTORY_ITEM_VARIANT_QUERY, { id: shopifyGid("InventoryItem", parsed.data.inventory_item_id) }, inventoryItemVariantSchema);
87
+ if (!read.ok) return read;
88
+ const variant = read.value.inventoryItem?.variant;
89
+ // An item no variant carries (deleted between the delivery and the read) has no level to apply.
90
+ if (!variant) return Ok([]);
91
+ return Ok([{ kind: "inventory.changed", levels: [{ externalId: variant.legacyResourceId, available: Math.max(0, variant.inventoryQuantity ?? 0) }] }]);
92
+ }
93
+ if (topic === "orders/cancelled") {
94
+ const parsed = withId.safeParse(data);
95
+ if (!parsed.success) return malformed(topic, parsed.error);
96
+ return Ok([{ kind: "order.cancelled", remoteOrderId: parsed.data.id }]);
97
+ }
98
+ if (topic === "orders/fulfilled" || topic === "orders/partially_fulfilled") {
99
+ const parsed = fulfilledPayload.safeParse(data);
100
+ if (!parsed.success) return malformed(topic, parsed.error);
101
+ return Ok([{ kind: "order.fulfilled", remoteOrderId: parsed.data.id, partial: topic === "orders/partially_fulfilled", shipments: shipments(parsed.data.fulfillments) }]);
102
+ }
103
+ if (topic === "refunds/create") {
104
+ const parsed = refundPayload.safeParse(data);
105
+ if (!parsed.success) return malformed(topic, parsed.error);
106
+ return Ok([{
107
+ kind: "refund.created",
108
+ remoteOrderId: parsed.data.order_id,
109
+ remoteRefundId: parsed.data.id,
110
+ lines: parsed.data.refund_line_items.flatMap((entry) => {
111
+ const externalVariantId = entry.line_item.variant_id ?? entry.line_item.product_id;
112
+ return externalVariantId == null ? [] : [{ externalVariantId, quantity: entry.quantity }];
113
+ }),
114
+ }]);
115
+ }
116
+ const returnStatus = RETURN_STATUS[topic];
117
+ if (returnStatus !== undefined) {
118
+ const parsed = withId.safeParse(data);
119
+ if (!parsed.success) return malformed(topic, parsed.error);
120
+ return Ok([{ kind: "return.updated", remoteReturnId: parsed.data.id, status: returnStatus }]);
121
+ }
122
+ if (topic === "app/uninstalled") return Ok([{ kind: "connection.revoked" }]);
123
+ const compliance = COMPLIANCE[topic];
124
+ if (compliance !== undefined) {
125
+ const parsed = z.record(z.string(), z.unknown()).safeParse(data);
126
+ if (!parsed.success) return malformed(topic, parsed.error);
127
+ return Ok([{ kind: "compliance.request", request: compliance, data: parsed.data }]);
128
+ }
129
+ return Ok([]);
130
+ }