Files
linumiq-invoice/app/services/invoice/loadOrderForInvoice.server.ts
T
Gerhard Scheikl a2b3c14022 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.
2026-05-15 15:24:43 +02:00

324 lines
9.0 KiB
TypeScript

import type { AdminApiContext } from "@shopify/shopify-app-react-router/server";
/**
* Raw shape of the data we need from the Shopify Admin GraphQL API to
* compose an invoice. Kept narrow so the composer is testable with fixtures.
*/
export interface RawOrderForInvoice {
id: string;
name: string;
orderNumber: number;
createdAt: string;
processedAt: string | null;
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;
email: string | null;
locale: string | null;
} | null;
billingAddress: RawAddress | null;
shippingAddress: RawAddress | null;
lineItems: RawLineItem[];
taxLines: RawTaxLine[];
shippingLine: RawShippingLine | null;
fulfillments: RawFulfillment[];
/** 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. */
discountCodes: string[];
taxesIncluded: boolean;
subtotalSet: { shopMoney: RawMoney } | null;
totalTaxSet: { shopMoney: RawMoney } | null;
totalPriceSet: { shopMoney: RawMoney } | null;
purchasingEntity: {
company?: {
name: string;
vatId: string | null;
address: RawAddress | null;
} | null;
} | null;
}
export interface RawAddress {
name: string | null;
company: string | null;
address1: string | null;
address2: string | null;
zip: string | null;
city: string | null;
province: string | null;
countryCode: string | null;
}
export interface RawMoney {
amount: string;
currencyCode: string;
}
export interface RawLineItem {
title: string;
sku: string | null;
quantity: number;
originalUnitPriceSet: { shopMoney: RawMoney };
/** Per-unit price after Shopify has allocated cart-level discounts to this
* line. May be null when no discount applied (in which case use the
* original price). */
discountedUnitPriceSet: { shopMoney: RawMoney } | null;
taxLines: RawTaxLine[];
imageUrl: string | null;
}
export interface RawTaxLine {
title: string | null;
rate: number | null;
ratePercentage: number | null;
priceSet: { shopMoney: RawMoney };
}
export interface RawShippingLine {
title: string | null;
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[];
}
export interface RawTrackingInfo {
number: string | null;
url: string | null;
company: string | null;
}
export interface RawFulfillment {
/** ISO timestamp of when the fulfillment was created (i.e. when the goods
* were dispatched / handed over). Used for the legally-required delivery
* date on the invoice when present. */
createdAt: string | null;
trackingInfo: RawTrackingInfo[];
}
const QUERY = `#graphql
query OrderForInvoice($id: ID!) {
order(id: $id) {
id
name
number
createdAt
processedAt
currencyCode
displayFinancialStatus
paymentGatewayNames
taxesIncluded
requiresShipping
customer {
firstName
lastName
email
locale
}
billingAddress {
name
company
address1
address2
zip
city
province
countryCode: countryCodeV2
}
shippingAddress {
name
company
address1
address2
zip
city
province
countryCode: countryCodeV2
}
subtotalPriceSet { shopMoney { amount currencyCode } }
totalTaxSet { shopMoney { amount currencyCode } }
totalPriceSet { shopMoney { amount currencyCode } }
taxLines {
title
rate
ratePercentage
priceSet { shopMoney { amount currencyCode } }
}
discountCode
discountCodes
shippingLine {
title
code
source
carrierIdentifier
deliveryCategory
originalPriceSet { shopMoney { amount currencyCode } }
discountedPriceSet { shopMoney { amount currencyCode } }
taxLines {
title
rate
ratePercentage
priceSet { shopMoney { amount currencyCode } }
}
}
fulfillments(first: 10) {
createdAt
trackingInfo {
number
url
company
}
}
lineItems(first: 250) {
edges {
node {
title
sku
quantity
originalUnitPriceSet { shopMoney { amount currencyCode } }
discountedUnitPriceSet { shopMoney { amount currencyCode } }
image { url altText }
taxLines {
title
rate
ratePercentage
priceSet { shopMoney { amount currencyCode } }
}
}
}
}
purchasingEntity {
... on PurchasingCompany {
company {
name
}
location {
taxRegistrationId
billingAddress {
address1
address2
zip
city
countryCode
}
}
}
}
}
}
`;
interface RawAdminResponse {
data?: {
order?: {
id: string;
name: string;
number: number;
createdAt: string;
processedAt: string | null;
currencyCode: string;
displayFinancialStatus: string | null;
paymentGatewayNames: string[] | null;
taxesIncluded: boolean;
requiresShipping: boolean | null;
customer: {
firstName: string | null;
lastName: string | null;
email: string | null;
locale: string | null;
} | null;
billingAddress: RawAddress | null;
shippingAddress: RawAddress | null;
subtotalPriceSet: { shopMoney: RawMoney } | null;
totalTaxSet: { shopMoney: RawMoney } | null;
totalPriceSet: { shopMoney: RawMoney } | null;
taxLines: RawTaxLine[];
discountCode: string | null;
discountCodes: string[] | null;
shippingLine: RawShippingLine | null;
fulfillments: RawFulfillment[] | null;
lineItems: { edges: { node: RawLineItem }[] };
purchasingEntity: {
company?: { name: string } | null;
location?: {
taxRegistrationId: string | null;
billingAddress: RawAddress | null;
} | null;
} | null;
} | null;
};
}
export async function loadOrderForInvoice(
admin: AdminApiContext,
orderGid: string,
): Promise<RawOrderForInvoice> {
const response = await admin.graphql(QUERY, { variables: { id: orderGid } });
const json = (await response.json()) as RawAdminResponse;
const order = json.data?.order;
if (!order) {
throw new Error(`Order ${orderGid} not found.`);
}
const purchasingCompany = order.purchasingEntity?.company
? {
name: order.purchasingEntity.company.name,
vatId: order.purchasingEntity.location?.taxRegistrationId ?? null,
address: order.purchasingEntity.location?.billingAddress ?? null,
}
: null;
return {
id: order.id,
name: order.name,
orderNumber: order.number,
createdAt: order.createdAt,
processedAt: order.processedAt,
currencyCode: order.currencyCode,
displayFinancialStatus: order.displayFinancialStatus,
paymentGatewayNames: order.paymentGatewayNames ?? [],
taxesIncluded: order.taxesIncluded,
requiresShipping: order.requiresShipping ?? false,
customer: order.customer,
billingAddress: order.billingAddress,
shippingAddress: order.shippingAddress,
subtotalSet: order.subtotalPriceSet,
totalTaxSet: order.totalTaxSet,
totalPriceSet: order.totalPriceSet,
taxLines: order.taxLines || [],
discountCodes: order.discountCodes && order.discountCodes.length > 0
? order.discountCodes
: (order.discountCode ? [order.discountCode] : []),
shippingLine: order.shippingLine ?? null,
fulfillments: order.fulfillments ?? [],
lineItems: (order.lineItems?.edges || []).map((e) => {
const node = e.node as unknown as RawLineItem & { image?: { url: string | null } | null };
return {
title: node.title,
sku: node.sku,
quantity: node.quantity,
originalUnitPriceSet: node.originalUnitPriceSet,
discountedUnitPriceSet: node.discountedUnitPriceSet ?? null,
taxLines: node.taxLines,
imageUrl: node.image?.url ?? null,
};
}),
purchasingEntity: { company: purchasingCompany },
};
}