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;
}
+15
View File
@@ -58,6 +58,11 @@ export interface InvoiceStrings {
paymentStatusUnpaid: string;
paymentStatusPartial: string;
paymentStatusRefunded: string;
orderNumberLabel: string;
shippingAddressHeading: string;
shippingMethodLabel: string;
trackingLabel: string;
shippingItemPrefix: string;
}
/** Status displayed for the order's payment, derived from Shopify's
@@ -153,6 +158,11 @@ const de: InvoiceStrings = {
paymentStatusUnpaid: "Offen",
paymentStatusPartial: "Teilweise bezahlt",
paymentStatusRefunded: "Erstattet",
orderNumberLabel: "Bestellnummer",
shippingAddressHeading: "Lieferadresse",
shippingMethodLabel: "Versandart",
trackingLabel: "Sendungsnummer",
shippingItemPrefix: "Versand",
};
const en: InvoiceStrings = {
@@ -215,6 +225,11 @@ const en: InvoiceStrings = {
paymentStatusUnpaid: "Outstanding",
paymentStatusPartial: "Partially paid",
paymentStatusRefunded: "Refunded",
orderNumberLabel: "Order no.",
shippingAddressHeading: "Shipping address",
shippingMethodLabel: "Shipping method",
trackingLabel: "Tracking no.",
shippingItemPrefix: "Shipping",
};
// Locale → invoice language. We only render in German (`de`) when the
@@ -161,6 +161,8 @@ export async function loadDraftOrderForOffer(
currencyCode: draft.currencyCode,
displayFinancialStatus: null,
paymentGatewayNames: [],
shippingLine: null,
fulfillments: [],
taxesIncluded: draft.taxesIncluded,
customer: draft.customer,
billingAddress: draft.billingAddress,
@@ -23,6 +23,8 @@ export interface RawOrderForInvoice {
shippingAddress: RawAddress | null;
lineItems: RawLineItem[];
taxLines: RawTaxLine[];
shippingLine: RawShippingLine | null;
fulfillments: RawFulfillment[];
taxesIncluded: boolean;
subtotalSet: { shopMoney: RawMoney } | null;
totalTaxSet: { shopMoney: RawMoney } | null;
@@ -68,6 +70,26 @@ export interface RawTaxLine {
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 {
trackingInfo: RawTrackingInfo[];
}
const QUERY = `#graphql
query OrderForInvoice($id: ID!) {
order(id: $id) {
@@ -115,6 +137,27 @@ const QUERY = `#graphql
ratePercentage
priceSet { shopMoney { amount currencyCode } }
}
shippingLine {
title
code
source
carrierIdentifier
originalPriceSet { shopMoney { amount currencyCode } }
discountedPriceSet { shopMoney { amount currencyCode } }
taxLines {
title
rate
ratePercentage
priceSet { shopMoney { amount currencyCode } }
}
}
fulfillments(first: 10) {
trackingInfo {
number
url
company
}
}
lineItems(first: 250) {
edges {
node {
@@ -177,6 +220,8 @@ interface RawAdminResponse {
totalTaxSet: { shopMoney: RawMoney } | null;
totalPriceSet: { shopMoney: RawMoney } | null;
taxLines: RawTaxLine[];
shippingLine: RawShippingLine | null;
fulfillments: RawFulfillment[] | null;
lineItems: { edges: { node: RawLineItem }[] };
purchasingEntity: {
company?: { name: string } | null;
@@ -225,6 +270,8 @@ export async function loadOrderForInvoice(
totalTaxSet: order.totalTaxSet,
totalPriceSet: order.totalPriceSet,
taxLines: order.taxLines || [],
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 {
@@ -56,6 +56,18 @@ const styles = StyleSheet.create({
fontFamily: "Helvetica-Bold",
fontSize: 10,
},
shippingAddressBlock: {
marginTop: 10,
paddingTop: 6,
borderTopWidth: 0.5,
borderTopColor: TABLE_BORDER,
},
shippingAddressHeading: {
fontFamily: "Helvetica-Bold",
color: BRAND_BLUE,
fontSize: 8,
marginBottom: 2,
},
metaBlock: {
width: "40%",
},
@@ -264,6 +276,12 @@ export function InvoiceDocument({ invoice }: DocProps) {
<View style={styles.recipientBlock}>
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
<Recipient recipient={invoice.recipient} />
{invoice.separateShippingAddress ? (
<View style={styles.shippingAddressBlock}>
<Text style={styles.shippingAddressHeading}>{t.shippingAddressHeading}</Text>
<Recipient recipient={invoice.separateShippingAddress} />
</View>
) : null}
</View>
<View style={styles.metaBlock}>
<View style={styles.metaTable}>
@@ -271,6 +289,12 @@ export function InvoiceDocument({ invoice }: DocProps) {
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerNumber : t.invoiceNumber}</Text>
<Text style={styles.invoiceNumberBig}>{invoice.number}</Text>
</View>
{invoice.kind === "invoice" && invoice.orderName ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.orderNumberLabel}</Text>
<Text style={styles.metaValue}>{invoice.orderName}</Text>
</View>
) : null}
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerDate : t.invoiceDate}</Text>
<Text style={styles.metaValue}>{formatDate(invoice.invoiceDate, invoice.language)}</Text>
@@ -303,6 +327,25 @@ export function InvoiceDocument({ invoice }: DocProps) {
</Text>
</View>
)}
{invoice.kind === "invoice" && invoice.shippingMethod ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.shippingMethodLabel}</Text>
<Text style={styles.metaValue}>{invoice.shippingMethod}</Text>
</View>
) : null}
{invoice.kind === "invoice" && invoice.tracking.map((tr) => (
<View key={tr.number} style={styles.metaRow}>
<Text style={styles.metaLabel}>
{t.trackingLabel}
{tr.company ? ` (${tr.company})` : ""}
</Text>
{tr.url ? (
<Link src={tr.url} style={styles.metaValue}>{tr.number}</Link>
) : (
<Text style={styles.metaValue}>{tr.number}</Text>
)}
</View>
))}
</View>
</View>
</View>
+22
View File
@@ -46,6 +46,28 @@ export interface InvoiceViewModel {
/** Names of the payment gateways used (e.g. ["bogus"], ["manual",
* "shopify_payments"]). Empty when unknown / draft. */
paymentGatewayNames: string[];
/** Shopify's human-friendly order identifier (e.g. "#1004"). Distinct from
* the sequential `number` used as the invoice number. */
orderName: string;
/** Shipping address — only set when it differs from the billing address.
* Renderer uses this to show a separate delivery-address block. */
separateShippingAddress?: RecipientData;
/** Human-readable shipping method title (e.g. "Standard", "DHL Express").
* Empty / undefined when there is no shipping line (digital orders). */
shippingMethod?: string;
/** Tracking entries collected from order fulfillments. Empty when the
* order is unfulfilled or has no tracking. */
tracking: TrackingInfo[];
}
export interface TrackingInfo {
number: string;
url?: string;
company?: string;
}
export interface IssuerData {
+78 -12
View File
@@ -152,7 +152,39 @@ function buildAtB2BOrder(): RawOrderForInvoice {
province: null,
countryCode: "AT",
},
shippingAddress: null,
shippingAddress: {
name: "Lukas Schmidhofer",
company: "Schmidhofer Dienstleistungen",
address1: "Lagerweg 4",
address2: null,
zip: "8020",
city: "Graz",
province: null,
countryCode: "AT",
},
shippingLine: {
title: "Standardversand",
code: "STD",
source: "shopify",
carrierIdentifier: null,
originalPriceSet: { shopMoney: { amount: "5.00", currencyCode: "EUR" } },
discountedPriceSet: { shopMoney: { amount: "5.00", currencyCode: "EUR" } },
taxLines: [
{
title: "USt 20%",
rate: 0.2,
ratePercentage: 20,
priceSet: { shopMoney: { amount: "1.00", currencyCode: "EUR" } },
},
],
},
fulfillments: [
{
trackingInfo: [
{ number: "JJD0099887766", url: "https://example.test/track/JJD0099887766", company: "DHL" },
],
},
],
lineItems: [
{
title: "Bluetooth Tracker",
@@ -179,8 +211,8 @@ function buildAtB2BOrder(): RawOrderForInvoice {
},
],
subtotalSet: { shopMoney: { amount: lineNet.toFixed(2), currencyCode: "EUR" } },
totalTaxSet: { shopMoney: { amount: lineTax.toFixed(2), currencyCode: "EUR" } },
totalPriceSet: { shopMoney: { amount: lineGross.toFixed(2), currencyCode: "EUR" } },
totalTaxSet: { shopMoney: { amount: (lineTax + 1.0).toFixed(2), currencyCode: "EUR" } },
totalPriceSet: { shopMoney: { amount: (lineGross + 6.0).toFixed(2), currencyCode: "EUR" } },
purchasingEntity: {
company: {
name: "Schmidhofer Dienstleistungen",
@@ -203,6 +235,10 @@ function buildEuB2BReverseChargeOrder(): RawOrderForInvoice {
o.purchasingEntity!.company!.vatId = "DE123456789";
o.lineItems[0].taxLines = [];
o.taxLines = [];
// No VAT for reverse-charge; clear shipping VAT too.
o.shippingLine = null;
o.fulfillments = [];
o.shippingAddress = null;
o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
return o;
@@ -217,6 +253,9 @@ function buildExportOrder(): RawOrderForInvoice {
o.billingAddress!.city = "New York";
o.lineItems[0].taxLines = [];
o.taxLines = [];
o.shippingLine = null;
o.fulfillments = [];
o.shippingAddress = null;
o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
o.customer!.locale = "en";
@@ -265,22 +304,33 @@ async function main() {
assertEq("currency", vm.currency, "EUR");
assert("isB2B detected", vm.isB2B);
assertEq("recipientVatId", vm.recipientVatId, "ATU57680511");
assertEq("line count", vm.lines.length, 1);
assertEq("line count (1 product + 1 shipping)", vm.lines.length, 2);
const ln = vm.lines[0];
assertEq("line title", ln.title, "Bluetooth Tracker");
assertEq("line qty", ln.quantity, 6);
assertNear("line unit net", ln.unitPriceNet, 5.99);
assertNear("line total net", ln.totalNet, 35.94);
assertNear("net total", vm.totals.net, 35.94);
const shipLine = vm.lines[1];
assert("shipping line title prefixed", shipLine.title.startsWith("Versand"),
`got "${shipLine.title}"`);
assertNear("shipping line net", shipLine.totalNet, 5.0);
assertNear("net total (incl. shipping)", vm.totals.net, 40.94);
assertEq("vat breakdown rows", vm.totals.vatBreakdown.length, 1);
assertNear("vat amount", vm.totals.vatBreakdown[0].tax, 7.19);
assertNear("vat amount (incl. shipping VAT)", vm.totals.vatBreakdown[0].tax, 8.19);
assertEq("vat rate %", vm.totals.vatBreakdown[0].ratePct, 20);
assertNear("gross", vm.totals.gross, 43.13);
assertNear("gross (incl. shipping)", vm.totals.gross, 49.13);
assertEq("no notices for AT B2B with VAT charged", vm.notices.length, 0);
assert("due date 14 days after invoice date", !!vm.dueDate
&& Math.round((vm.dueDate.getTime() - vm.invoiceDate.getTime()) / 86400000) === 14);
assertEq("paymentGatewayNames propagated", vm.paymentGatewayNames.join(","), "manual");
assertEq("paymentStatus derived from displayFinancialStatus=PENDING", vm.paymentStatus, "unpaid");
assertEq("orderName propagated", vm.orderName, "#1004");
assertEq("shippingMethod propagated", vm.shippingMethod, "Standardversand");
assertEq("tracking entries", vm.tracking.length, 1);
assertEq("tracking number", vm.tracking[0].number, "JJD0099887766");
assertEq("tracking carrier", vm.tracking[0].company, "DHL");
assert("separateShippingAddress detected (differs from billing)",
vm.separateShippingAddress?.addressLine1 === "Lagerweg 4");
console.log("• EU B2B reverse-charge notice");
const euOrder = buildEuB2BReverseChargeOrder();
@@ -346,15 +396,16 @@ async function main() {
assertEq("kind = storno", storno.kind, "storno");
assertEq("cancelsNumber populated", storno.cancelsNumber, "RE-1004");
assert("dueDate suppressed for storno", storno.dueDate == null);
assertEq("line count preserved", storno.lines.length, 1);
assertEq("line count preserved", storno.lines.length, 2);
assertNear("line qty preserved (only money negated)", storno.lines[0].quantity, 6);
assertNear("line unit price negated", storno.lines[0].unitPriceNet, -5.99);
assertNear("line totalNet negated", storno.lines[0].totalNet, -35.94);
assertNear("totals.net negated", storno.totals.net, -35.94);
assertNear("totals.totalVat negated", storno.totals.totalVat, -7.19);
assertNear("totals.gross negated", storno.totals.gross, -43.13);
assertNear("shipping line totalNet negated", storno.lines[1].totalNet, -5.0);
assertNear("totals.net negated", storno.totals.net, -40.94);
assertNear("totals.totalVat negated", storno.totals.totalVat, -8.19);
assertNear("totals.gross negated", storno.totals.gross, -49.13);
assertEq("vat breakdown row count preserved", storno.totals.vatBreakdown.length, 1);
assertNear("vat breakdown tax negated", storno.totals.vatBreakdown[0].tax, -7.19);
assertNear("vat breakdown tax negated", storno.totals.vatBreakdown[0].tax, -8.19);
console.log("• Render storno PDF");
storno.issuer.logoDataUrl = vm.issuer.logoDataUrl;
@@ -416,6 +467,21 @@ async function main() {
assert("EN PDF shows payment status row", enText.includes("Payment status"));
assert("EN PDF shows payment status value 'Outstanding' for PENDING", enText.includes("Outstanding"));
// Shipment + order-number block.
assert("DE PDF shows order number row 'Bestellnummer'", deText.includes("Bestellnummer"));
assert("DE PDF shows Shopify order name '#1004'", deText.includes("#1004"));
assert("DE PDF shows shipping method row 'Versandart'", deText.includes("Versandart"));
assert("DE PDF shows shipping method value 'Standardversand'", deText.includes("Standardversand"));
assert("DE PDF shows tracking row 'Sendungsnummer'", deText.includes("Sendungsnummer"));
assert("DE PDF shows tracking number", deText.includes("JJD0099887766"));
assert("DE PDF shows shipping line item with prefix", deText.includes("Versand"));
assert("DE PDF shows separate delivery address heading", deText.includes("Lieferadresse"));
assert("DE PDF shows shipping address line", deText.includes("Lagerweg 4"));
assert("EN PDF shows order number row 'Order no.'", enText.includes("Order no."));
assert("EN PDF shows shipping method row 'Shipping method'", enText.includes("Shipping method"));
assert("EN PDF shows tracking row 'Tracking no.'", enText.includes("Tracking no."));
assert("EN PDF shows separate delivery address heading", enText.includes("Shipping address"));
// Fallback: when footerNoteEn is empty, English uses the German note.
console.log("• Footer note fallback (en → de when EN empty)");
const settingsNoEn = { ...(settings as object), footerNoteEn: "" } as never;