fix(invoice): drop fulfillmentOrders query (scope-denied), keep shippingLine pickup heuristic

Querying Order.fulfillmentOrders.deliveryMethod requires the
read_merchant_managed_fulfillment_orders scope (not read_orders, despite
what shopify.dev claims) and was failing with 'Access denied for
fulfillmentOrders field' against real stores.

Adding that scope would force every install to re-grant permissions, so
instead we rely on shippingLine alone:

- Shopify Local Pickup app: shippingLine.code = 'Pickup' (caught by the
  regex) and shippingLine.title is the chosen location name itself   (e.g. 'Lager Graz') \u2014 perfect as pickupLocationName.
- Custom-rate pickup ('Abholung im Lager'): regex matches title/code,
  title is used as the location hint.

Removes RawDeliveryMethod, the deliveryMethods field on RawOrderForInvoice,
and the fulfillmentOrders edges from RawAdminResponse.
This commit is contained in:
Gerhard Scheikl
2026-05-15 15:04:16 +02:00
parent d742e75419
commit f16ef4e103
5 changed files with 209 additions and 78 deletions
+17 -16
View File
@@ -487,11 +487,18 @@ function mapTracking(order: RawOrderForInvoice): TrackingInfo[] {
}
/**
* Detects whether the order is a "local pickup" order. Primary signal is
* Shopify's `DeliveryMethodType` on the fulfillment order (`PICK_UP`),
* which is what the Shopify Local Pickup app sets. Falls back to a string
* heuristic on `shippingLine.source/code/title` for merchants who model
* pickup as a custom shipping rate named "Abholung"/"Pickup".
* Detects whether the order is a "local pickup" order. Pickup is detected
* heuristically from `shippingLine.{source,code,title,carrierIdentifier}`:
*
* - The Shopify Local Pickup app sets `shippingLine.code = "Pickup"` and
* uses the chosen pickup-location name as the shipping-line title — so
* the title doubles as the location name.
* - Merchants who model pickup as a custom shipping rate typically include
* "Abholung"/"Pickup" in the title or code.
*
* (We deliberately do NOT query `Order.fulfillmentOrders.deliveryMethod`
* here: that field requires the `read_merchant_managed_fulfillment_orders`
* scope, which would force every install to re-grant permissions.)
*
* Returns the pickup descriptor (with location name when known) or `null`
* when the order is a normal shipping order. Callers should not render the
@@ -500,23 +507,17 @@ function mapTracking(order: RawOrderForInvoice): TrackingInfo[] {
function detectPickup(
order: RawOrderForInvoice,
): { locationName: string | null } | null {
// Primary: DeliveryMethodType from fulfillment orders.
for (const dm of order.deliveryMethods ?? []) {
if (dm.methodType === "PICK_UP") {
return { locationName: dm.locationName };
}
}
// Fallback: legacy string heuristic on shippingLine.
const sl = order.shippingLine;
if (!sl) return null;
const haystack = [sl.source, sl.code, sl.title, sl.carrierIdentifier]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (/pick[\s-]?up|abholung|abhol\b/.test(haystack)) {
return { locationName: sl.title?.trim() || null };
}
return null;
if (!/pick[\s-]?up|abholung|abhol\b/.test(haystack)) return null;
// For Shopify Local Pickup, `title` is the location name itself
// (e.g. "Lager Graz"). For custom-rate pickup ("Abholung im Lager"),
// it's a generic description — still better than nothing as a hint.
return { locationName: sl.title?.trim() || null };
}
/**
@@ -163,7 +163,6 @@ export async function loadDraftOrderForOffer(
paymentGatewayNames: [],
shippingLine: null,
fulfillments: [],
deliveryMethods: [],
discountCodes: [],
taxesIncluded: draft.taxesIncluded,
customer: draft.customer,
@@ -25,11 +25,6 @@ export interface RawOrderForInvoice {
taxLines: RawTaxLine[];
shippingLine: RawShippingLine | null;
fulfillments: RawFulfillment[];
/** Delivery methods declared on the order's fulfillment orders. Used to
* reliably detect local pickup (`methodType === "PICK_UP"`) and to
* surface the pickup-location name. May be empty for unfulfilled or
* digital orders. */
deliveryMethods: RawDeliveryMethod[];
/** Discount codes applied to the cart (e.g. `["SUMMER10"]`). Empty when
* no codes were used. Manual / automatic discounts without a code are
* not exposed here. */
@@ -107,17 +102,6 @@ export interface RawFulfillment {
trackingInfo: RawTrackingInfo[];
}
/** Subset of Shopify's `DeliveryMethod` we care about. `methodType` is one
* of the enum values from `DeliveryMethodType` — e.g. `SHIPPING`,
* `PICK_UP`, `LOCAL`, `RETAIL`, `PICKUP_POINT`, `NONE`. */
export interface RawDeliveryMethod {
methodType: string | null;
/** Name of the location the customer chose to pick up from (when
* `methodType === "PICK_UP"`). Comes from the assigned location of the
* fulfillment order. */
locationName: string | null;
}
const QUERY = `#graphql
query OrderForInvoice($id: ID!) {
order(id: $id) {
@@ -189,18 +173,6 @@ const QUERY = `#graphql
company
}
}
fulfillmentOrders(first: 20) {
edges {
node {
deliveryMethod {
methodType
}
assignedLocation {
name
}
}
}
}
lineItems(first: 250) {
edges {
node {
@@ -268,14 +240,6 @@ interface RawAdminResponse {
discountCodes: string[] | null;
shippingLine: RawShippingLine | null;
fulfillments: RawFulfillment[] | null;
fulfillmentOrders: {
edges: {
node: {
deliveryMethod: { methodType: string | null } | null;
assignedLocation: { name: string | null } | null;
};
}[];
} | null;
lineItems: { edges: { node: RawLineItem }[] };
purchasingEntity: {
company?: { name: string } | null;
@@ -329,10 +293,6 @@ export async function loadOrderForInvoice(
: (order.discountCode ? [order.discountCode] : []),
shippingLine: order.shippingLine ?? null,
fulfillments: order.fulfillments ?? [],
deliveryMethods: (order.fulfillmentOrders?.edges ?? []).map((e) => ({
methodType: e.node.deliveryMethod?.methodType ?? null,
locationName: e.node.assignedLocation?.name ?? null,
})),
lineItems: (order.lineItems?.edges || []).map((e) => {
const node = e.node as unknown as RawLineItem & { image?: { url: string | null } | null };
return {