first version
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user