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
+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;