fix(invoice): detect pickup via DeliveryMethodType and show 'Abholort: <location>' meta row
- Use Order.fulfillmentOrders.deliveryMethod.methodType === 'PICK_UP' as the primary signal (Shopify Local Pickup app exposes this reliably; the shippingLine title is just the location name with no 'pickup' keyword). Keep the legacy shippingLine string heuristic as a fallback for custom shipping rates merchants name 'Abholung'/'Pickup'. - Surface assignedLocation.name as pickupLocationName on the view model. - Replace the 'Versandart: <location name>' row with 'Abholort: <location>' (DE) / 'Pick-up location: <location>' (EN); falls back to plain 'Abholung'/'Pick-up' when the location name is unavailable.
This commit is contained in:
@@ -62,10 +62,14 @@ export function composeInvoice({
|
|||||||
});
|
});
|
||||||
let notices = deriveNotices({ order, settings, isB2B });
|
let notices = deriveNotices({ order, settings, isB2B });
|
||||||
|
|
||||||
const isPickup = detectPickup(order.shippingLine);
|
const pickupInfo = detectPickup(order);
|
||||||
|
const isPickup = pickupInfo != null;
|
||||||
const separateShippingAddress = isPickup ? undefined : mapSeparateShippingAddress(order);
|
const separateShippingAddress = isPickup ? undefined : mapSeparateShippingAddress(order);
|
||||||
|
// For shipping orders we surface the carrier label (e.g. "Standardversand").
|
||||||
|
// For pickup orders the meta row uses a different label entirely
|
||||||
|
// ("Abholort: <location>") — see the renderer.
|
||||||
const shippingMethod = isPickup
|
const shippingMethod = isPickup
|
||||||
? strings.pickupLabel
|
? undefined
|
||||||
: order.shippingLine?.title?.trim() || undefined;
|
: order.shippingLine?.title?.trim() || undefined;
|
||||||
const tracking = mapTracking(order);
|
const tracking = mapTracking(order);
|
||||||
|
|
||||||
@@ -135,6 +139,7 @@ export function composeInvoice({
|
|||||||
tracking,
|
tracking,
|
||||||
discountCodes: order.discountCodes ?? [],
|
discountCodes: order.discountCodes ?? [],
|
||||||
isPickup,
|
isPickup,
|
||||||
|
pickupLocationName: pickupInfo?.locationName ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,24 +487,36 @@ function mapTracking(order: RawOrderForInvoice): TrackingInfo[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Heuristically detects whether the order's shipping line is a "local
|
* Detects whether the order is a "local pickup" order. Primary signal is
|
||||||
* pickup" line. Shopify exposes pickup either via the dedicated Local
|
* Shopify's `DeliveryMethodType` on the fulfillment order (`PICK_UP`),
|
||||||
* Pickup app (source contains "pickup") or via a custom rate the merchant
|
* which is what the Shopify Local Pickup app sets. Falls back to a string
|
||||||
* named "Abholung"/"Pickup". When detected, callers should NOT render the
|
* heuristic on `shippingLine.source/code/title` for merchants who model
|
||||||
|
* pickup as a custom shipping rate named "Abholung"/"Pickup".
|
||||||
|
*
|
||||||
|
* 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".
|
* pickup-location address as a separate "delivery address".
|
||||||
*/
|
*/
|
||||||
function detectPickup(shippingLine: RawShippingLine | null): boolean {
|
function detectPickup(
|
||||||
if (!shippingLine) return false;
|
order: RawOrderForInvoice,
|
||||||
const haystack = [
|
): { locationName: string | null } | null {
|
||||||
shippingLine.source,
|
// Primary: DeliveryMethodType from fulfillment orders.
|
||||||
shippingLine.code,
|
for (const dm of order.deliveryMethods ?? []) {
|
||||||
shippingLine.title,
|
if (dm.methodType === "PICK_UP") {
|
||||||
shippingLine.carrierIdentifier,
|
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)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
return /pick[\s-]?up|abholung|abhol\b/.test(haystack);
|
if (/pick[\s-]?up|abholung|abhol\b/.test(haystack)) {
|
||||||
|
return { locationName: sl.title?.trim() || null };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export interface InvoiceStrings {
|
|||||||
shippingItemPrefix: string;
|
shippingItemPrefix: string;
|
||||||
discountCodeLabel: string;
|
discountCodeLabel: string;
|
||||||
pickupLabel: string;
|
pickupLabel: string;
|
||||||
|
/** Used as the meta-row label when the order is a local pickup. The row
|
||||||
|
* value is then the pickup location name (e.g. "Lager Graz"). */
|
||||||
|
pickupLocationLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Status displayed for the order's payment, derived from Shopify's
|
/** Status displayed for the order's payment, derived from Shopify's
|
||||||
@@ -167,6 +170,7 @@ const de: InvoiceStrings = {
|
|||||||
shippingItemPrefix: "Versand",
|
shippingItemPrefix: "Versand",
|
||||||
discountCodeLabel: "Rabattcode",
|
discountCodeLabel: "Rabattcode",
|
||||||
pickupLabel: "Abholung",
|
pickupLabel: "Abholung",
|
||||||
|
pickupLocationLabel: "Abholort",
|
||||||
};
|
};
|
||||||
|
|
||||||
const en: InvoiceStrings = {
|
const en: InvoiceStrings = {
|
||||||
@@ -236,6 +240,7 @@ const en: InvoiceStrings = {
|
|||||||
shippingItemPrefix: "Shipping",
|
shippingItemPrefix: "Shipping",
|
||||||
discountCodeLabel: "Discount code",
|
discountCodeLabel: "Discount code",
|
||||||
pickupLabel: "Pick-up",
|
pickupLabel: "Pick-up",
|
||||||
|
pickupLocationLabel: "Pick-up location",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Locale → invoice language. We only render in German (`de`) when the
|
// Locale → invoice language. We only render in German (`de`) when the
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export async function loadDraftOrderForOffer(
|
|||||||
paymentGatewayNames: [],
|
paymentGatewayNames: [],
|
||||||
shippingLine: null,
|
shippingLine: null,
|
||||||
fulfillments: [],
|
fulfillments: [],
|
||||||
|
deliveryMethods: [],
|
||||||
discountCodes: [],
|
discountCodes: [],
|
||||||
taxesIncluded: draft.taxesIncluded,
|
taxesIncluded: draft.taxesIncluded,
|
||||||
customer: draft.customer,
|
customer: draft.customer,
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export interface RawOrderForInvoice {
|
|||||||
taxLines: RawTaxLine[];
|
taxLines: RawTaxLine[];
|
||||||
shippingLine: RawShippingLine | null;
|
shippingLine: RawShippingLine | null;
|
||||||
fulfillments: RawFulfillment[];
|
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
|
/** Discount codes applied to the cart (e.g. `["SUMMER10"]`). Empty when
|
||||||
* no codes were used. Manual / automatic discounts without a code are
|
* no codes were used. Manual / automatic discounts without a code are
|
||||||
* not exposed here. */
|
* not exposed here. */
|
||||||
@@ -102,6 +107,17 @@ export interface RawFulfillment {
|
|||||||
trackingInfo: RawTrackingInfo[];
|
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
|
const QUERY = `#graphql
|
||||||
query OrderForInvoice($id: ID!) {
|
query OrderForInvoice($id: ID!) {
|
||||||
order(id: $id) {
|
order(id: $id) {
|
||||||
@@ -173,6 +189,18 @@ const QUERY = `#graphql
|
|||||||
company
|
company
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fulfillmentOrders(first: 20) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
deliveryMethod {
|
||||||
|
methodType
|
||||||
|
}
|
||||||
|
assignedLocation {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
lineItems(first: 250) {
|
lineItems(first: 250) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -240,6 +268,14 @@ interface RawAdminResponse {
|
|||||||
discountCodes: string[] | null;
|
discountCodes: string[] | null;
|
||||||
shippingLine: RawShippingLine | null;
|
shippingLine: RawShippingLine | null;
|
||||||
fulfillments: RawFulfillment[] | null;
|
fulfillments: RawFulfillment[] | null;
|
||||||
|
fulfillmentOrders: {
|
||||||
|
edges: {
|
||||||
|
node: {
|
||||||
|
deliveryMethod: { methodType: string | null } | null;
|
||||||
|
assignedLocation: { name: string | null } | null;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
} | null;
|
||||||
lineItems: { edges: { node: RawLineItem }[] };
|
lineItems: { edges: { node: RawLineItem }[] };
|
||||||
purchasingEntity: {
|
purchasingEntity: {
|
||||||
company?: { name: string } | null;
|
company?: { name: string } | null;
|
||||||
@@ -293,6 +329,10 @@ export async function loadOrderForInvoice(
|
|||||||
: (order.discountCode ? [order.discountCode] : []),
|
: (order.discountCode ? [order.discountCode] : []),
|
||||||
shippingLine: order.shippingLine ?? null,
|
shippingLine: order.shippingLine ?? null,
|
||||||
fulfillments: order.fulfillments ?? [],
|
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) => {
|
lineItems: (order.lineItems?.edges || []).map((e) => {
|
||||||
const node = e.node as unknown as RawLineItem & { image?: { url: string | null } | null };
|
const node = e.node as unknown as RawLineItem & { image?: { url: string | null } | null };
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -329,7 +329,14 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
|||||||
<Text style={styles.metaValue}>{invoice.discountCodes.join(", ")}</Text>
|
<Text style={styles.metaValue}>{invoice.discountCodes.join(", ")}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{invoice.kind === "invoice" && invoice.shippingMethod ? (
|
{invoice.kind === "invoice" && invoice.isPickup ? (
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<Text style={styles.metaLabel}>{t.pickupLocationLabel}</Text>
|
||||||
|
<Text style={styles.metaValue}>
|
||||||
|
{invoice.pickupLocationName ?? t.pickupLabel}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : invoice.kind === "invoice" && invoice.shippingMethod ? (
|
||||||
<View style={styles.metaRow}>
|
<View style={styles.metaRow}>
|
||||||
<Text style={styles.metaLabel}>{t.shippingMethodLabel}</Text>
|
<Text style={styles.metaLabel}>{t.shippingMethodLabel}</Text>
|
||||||
<Text style={styles.metaValue}>{invoice.shippingMethod}</Text>
|
<Text style={styles.metaValue}>{invoice.shippingMethod}</Text>
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ export interface InvoiceViewModel {
|
|||||||
/** True when the customer chose local pickup (so we shouldn't render the
|
/** True when the customer chose local pickup (so we shouldn't render the
|
||||||
* pickup-location address as a "delivery address"). */
|
* pickup-location address as a "delivery address"). */
|
||||||
isPickup: boolean;
|
isPickup: boolean;
|
||||||
|
|
||||||
|
/** Name of the pickup location (e.g. "Lager Graz"). Set only when
|
||||||
|
* `isPickup` is true and the location name was available. */
|
||||||
|
pickupLocationName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackingInfo {
|
export interface TrackingInfo {
|
||||||
|
|||||||
+34
-10
@@ -137,6 +137,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
|||||||
paymentGatewayNames: ["manual"],
|
paymentGatewayNames: ["manual"],
|
||||||
taxesIncluded: false,
|
taxesIncluded: false,
|
||||||
discountCodes: [],
|
discountCodes: [],
|
||||||
|
deliveryMethods: [],
|
||||||
customer: {
|
customer: {
|
||||||
firstName: "Lukas",
|
firstName: "Lukas",
|
||||||
lastName: "Schmidhofer",
|
lastName: "Schmidhofer",
|
||||||
@@ -306,6 +307,10 @@ function buildPickupOrder(): RawOrderForInvoice {
|
|||||||
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
|
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
|
||||||
taxLines: [],
|
taxLines: [],
|
||||||
};
|
};
|
||||||
|
// Primary signal — what Shopify Local Pickup actually populates.
|
||||||
|
o.deliveryMethods = [
|
||||||
|
{ methodType: "PICK_UP", locationName: "Lager Graz" },
|
||||||
|
];
|
||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,26 +598,45 @@ async function main() {
|
|||||||
settings: settings as never,
|
settings: settings as never,
|
||||||
invoiceNumber: "RE-1030",
|
invoiceNumber: "RE-1030",
|
||||||
});
|
});
|
||||||
assert("isPickup detected", pickupVm.isPickup);
|
assert("isPickup detected via DeliveryMethodType=PICK_UP", pickupVm.isPickup);
|
||||||
assertEq("shippingMethod replaced with localized label", pickupVm.shippingMethod, "Abholung");
|
assertEq("pickupLocationName propagated", pickupVm.pickupLocationName, "Lager Graz");
|
||||||
|
assert("shippingMethod cleared for pickup (renderer uses pickup row instead)",
|
||||||
|
pickupVm.shippingMethod == null);
|
||||||
assert(
|
assert(
|
||||||
"separateShippingAddress suppressed for pickup",
|
"separateShippingAddress suppressed for pickup",
|
||||||
pickupVm.separateShippingAddress == null,
|
pickupVm.separateShippingAddress == null,
|
||||||
);
|
);
|
||||||
|
pickupVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||||
|
const pickupText = await pdfToText(await renderInvoicePdf(pickupVm));
|
||||||
|
assert("DE pickup PDF shows 'Abholort' label", pickupText.includes("Abholort"));
|
||||||
|
assert("DE pickup PDF shows location name", pickupText.includes("Lager Graz"));
|
||||||
|
assert("DE pickup PDF does NOT show 'Versandart'", !pickupText.includes("Versandart"));
|
||||||
|
assert(
|
||||||
|
"DE pickup PDF does NOT render pickup-location address as delivery address",
|
||||||
|
!pickupText.includes("Lieferadresse"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shopify-Local-Pickup-app fallback: methodType missing but shippingLine
|
||||||
|
// is named "Pickup at …".
|
||||||
|
const legacyPickupOrder = buildPickupOrder();
|
||||||
|
legacyPickupOrder.deliveryMethods = [];
|
||||||
|
const legacyPickupVm = composeInvoice({
|
||||||
|
order: legacyPickupOrder,
|
||||||
|
settings: settings as never,
|
||||||
|
invoiceNumber: "RE-1032",
|
||||||
|
});
|
||||||
|
assert("legacy heuristic still detects pickup from shippingLine", legacyPickupVm.isPickup);
|
||||||
|
|
||||||
|
// EN translation
|
||||||
const pickupEnVm = composeInvoice({
|
const pickupEnVm = composeInvoice({
|
||||||
order: pickupOrder,
|
order: pickupOrder,
|
||||||
settings: settings as never,
|
settings: settings as never,
|
||||||
invoiceNumber: "RE-1031",
|
invoiceNumber: "RE-1031",
|
||||||
forceLanguage: "en",
|
forceLanguage: "en",
|
||||||
});
|
});
|
||||||
assertEq("pickup label EN", pickupEnVm.shippingMethod, "Pick-up");
|
pickupEnVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||||
pickupVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
const pickupEnText = await pdfToText(await renderInvoicePdf(pickupEnVm));
|
||||||
const pickupText = await pdfToText(await renderInvoicePdf(pickupVm));
|
assert("EN pickup PDF shows 'Pick-up location' label", pickupEnText.includes("Pick-up location"));
|
||||||
assert("DE pickup PDF shows 'Abholung'", pickupText.includes("Abholung"));
|
|
||||||
assert(
|
|
||||||
"DE pickup PDF does NOT render pickup-location address as delivery address",
|
|
||||||
!pickupText.includes("Lieferadresse"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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)");
|
||||||
|
|||||||
Reference in New Issue
Block a user