Files
linumiq-invoice/scripts/render-sample.ts
T
Gerhard Scheikl c5b6bfc20d refactor(invoice): drop dead paid/paidStamp; classify VOIDED; warn on unknown payment status
Audit cleanup of payment-status code paths uncovered while shipping
the partial-refund fix:

#1 Drop `viewModel.paid` (boolean). It was set from
   `displayFinancialStatus === "PAID"` and never read anywhere. With
   refunds in the picture it had become a footgun: a fully refunded
   order that started PAID would still satisfy `paid === true`, but
   `paymentStatus === "refunded"`. Callers should use `paymentStatus`
   / `requiresPayment` exclusively.

#2 Remove the unused `paidStamp` translation ("BEZAHLT" / "PAID").
   Defined in both locales but never rendered.

#3 Classify VOIDED orders as a distinct `"voided"` payment status
   (rendered "Annulliert" / "Voided") instead of "unpaid". A voided
   order had its authorisation cancelled before capture — no money
   was received and none is owed. The previous "Offen" / "Outstanding"
   label combined with a GiroCode would have invited the customer to
   pay an order that's already been called off. `requiresPayment`
   now also excludes `"voided"`, so GiroCode + payment-terms
   paragraph are suppressed (mirrors the `"refunded"` treatment).
   "Annulliert" is used in German rather than "Storniert" to avoid
   confusion with our storno cancellation document concept.

#6 `derivePaymentStatus` now logs a `console.warn` when it
   encounters a non-empty `displayFinancialStatus` value that isn't
   one of the documented Shopify enum members (PAID, PARTIALLY_PAID,
   REFUNDED, PARTIALLY_REFUNDED, VOIDED, PENDING, AUTHORIZED,
   EXPIRED). Future Shopify enum additions will surface in logs
   instead of silently mapping to "unpaid".

