Files
linumiq-invoice/scripts/render-sample.ts
T
Gerhard Scheikl 9c732618e1 fix(invoice): suppress GiroCode + payment terms for refunded (and paid) orders
User reported that fully refunded orders still rendered a SEPA GiroCode
QR asking the customer to wire the original total. The existing gate
("!viewModel.paid") only excluded literally-PAID orders; REFUNDED,
PARTIALLY_REFUNDED, VOIDED, AUTHORIZED, etc. all sneaked through and
produced a confusing payment request for an order whose outstanding
balance is in fact 0.

Root cause: the view model exposed two related but ambiguous flags
("paid" and "paymentStatus") and the renderer mixed them
inconsistently. Both the GiroCode generation step in
generateInvoice.server.tsx AND the GiroCode/payment-terms render gates
in InvoiceDocument.tsx checked the wrong one.

Fix: introduce a single derived "requiresPayment" flag on the view
model (composeInvoice.ts) that is true only when:
  - the document is a regular invoice (not a storno or an offer), AND
  - paymentStatus is neither "paid" nor "refunded".

That single flag now drives:
  - GiroCode QR generation (skip QR fetch for paid/refunded)
  - GiroCode block render in the PDF
  - payment-terms paragraph render in the PDF

The existing "Zahlstatus: Erstattet" / "Payment status: Refunded"
meta-row continues to communicate the refund visually — the change
just removes the contradictory call-to-pay.

Side benefits:
  - Storno (cancellation invoice) PDFs no longer emit the German
    "Bitte überweise …" payment-terms paragraph (kind=storno was
    falling through the same not-paid branch).
  - Same suppression for fully PAID orders (covered by a second smoke
    fixture) so the QR doesn't suggest re-payment after the fact.

Verification: new smoke fixtures (REFUNDED + PAID) build a real
GiroCode data URL and verify the PDF text neither contains the
"GiroCode" caption nor the "Bitte überweise" German payment terms,
while still showing the corresponding "Erstattet" / "Bezahlt" status
row. tsc / smoke / tests / build all green.
2026-05-15 16:33:34 +02:00

