@porulle/adapter-shopify 0.72.0 → 0.73.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -24,6 +24,9 @@ export declare const ORDER_CREATE_MUTATION = "mutation PorulleOrderCreate($order
24
24
  * took the payment, so it refunds and it writes to the shopper.
25
25
  */
26
26
  export declare const ORDER_CANCEL_MUTATION = "mutation PorulleOrderCancel($orderId: ID!, $reason: OrderCancelReason!, $staffNote: String) {\n orderCancel(orderId: $orderId, reason: $reason, restock: true, notifyCustomer: false, staffNote: $staffNote) { orderCancelUserErrors { code message } }\n}";
27
+ /** The order's shipped lines: a return names what it takes back by fulfilment line, not order line. */
28
+ export declare const ORDER_FULFILLMENT_LINES_QUERY = "query PorulleOrderFulfillmentLines($id: ID!) {\n order(id: $id) { fulfillments(first: 20) { fulfillmentLineItems(first: 50) { nodes { id quantity lineItem { variant { legacyResourceId } } } } } }\n}";
29
+ export declare const RETURN_REQUEST_MUTATION = "mutation PorulleReturnRequest($input: ReturnRequestInput!) {\n returnRequest(input: $input) { return { id } userErrors { code message } }\n}";
27
30
  export declare const ORDER_BY_SOURCE_QUERY = "query PorulleOrderBySource($query: String!) {\n orders(first: 1, query: $query) { nodes { legacyResourceId } }\n}";
28
31
  export declare const ORDER_STATUS_QUERY = "query PorulleOrderStatus($id: ID!) {\n order(id: $id) { cancelledAt displayFinancialStatus displayFulfillmentStatus }\n}";
29
32
  export declare function shopifyConnector(options: ShopifyConnectorOptions): ChannelConnector;
