fix(invoice): detect pickup via missing shippingAddress (real signal for built-in 'Shop location' rate)
Reproduced against real dev order #1032: the built-in 'Shop location' shipping rate sets neither a pickup keyword nor deliveryCategory: shippingLine: { title: 'Shop location', code: 'Shop location', source: 'shopify', deliveryCategory: null } shippingAddress: null requiresShipping: true So neither v2 (string regex on title/code) nor v3 (deliveryCategory) caught it. The robust signal is 'requiresShipping && shippingAddress == null': Shopify rejects checkout for a normal shipping order without an address, so this combination is conclusive proof of pickup. - Query Order.requiresShipping (only needs read_orders). - detectPickup() now treats missing-address-but-requires-shipping as the primary signal; deliveryCategory + title/code regex remain as fallbacks for Local-Pickup-app installs and custom rates. - New fixture buildShopLocationPickupOrder() in render-sample.ts mirrors order #1032 exactly so we never regress on this shape.
This commit is contained in:
@@ -487,50 +487,52 @@ function mapTracking(order: RawOrderForInvoice): TrackingInfo[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether the order is a "local pickup" order using two signals:
|
||||
* Detects whether the order is a "local pickup" order using three signals
|
||||
* (any one is enough). All rely only on the `read_orders` scope.
|
||||
*
|
||||
* 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").
|
||||
* 1. **No shipping address** despite `requiresShipping == true`. Shopify
|
||||
* never lets a regular ship-to-customer order check out without one,
|
||||
* so this combination is a textbook pickup. This is the *only* signal
|
||||
* for the built-in "Shop location" rate, which leaves
|
||||
* `deliveryCategory` null and the title/code as the bare location
|
||||
* name (e.g. "Shop location" / "Lager Graz").
|
||||
* 2. `shippingLine.deliveryCategory` contains "pickup"/"local_pickup".
|
||||
* Set by some Local Pickup integrations.
|
||||
* 3. Regex on `shippingLine.{source,code,title,carrierIdentifier}` for
|
||||
* custom rates titled "Abholung"/"Pickup".
|
||||
*
|
||||
* (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.)
|
||||
*
|
||||
* 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").
|
||||
* Location name is taken from `shippingLine.title` — for the Shopify
|
||||
* Local Pickup app and the built-in "Shop location" rate, the title IS
|
||||
* the chosen location name.
|
||||
*
|
||||
* 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".
|
||||
* 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();
|
||||
// Strongest signal: shipping is required but there's no shipping address.
|
||||
// Shopify rejects checkout otherwise, so this is conclusive.
|
||||
const noShipAddrButRequired =
|
||||
order.requiresShipping && order.shippingAddress == null;
|
||||
// Secondary: explicit pickup category from Local Pickup apps.
|
||||
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]
|
||||
// Tertiary: regex on title/code/source/carrier — covers merchants who
|
||||
// model pickup as a custom shipping rate.
|
||||
const haystack = [sl?.source, sl?.code, sl?.title, sl?.carrierIdentifier]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
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, it's a generic description
|
||||
// — still better than nothing as a hint.
|
||||
return { locationName: sl.title?.trim() || null };
|
||||
if (!noShipAddrButRequired && !isPickupCategory && !isPickupString) return null;
|
||||
return { locationName: sl?.title?.trim() || null };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -161,6 +161,7 @@ export async function loadDraftOrderForOffer(
|
||||
currencyCode: draft.currencyCode,
|
||||
displayFinancialStatus: null,
|
||||
paymentGatewayNames: [],
|
||||
requiresShipping: false,
|
||||
shippingLine: null,
|
||||
fulfillments: [],
|
||||
discountCodes: [],
|
||||
|
||||
@@ -13,6 +13,11 @@ export interface RawOrderForInvoice {
|
||||
currencyCode: string;
|
||||
displayFinancialStatus: string | null;
|
||||
paymentGatewayNames: string[];
|
||||
/** True when the order contains at least one shippable line item. For
|
||||
* pickup orders this is `true` but `shippingAddress` is `null` — that
|
||||
* combination is the most reliable pickup signal we have without
|
||||
* hitting `read_merchant_managed_fulfillment_orders`. */
|
||||
requiresShipping: boolean;
|
||||
customer: {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
@@ -118,6 +123,7 @@ const QUERY = `#graphql
|
||||
displayFinancialStatus
|
||||
paymentGatewayNames
|
||||
taxesIncluded
|
||||
requiresShipping
|
||||
customer {
|
||||
firstName
|
||||
lastName
|
||||
@@ -229,6 +235,7 @@ interface RawAdminResponse {
|
||||
displayFinancialStatus: string | null;
|
||||
paymentGatewayNames: string[] | null;
|
||||
taxesIncluded: boolean;
|
||||
requiresShipping: boolean | null;
|
||||
customer: {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
@@ -286,6 +293,7 @@ export async function loadOrderForInvoice(
|
||||
displayFinancialStatus: order.displayFinancialStatus,
|
||||
paymentGatewayNames: order.paymentGatewayNames ?? [],
|
||||
taxesIncluded: order.taxesIncluded,
|
||||
requiresShipping: order.requiresShipping ?? false,
|
||||
customer: order.customer,
|
||||
billingAddress: order.billingAddress,
|
||||
shippingAddress: order.shippingAddress,
|
||||
|
||||
Reference in New Issue
Block a user