c5b6bfc20d
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.
899 lines
40 KiB
TypeScript
899 lines
40 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|