diff --git a/app/services/invoice/composeInvoice.ts b/app/services/invoice/composeInvoice.ts index eb06dc0..bc136c6 100644 --- a/app/services/invoice/composeInvoice.ts +++ b/app/services/invoice/composeInvoice.ts @@ -62,10 +62,14 @@ export function composeInvoice({ }); 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); + // For shipping orders we surface the carrier label (e.g. "Standardversand"). + // For pickup orders the meta row uses a different label entirely + // ("Abholort: ") — see the renderer. const shippingMethod = isPickup - ? strings.pickupLabel + ? undefined : order.shippingLine?.title?.trim() || undefined; const tracking = mapTracking(order); @@ -135,6 +139,7 @@ export function composeInvoice({ tracking, discountCodes: order.discountCodes ?? [], 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 - * pickup" line. Shopify exposes pickup either via the dedicated Local - * Pickup app (source contains "pickup") or via a custom rate the merchant - * named "Abholung"/"Pickup". When detected, callers should NOT render the + * 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". + * + * 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". */ -function detectPickup(shippingLine: RawShippingLine | null): boolean { - if (!shippingLine) return false; - const haystack = [ - shippingLine.source, - shippingLine.code, - shippingLine.title, - shippingLine.carrierIdentifier, - ] +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(); - 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; } /** diff --git a/app/services/invoice/i18n.ts b/app/services/invoice/i18n.ts index 2e0e79d..c371c41 100644 --- a/app/services/invoice/i18n.ts +++ b/app/services/invoice/i18n.ts @@ -65,6 +65,9 @@ export interface InvoiceStrings { shippingItemPrefix: string; discountCodeLabel: 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 @@ -167,6 +170,7 @@ const de: InvoiceStrings = { shippingItemPrefix: "Versand", discountCodeLabel: "Rabattcode", pickupLabel: "Abholung", + pickupLocationLabel: "Abholort", }; const en: InvoiceStrings = { @@ -236,6 +240,7 @@ const en: InvoiceStrings = { shippingItemPrefix: "Shipping", discountCodeLabel: "Discount code", pickupLabel: "Pick-up", + pickupLocationLabel: "Pick-up location", }; // Locale → invoice language. We only render in German (`de`) when the diff --git a/app/services/invoice/loadDraftOrderForOffer.server.ts b/app/services/invoice/loadDraftOrderForOffer.server.ts index e01f00f..837c815 100644 --- a/app/services/invoice/loadDraftOrderForOffer.server.ts +++ b/app/services/invoice/loadDraftOrderForOffer.server.ts @@ -163,6 +163,7 @@ export async function loadDraftOrderForOffer( paymentGatewayNames: [], shippingLine: null, fulfillments: [], + deliveryMethods: [], discountCodes: [], taxesIncluded: draft.taxesIncluded, customer: draft.customer, diff --git a/app/services/invoice/loadOrderForInvoice.server.ts b/app/services/invoice/loadOrderForInvoice.server.ts index c3bc7a5..fb70c15 100644 --- a/app/services/invoice/loadOrderForInvoice.server.ts +++ b/app/services/invoice/loadOrderForInvoice.server.ts @@ -25,6 +25,11 @@ 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. */ @@ -102,6 +107,17 @@ 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) { @@ -173,6 +189,18 @@ const QUERY = `#graphql company } } + fulfillmentOrders(first: 20) { + edges { + node { + deliveryMethod { + methodType + } + assignedLocation { + name + } + } + } + } lineItems(first: 250) { edges { node { @@ -240,6 +268,14 @@ 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; @@ -293,6 +329,10 @@ 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 { diff --git a/app/services/invoice/pdf/InvoiceDocument.tsx b/app/services/invoice/pdf/InvoiceDocument.tsx index f009157..8142bc1 100644 --- a/app/services/invoice/pdf/InvoiceDocument.tsx +++ b/app/services/invoice/pdf/InvoiceDocument.tsx @@ -329,7 +329,14 @@ export function InvoiceDocument({ invoice }: DocProps) { {invoice.discountCodes.join(", ")} ) : null} - {invoice.kind === "invoice" && invoice.shippingMethod ? ( + {invoice.kind === "invoice" && invoice.isPickup ? ( + + {t.pickupLocationLabel} + + {invoice.pickupLocationName ?? t.pickupLabel} + + + ) : invoice.kind === "invoice" && invoice.shippingMethod ? ( {t.shippingMethodLabel} {invoice.shippingMethod} diff --git a/app/services/invoice/types.ts b/app/services/invoice/types.ts index 809a132..a5b0107 100644 --- a/app/services/invoice/types.ts +++ b/app/services/invoice/types.ts @@ -70,6 +70,10 @@ export interface InvoiceViewModel { /** True when the customer chose local pickup (so we shouldn't render the * pickup-location address as a "delivery address"). */ 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 { diff --git a/scripts/render-sample.ts b/scripts/render-sample.ts index ea95f26..c75bc98 100644 --- a/scripts/render-sample.ts +++ b/scripts/render-sample.ts @@ -137,6 +137,7 @@ function buildAtB2BOrder(): RawOrderForInvoice { paymentGatewayNames: ["manual"], taxesIncluded: false, discountCodes: [], + deliveryMethods: [], customer: { firstName: "Lukas", lastName: "Schmidhofer", @@ -306,6 +307,10 @@ function buildPickupOrder(): RawOrderForInvoice { discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } }, taxLines: [], }; + // Primary signal — what Shopify Local Pickup actually populates. + o.deliveryMethods = [ + { methodType: "PICK_UP", locationName: "Lager Graz" }, + ]; return o; } @@ -593,26 +598,45 @@ async function main() { settings: settings as never, invoiceNumber: "RE-1030", }); - assert("isPickup detected", pickupVm.isPickup); - assertEq("shippingMethod replaced with localized label", pickupVm.shippingMethod, "Abholung"); + assert("isPickup detected via DeliveryMethodType=PICK_UP", pickupVm.isPickup); + assertEq("pickupLocationName propagated", pickupVm.pickupLocationName, "Lager Graz"); + assert("shippingMethod cleared for pickup (renderer uses pickup row instead)", + pickupVm.shippingMethod == null); assert( "separateShippingAddress suppressed for pickup", 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({ order: pickupOrder, settings: settings as never, invoiceNumber: "RE-1031", forceLanguage: "en", }); - assertEq("pickup label EN", pickupEnVm.shippingMethod, "Pick-up"); - pickupVm.issuer.logoDataUrl = vm.issuer.logoDataUrl; - const pickupText = await pdfToText(await renderInvoicePdf(pickupVm)); - 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"), - ); + pickupEnVm.issuer.logoDataUrl = vm.issuer.logoDataUrl; + const pickupEnText = await pdfToText(await renderInvoicePdf(pickupEnVm)); + assert("EN pickup PDF shows 'Pick-up location' label", pickupEnText.includes("Pick-up location")); // Fallback: when footerNoteEn is empty, English uses the German note. console.log("• Footer note fallback (en → de when EN empty)");