feat(invoice): per-line + cart discounts, fulfillment delivery date, pickup label, header layout refresh
- discounts: read discountedUnitPriceSet (per-line) and discountCode/discountCodes (order-level) from Shopify; render discounted unit price with strikethrough original on the invoice line and add a 'Rabattcode'/'Discount code' meta row when codes were used. - delivery date: pick the latest fulfillment.createdAt for §11 UStG instead of hard-coding processedAt; fall back to invoice date when unfulfilled. - pickup: detect Shopify Local Pickup (and 'Abholung'/'Pickup' custom rates) via shippingLine.source/code/title; suppress the pickup-location 'shipping address' block and render localized 'Abholung'/'Pick-up' as the shipping method. - layout: move the company logo to the top-left and the meta block to the top-right, putting recipient (and any separate delivery address) on its own row below; drop the standalone invoice-/order-number meta rows and surface them inside the title (e.g. 'Rechnung Nr. RE-1004 · Bestellnummer: #1004') to recover vertical space. - tests: smoke fixtures cover discount, pickup, and fulfillment-date variants without disturbing the AT B2B totals.
This commit is contained in:
@@ -136,6 +136,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
||||
displayFinancialStatus: "PENDING",
|
||||
paymentGatewayNames: ["manual"],
|
||||
taxesIncluded: false,
|
||||
discountCodes: [],
|
||||
customer: {
|
||||
firstName: "Lukas",
|
||||
lastName: "Schmidhofer",
|
||||
@@ -180,6 +181,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
||||
},
|
||||
fulfillments: [
|
||||
{
|
||||
createdAt: "2026-05-13T10:30:00.000Z",
|
||||
trackingInfo: [
|
||||
{ number: "JJD0099887766", url: "https://example.test/track/JJD0099887766", company: "DHL" },
|
||||
],
|
||||
@@ -191,6 +193,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
||||
sku: "BT-TRK-001",
|
||||
quantity: qty,
|
||||
originalUnitPriceSet: { shopMoney: { amount: unitNet.toFixed(2), currencyCode: "EUR" } },
|
||||
discountedUnitPriceSet: null,
|
||||
imageUrl: "file://product-image", // placeholder; the smoke script inlines a real data: URL on the composed line below.
|
||||
taxLines: [
|
||||
{
|
||||
@@ -262,6 +265,50 @@ function buildExportOrder(): RawOrderForInvoice {
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of the AT B2B order with a per-line discount: unit price stays
|
||||
* 7.19 EUR gross (5.99 net) but Shopify allocated a 1.00 EUR/unit discount,
|
||||
* so the discounted unit price is 6.19 gross (5.16 net). Also adds an
|
||||
* order-level discount code ("SUMMER10") for the meta-block render.
|
||||
*/
|
||||
function buildDiscountedOrder(): RawOrderForInvoice {
|
||||
const o = buildAtB2BOrder();
|
||||
o.discountCodes = ["SUMMER10"];
|
||||
// Discount of 1.00 EUR/unit applied: net unit drops from 5.99 to 4.99,
|
||||
// qty 6 → 29.94 net, tax (20%) = 5.99.
|
||||
o.lineItems[0].discountedUnitPriceSet = {
|
||||
shopMoney: { amount: "4.99", currencyCode: "EUR" },
|
||||
};
|
||||
o.lineItems[0].taxLines = [
|
||||
{
|
||||
title: "USt 20%",
|
||||
rate: 0.2,
|
||||
ratePercentage: 20,
|
||||
priceSet: { shopMoney: { amount: "5.99", currencyCode: "EUR" } },
|
||||
},
|
||||
];
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of the AT B2B order whose shipping line is local pickup. The
|
||||
* "shipping address" still carries the pickup-location address (as Shopify
|
||||
* does), but the composer should detect the pickup and suppress it.
|
||||
*/
|
||||
function buildPickupOrder(): RawOrderForInvoice {
|
||||
const o = buildAtB2BOrder();
|
||||
o.shippingLine = {
|
||||
title: "Local Pickup — Lager Graz",
|
||||
code: "PICKUP",
|
||||
source: "shopify-local-pickup",
|
||||
carrierIdentifier: null,
|
||||
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
|
||||
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
|
||||
taxLines: [],
|
||||
};
|
||||
return o;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Run assertions
|
||||
// ------------------------------------------------------------------
|
||||
@@ -482,6 +529,91 @@ async function main() {
|
||||
assert("EN PDF shows tracking row 'Tracking no.'", enText.includes("Tracking no."));
|
||||
assert("EN PDF shows separate delivery address heading", enText.includes("Shipping address"));
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Delivery date follows latest fulfillment, not processedAt
|
||||
// ----------------------------------------------------------------
|
||||
console.log("• Delivery date is taken from latest fulfillment");
|
||||
// The AT B2B fixture has processedAt 2026-04-15 and fulfillment.createdAt
|
||||
// 2026-05-13 — the composer must pick the fulfillment.
|
||||
assertEq(
|
||||
"vm.deliveryDate matches fulfillment.createdAt",
|
||||
vm.deliveryDate.toISOString().slice(0, 10),
|
||||
"2026-05-13",
|
||||
);
|
||||
// EU/Export variants have no fulfillments, so delivery date == invoice date.
|
||||
assertEq(
|
||||
"EU vm.deliveryDate falls back to invoiceDate when unfulfilled",
|
||||
euVm.deliveryDate.toISOString().slice(0, 10),
|
||||
euVm.invoiceDate.toISOString().slice(0, 10),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Discount: per-line strikethrough + cart code
|
||||
// ----------------------------------------------------------------
|
||||
console.log("• Discount (per-line + cart-level)");
|
||||
const discOrder = buildDiscountedOrder();
|
||||
const discVm = composeInvoice({
|
||||
order: discOrder,
|
||||
settings: settings as never,
|
||||
invoiceNumber: "RE-1020",
|
||||
});
|
||||
assertEq("discountCodes propagated", discVm.discountCodes.join(","), "SUMMER10");
|
||||
assertNear("discounted unit net (~4.99)", discVm.lines[0].unitPriceNet, 4.99);
|
||||
assert(
|
||||
"originalUnitPriceNet populated when discounted differs",
|
||||
discVm.lines[0].originalUnitPriceNet != null,
|
||||
);
|
||||
assertNear(
|
||||
"originalUnitPriceNet matches pre-discount net (~5.99)",
|
||||
discVm.lines[0].originalUnitPriceNet ?? 0,
|
||||
5.99,
|
||||
);
|
||||
discVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||
const discPdf = await renderInvoicePdf(discVm);
|
||||
const discText = await pdfToText(discPdf);
|
||||
assert("DE PDF shows discount-code label", discText.includes("Rabattcode"));
|
||||
assert("DE PDF shows discount code value", discText.includes("SUMMER10"));
|
||||
const discEnVm = composeInvoice({
|
||||
order: discOrder,
|
||||
settings: settings as never,
|
||||
invoiceNumber: "RE-1021",
|
||||
forceLanguage: "en",
|
||||
});
|
||||
discEnVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||
const discEnText = await pdfToText(await renderInvoicePdf(discEnVm));
|
||||
assert("EN PDF shows discount-code label", discEnText.includes("Discount code"));
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Pickup: separate shipping address suppressed; method localized
|
||||
// ----------------------------------------------------------------
|
||||
console.log("• Local pickup");
|
||||
const pickupOrder = buildPickupOrder();
|
||||
const pickupVm = composeInvoice({
|
||||
order: pickupOrder,
|
||||
settings: settings as never,
|
||||
invoiceNumber: "RE-1030",
|
||||
});
|
||||
assert("isPickup detected", pickupVm.isPickup);
|
||||
assertEq("shippingMethod replaced with localized label", pickupVm.shippingMethod, "Abholung");
|
||||
assert(
|
||||
"separateShippingAddress suppressed for pickup",
|
||||
pickupVm.separateShippingAddress == null,
|
||||
);
|
||||
const pickupEnVm = composeInvoice({
|
||||
order: pickupOrder,
|
||||
settings: settings as never,
|
||||
invoiceNumber: "RE-1031",
|
||||
forceLanguage: "en",
|
||||
});
|
||||
assertEq("pickup label EN", pickupEnVm.shippingMethod, "Pick-up");
|
||||
pickupVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||
const pickupText = await pdfToText(await renderInvoicePdf(pickupVm));
|
||||
assert("DE pickup PDF shows 'Abholung'", pickupText.includes("Abholung"));
|
||||
assert(
|
||||
"DE pickup PDF does NOT render pickup-location address as delivery address",
|
||||
!pickupText.includes("Lieferadresse"),
|
||||
);
|
||||
|
||||
// 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