Files
linumiq-invoice/scripts/render-sample.ts
T
Gerhard Scheikl 5b2aa5d62b first version
2026-04-28 21:56:11 +02:00

351 lines
14 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";
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}`);
}
// ------------------------------------------------------------------
// 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: "",
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" } },
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);
vm.issuer.logoDataUrl = `data:image/png;base64,${logoBytes.toString("base64")}`;
// 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)`);
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);
});