8a40bcbee6
Order 1032 on dev still rendered as 'Versandart: Lager Graz' because the shipping line's title/code/source contained no 'pickup' keyword — only `shippingLine.deliveryCategory == "pickup"` flagged it as a pickup. `shippingLine.deliveryCategory` only requires `read_orders` (already granted), so query and use it as the primary signal. Keep the regex on title/code/source/carrier as a fallback for custom rates without a proper pickup category.
316 lines
8.6 KiB
TypeScript
316 lines
8.6 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[];
|
|
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
|
|
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;
|
|
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,
|
|
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 },
|
|
};
|
|
}
|