fix(invoice): use shippingLine.deliveryCategory as primary pickup signal

Order 1032 on dev still rendered as 'Versandart: Lager Graz' because the
shipping line's title/code/source contained no 'pickup' keyword — only
`shippingLine.deliveryCategory == "pickup"` flagged it as a pickup.

`shippingLine.deliveryCategory` only requires `read_orders` (already
granted), so query and use it as the primary signal. Keep the regex on
title/code/source/carrier as a fallback for custom rates without a proper
pickup category.
This commit is contained in:
Gerhard Scheikl
2026-05-15 15:12:59 +02:00
parent 4e522f41df
commit 8a40bcbee6
25 changed files with 1619 additions and 16 deletions
+29 -16
View File
@@ -487,36 +487,49 @@ function mapTracking(order: RawOrderForInvoice): TrackingInfo[] {
}
/**
* Detects whether the order is a "local pickup" order. Pickup is detected
* heuristically from `shippingLine.{source,code,title,carrierIdentifier}`:
* Detects whether the order is a "local pickup" order using two signals:
*
* - 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.
* 1. Primary: `shippingLine.deliveryCategory` (e.g. `"pickup"`,
* `"local_pickup"`). This is what Shopify's Local Pickup app and any
* properly-categorised custom pickup rate set, and only requires the
* `read_orders` scope.
* 2. Fallback: regex on `shippingLine.{source,code,title,carrierIdentifier}`
* for merchants who model pickup as a custom shipping rate without a
* pickup category (e.g. titled "Abholung im Lager").
*
* (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.)
* (We deliberately do NOT query `Order.fulfillmentOrders.deliveryMethod`:
* 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
* pickup-location address as a separate "delivery address".
* When pickup is detected, the location name is taken from
* `shippingLine.title` — for Shopify Local Pickup the title IS the chosen
* location name (e.g. "Lager Graz").
*
* Returns the pickup descriptor or `null` when the order is a normal
* shipping order. Callers should not render the pickup-location address as
* a separate "delivery address".
*/
function detectPickup(
order: RawOrderForInvoice,
): { locationName: string | null } | null {
const sl = order.shippingLine;
if (!sl) return null;
// Primary signal: shippingLine.deliveryCategory is "pickup" / "local_pickup"
// for any pickup-like fulfillment (set by Shopify's Local Pickup app and by
// custom apps that use the proper category). Doesn't require any extra scope.
const dc = (sl.deliveryCategory ?? "").toLowerCase();
const isPickupCategory = dc.includes("pickup") || dc.includes("pick_up") || dc.includes("pick-up");
// Fallback: string heuristic on title/code/source/carrier — covers
// merchants who model pickup as a custom shipping rate without category.
const haystack = [sl.source, sl.code, sl.title, sl.carrierIdentifier]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (!/pick[\s-]?up|abholung|abhol\b/.test(haystack)) return null;
const isPickupString = /pick[\s-]?up|abholung|abhol\b/.test(haystack);
if (!isPickupCategory && !isPickupString) 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.
// (e.g. "Lager Graz"). For custom-rate pickup, it's a generic description
// — still better than nothing as a hint.
return { locationName: sl.title?.trim() || null };
}
@@ -83,6 +83,10 @@ export interface RawShippingLine {
code: string | null;
source: string | null;
carrierIdentifier: string | null;
/** Lowercase string like "shipping", "pickup", "local_pickup". Used as
* the primary pickup signal because it doesn't require the
* fulfillment-orders scope. */
deliveryCategory: string | null;
originalPriceSet: { shopMoney: RawMoney } | null;
discountedPriceSet: { shopMoney: RawMoney } | null;
taxLines: RawTaxLine[];
@@ -156,6 +160,7 @@ const QUERY = `#graphql
code
source
carrierIdentifier
deliveryCategory
originalPriceSet { shopMoney { amount currencyCode } }
discountedPriceSet { shopMoney { amount currencyCode } }
taxLines {