/** * 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(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 { 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)`); // ---------------------------------------------------------------- // 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: " 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). assert("DE PDF uses informal salutation 'Hallo,'", deText.includes("Hallo,")); assert("DE PDF no longer uses 'Sehr geehrte Damen und Herren'", !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); });