803 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Smoke test — renders an invoice PDF for a synthetic order matching the
* style of `data/rechnung.png` (1× Bluetooth Tracker @ 5,99 EUR × 6, B2B
* customer "Schmidhofer Dienstleistungen" with UID ATU57680511) and writes
* it to `data/sample-rechnung.pdf` for visual review.
*
* Also runs assertions on:
* - VAT calculations
* - GiroCode payload shape
* - IBAN validation
* - Numbering allocation in both modes
* - Notice derivation for B2B EU reverse-charge / export / Kleinunternehmer
*
* Run with: npx tsx scripts/render-sample.ts
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { composeInvoice } from "../app/services/invoice/composeInvoice";
import { buildGiroCodeDataUrl, buildGiroCodePayload } from "../app/services/invoice/girocode";
import {
isValidAtVatId,
isValidBic,
isValidIban,
normaliseIban,
} from "../app/services/invoice/validation";
import { renderInvoicePdf } from "../app/services/invoice/generateInvoice.server";
import type { RawOrderForInvoice } from "../app/services/invoice/loadOrderForInvoice.server";
// ------------------------------------------------------------------
// Lightweight assertion helper
// ------------------------------------------------------------------
let failed = 0;
function assert(name: string, cond: boolean, detail?: string) {
if (cond) {
console.log(`${name}`);
} else {
failed++;
console.error(`${name}${detail ? `${detail}` : ""}`);
}
}
function assertEq<T>(name: string, actual: T, expected: T) {
assert(name, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function assertNear(name: string, actual: number, expected: number, eps = 0.01) {
assert(name, Math.abs(actual - expected) <= eps, `expected ~${expected}, got ${actual}`);
}
/**
* Extracts text from a PDF buffer using the system `pdftotext` (poppler).
* The smoke script runs in a dev environment where poppler is available;
* if it ever isn't, the assertion failures will surface a clear ENOENT.
*/
async function pdfToText(pdf: Buffer): Promise<string> {
const inPath = resolve(tmpdir(), `linumiq-smoke-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`);
writeFileSync(inPath, pdf);
try {
const out = execFileSync("pdftotext", ["-layout", "-enc", "UTF-8", inPath, "-"], { encoding: "utf8" });
return out;
} finally {
try { require("node:fs").unlinkSync(inPath); } catch { /* best-effort cleanup */ }
}
}
// ------------------------------------------------------------------
// Synthetic settings (mirrors the reference invoice)
// ------------------------------------------------------------------
const settings = {
id: "test",
shopDomain: "linumiq.myshopify.com",
companyName: "LinumIQ",
legalForm: "e.U.",
ownerName: "Gerhard Berger",
addressLine1: "Anton-Kleinoscheg-Straße 64c/4",
addressLine2: "",
postalCode: "8051",
city: "Graz",
countryCode: "AT",
phone: "+43 660 1234567",
email: "office@linumiq.com",
website: "www.linumiq.com",
vatId: "ATU12345678",
taxNumber: "12 345/6789",
registrationNo: "FN 123456a",
registrationCourt: "Landesgericht Graz",
bankName: "Raiffeisen Steiermark",
iban: "AT611904300234573201",
bic: "RZSTAT2G",
giroCodeEnabled: true,
numberingMode: "shopify_order_number",
invoicePrefix: "RE-",
invoiceSeed: 1000,
defaultLanguage: "de",
paymentTermDays: 14,
footerNote: "Vielen Dank für Ihren Auftrag.",
footerNoteEn: "Thank you for your business.",
kleinunternehmer: false,
logoUrl: "",
smtpHost: "",
smtpPort: 587,
smtpSecure: false,
smtpUser: "",
smtpPassword: "",
smtpFromName: "",
smtpFromEmail: "",
smtpReplyTo: "",
createdAt: new Date(),
updatedAt: new Date(),
} as const;
// ------------------------------------------------------------------
// Synthetic AT B2B order matching the reference image
// ------------------------------------------------------------------
function buildAtB2BOrder(): RawOrderForInvoice {
// 6 × 5,99 EUR net = 35,94 EUR net; 20% VAT = 7,19 EUR; gross = 43,13 EUR
const qty = 6;
const unitNet = 5.99;
const lineNet = qty * unitNet; // 35.94
const lineTax = +(lineNet * 0.2).toFixed(2); // 7.19
const lineGross = +(lineNet + lineTax).toFixed(2); // 43.13
return {
id: "gid://shopify/Order/9000000001",
name: "#1004",
orderNumber: 1004,
createdAt: "2026-04-15T10:00:00Z",
processedAt: "2026-04-15T10:00:00Z",
currencyCode: "EUR",
displayFinancialStatus: "PENDING",
paymentGatewayNames: ["manual"],
taxesIncluded: false,
requiresShipping: true,
discountCodes: [],
customer: {
firstName: "Lukas",
lastName: "Schmidhofer",
email: "lukas@schmidhofer.example",
locale: "de-AT",
},
billingAddress: {
name: "Lukas Schmidhofer",
company: "Schmidhofer Dienstleistungen",
address1: "Hauptstraße 12",
address2: null,
zip: "8010",
city: "Graz",
province: null,
countryCode: "AT",
},
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,
deliveryCategory: "shipping",
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: [
{
createdAt: "2026-05-13T10:30:00.000Z",
trackingInfo: [
{ number: "JJD0099887766", url: "https://example.test/track/JJD0099887766", company: "DHL" },
],
},
],
lineItems: [
{
title: "Bluetooth Tracker",
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: [
{
title: "USt 20%",
rate: 0.2,
ratePercentage: 20,
priceSet: { shopMoney: { amount: lineTax.toFixed(2), currencyCode: "EUR" } },
},
],
},
],
taxLines: [
{
title: "USt 20%",
rate: 0.2,
ratePercentage: 20,
priceSet: { shopMoney: { amount: lineTax.toFixed(2), currencyCode: "EUR" } },
},
],
subtotalSet: { shopMoney: { amount: lineNet.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",
vatId: "ATU57680511",
address: null,
},
},
};
}
// ------------------------------------------------------------------
// Variants for VAT-scenario assertions
// ------------------------------------------------------------------
function buildEuB2BReverseChargeOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.billingAddress!.countryCode = "DE";
o.billingAddress!.company = "Müller GmbH";
o.billingAddress!.zip = "80331";
o.billingAddress!.city = "München";
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;
}
function buildExportOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.purchasingEntity = null;
o.billingAddress!.countryCode = "US";
o.billingAddress!.company = "";
o.billingAddress!.name = "Jane Doe";
o.billingAddress!.zip = "10001";
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";
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: "Lager Graz",
code: "Pickup",
source: "shopify",
carrierIdentifier: null,
deliveryCategory: "pickup",
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
taxLines: [],
};
return o;
}
/** Pickup variant where neither title/code nor source mention "pickup" —
* detection must rely purely on `deliveryCategory`. Mirrors what we
* observed on a real Shopify Local Pickup install. */
function buildCategoryOnlyPickupOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.shippingLine = {
title: "Lager Graz",
code: "Standard",
source: "shopify",
carrierIdentifier: null,
deliveryCategory: "pickup",
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
taxLines: [],
};
return o;
}
/** Pickup variant matching a REAL observed order on
* linumiq-dev.myshopify.com (#1032): Shopify's built-in "Shop location"
* rate. NO "pickup" string anywhere, deliveryCategory is `null`, and
* shippingAddress is also `null` — detection must rely on
* `requiresShipping && shippingAddress == null`. */
function buildShopLocationPickupOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.shippingLine = {
title: "Shop location",
code: "Shop location",
source: "shopify",
carrierIdentifier: null,
deliveryCategory: null,
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
taxLines: [],
};
o.shippingAddress = null;
o.requiresShipping = true;
return o;
}
// ------------------------------------------------------------------
// Run assertions
// ------------------------------------------------------------------
async function main() {
console.log("• Validation helpers");
assert("isValidIban accepts a known-good AT IBAN", isValidIban("AT611904300234573201"));
assert("isValidIban rejects garbage", !isValidIban("AT00 0000 0000 0000 0001"));
assert("normaliseIban strips spaces and uppercases", normaliseIban("at61 1904 3002 3457 3201") === "AT611904300234573201");
assert("isValidBic accepts 8-char", isValidBic("RZSTAT2G"));
assert("isValidBic accepts 11-char", isValidBic("RZSTAT2G123"));
assert("isValidBic rejects too-short", !isValidBic("RZ"));
assert("isValidAtVatId accepts ATU + 8 digits", isValidAtVatId("ATU12345678"));
assert("isValidAtVatId rejects non-AT", !isValidAtVatId("DE123456789"));
console.log("• GiroCode payload");
const payload = buildGiroCodePayload({
beneficiaryName: settings.companyName,
iban: settings.iban,
bic: settings.bic,
amount: 43.13,
remittance: "RE-1004",
});
const lines = payload.split("\n");
assertEq("BCD service tag", lines[0], "BCD");
assertEq("version 002", lines[1], "002");
assertEq("charset UTF-8", lines[2], "1");
assertEq("SCT", lines[3], "SCT");
assertEq("BIC", lines[4], "RZSTAT2G");
assertEq("Beneficiary name", lines[5], "LinumIQ");
assertEq("IBAN", lines[6], "AT611904300234573201");
assertEq("Amount", lines[7], "EUR43.13");
assertEq("Remittance", lines[10], "RE-1004");
assert("Payload is < 331 bytes per EPC069-12", Buffer.byteLength(payload, "utf8") < 331,
`actual ${Buffer.byteLength(payload, "utf8")}`);
console.log("• AT B2B compose (matches reference invoice)");
const order = buildAtB2BOrder();
const vm = composeInvoice({ order, settings: settings as never, invoiceNumber: "RE-1004" });
assertEq("language picked from de-AT", vm.language, "de");
assertEq("currency", vm.currency, "EUR");
assert("isB2B detected", vm.isB2B);
assertEq("recipientVatId", vm.recipientVatId, "ATU57680511");
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);
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 (incl. shipping VAT)", vm.totals.vatBreakdown[0].tax, 8.19);
assertEq("vat rate %", vm.totals.vatBreakdown[0].ratePct, 20);
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();
const euVm = composeInvoice({ order: euOrder, settings: settings as never, invoiceNumber: "RE-1005" });
assert("isB2B", euVm.isB2B);
assertEq("zero VAT", euVm.totals.totalVat, 0);
assertEq("reverseCharge notice present", euVm.notices.find((n) => n.kind === "reverseCharge")?.kind, "reverseCharge");
console.log("• Third-country export notice");
const exportOrder = buildExportOrder();
const exVm = composeInvoice({ order: exportOrder, settings: settings as never, invoiceNumber: "RE-1006" });
assertEq("zero VAT", exVm.totals.totalVat, 0);
assertEq("language fallback to en", exVm.language, "en");
assertEq("export notice present", exVm.notices.find((n) => n.kind === "export")?.kind, "export");
console.log("• Kleinunternehmer notice");
const k = composeInvoice({
order: buildAtB2BOrder(),
settings: { ...(settings as object), kleinunternehmer: true } as never,
invoiceNumber: "RE-1007",
});
assertEq("kleinunternehmer notice present", k.notices.find((n) => n.kind === "kleinunternehmer")?.kind, "kleinunternehmer");
console.log("• Render PDF for AT B2B order");
// Attach logo (loaded from disk so the smoke output mirrors a real merchant setup).
const logoPath = resolve(__dirname, "..", "data", "linumiq-logo.png");
const logoBytes = readFileSync(logoPath);
const logoDataUrl = `data:image/png;base64,${logoBytes.toString("base64")}`;
vm.issuer.logoDataUrl = logoDataUrl;
// Stand in for a real product image (the orchestrator fetches it from
// Shopify CDN; here we re-use the logo bytes so the table cell exercises
// the icon rendering path).
assertEq("composer propagates imageUrl", vm.lines[0].imageUrl, "file://product-image");
vm.lines[0].imageDataUrl = logoDataUrl;
// Attach GiroCode (rendered manually here since the orchestrator does it).
vm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
beneficiaryName: settings.companyName,
iban: settings.iban,
bic: settings.bic,
amount: vm.totals.gross,
remittance: vm.number,
});
const buffer = await renderInvoicePdf(vm);
const out = resolve(__dirname, "..", "data", "sample-rechnung.pdf");
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, buffer);
const start = buffer.subarray(0, 5).toString("ascii");
assertEq("PDF magic header %PDF-", start, "%PDF-");
assert("PDF size > 5 KB", buffer.length > 5_000, `actual ${buffer.length}`);
console.log(` → wrote ${out} (${buffer.length} bytes)`);
// ----------------------------------------------------------------
// Storno (cancellation invoice) composition + render
// ----------------------------------------------------------------
console.log("• Storno composition (cancels RE-1004)");
const storno = composeInvoice({
order,
settings: settings as never,
invoiceNumber: "RE-1004-S",
storno: { cancelsNumber: "RE-1004" },
});
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, 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("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, -8.19);
console.log("• Render storno PDF");
storno.issuer.logoDataUrl = vm.issuer.logoDataUrl;
// Storno deliberately omits GiroCode (negative amount).
const stornoBuf = await renderInvoicePdf(storno);
const stornoOut = resolve(__dirname, "..", "data", "sample-storno.pdf");
writeFileSync(stornoOut, stornoBuf);
assertEq("storno PDF magic", stornoBuf.subarray(0, 5).toString("ascii"), "%PDF-");
assert("storno PDF > 5 KB", stornoBuf.length > 5_000, `actual ${stornoBuf.length}`);
console.log(` → wrote ${stornoOut} (${stornoBuf.length} bytes)`);
// ----------------------------------------------------------------
// Refunded order: GiroCode + payment-terms must be suppressed
// ----------------------------------------------------------------
console.log("• Refunded order (REFUNDED) suppresses GiroCode + payment terms");
{
const refundedOrder = { ...buildAtB2BOrder(), displayFinancialStatus: "REFUNDED" };
const refundedVm = composeInvoice({
order: refundedOrder, settings: settings as never, invoiceNumber: "RE-1014",
});
assertEq("paymentStatus=refunded", refundedVm.paymentStatus, "refunded");
assert("requiresPayment=false for refunded", refundedVm.requiresPayment === false);
refundedVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
// The orchestrator gates GiroCode generation on requiresPayment too —
// simulate that here by NOT attaching giroCodePngDataUrl. The PDF
// render-gate must independently refuse to render even if a stale
// QR data URL were attached, so set one anyway and verify both
// suppression layers.
refundedVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
beneficiaryName: settings.companyName,
iban: settings.iban,
bic: settings.bic,
amount: refundedVm.totals.gross,
remittance: refundedVm.number,
});
const refundedText = await pdfToText(await renderInvoicePdf(refundedVm));
assert("Refunded PDF does NOT show GiroCode caption",
!refundedText.includes("GiroCode"));
assert("Refunded PDF does NOT show DE payment terms",
!refundedText.includes("Bitte überweise"));
assert("Refunded PDF still shows the 'Erstattet' status row",
refundedText.includes("Erstattet"));
}
// Same gating must apply to PAID orders.
console.log("• Paid order (PAID) suppresses GiroCode + payment terms");
{
const paidOrder = { ...buildAtB2BOrder(), displayFinancialStatus: "PAID" };
const paidVm = composeInvoice({
order: paidOrder, settings: settings as never, invoiceNumber: "RE-1015",
});
assert("requiresPayment=false for paid", paidVm.requiresPayment === false);
paidVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
paidVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
beneficiaryName: settings.companyName,
iban: settings.iban,
bic: settings.bic,
amount: paidVm.totals.gross,
remittance: paidVm.number,
});
const paidText = await pdfToText(await renderInvoicePdf(paidVm));
assert("Paid PDF does NOT show GiroCode caption", !paidText.includes("GiroCode"));
assert("Paid PDF does NOT show DE payment terms", !paidText.includes("Bitte überweise"));
assert("Paid PDF shows the 'Bezahlt' status row", paidText.includes("Bezahlt"));
}
// ----------------------------------------------------------------
// Footer note translation
// ----------------------------------------------------------------
console.log("• Footer note (per-language)");
const deVm = composeInvoice({ order, settings: settings as never, invoiceNumber: "RE-1010" });
assertEq("DE footerNote propagated", deVm.issuer.footerNote, "Vielen Dank für Ihren Auftrag.");
assertEq("EN footerNote propagated", deVm.issuer.footerNoteEn, "Thank you for your business.");
const enVm = composeInvoice({ order, settings: settings as never, invoiceNumber: "RE-1011", forceLanguage: "en" });
assertEq("forceLanguage=en", enVm.language, "en");
// Render both and verify the rendered text differs by language.
const dePdf = await renderInvoicePdf(deVm);
const enPdf = await renderInvoicePdf(enVm);
const deText = await pdfToText(dePdf);
const enText = await pdfToText(enPdf);
assert("DE PDF contains German footer", deText.includes("Vielen Dank für Ihren Auftrag."), `text snippet: ${deText.slice(0, 200)}`);
assert("DE PDF does NOT contain English footer", !deText.includes("Thank you for your business."));
assert("EN PDF contains English footer", enText.includes("Thank you for your business."), `text snippet: ${enText.slice(0, 200)}`);
assert("EN PDF does NOT contain German footer", !enText.includes("Vielen Dank für Ihren Auftrag."));
// Closing line: generic thank-you, no owner-name signature directly under it.
// (The legal "Inhaber: <name>" block in the page footer is unrelated and stays.)
assert("DE PDF closing reads 'Danke für deinen Einkauf'", deText.includes("Danke für deinen Einkauf"));
assert("DE PDF no longer contains 'Mit freundlichen Grüßen'", !deText.includes("Mit freundlichen Grüßen"));
assert(
"DE PDF: owner name is NOT a signature under the closing",
!/Danke für deinen Einkauf[\s\S]{0,40}Gerhard Berger/.test(deText),
);
assert("EN PDF closing reads 'Thank you for your purchase.'", enText.includes("Thank you for your purchase."));
assert("EN PDF no longer contains 'Kind regards'", !enText.includes("Kind regards"));
assert(
"EN PDF: owner name is NOT a signature under the closing",
!/Thank you for your purchase\.[\s\S]{0,40}Gerhard Berger/.test(enText),
);
// Informal German tone (du/dein) — make sure no formal "Sie/Ihren" remains
// in the strings we control (footer / signature lines come from settings).
// The PDF intentionally has NO salutation — this is an invoice, not a
// letter. Both formal ("Sehr geehrte …") and informal ("Hallo,") are
// suppressed.
assert("DE PDF has no 'Hallo,' salutation", !deText.includes("Hallo,"));
assert("DE PDF has no 'Sehr geehrte Damen und Herren' salutation", !deText.includes("Sehr geehrte Damen und Herren"));
assert("DE PDF uses informal 'deine Bestellung'", deText.includes("deine Bestellung"));
assert(
"DE PDF payment-terms uses informal 'überweise … für dich'",
deText.includes("Bitte überweise") && deText.includes("für dich"),
);
assert("DE PDF shows payment status row", deText.includes("Zahlstatus"));
assert("DE PDF shows payment status value 'Offen' for PENDING", deText.includes("Offen"));
assert("DE PDF shows payment method row", deText.includes("Zahlart"));
// The Shopify Admin GraphQL API returns the *English* template name for
// built-in manual payment gateways even on German-locale shops — we
// localize it ourselves via i18n.paymentGatewayLabels so the PDF matches
// what the customer saw on the order-confirmation page.
assert("DE PDF localizes 'manual' gateway to 'Manuelle Zahlung'",
deText.includes("Manuelle Zahlung"));
assert("DE PDF no longer shows raw English 'Manual' as gateway label",
!/Zahlart[\s\S]{0,20}Manual\b/.test(deText));
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"));
// Order-number suppression: when the invoice number's trailing digits
// match the Shopify order name (default numbering mode), the redundant
// "· Bestellnummer: #1004" suffix should be dropped from the title.
const sameNumVm = composeInvoice({
order, settings: settings as never, invoiceNumber: "RE-1004",
});
sameNumVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const sameNumText = await pdfToText(await renderInvoicePdf(sameNumVm));
assert("PDF suppresses 'Bestellnummer' suffix when invoice# matches order#",
!sameNumText.includes("Bestellnummer"));
assert("PDF still shows the invoice number itself when suppressed",
sameNumText.includes("RE-1004"));
// ----------------------------------------------------------------
// 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 via shippingLine heuristic", pickupVm.isPickup);
assertEq("pickupLocationName propagated from shippingLine.title", pickupVm.pickupLocationName, "Lager Graz");
assert("shippingMethod cleared for pickup (renderer uses pickup row instead)",
pickupVm.shippingMethod == null);
assert(
"separateShippingAddress suppressed for pickup",
pickupVm.separateShippingAddress == null,
);
pickupVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const pickupText = await pdfToText(await renderInvoicePdf(pickupVm));
assert("DE pickup PDF shows 'Abholort' label", pickupText.includes("Abholort"));
assert("DE pickup PDF shows location name", pickupText.includes("Lager Graz"));
assert("DE pickup PDF does NOT show 'Versandart'", !pickupText.includes("Versandart"));
assert(
"DE pickup PDF does NOT render pickup-location address as delivery address",
!pickupText.includes("Lieferadresse"),
);
// EN translation
const pickupEnVm = composeInvoice({
order: pickupOrder,
settings: settings as never,
invoiceNumber: "RE-1031",
forceLanguage: "en",
});
pickupEnVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const pickupEnText = await pdfToText(await renderInvoicePdf(pickupEnVm));
assert("EN pickup PDF shows 'Pick-up location' label", pickupEnText.includes("Pick-up location"));
// Real-world pickup variant: shippingLine has no "pickup" keyword in
// title/code/source — only `deliveryCategory` says it's pickup.
const categoryPickupVm = composeInvoice({
order: buildCategoryOnlyPickupOrder(),
settings: settings as never,
invoiceNumber: "RE-1033",
});
assert("isPickup detected from deliveryCategory alone", categoryPickupVm.isPickup);
assertEq("pickupLocationName from title when category-only",
categoryPickupVm.pickupLocationName, "Lager Graz");
assert("shippingMethod cleared in category-only pickup",
categoryPickupVm.shippingMethod == null);
// Real-world "Shop location" pickup (matches dev order #1032): no
// "pickup" keyword anywhere, deliveryCategory null, shippingAddress null.
// The only signal is `requiresShipping && !shippingAddress`.
const shopLocPickupVm = composeInvoice({
order: buildShopLocationPickupOrder(),
settings: settings as never,
invoiceNumber: "RE-1034",
});
assert("isPickup detected from missing shippingAddress (Shop location rate)",
shopLocPickupVm.isPickup);
assertEq("pickupLocationName from shippingLine.title for Shop location",
shopLocPickupVm.pickupLocationName, "Shop location");
assert("shippingMethod cleared for Shop location pickup",
shopLocPickupVm.shippingMethod == null);
// 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;
const fbVm = composeInvoice({ order, settings: settingsNoEn, invoiceNumber: "RE-1012", forceLanguage: "en" });
const fbPdf = await renderInvoicePdf(fbVm);
const fbText = await pdfToText(fbPdf);
assert("EN PDF falls back to DE footer when EN empty", fbText.includes("Vielen Dank für Ihren Auftrag."));
// Empty German footer + empty English footer renders nothing (no crash).
console.log("• Footer note empty (no crash, nothing rendered)");
const settingsBlank = { ...(settings as object), footerNote: "", footerNoteEn: "" } as never;
const blankVm = composeInvoice({ order, settings: settingsBlank, invoiceNumber: "RE-1013" });
const blankPdf = await renderInvoicePdf(blankVm);
const blankText = await pdfToText(blankPdf);
assert("blank-footer DE PDF produced", blankPdf.length > 4_000);
assert("blank-footer DE PDF has no German footer text", !blankText.includes("Vielen Dank für Ihren Auftrag."));
if (failed > 0) {
console.error(`\n${failed} assertion(s) FAILED`);
process.exit(1);
}
console.log("\nAll smoke checks passed.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});