feat(invoice): add Shopify order #, shipping address/method/cost and tracking

- Query Order.shippingLine and Order.fulfillments.trackingInfo from Admin GraphQL.
- Surface orderName (#1004) so customers recognise their order alongside the sequential invoice number.
- Render shipping cost as a synthetic line item (folds into the VAT breakdown).
- Show shipping method (Versandart / Shipping method) and tracking numbers (clickable when URL present) in the meta block.
- Render a separate delivery-address block when the shipping address differs from billing.
- DE strings stay informal (Versandart / Sendungsnummer / Lieferadresse / Versand).
This commit is contained in:
Gerhard Scheikl
2026-05-15 13:41:53 +02:00
parent 55a0dd03f2
commit 8780b4a68a
7 changed files with 345 additions and 16 deletions
+138 -4
View File
@@ -1,6 +1,6 @@
import type { ShopSettings } from "@prisma/client";
import type { RawOrderForInvoice, RawTaxLine } from "./loadOrderForInvoice.server";
import type { RawOrderForInvoice, RawShippingLine, RawTaxLine } from "./loadOrderForInvoice.server";
import type {
InvoiceLine,
InvoiceNotice,
@@ -8,10 +8,11 @@ import type {
InvoiceViewModel,
IssuerData,
RecipientData,
TrackingInfo,
VatBreakdownEntry,
} from "./types";
import { addDays } from "./format";
import { derivePaymentStatus, pickLanguage, type InvoiceLanguage } from "./i18n";
import { derivePaymentStatus, getStrings, pickLanguage, type InvoiceLanguage } from "./i18n";
interface ComposeArgs {
order: RawOrderForInvoice;
@@ -55,9 +56,16 @@ export function composeInvoice({
const isB2B = !!order.purchasingEntity?.company;
const recipientVatId = order.purchasingEntity?.company?.vatId ?? undefined;
let { lines, totals } = mapLinesAndTotals(order);
const strings = getStrings(language);
let { lines, totals } = mapLinesAndTotals(order, {
shippingItemPrefix: strings.shippingItemPrefix,
});
let notices = deriveNotices({ order, settings, isB2B });
const separateShippingAddress = mapSeparateShippingAddress(order);
const shippingMethod = order.shippingLine?.title?.trim() || undefined;
const tracking = mapTracking(order);
const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt);
const deliveryDate = invoiceDate;
// For offers we treat `dueDate` as the offer's validity expiry (default 30
@@ -113,6 +121,10 @@ export function composeInvoice({
paid,
paymentStatus,
paymentGatewayNames,
orderName: order.name,
separateShippingAddress,
shippingMethod,
tracking,
};
}
@@ -172,7 +184,10 @@ function mapRecipient(order: RawOrderForInvoice): RecipientData {
};
}
function mapLinesAndTotals(order: RawOrderForInvoice): {
function mapLinesAndTotals(
order: RawOrderForInvoice,
opts: { shippingItemPrefix: string },
): {
lines: InvoiceLine[];
totals: InvoiceTotals;
} {
@@ -224,6 +239,18 @@ function mapLinesAndTotals(order: RawOrderForInvoice): {
});
}
// Append the shipping line as a synthetic invoice row when the order has
// a shipping cost > 0. This makes shipping appear in the items table
// (visible to the customer) and folds its tax into the VAT breakdown.
const shippingLineNet = appendShippingLine(
order.shippingLine,
taxesIncluded,
linesOut,
vatMap,
opts.shippingItemPrefix,
);
netSum += shippingLineNet;
const vatBreakdown = Array.from(vatMap.values())
.map((e) => ({ ratePct: e.ratePct, net: round2(e.net), tax: round2(e.tax) }))
.filter((e) => e.tax > 0)
@@ -318,3 +345,110 @@ const EU_COUNTRIES = new Set([
function isEuCountry(code: string): boolean {
return EU_COUNTRIES.has(code.toUpperCase());
}
/**
* Add a synthetic line item for the order's shipping cost. Returns the net
* amount added (used to keep the running net subtotal in sync). Returns 0
* when there's no shipping line or the shipping price is zero (e.g. free
* shipping or digital orders).
*/
function appendShippingLine(
shippingLine: RawShippingLine | null,
taxesIncluded: boolean,
linesOut: InvoiceLine[],
vatMap: Map<number, VatBreakdownEntry>,
prefix: string,
): number {
if (!shippingLine) return 0;
const priceSet = shippingLine.discountedPriceSet ?? shippingLine.originalPriceSet;
const grossOrNet = priceSet ? parseFloat(priceSet.shopMoney.amount) : 0;
if (!Number.isFinite(grossOrNet) || grossOrNet === 0) return 0;
const tax = shippingLine.taxLines.reduce(
(acc, t) => acc + parseFloat(t.priceSet.shopMoney.amount),
0,
);
const net = taxesIncluded ? grossOrNet - tax : grossOrNet;
const title = shippingLine.title?.trim()
? `${prefix}: ${shippingLine.title.trim()}`
: prefix;
linesOut.push({
position: linesOut.length + 1,
title,
quantity: 1,
unitPriceNet: round2(net),
totalNet: round2(net),
});
shippingLine.taxLines.forEach((t) =>
accumulateVat(vatMap, t, parseFloat(t.priceSet.shopMoney.amount), net),
);
return net;
}
/**
* Returns the shipping address as a recipient block when it differs in any
* meaningful way from the billing address. Returns undefined when both are
* the same (so the renderer doesn't show a redundant block) or when there
* is no shipping address at all.
*/
function mapSeparateShippingAddress(
order: RawOrderForInvoice,
): RecipientData | undefined {
const ship = order.shippingAddress;
const bill = order.billingAddress;
if (!ship) return undefined;
// No billing address → just use the existing recipient block, no need to
// duplicate.
if (!bill) return undefined;
const sameAddress =
(ship.name ?? "") === (bill.name ?? "") &&
(ship.company ?? "") === (bill.company ?? "") &&
(ship.address1 ?? "") === (bill.address1 ?? "") &&
(ship.address2 ?? "") === (bill.address2 ?? "") &&
(ship.zip ?? "") === (bill.zip ?? "") &&
(ship.city ?? "") === (bill.city ?? "") &&
(ship.countryCode ?? "") === (bill.countryCode ?? "");
if (sameAddress) return undefined;
const customerFullName = [order.customer?.firstName, order.customer?.lastName]
.filter(Boolean)
.join(" ")
.trim();
return {
name: ship.name ?? customerFullName,
company: ship.company ?? "",
addressLine1: ship.address1 ?? "",
addressLine2: ship.address2 ?? "",
postalCode: ship.zip ?? "",
city: ship.city ?? "",
countryCode: ship.countryCode ?? "",
};
}
/**
* Flatten tracking info from all fulfillments. Skips entries without a
* tracking number. Deduplicates on `number`.
*/
function mapTracking(order: RawOrderForInvoice): TrackingInfo[] {
const out: TrackingInfo[] = [];
const seen = new Set<string>();
for (const f of order.fulfillments ?? []) {
for (const t of f.trackingInfo ?? []) {
const number = (t.number ?? "").trim();
if (!number || seen.has(number)) continue;
seen.add(number);
out.push({
number,
url: t.url?.trim() || undefined,
company: t.company?.trim() || undefined,
});
}
}
return out;
}