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"`,
|
* 1. **No shipping address** despite `requiresShipping == true`. Shopify
|
||||||
* `"local_pickup"`). This is what Shopify's Local Pickup app and any
|
* never lets a regular ship-to-customer order check out without one,
|
||||||
* properly-categorised custom pickup rate set, and only requires the
|
* so this combination is a textbook pickup. This is the *only* signal
|
||||||
* `read_orders` scope.
|
* for the built-in "Shop location" rate, which leaves
|
||||||
* 2. Fallback: regex on `shippingLine.{source,code,title,carrierIdentifier}`
|
* `deliveryCategory` null and the title/code as the bare location
|
||||||
* for merchants who model pickup as a custom shipping rate without a
|
* name (e.g. "Shop location" / "Lager Graz").
|
||||||
* pickup category (e.g. titled "Abholung im Lager").
|
* 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`:
|
* (We deliberately do NOT query `Order.fulfillmentOrders.deliveryMethod`:
|
||||||
* that field requires the `read_merchant_managed_fulfillment_orders` scope,
|
* that field requires the `read_merchant_managed_fulfillment_orders` scope,
|
||||||
* which would force every install to re-grant permissions.)
|
* which would force every install to re-grant permissions.)
|
||||||
*
|
*
|
||||||
* When pickup is detected, the location name is taken from
|
* Location name is taken from `shippingLine.title` — for the Shopify
|
||||||
* `shippingLine.title` — for Shopify Local Pickup the title IS the chosen
|
* Local Pickup app and the built-in "Shop location" rate, the title IS
|
||||||
* location name (e.g. "Lager Graz").
|
* the chosen location name.
|
||||||
*
|
*
|
||||||
* Returns the pickup descriptor or `null` when the order is a normal
|
* Returns the pickup descriptor or `null` when the order is a normal
|
||||||
* shipping order. Callers should not render the pickup-location address as
|
* shipping order. Callers should not render the pickup-location address
|
||||||
* a separate "delivery address".
|
* as a separate "delivery address".
|
||||||
*/
|
*/
|
||||||
function detectPickup(
|
function detectPickup(
|
||||||
order: RawOrderForInvoice,
|
order: RawOrderForInvoice,
|
||||||
): { locationName: string | null } | null {
|
): { locationName: string | null } | null {
|
||||||
const sl = order.shippingLine;
|
const sl = order.shippingLine;
|
||||||
if (!sl) return null;
|
// Strongest signal: shipping is required but there's no shipping address.
|
||||||
// Primary signal: shippingLine.deliveryCategory is "pickup" / "local_pickup"
|
// Shopify rejects checkout otherwise, so this is conclusive.
|
||||||
// for any pickup-like fulfillment (set by Shopify's Local Pickup app and by
|
const noShipAddrButRequired =
|
||||||
// custom apps that use the proper category). Doesn't require any extra scope.
|
order.requiresShipping && order.shippingAddress == null;
|
||||||
const dc = (sl.deliveryCategory ?? "").toLowerCase();
|
// 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");
|
const isPickupCategory = dc.includes("pickup") || dc.includes("pick_up") || dc.includes("pick-up");
|
||||||
// Fallback: string heuristic on title/code/source/carrier — covers
|
// Tertiary: regex on title/code/source/carrier — covers merchants who
|
||||||
// merchants who model pickup as a custom shipping rate without category.
|
// model pickup as a custom shipping rate.
|
||||||
const haystack = [sl.source, sl.code, sl.title, sl.carrierIdentifier]
|
const haystack = [sl?.source, sl?.code, sl?.title, sl?.carrierIdentifier]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
const isPickupString = /pick[\s-]?up|abholung|abhol\b/.test(haystack);
|
const isPickupString = /pick[\s-]?up|abholung|abhol\b/.test(haystack);
|
||||||
if (!isPickupCategory && !isPickupString) return null;
|
if (!noShipAddrButRequired && !isPickupCategory && !isPickupString) return null;
|
||||||
// For Shopify Local Pickup, `title` is the location name itself
|
return { locationName: sl?.title?.trim() || null };
|
||||||
// (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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ export async function loadDraftOrderForOffer(
|
|||||||
currencyCode: draft.currencyCode,
|
currencyCode: draft.currencyCode,
|
||||||
displayFinancialStatus: null,
|
displayFinancialStatus: null,
|
||||||
paymentGatewayNames: [],
|
paymentGatewayNames: [],
|
||||||
|
requiresShipping: false,
|
||||||
shippingLine: null,
|
shippingLine: null,
|
||||||
fulfillments: [],
|
fulfillments: [],
|
||||||
discountCodes: [],
|
discountCodes: [],
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export interface RawOrderForInvoice {
|
|||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
displayFinancialStatus: string | null;
|
displayFinancialStatus: string | null;
|
||||||
paymentGatewayNames: string[];
|
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: {
|
customer: {
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
@@ -118,6 +123,7 @@ const QUERY = `#graphql
|
|||||||
displayFinancialStatus
|
displayFinancialStatus
|
||||||
paymentGatewayNames
|
paymentGatewayNames
|
||||||
taxesIncluded
|
taxesIncluded
|
||||||
|
requiresShipping
|
||||||
customer {
|
customer {
|
||||||
firstName
|
firstName
|
||||||
lastName
|
lastName
|
||||||
@@ -229,6 +235,7 @@ interface RawAdminResponse {
|
|||||||
displayFinancialStatus: string | null;
|
displayFinancialStatus: string | null;
|
||||||
paymentGatewayNames: string[] | null;
|
paymentGatewayNames: string[] | null;
|
||||||
taxesIncluded: boolean;
|
taxesIncluded: boolean;
|
||||||
|
requiresShipping: boolean | null;
|
||||||
customer: {
|
customer: {
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
@@ -286,6 +293,7 @@ export async function loadOrderForInvoice(
|
|||||||
displayFinancialStatus: order.displayFinancialStatus,
|
displayFinancialStatus: order.displayFinancialStatus,
|
||||||
paymentGatewayNames: order.paymentGatewayNames ?? [],
|
paymentGatewayNames: order.paymentGatewayNames ?? [],
|
||||||
taxesIncluded: order.taxesIncluded,
|
taxesIncluded: order.taxesIncluded,
|
||||||
|
requiresShipping: order.requiresShipping ?? false,
|
||||||
customer: order.customer,
|
customer: order.customer,
|
||||||
billingAddress: order.billingAddress,
|
billingAddress: order.billingAddress,
|
||||||
shippingAddress: order.shippingAddress,
|
shippingAddress: order.shippingAddress,
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
|||||||
displayFinancialStatus: "PENDING",
|
displayFinancialStatus: "PENDING",
|
||||||
paymentGatewayNames: ["manual"],
|
paymentGatewayNames: ["manual"],
|
||||||
taxesIncluded: false,
|
taxesIncluded: false,
|
||||||
|
requiresShipping: true,
|
||||||
discountCodes: [],
|
discountCodes: [],
|
||||||
customer: {
|
customer: {
|
||||||
firstName: "Lukas",
|
firstName: "Lukas",
|
||||||
@@ -329,6 +330,28 @@ function buildCategoryOnlyPickupOrder(): RawOrderForInvoice {
|
|||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pickup variant matching a REAL observed order on
|
||||||
|
* linumiq-dev.myshopify.com (#1032): Shopify's built-in "Shop location"
|
||||||
|
* rate. NO "pickup" string anywhere, deliveryCategory is `null`, and
|
||||||
|
* shippingAddress is also `null` — detection must rely on
|
||||||
|
* `requiresShipping && shippingAddress == null`. */
|
||||||
|
function buildShopLocationPickupOrder(): RawOrderForInvoice {
|
||||||
|
const o = buildAtB2BOrder();
|
||||||
|
o.shippingLine = {
|
||||||
|
title: "Shop location",
|
||||||
|
code: "Shop location",
|
||||||
|
source: "shopify",
|
||||||
|
carrierIdentifier: null,
|
||||||
|
deliveryCategory: null,
|
||||||
|
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
|
||||||
|
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
|
||||||
|
taxLines: [],
|
||||||
|
};
|
||||||
|
o.shippingAddress = null;
|
||||||
|
o.requiresShipping = true;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Run assertions
|
// Run assertions
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -655,6 +678,21 @@ async function main() {
|
|||||||
assert("shippingMethod cleared in category-only pickup",
|
assert("shippingMethod cleared in category-only pickup",
|
||||||
categoryPickupVm.shippingMethod == null);
|
categoryPickupVm.shippingMethod == null);
|
||||||
|
|
||||||
|
// Real-world "Shop location" pickup (matches dev order #1032): no
|
||||||
|
// "pickup" keyword anywhere, deliveryCategory null, shippingAddress null.
|
||||||
|
// The only signal is `requiresShipping && !shippingAddress`.
|
||||||
|
const shopLocPickupVm = composeInvoice({
|
||||||
|
order: buildShopLocationPickupOrder(),
|
||||||
|
settings: settings as never,
|
||||||
|
invoiceNumber: "RE-1034",
|
||||||
|
});
|
||||||
|
assert("isPickup detected from missing shippingAddress (Shop location rate)",
|
||||||
|
shopLocPickupVm.isPickup);
|
||||||
|
assertEq("pickupLocationName from shippingLine.title for Shop location",
|
||||||
|
shopLocPickupVm.pickupLocationName, "Shop location");
|
||||||
|
assert("shippingMethod cleared for Shop location pickup",
|
||||||
|
shopLocPickupVm.shippingMethod == null);
|
||||||
|
|
||||||
// Fallback: when footerNoteEn is empty, English uses the German note.
|
// Fallback: when footerNoteEn is empty, English uses the German note.
|
||||||
console.log("• Footer note fallback (en → de when EN empty)");
|
console.log("• Footer note fallback (en → de when EN empty)");
|
||||||
const settingsNoEn = { ...(settings as object), footerNoteEn: "" } as never;
|
const settingsNoEn = { ...(settings as object), footerNoteEn: "" } as never;
|
||||||
|
|||||||
Reference in New Issue
Block a user