@porulle/adapter-shopify 0.68.4 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/adapter-shopify",
3
- "version": "0.68.4",
3
+ "version": "0.69.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.68.4"
19
+ "@porulle/core": "0.69.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^24.5.2",
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
- import { defineChannelConnector, Err, Ok } from "@porulle/core";
2
+ import { CHANNEL_CANCEL_REFUSED, defineChannelConnector, Err, Ok } from "@porulle/core";
3
3
  import type {
4
+ ChannelCancelReason,
4
5
  ChannelConnector,
5
6
  ChannelConnectorError,
6
7
  ChannelInventoryLevel,
@@ -58,6 +59,14 @@ export const ORDER_CREATE_MUTATION = `mutation PorulleOrderCreate($order: OrderC
58
59
  orderCreate(order: $order, options: $options) { order { legacyResourceId } userErrors { field message } }
59
60
  }`;
60
61
 
62
+ /**
63
+ * Restocks at the store and refunds nothing there, and tells the customer nothing: the marketplace
64
+ * took the payment, so it refunds and it writes to the shopper.
65
+ */
66
+ export const ORDER_CANCEL_MUTATION = `mutation PorulleOrderCancel($orderId: ID!, $reason: OrderCancelReason!, $staffNote: String) {
67
+ orderCancel(orderId: $orderId, reason: $reason, restock: true, notifyCustomer: false, staffNote: $staffNote) { orderCancelUserErrors { code message } }
68
+ }`;
69
+
61
70
  export const ORDER_BY_SOURCE_QUERY = `query PorulleOrderBySource($query: String!) {
62
71
  orders(first: 1, query: $query) { nodes { legacyResourceId } }
63
72
  }`;
@@ -80,6 +89,17 @@ const orderCreateSchema = z.object({
80
89
  userErrors: z.array(z.object({ field: z.array(z.string()).nullable(), message: z.string() })),
81
90
  }),
82
91
  });
92
+ const orderCancelSchema = z.object({
93
+ orderCancel: z.object({ orderCancelUserErrors: z.array(z.object({ code: z.string().nullable(), message: z.string() })) }),
94
+ });
95
+ const CANCEL_REASON: Record<ChannelCancelReason, string> = {
96
+ customer: "CUSTOMER",
97
+ inventory: "INVENTORY",
98
+ declined: "DECLINED",
99
+ fraud: "FRAUD",
100
+ staff: "STAFF",
101
+ other: "OTHER",
102
+ };
83
103
  const orderBySourceSchema = z.object({ orders: z.object({ nodes: z.array(z.object({ legacyResourceId: z.string() })) }) });
84
104
  const orderStatusSchema = z.object({
85
105
  order: z.object({ cancelledAt: z.string().nullable(), displayFinancialStatus: z.string().nullable(), displayFulfillmentStatus: z.string() }).nullable(),
@@ -278,6 +298,23 @@ export function shopifyConnector(options: ShopifyConnectorOptions): ChannelConne
278
298
  if (!read.value.order) return Err({ code: "SHOPIFY_ORDER_NOT_FOUND", message: `Shopify has no order ${remoteId}.`, retriable: false });
279
299
  return Ok(orderStatus(read.value.order));
280
300
  },
301
+ async cancelOrder(store, remoteId, input) {
302
+ const shop = target(store);
303
+ if (!shop) return Err(credentialsRequired);
304
+ const orderId = `gid://shopify/Order/${remoteId}`;
305
+ const cancelled = await shopifyGraphql(shop, ORDER_CANCEL_MUTATION, {
306
+ orderId,
307
+ reason: CANCEL_REASON[input.reason],
308
+ staffNote: input.staffNote?.slice(0, 255) ?? null,
309
+ }, orderCancelSchema);
310
+ if (!cancelled.ok) return cancelled;
311
+ const refusals = cancelled.value.orderCancel.orderCancelUserErrors;
312
+ if (refusals.length === 0) return Ok(undefined);
313
+ // Cancelling twice is not a refusal: an order the store already cancelled is the outcome asked for.
314
+ const read = await shopifyGraphql(shop, ORDER_STATUS_QUERY, { id: orderId }, orderStatusSchema);
315
+ if (read.ok && read.value.order?.cancelledAt) return Ok(undefined);
316
+ return Err({ code: CHANNEL_CANCEL_REFUSED, message: `Shopify refused to cancel the order: ${refusals.map((error) => error.message).join("; ")}.`, retriable: false });
317
+ },
281
318
  /**
282
319
  * Every Shopify delivery — catalogue, stock, orders, uninstall and the mandatory compliance topics —
283
320
  * arrives at ONE app-level address declared in `shopify.app.toml`, signed with the app's client