package/dist/index.js CHANGED
@@ -26,6 +26,13 @@ export const ORDER_CREATE_MUTATION = `mutation PorulleOrderCreate($order: OrderC
26
26
  export const ORDER_CANCEL_MUTATION = `mutation PorulleOrderCancel($orderId: ID!, $reason: OrderCancelReason!, $staffNote: String) {
27
27
  orderCancel(orderId: $orderId, reason: $reason, restock: true, notifyCustomer: false, staffNote: $staffNote) { orderCancelUserErrors { code message } }
28
28
  }`;
29
+ /** The order's shipped lines: a return names what it takes back by fulfilment line, not order line. */
30
+ export const ORDER_FULFILLMENT_LINES_QUERY = `query PorulleOrderFulfillmentLines($id: ID!) {
31
+ order(id: $id) { fulfillments(first: 20) { fulfillmentLineItems(first: 50) { nodes { id quantity lineItem { variant { legacyResourceId } } } } } }
32
+ }`;
33
+ export const RETURN_REQUEST_MUTATION = `mutation PorulleReturnRequest($input: ReturnRequestInput!) {
34
+ returnRequest(input: $input) { return { id } userErrors { code message } }
35
+ }`;
29
36
  export const ORDER_BY_SOURCE_QUERY = `query PorulleOrderBySource($query: String!) {
30
37
  orders(first: 1, query: $query) { nodes { legacyResourceId } }
31
38
  }`;
@@ -57,6 +64,16 @@ const CANCEL_REASON = {
57
64
  staff: "STAFF",
58
65
  other: "OTHER",
59
66
  };
67
+ const fulfillmentLinesSchema = z.object({
68
+ order: z.object({
69
+ fulfillments: z.array(z.object({
70
+ fulfillmentLineItems: z.object({ nodes: z.array(z.object({ id: z.string(), quantity: z.number(), lineItem: z.object({ variant: z.object({ legacyResourceId: z.string() }).nullable() }) })) }),
71
+ })),
72
+ }).nullable(),
73
+ });
74
+ const returnRequestSchema = z.object({
75
+ returnRequest: z.object({ return: z.object({ id: z.string() }).nullable(), userErrors: z.array(z.object({ code: z.string().nullable(), message: z.string() })) }),
76
+ });
60
77
  const orderBySourceSchema = z.object({ orders: z.object({ nodes: z.array(z.object({ legacyResourceId: z.string() })) }) });
61
78
  const orderStatusSchema = z.object({
62
79
  order: z.object({ cancelledAt: z.string().nullable(), displayFinancialStatus: z.string().nullable(), displayFulfillmentStatus: z.string() }).nullable(),
@@ -295,6 +312,37 @@ export function shopifyConnector(options) {
295
312
  return Ok(undefined);
296
313
  return Err({ code: CHANNEL_CANCEL_REFUSED, message: `Shopify refused to cancel the order: ${refusals.map((error) => error.message).join("; ")}.`, retriable: false });
297
314
  },
315
+ async requestReturn(store, remoteOrderId, input) {
316
+ const shop = target(store);
317
+ if (!shop)
318
+ return Err(credentialsRequired);
319
+ const shipped = await shopifyGraphql(shop, ORDER_FULFILLMENT_LINES_QUERY, { id: `gid://shopify/Order/${remoteOrderId}` }, fulfillmentLinesSchema);
320
+ if (!shipped.ok)
321
+ return shipped;
322
+ const fulfilmentLines = (shipped.value.order?.fulfillments ?? []).flatMap((fulfillment) => fulfillment.fulfillmentLineItems.nodes);
323
+ const returnLineItems = [];
324
+ const note = [input.reason, input.note].filter((part) => part !== undefined && part !== "").join(" — ").slice(0, 300);
325
+ for (const wanted of input.lines) {
326
+ let remaining = wanted.quantity;
327
+ for (const line of fulfilmentLines.filter((candidate) => candidate.lineItem.variant?.legacyResourceId === wanted.externalVariantId)) {
328
+ if (remaining === 0)
329
+ break;
330
+ const take = Math.min(remaining, line.quantity);
331
+ returnLineItems.push({ fulfillmentLineItemId: line.id, quantity: take, customerNote: note });
332
+ remaining -= take;
333
+ }
334
+ if (remaining > 0)
335
+ return Err({ code: "SHOPIFY_RETURN_NOT_SHIPPED", message: `Shopify has not shipped ${remaining} of variant ${wanted.externalVariantId}, so it cannot take them back.`, retriable: false });
336
+ }
337
+ const requested = await shopifyGraphql(shop, RETURN_REQUEST_MUTATION, { input: { orderId: `gid://shopify/Order/${remoteOrderId}`, returnLineItems } }, returnRequestSchema);
338
+ if (!requested.ok)
339
+ return requested;
340
+ const { return: created, userErrors } = requested.value.returnRequest;
341
+ if (userErrors.length > 0 || !created) {
342
+ return Err({ code: "SHOPIFY_RETURN_REFUSED", message: `Shopify refused the return: ${userErrors.map((error) => error.message).join("; ") || "no return created"}.`, retriable: false });
343
+ }
344
+ return Ok({ remoteReturnId: created.id.split("/").pop() ?? created.id });
345
+ },
298
346
  /**
299
347
  * Every Shopify delivery — catalogue, stock, orders, uninstall and the mandatory compliance topics —
300
348
  * arrives at ONE app-level address declared in `shopify.app.toml`, signed with the app's client
package/dist/oauth.d.ts CHANGED
@@ -12,8 +12,10 @@ import type { ChannelConnectorError, Result } from "@porulle/core";
12
12
  * - `read_inventory`: variant stock and the inventory item behind a stock webhook.
13
13
  * - `write_orders`: a paid platform order is created in the store (`orderCreate`). A write scope
14
14
  * includes its read scope, which `orders/fulfilled` and `orders/cancelled` need.
15
+ * - `read_returns`, `write_returns`: a shopper's return is asked of the store (`returnRequest`) and
16
+ * its `returns/*` webhooks say how the store answered.
15
17
  */
16
- export declare const REQUIRED_SCOPES: readonly ["read_products", "write_products", "read_inventory", "write_orders"];
18
+ export declare const REQUIRED_SCOPES: readonly ["read_products", "write_products", "read_inventory", "write_orders", "read_returns", "write_returns"];
17
19
  /** Refresh this long before Shopify's stated expiry, so a call never starts on a token about to lapse. */
18
20
  export declare const ACCESS_TOKEN_REFRESH_MARGIN_MS: number;
19
21
  /** What the store row holds. Read back with {@link parseShopifyCredentials}; never cast. */
package/dist/oauth.js CHANGED
@@ -14,8 +14,10 @@ import { z } from "zod";
14
14
  * - `read_inventory`: variant stock and the inventory item behind a stock webhook.
15
15
  * - `write_orders`: a paid platform order is created in the store (`orderCreate`). A write scope
16
16
  * includes its read scope, which `orders/fulfilled` and `orders/cancelled` need.
17
+ * - `read_returns`, `write_returns`: a shopper's return is asked of the store (`returnRequest`) and
18
+ * its `returns/*` webhooks say how the store answered.
17
19
  */
18
- export const REQUIRED_SCOPES = ["read_products", "write_products", "read_inventory", "write_orders"];
20
+ export const REQUIRED_SCOPES = ["read_products", "write_products", "read_inventory", "write_orders", "read_returns", "write_returns"];
19
21
  /** Refresh this long before Shopify's stated expiry, so a call never starts on a token about to lapse. */
20
22
  export const ACCESS_TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
21
23
  const credentialsSchema = z.object({