many updates :-)

This commit is contained in:
Gerhard Scheikl
2026-05-08 10:40:19 +02:00
parent 5b2aa5d62b
commit 770c6fd16a
16 changed files with 876 additions and 151 deletions
+71
View File
@@ -0,0 +1,71 @@
/**
* Regenerates a single existing invoice using the offline session token,
* downloads the resulting PDF and pretty-prints the closing/footer text so
* we can verify the new wording end-to-end against the live dev store.
*
* Usage: npx tsx scripts/regenerate-invoice.ts [invoiceNumber]
* default invoiceNumber = RE-1001
*/
import "dotenv/config";
import { writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { resolve } from "node:path";
import db from "../app/db.server";
import { unauthenticated } from "../app/shopify.server";
import { generateInvoice } from "../app/services/invoice/generateInvoice.server";
async function main() {
const wanted = process.argv[2] ?? "RE-1001";
const invoice = await db.invoice.findFirst({
where: { invoiceNumber: wanted, kind: "invoice", cancelledAt: null },
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
if (!invoice) throw new Error(`No invoice ${wanted} found in DB`);
console.log(`Found ${invoice.invoiceNumber} (status=${invoice.status}, sentAt=${invoice.sentAt ?? "—"}) for order ${invoice.orderId}`);
const { admin } = await unauthenticated.admin(invoice.shopDomain);
const result = await generateInvoice({
shopDomain: invoice.shopDomain,
admin,
orderId: invoice.orderId,
forceRegenerate: true,
});
console.log(`Regenerated → version ${result.version}, pdfUrl=${result.pdfUrl}`);
const out = resolve(process.cwd(), "data", `regen-${result.invoiceNumber}.pdf`);
const res = await fetch(result.pdfUrl);
if (!res.ok) throw new Error(`download failed: ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
writeFileSync(out, buf);
console.log(`Saved ${out} (${buf.length} bytes)`);
const text = execFileSync("pdftotext", ["-layout", "-enc", "UTF-8", out, "-"], { encoding: "utf8" });
const okDe = text.includes("Danke für deinen Einkauf");
const okEn = text.includes("Thank you for your purchase.");
const oldDe = text.includes("Mit freundlichen Grüßen");
const oldEn = /\bKind regards\b/.test(text);
console.log(`\n--- closing text checks ---`);
console.log(` contains 'Danke für deinen Einkauf' : ${okDe}`);
console.log(` contains 'Thank you for your purchase.': ${okEn}`);
console.log(` contains old 'Mit freundlichen Grüßen' : ${oldDe}`);
console.log(` contains old 'Kind regards' : ${oldEn}`);
const langOk = invoice.language === "en" ? okEn : okDe;
const cleanOld = invoice.language === "en" ? !oldEn : !oldDe;
if (!langOk || !cleanOld) {
console.error(`FAIL: closing line did not match expected wording for language=${invoice.language}`);
process.exit(1);
}
console.log(`PASS: regenerated ${invoice.invoiceNumber} (lang=${invoice.language}) shows the new closing.`);
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(() => db.$disconnect());
+80 -2
View File
@@ -17,6 +17,8 @@
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);
@@ -51,6 +53,22 @@ 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)
// ------------------------------------------------------------------
@@ -81,7 +99,8 @@ const settings = {
invoiceSeed: 1000,
defaultLanguage: "de",
paymentTermDays: 14,
footerNote: "",
footerNote: "Vielen Dank für Ihren Auftrag.",
footerNoteEn: "Thank you for your business.",
kleinunternehmer: false,
logoUrl: "",
smtpHost: "",
@@ -139,6 +158,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
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%",
@@ -285,7 +305,13 @@ async function main() {
// 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")}`;
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,
@@ -337,6 +363,58 @@ async function main() {
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);