f16ef4e103
Querying Order.fulfillmentOrders.deliveryMethod requires the
read_merchant_managed_fulfillment_orders scope (not read_orders, despite
what shopify.dev claims) and was failing with 'Access denied for
fulfillmentOrders field' against real stores.
Adding that scope would force every install to re-grant permissions, so
instead we rely on shippingLine alone:
- Shopify Local Pickup app: shippingLine.code = 'Pickup' (caught by the
regex) and shippingLine.title is the chosen location name itself (e.g. 'Lager Graz') \u2014 perfect as pickupLocationName.
- Custom-rate pickup ('Abholung im Lager'): regex matches title/code,
title is used as the location hint.
Removes RawDeliveryMethod, the deliveryMethods field on RawOrderForInvoice,
and the fulfillmentOrders edges from RawAdminResponse.
311 lines
8.3 KiB
TypeScript
311 lines
8.3 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;
|
|
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
|
|
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 },
|
|
};
|
|
}
|