EXPIRED stays mapped to "unpaid" — abandoned-checkout-style edge
case left intentionally for a separate decision (#4 in the audit).

Verification: render-sample now also exercises a VOIDED fixture
(status row "Annulliert", no GiroCode, no payment terms). tsc /
smoke / tests / build all green.
2026-05-15 18:12:06 +02:00

899 lines
40 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";
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",
paymentGatewayNames: ["manual"],
taxesIncluded: false,
requiresShipping: true,
discountCodes: [],
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: {
name: "Lukas Schmidhofer",
company: "Schmidhofer Dienstleistungen",
address1: "Lagerweg 4",
address2: null,
zip: "8020",
city: "Graz",
province: null,
countryCode: "AT",
},
shippingLine: {
title: "Standardversand",
code: "STD",
source: "shopify",
carrierIdentifier: null,
deliveryCategory: "shipping",
originalPriceSet: { shopMoney: { amount: "5.00", currencyCode: "EUR" } },
discountedPriceSet: { shopMoney: { amount: "5.00", currencyCode: "EUR" } },
taxLines: [
{
title: "USt 20%",
rate: 0.2,
ratePercentage: 20,
priceSet: { shopMoney: { amount: "1.00", currencyCode: "EUR" } },
},
],
},
fulfillments: [
{
createdAt: "2026-05-13T10:30:00.000Z",
trackingInfo: [
{ number: "JJD0099887766", url: "https://example.test/track/JJD0099887766", company: "DHL" },
],
},
],
lineItems: [
{
title: "Bluetooth Tracker",
sku: "BT-TRK-001",
quantity: qty,
originalUnitPriceSet: { shopMoney: { amount: unitNet.toFixed(2), currencyCode: "EUR" } },
discountedUnitPriceSet: null,
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 + 1.0).toFixed(2), currencyCode: "EUR" } },
totalPriceSet: { shopMoney: { amount: (lineGross + 6.0).toFixed(2), currencyCode: "EUR" } },
totalRefundedSet: null,
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 = [];
// No VAT for reverse-charge; clear shipping VAT too.
o.shippingLine = null;
o.fulfillments = [];
o.shippingAddress = null;
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.shippingLine = null;
o.fulfillments = [];
o.shippingAddress = null;
o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
o.customer!.locale = "en";
return o;
}
/**
* Variant of the AT B2B order with a per-line discount: unit price stays
* 7.19 EUR gross (5.99 net) but Shopify allocated a 1.00 EUR/unit discount,
* so the discounted unit price is 6.19 gross (5.16 net). Also adds an
* order-level discount code ("SUMMER10") for the meta-block render.
*/
function buildDiscountedOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.discountCodes = ["SUMMER10"];
// Discount of 1.00 EUR/unit applied: net unit drops from 5.99 to 4.99,
// qty 6 → 29.94 net, tax (20%) = 5.99.
o.lineItems[0].discountedUnitPriceSet = {
shopMoney: { amount: "4.99", currencyCode: "EUR" },
};
o.lineItems[0].taxLines = [
{
title: "USt 20%",
rate: 0.2,
ratePercentage: 20,
priceSet: { shopMoney: { amount: "5.99", currencyCode: "EUR" } },
},
];
return o;
}
/**
* Variant of the AT B2B order whose shipping line is local pickup. The
* "shipping address" still carries the pickup-location address (as Shopify
* does), but the composer should detect the pickup and suppress it.
*/
function buildPickupOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.shippingLine = {
title: "Lager Graz",
code: "Pickup",
source: "shopify",
carrierIdentifier: null,
deliveryCategory: "pickup",
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
taxLines: [],
};
return o;
}
/** Pickup variant where neither title/code nor source mention "pickup" —
* detection must rely purely on `deliveryCategory`. Mirrors what we
* observed on a real Shopify Local Pickup install. */
function buildCategoryOnlyPickupOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.shippingLine = {
title: "Lager Graz",
code: "Standard",
source: "shopify",
carrierIdentifier: null,
deliveryCategory: "pickup",
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
taxLines: [],
};
return o;
}
/** Pickup variant matching a REAL observed order on
* linumiq-dev.myshopify.com (#1032): Shopify's built-in "Shop location"
* rate. NO "pickup" string anywhere, deliveryCategory is `null`, and
* shippingAddress is also `null` — detection must rely on
* `requiresShipping && shippingAddress == null`. */
function buildShopLocationPickupOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.shippingLine = {
title: "Shop location",
code: "Shop location",
source: "shopify",
carrierIdentifier: null,
deliveryCategory: null,
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
taxLines: [],
};
o.shippingAddress = null;
o.requiresShipping = true;
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 (1 product + 1 shipping)", vm.lines.length, 2);
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);
const shipLine = vm.lines[1];
assert("shipping line title prefixed", shipLine.title.startsWith("Versand"),
`got "${shipLine.title}"`);
assertNear("shipping line net", shipLine.totalNet, 5.0);
assertNear("net total (incl. shipping)", vm.totals.net, 40.94);
assertEq("vat breakdown rows", vm.totals.vatBreakdown.length, 1);
assertNear("vat amount (incl. shipping VAT)", vm.totals.vatBreakdown[0].tax, 8.19);
assertEq("vat rate %", vm.totals.vatBreakdown[0].ratePct, 20);
assertNear("gross (incl. shipping)", vm.totals.gross, 49.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);
assertEq("paymentGatewayNames propagated", vm.paymentGatewayNames.join(","), "manual");
assertEq("paymentStatus derived from displayFinancialStatus=PENDING", vm.paymentStatus, "unpaid");
assertEq("orderName propagated", vm.orderName, "#1004");
assertEq("shippingMethod propagated", vm.shippingMethod, "Standardversand");
assertEq("tracking entries", vm.tracking.length, 1);
assertEq("tracking number", vm.tracking[0].number, "JJD0099887766");
assertEq("tracking carrier", vm.tracking[0].company, "DHL");
assert("separateShippingAddress detected (differs from billing)",
vm.separateShippingAddress?.addressLine1 === "Lagerweg 4");
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, 2);
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("shipping line totalNet negated", storno.lines[1].totalNet, -5.0);
assertNear("totals.net negated", storno.totals.net, -40.94);
assertNear("totals.totalVat negated", storno.totals.totalVat, -8.19);
assertNear("totals.gross negated", storno.totals.gross, -49.13);
assertEq("vat breakdown row count preserved", storno.totals.vatBreakdown.length, 1);
assertNear("vat breakdown tax negated", storno.totals.vatBreakdown[0].tax, -8.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)`);
// ----------------------------------------------------------------
// Refunded order: GiroCode + payment-terms must be suppressed
// ----------------------------------------------------------------
console.log("• Refunded order (REFUNDED) suppresses GiroCode + payment terms");
{
const baseRefunded = buildAtB2BOrder();
const refundedOrder = {
...baseRefunded,
displayFinancialStatus: "REFUNDED",
// Mirror the full gross as refunded so the new "Offener Betrag"
// row should print 0,00 \u20ac.
totalRefundedSet: baseRefunded.totalPriceSet,
};
const refundedVm = composeInvoice({
order: refundedOrder, settings: settings as never, invoiceNumber: "RE-1014",
});
assertEq("paymentStatus=refunded", refundedVm.paymentStatus, "refunded");
assert("requiresPayment=false for refunded", refundedVm.requiresPayment === false);
assertNear("refundedAmount mirrors totalRefundedSet", refundedVm.refundedAmount, refundedVm.totals.gross);
refundedVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
// The orchestrator gates GiroCode generation on requiresPayment too \u2014
// simulate a stale QR data URL anyway and verify the PDF render-gate
// independently refuses to render it.
refundedVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
beneficiaryName: settings.companyName,
iban: settings.iban,
bic: settings.bic,
amount: refundedVm.totals.gross,
remittance: refundedVm.number,
});
const refundedText = await pdfToText(await renderInvoicePdf(refundedVm));
assert("Refunded PDF does NOT show GiroCode caption",
!refundedText.includes("GiroCode"));
assert("Refunded PDF does NOT show DE payment terms",
!refundedText.includes("Bitte \u00fcberweise"));
assert("Refunded PDF still shows the 'Erstattet' status row",
refundedText.includes("Erstattet"));
assert("Refunded PDF shows the 'Zur\u00fcckerstattet' totals row",
refundedText.includes("Zur\u00fcckerstattet"));
assert("Refunded PDF labels the final row 'Endbetrag' (nothing is outstanding)",
refundedText.includes("Endbetrag") && !refundedText.includes("Offener Betrag"));
assert("Refunded PDF shows 0,00 EUR as outstanding",
refundedText.includes("0,00 EUR"));
}
// Same gating must apply to PAID orders.
console.log("• Paid order (PAID) suppresses GiroCode + payment terms");
{
const paidOrder = { ...buildAtB2BOrder(), displayFinancialStatus: "PAID" };
const paidVm = composeInvoice({
order: paidOrder, settings: settings as never, invoiceNumber: "RE-1015",
});
assert("requiresPayment=false for paid", paidVm.requiresPayment === false);
paidVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
paidVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
beneficiaryName: settings.companyName,
iban: settings.iban,
bic: settings.bic,
amount: paidVm.totals.gross,
remittance: paidVm.number,
});
const paidText = await pdfToText(await renderInvoicePdf(paidVm));
assert("Paid PDF does NOT show GiroCode caption", !paidText.includes("GiroCode"));
assert("Paid PDF does NOT show DE payment terms", !paidText.includes("Bitte überweise"));
assert("Paid PDF shows the 'Bezahlt' status row", paidText.includes("Bezahlt"));
}
// ----------------------------------------------------------------
// Partial refund on a paid order: status stays "Bezahlt", the final
// row is labelled "Endbetrag" (not "Offener Betrag"), and the kept
// amount is shown.
// ----------------------------------------------------------------
console.log("• Partial refund on a paid order (PARTIALLY_REFUNDED)");
{
const basePartial = buildAtB2BOrder();
const grossStr = basePartial.totalPriceSet?.shopMoney.amount ?? "0";
const grossNum = parseFloat(grossStr);
const partialRefund = +(grossNum * 0.25).toFixed(2);
const partialOrder = {
...basePartial,
displayFinancialStatus: "PARTIALLY_REFUNDED",
totalRefundedSet: { shopMoney: { amount: partialRefund.toFixed(2), currencyCode: "EUR" } },
};
const partialVm = composeInvoice({
order: partialOrder, settings: settings as never, invoiceNumber: "RE-1016",
});
assertEq("paymentStatus reclassified to paid (partial refund < gross)",
partialVm.paymentStatus, "paid");
assert("requiresPayment=false for partially refunded paid order",
partialVm.requiresPayment === false);
assertNear("refundedAmount mirrors partial refund",
partialVm.refundedAmount, partialRefund);
partialVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const partialText = await pdfToText(await renderInvoicePdf(partialVm));
assert("Partial-refund PDF shows 'Bezahlt' status row (not Erstattet)",
partialText.includes("Bezahlt") && !partialText.includes("Erstattet"));
assert("Partial-refund PDF shows 'Zurückerstattet' totals row",
partialText.includes("Zurückerstattet"));
assert("Partial-refund PDF labels the final row 'Endbetrag' (not 'Offener Betrag')",
partialText.includes("Endbetrag") && !partialText.includes("Offener Betrag"));
assert("Partial-refund PDF does NOT show GiroCode caption",
!partialText.includes("GiroCode"));
assert("Partial-refund PDF does NOT show DE payment terms",
!partialText.includes("Bitte überweise"));
}
// ----------------------------------------------------------------
// Defensive: PARTIALLY_REFUNDED where the refund equals the gross
// (Shopify hasn't flipped to REFUNDED yet) must still classify as
// refunded.
// ----------------------------------------------------------------
console.log("• PARTIALLY_REFUNDED with refund==gross stays 'refunded'");
{
const baseFull = buildAtB2BOrder();
const fullRefundOrder = {
...baseFull,
displayFinancialStatus: "PARTIALLY_REFUNDED",
totalRefundedSet: baseFull.totalPriceSet,
};
const vmFull = composeInvoice({
order: fullRefundOrder, settings: settings as never, invoiceNumber: "RE-1017",
});
assertEq("paymentStatus stays refunded when refund==gross",
vmFull.paymentStatus, "refunded");
assert("requiresPayment still false", vmFull.requiresPayment === false);
}
// ----------------------------------------------------------------
// VOIDED: authorisation cancelled before capture. No money received,
// none owed. Must classify as "voided" (not "unpaid") and suppress
// GiroCode + payment terms.
// ----------------------------------------------------------------
console.log("• Voided order (VOIDED) classifies as voided, no GiroCode");
{
const voidedOrder = { ...buildAtB2BOrder(), displayFinancialStatus: "VOIDED" };
const voidedVm = composeInvoice({
order: voidedOrder, settings: settings as never, invoiceNumber: "RE-1018",
});
assertEq("paymentStatus=voided for VOIDED", voidedVm.paymentStatus, "voided");
assert("requiresPayment=false for voided", voidedVm.requiresPayment === false);
voidedVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const voidedText = await pdfToText(await renderInvoicePdf(voidedVm));
assert("Voided PDF shows the 'Annulliert' status row",
voidedText.includes("Annulliert"));
assert("Voided PDF does NOT show GiroCode caption",
!voidedText.includes("GiroCode"));
assert("Voided PDF does NOT show DE payment terms",
!voidedText.includes("Bitte überweise"));
}
// ----------------------------------------------------------------
// 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),
);
// Informal German tone (du/dein) — make sure no formal "Sie/Ihren" remains
// in the strings we control (footer / signature lines come from settings).
// The PDF intentionally has NO salutation — this is an invoice, not a
// letter. Both formal ("Sehr geehrte …") and informal ("Hallo,") are
// suppressed.
assert("DE PDF has no 'Hallo,' salutation", !deText.includes("Hallo,"));
assert("DE PDF has no 'Sehr geehrte Damen und Herren' salutation", !deText.includes("Sehr geehrte Damen und Herren"));
assert("DE PDF uses informal 'deine Bestellung'", deText.includes("deine Bestellung"));
assert(
"DE PDF payment-terms uses informal 'überweise … für dich'",
deText.includes("Bitte überweise") && deText.includes("für dich"),
);
assert("DE PDF shows payment status row", deText.includes("Zahlstatus"));
assert("DE PDF shows payment status value 'Offen' for PENDING", deText.includes("Offen"));
assert("DE PDF shows payment method row", deText.includes("Zahlart"));
// The Shopify Admin GraphQL API returns the *English* template name for
// built-in manual payment gateways even on German-locale shops — we
// localize it ourselves via i18n.paymentGatewayLabels so the PDF matches
// what the customer saw on the order-confirmation page.
assert("DE PDF localizes 'manual' gateway to 'Manuelle Zahlung'",
deText.includes("Manuelle Zahlung"));
assert("DE PDF no longer shows raw English 'Manual' as gateway label",
!/Zahlart[\s\S]{0,20}Manual\b/.test(deText));
assert("EN PDF shows payment status row", enText.includes("Payment status"));
assert("EN PDF shows payment status value 'Outstanding' for PENDING", enText.includes("Outstanding"));
// Shipment + order-number block.
assert("DE PDF shows order number row 'Bestellnummer'", deText.includes("Bestellnummer"));
assert("DE PDF shows Shopify order name '#1004'", deText.includes("#1004"));
assert("DE PDF shows shipping method row 'Versandart'", deText.includes("Versandart"));
assert("DE PDF shows shipping method value 'Standardversand'", deText.includes("Standardversand"));
assert("DE PDF shows tracking row 'Sendungsnummer'", deText.includes("Sendungsnummer"));
assert("DE PDF shows tracking number", deText.includes("JJD0099887766"));
assert("DE PDF shows shipping line item with prefix", deText.includes("Versand"));
assert("DE PDF shows separate delivery address heading", deText.includes("Lieferadresse"));
assert("DE PDF shows shipping address line", deText.includes("Lagerweg 4"));
assert("EN PDF shows order number row 'Order no.'", enText.includes("Order no."));
assert("EN PDF shows shipping method row 'Shipping method'", enText.includes("Shipping method"));
assert("EN PDF shows tracking row 'Tracking no.'", enText.includes("Tracking no."));
assert("EN PDF shows separate delivery address heading", enText.includes("Shipping address"));
// Order-number suppression: when the invoice number's trailing digits
// match the Shopify order name (default numbering mode), the redundant
// "· Bestellnummer: #1004" suffix should be dropped from the title.
const sameNumVm = composeInvoice({
order, settings: settings as never, invoiceNumber: "RE-1004",
});
sameNumVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const sameNumText = await pdfToText(await renderInvoicePdf(sameNumVm));
assert("PDF suppresses 'Bestellnummer' suffix when invoice# matches order#",
!sameNumText.includes("Bestellnummer"));
assert("PDF still shows the invoice number itself when suppressed",
sameNumText.includes("RE-1004"));
// ----------------------------------------------------------------
// Delivery date follows latest fulfillment, not processedAt
// ----------------------------------------------------------------
console.log("• Delivery date is taken from latest fulfillment");
// The AT B2B fixture has processedAt 2026-04-15 and fulfillment.createdAt
// 2026-05-13 — the composer must pick the fulfillment.
assertEq(
"vm.deliveryDate matches fulfillment.createdAt",
vm.deliveryDate.toISOString().slice(0, 10),
"2026-05-13",
);
// EU/Export variants have no fulfillments, so delivery date == invoice date.
assertEq(
"EU vm.deliveryDate falls back to invoiceDate when unfulfilled",
euVm.deliveryDate.toISOString().slice(0, 10),
euVm.invoiceDate.toISOString().slice(0, 10),
);
// ----------------------------------------------------------------
// Discount: per-line strikethrough + cart code
// ----------------------------------------------------------------
console.log("• Discount (per-line + cart-level)");
const discOrder = buildDiscountedOrder();
const discVm = composeInvoice({
order: discOrder,
settings: settings as never,
invoiceNumber: "RE-1020",
});
assertEq("discountCodes propagated", discVm.discountCodes.join(","), "SUMMER10");
assertNear("discounted unit net (~4.99)", discVm.lines[0].unitPriceNet, 4.99);
assert(
"originalUnitPriceNet populated when discounted differs",
discVm.lines[0].originalUnitPriceNet != null,
);
assertNear(
"originalUnitPriceNet matches pre-discount net (~5.99)",
discVm.lines[0].originalUnitPriceNet ?? 0,
5.99,
);
discVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const discPdf = await renderInvoicePdf(discVm);
const discText = await pdfToText(discPdf);
assert("DE PDF shows discount-code label", discText.includes("Rabattcode"));
assert("DE PDF shows discount code value", discText.includes("SUMMER10"));
const discEnVm = composeInvoice({
order: discOrder,
settings: settings as never,
invoiceNumber: "RE-1021",
forceLanguage: "en",
});
discEnVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const discEnText = await pdfToText(await renderInvoicePdf(discEnVm));
assert("EN PDF shows discount-code label", discEnText.includes("Discount code"));
// ----------------------------------------------------------------
// Pickup: separate shipping address suppressed; method localized
// ----------------------------------------------------------------
console.log("• Local pickup");
const pickupOrder = buildPickupOrder();
const pickupVm = composeInvoice({
order: pickupOrder,
settings: settings as never,
invoiceNumber: "RE-1030",
});
assert("isPickup detected via shippingLine heuristic", pickupVm.isPickup);
assertEq("pickupLocationName propagated from shippingLine.title", pickupVm.pickupLocationName, "Lager Graz");
assert("shippingMethod cleared for pickup (renderer uses pickup row instead)",
pickupVm.shippingMethod == null);
assert(
"separateShippingAddress suppressed for pickup",
pickupVm.separateShippingAddress == null,
);
pickupVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const pickupText = await pdfToText(await renderInvoicePdf(pickupVm));
assert("DE pickup PDF shows 'Abholort' label", pickupText.includes("Abholort"));
assert("DE pickup PDF shows location name", pickupText.includes("Lager Graz"));
assert("DE pickup PDF does NOT show 'Versandart'", !pickupText.includes("Versandart"));
assert(
"DE pickup PDF does NOT render pickup-location address as delivery address",
!pickupText.includes("Lieferadresse"),
);
// EN translation
const pickupEnVm = composeInvoice({
order: pickupOrder,
settings: settings as never,
invoiceNumber: "RE-1031",
forceLanguage: "en",
});
pickupEnVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const pickupEnText = await pdfToText(await renderInvoicePdf(pickupEnVm));
assert("EN pickup PDF shows 'Pick-up location' label", pickupEnText.includes("Pick-up location"));
// Real-world pickup variant: shippingLine has no "pickup" keyword in
// title/code/source — only `deliveryCategory` says it's pickup.
const categoryPickupVm = composeInvoice({
order: buildCategoryOnlyPickupOrder(),
settings: settings as never,
invoiceNumber: "RE-1033",
});
assert("isPickup detected from deliveryCategory alone", categoryPickupVm.isPickup);
assertEq("pickupLocationName from title when category-only",
categoryPickupVm.pickupLocationName, "Lager Graz");
assert("shippingMethod cleared in category-only pickup",
categoryPickupVm.shippingMethod == null);
// Real-world "Shop location" pickup (matches dev order #1032): no
// "pickup" keyword anywhere, deliveryCategory null, shippingAddress null.
// The only signal is `requiresShipping && !shippingAddress`.
const shopLocPickupVm = composeInvoice({
order: buildShopLocationPickupOrder(),
settings: settings as never,
invoiceNumber: "RE-1034",
});
assert("isPickup detected from missing shippingAddress (Shop location rate)",
shopLocPickupVm.isPickup);
assertEq("pickupLocationName from shippingLine.title for Shop location",
shopLocPickupVm.pickupLocationName, "Shop location");
assert("shippingMethod cleared for Shop location pickup",
shopLocPickupVm.shippingMethod == null);
// 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);
});