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:
+78
-12
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user