first version

This commit is contained in:
Gerhard Scheikl
2026-04-28 21:56:11 +02:00
parent 0f75dbaccb
commit 5b2aa5d62b
50 changed files with 5514 additions and 481 deletions
+350
View File
@@ -0,0 +1,350 @@
/**
* 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);
});