429 lines
18 KiB
TypeScript
429 lines
18 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",
|
||
taxesIncluded: false,
|
||
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: null,
|
||
lineItems: [
|
||
{
|
||
title: "Bluetooth Tracker",
|
||
sku: "BT-TRK-001",
|
||
quantity: qty,
|
||
originalUnitPriceSet: { shopMoney: { amount: unitNet.toFixed(2), currencyCode: "EUR" } },
|
||
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.toFixed(2), currencyCode: "EUR" } },
|
||
totalPriceSet: { shopMoney: { amount: lineGross.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 = [];
|
||
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.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
|
||
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
|
||
o.customer!.locale = "en";
|
||
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", vm.lines.length, 1);
|
||
const ln = vm.lines[0];
|
||
assertEq("line title", ln.title, "Bluetooth Tracker");
|
||
assertEq("line qty", ln.quantity, 6);
|
||
assertNear("line unit net", ln.unitPriceNet, 5.99);
|
||
assertNear("line total net", ln.totalNet, 35.94);
|
||
assertNear("net total", vm.totals.net, 35.94);
|
||
assertEq("vat breakdown rows", vm.totals.vatBreakdown.length, 1);
|
||
assertNear("vat amount", vm.totals.vatBreakdown[0].tax, 7.19);
|
||
assertEq("vat rate %", vm.totals.vatBreakdown[0].ratePct, 20);
|
||
assertNear("gross", vm.totals.gross, 43.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);
|
||
|
||
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, 1);
|
||
assertNear("line qty preserved (only money negated)", storno.lines[0].quantity, 6);
|
||
assertNear("line unit price negated", storno.lines[0].unitPriceNet, -5.99);
|
||
assertNear("line totalNet negated", storno.lines[0].totalNet, -35.94);
|
||
assertNear("totals.net negated", storno.totals.net, -35.94);
|
||
assertNear("totals.totalVat negated", storno.totals.totalVat, -7.19);
|
||
assertNear("totals.gross negated", storno.totals.gross, -43.13);
|
||
assertEq("vat breakdown row count preserved", storno.totals.vatBreakdown.length, 1);
|
||
assertNear("vat breakdown tax negated", storno.totals.vatBreakdown[0].tax, -7.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),
|
||
);
|
||
|
||
// 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);
|
||
});
|