415a9dd462
- 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.
645 lines
28 KiB
TypeScript
645 lines
28 KiB
TypeScript
/**
|
||
* 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,
|
||
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,
|
||
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: "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
|
||
// ------------------------------------------------------------------
|
||
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: <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).
|
||
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"));
|
||
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"));
|
||
|
||
// ----------------------------------------------------------------
|
||
// 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;
|
||
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);
|
||
});
|