Files
linumiq-invoice/app/services/invoice/pdf/InvoiceDocument.tsx
T
Gerhard Scheikl c24d567ae4 fix(invoice): localize Shopify payment-gateway names on the PDF
Customer reported that on the German invoice PDF the payment method
showed up as 'Zahlart: Bank Deposit' while the order-confirmation page
on the storefront localized it correctly to 'Bank\u00fc1berweisung'. Cause:
Shopify's Admin GraphQL API only ever returns the *English* template
name in 'Order.paymentGatewayNames', even when the shop / order locale
is German \u2014 the localization happens client-side at checkout but is
NOT exposed via the API. So the PDF and the storefront naturally
diverge unless we mirror the translations ourselves.

Fix: introduce a per-language 'paymentGatewayLabels' map on
'InvoiceStrings' covering the built-in Shopify manual-payment
templates (Bank Deposit, Money Order, Cash on Delivery) plus the
standard non-manual gateways (Shopify Payments, PayPal, Klarna,
Sofort, Giropay, Bogus). 'prettifyGatewayName' now takes this map
and looks up the normalized key (lowercased, separators collapsed),
falling back to a title-cased rendering for unknown values.

DE result: 'Zahlart: Bank\u00fc1berweisung', 'Manuelle Zahlung', 'Nachnahme'.
EN result: unchanged.

New smoke assertions verify the DE PDF now shows 'Manuelle Zahlung'
for the AT B2B fixture's 'manual' gateway and that the raw English
'Manual' no longer appears next to the 'Zahlart' label.

Note on other Shopify-sourced strings on the PDF: 'shippingLine.title'
(e.g. 'Standard') is similarly merchant/locale-dependent, but unlike
gateway names it's fully customizable per-shop in Shopify Admin and
is not a fixed enum we can translate \u2014 left untouched pending an
explicit report. Product titles, discount codes and addresses are
likewise merchant-/customer-supplied and flow through verbatim by
design.
2026-05-15 16:08:19 +02:00

668 lines
22 KiB
TypeScript

/* eslint-disable react/no-unknown-property */
import {
Document,
Image,
Link,
Page,
StyleSheet,
Text,
View,
} from "@react-pdf/renderer";
import React from "react";
import { formatDate, formatMoney, formatQuantity, formatTaxRate } from "../format";
import { getStrings, paymentStatusLabel as getPaymentStatusLabel } from "../i18n";
import type { InvoiceLanguage } from "../i18n";
import type { InvoiceViewModel, InvoiceLine, IssuerData, RecipientData } from "../types";
// Brand blue chosen to roughly match the reference invoice. This is not
// pixel-perfect; merchants can tweak via a future setting if needed.
const BRAND_BLUE = "#1E8FCD";
const TEXT_DARK = "#1F2933";
const TEXT_MUTED = "#6B7280";
const TABLE_BORDER = "#E5E7EB";
const styles = StyleSheet.create({
page: {
paddingTop: 40,
paddingBottom: 110, // leaves room for fixed footer
paddingHorizontal: 40,
fontSize: 9,
fontFamily: "Helvetica",
color: TEXT_DARK,
lineHeight: 1.4,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 24,
},
logo: {
maxHeight: 50,
maxWidth: 180,
objectFit: "contain",
},
senderLine: {
fontSize: 7,
color: TEXT_MUTED,
marginBottom: 4,
textDecoration: "underline",
},
recipientBlock: {
width: "55%",
},
recipientBlockFull: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 20,
},
recipientName: {
fontFamily: "Helvetica-Bold",
fontSize: 10,
},
shippingAddressBlock: {
marginTop: 10,
paddingTop: 6,
borderTopWidth: 0.5,
borderTopColor: TABLE_BORDER,
},
shippingAddressHeading: {
fontFamily: "Helvetica-Bold",
color: BRAND_BLUE,
fontSize: 8,
marginBottom: 2,
},
metaBlock: {
width: "45%",
},
metaBlockHeader: {
width: "50%",
},
metaTable: {
flexDirection: "column",
},
metaRow: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 2,
},
metaLabel: {
color: TEXT_MUTED,
},
metaValue: {
fontFamily: "Helvetica-Bold",
},
unitOriginalStrike: {
color: TEXT_MUTED,
textDecoration: "line-through",
fontSize: 7,
},
invoiceNumberBig: {
color: BRAND_BLUE,
fontFamily: "Helvetica-Bold",
fontSize: 14,
},
title: {
color: BRAND_BLUE,
fontFamily: "Helvetica-Bold",
fontSize: 18,
marginTop: 20,
marginBottom: 12,
},
paragraph: {
marginBottom: 6,
},
table: {
marginTop: 10,
borderTopWidth: 0,
},
tableHeader: {
flexDirection: "row",
backgroundColor: BRAND_BLUE,
color: "#FFFFFF",
fontFamily: "Helvetica-Bold",
paddingVertical: 6,
paddingHorizontal: 4,
},
tableRow: {
flexDirection: "row",
borderBottomWidth: 0.5,
borderBottomColor: TABLE_BORDER,
paddingVertical: 6,
paddingHorizontal: 4,
},
colPos: { width: "8%" },
colDescription: { width: "44%" },
colQty: { width: "16%", textAlign: "right" },
colUnit: { width: "16%", textAlign: "right" },
colTotal: { width: "16%", textAlign: "right" },
descriptionCell: {
flexDirection: "row",
alignItems: "flex-start",
gap: 6,
},
productIcon: {
width: 28,
height: 28,
objectFit: "contain",
borderWidth: 0.5,
borderColor: TABLE_BORDER,
borderRadius: 2,
},
productIconPlaceholder: {
width: 28,
height: 28,
},
descriptionText: {
flex: 1,
},
itemTitle: {
fontFamily: "Helvetica-Bold",
},
itemSku: {
color: TEXT_MUTED,
fontSize: 7,
marginTop: 1,
},
totalsBlock: {
marginTop: 10,
alignSelf: "flex-end",
width: "50%",
// Match the table rows' horizontal padding so the right-aligned amounts
// line up perfectly with the "Total" column above.
paddingHorizontal: 4,
},
totalRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 3,
},
totalLabel: {
color: TEXT_DARK,
},
totalLabelBlue: {
color: BRAND_BLUE,
fontFamily: "Helvetica-Bold",
},
totalValue: {
textAlign: "right",
},
totalValueBoldBlue: {
textAlign: "right",
color: BRAND_BLUE,
fontFamily: "Helvetica-Bold",
},
noticeBlock: {
marginTop: 10,
padding: 6,
backgroundColor: "#F3F4F6",
fontSize: 8,
},
giroBlock: {
marginTop: 20,
flexDirection: "row",
alignItems: "flex-start",
gap: 10,
},
giroImage: {
width: 90,
height: 90,
},
giroCaption: {
fontFamily: "Helvetica-Bold",
color: BRAND_BLUE,
fontSize: 9,
marginBottom: 4,
},
giroDetails: {
fontSize: 8,
color: TEXT_DARK,
lineHeight: 1.4,
},
closing: {
marginTop: 24,
},
footer: {
position: "absolute",
bottom: 30,
left: 40,
right: 40,
borderTopWidth: 0.5,
borderTopColor: BRAND_BLUE,
paddingTop: 6,
flexDirection: "row",
justifyContent: "space-between",
fontSize: 7,
color: TEXT_DARK,
},
footerCol: { width: "23%" },
footerHeading: {
fontFamily: "Helvetica-Bold",
color: BRAND_BLUE,
fontSize: 7,
marginBottom: 3,
},
pageIndicator: {
position: "absolute",
bottom: 12,
right: 40,
fontSize: 7,
color: TEXT_MUTED,
},
stornoBanner: {
backgroundColor: "#B91C1C",
color: "#FFFFFF",
fontFamily: "Helvetica-Bold",
padding: 6,
marginBottom: 10,
fontSize: 11,
textAlign: "center",
},
});
interface DocProps {
invoice: InvoiceViewModel;
}
export function InvoiceDocument({ invoice }: DocProps) {
const t = getStrings(invoice.language);
const cur = invoice.currency;
return (
<Document
title={`${invoice.kind === "offer" ? t.offer : t.invoice} ${invoice.number}`}
author={invoice.issuer.companyName}
creator="LinumIQ Invoice"
>
<Page size="A4" style={styles.page}>
{invoice.kind === "storno" && (
<Text style={styles.stornoBanner}>
{t.stornoInvoice}
{invoice.cancelsNumber ? `${t.stornoReference(invoice.cancelsNumber)}` : ""}
</Text>
)}
<View style={styles.headerRow}>
{invoice.issuer.logoDataUrl ? (
<Image src={invoice.issuer.logoDataUrl} style={styles.logo} />
) : (
<View />
)}
<View style={styles.metaBlockHeader}>
<View style={styles.metaTable}>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerDate : t.invoiceDate}</Text>
<Text style={styles.metaValue}>{formatDate(invoice.invoiceDate, invoice.language)}</Text>
</View>
{invoice.kind !== "offer" && (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.deliveryDate}</Text>
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate, invoice.language)}</Text>
</View>
)}
{invoice.recipientVatId ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.customerVatId}</Text>
<Text style={styles.metaValue}>{invoice.recipientVatId}</Text>
</View>
) : null}
{invoice.kind === "invoice" && invoice.paymentGatewayNames.length > 0 && (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.paymentMethodLabel}</Text>
<Text style={styles.metaValue}>
{invoice.paymentGatewayNames.map((n) => prettifyGatewayName(n, t.paymentGatewayLabels)).join(", ")}
</Text>
</View>
)}
{invoice.kind === "invoice" && (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.paymentStatusLabel}</Text>
<Text style={styles.metaValue}>
{getPaymentStatusLabel(invoice.paymentStatus, t)}
</Text>
</View>
)}
{invoice.kind === "invoice" && invoice.discountCodes.length > 0 ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.discountCodeLabel}</Text>
<Text style={styles.metaValue}>{invoice.discountCodes.join(", ")}</Text>
</View>
) : null}
{invoice.kind === "invoice" && invoice.isPickup ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.pickupLocationLabel}</Text>
<Text style={styles.metaValue}>
{invoice.pickupLocationName ?? t.pickupLabel}
</Text>
</View>
) : invoice.kind === "invoice" && invoice.shippingMethod ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.shippingMethodLabel}</Text>
<Text style={styles.metaValue}>{invoice.shippingMethod}</Text>
</View>
) : null}
{invoice.kind === "invoice" && invoice.tracking.map((tr) => (
<View key={tr.number} style={styles.metaRow}>
<Text style={styles.metaLabel}>
{t.trackingLabel}
{tr.company ? ` (${tr.company})` : ""}
</Text>
{tr.url ? (
<Link src={tr.url} style={styles.metaValue}>{tr.number}</Link>
) : (
<Text style={styles.metaValue}>{tr.number}</Text>
)}
</View>
))}
</View>
</View>
</View>
<View style={styles.recipientBlockFull}>
<View style={styles.recipientBlock}>
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
<Recipient recipient={invoice.recipient} />
</View>
{invoice.separateShippingAddress ? (
<View style={styles.recipientBlock}>
<Text style={styles.shippingAddressHeading}>{t.shippingAddressHeading}</Text>
<Recipient recipient={invoice.separateShippingAddress} />
</View>
) : null}
</View>
<Text style={styles.title}>
{invoice.kind === "storno"
? t.stornoInvoice
: invoice.kind === "offer"
? t.offer
: t.invoice}{" "}
Nr. {invoice.number}
{invoice.kind === "invoice"
&& invoice.orderName
// Suppress the redundant "· Bestellnummer: #1004" suffix when
// the invoice number is just the Shopify order number with the
// configured prefix (default numbering mode) — they'd carry
// identical trailing digits and only confuse the customer.
&& invoice.number.replace(/\D+/g, "") !== invoice.orderName.replace(/\D+/g, "")
? ` · ${t.orderNumberLabel}: ${invoice.orderName}`
: ""}
</Text>
<Text style={styles.paragraph}>{t.salutationGeneric}</Text>
<Text style={styles.paragraph}>{t.thankYouLine}</Text>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={styles.colPos}>{t.position}</Text>
<Text style={styles.colDescription}>{t.description}</Text>
<Text style={styles.colQty}>{t.quantity}</Text>
<Text style={styles.colUnit}>{t.unitPrice}</Text>
<Text style={styles.colTotal}>{t.totalPrice}</Text>
</View>
{invoice.lines.map((line) => (
<LineRow key={line.position} line={line} language={invoice.language} currency={cur} />
))}
</View>
<View style={styles.totalsBlock}>
<View style={styles.totalRow}>
<Text style={styles.totalLabelBlue}>{t.netTotal}</Text>
<Text style={[styles.totalValue, { color: BRAND_BLUE, fontFamily: "Helvetica-Bold" }]}>
{formatMoney(invoice.totals.net, cur, invoice.language)}
</Text>
</View>
{invoice.totals.vatBreakdown.map((v) => (
<View key={`vat-${v.ratePct}`} style={styles.totalRow}>
<Text style={styles.totalLabel}>{t.vatLine(formatTaxRate(v.ratePct, invoice.language))}</Text>
<Text style={styles.totalValue}>{formatMoney(v.tax, cur, invoice.language)}</Text>
</View>
))}
<View style={[styles.totalRow, { borderTopWidth: 0.5, borderTopColor: TABLE_BORDER, marginTop: 4, paddingTop: 4 }]}>
<Text style={styles.totalLabelBlue}>{t.grossTotal}</Text>
<Text style={styles.totalValueBoldBlue}>
{formatMoney(invoice.totals.gross, cur, invoice.language)}
</Text>
</View>
</View>
{invoice.notices.length > 0 && (
<View style={styles.noticeBlock}>
{invoice.notices.map((n) => (
<Text key={n.kind}>
{n.kind === "reverseCharge" && t.reverseChargeNotice}
{n.kind === "export" && t.exportNotice}
{n.kind === "kleinunternehmer" && t.kleinunternehmerNotice}
</Text>
))}
</View>
)}
{invoice.kind === "offer" ? (
<Text style={[styles.paragraph, { marginTop: 16 }]}>
{invoice.dueDate ? t.offerValidUntil(formatDate(invoice.dueDate, invoice.language)) : null}
</Text>
) : !invoice.paid && (
<Text style={[styles.paragraph, { marginTop: 16 }]}>
{invoice.dueDate
? t.paymentTerms(
Math.max(0, Math.round((invoice.dueDate.getTime() - invoice.invoiceDate.getTime()) / 86400000)),
formatDate(invoice.dueDate, invoice.language),
)
: t.paymentTermsImmediate}
</Text>
)}
{invoice.giroCodePngDataUrl && !invoice.paid && (
<View style={styles.giroBlock}>
<Image src={invoice.giroCodePngDataUrl} style={styles.giroImage} />
<View>
<Text style={styles.giroCaption}>{t.giroCodeCaption}</Text>
<Text style={styles.giroDetails}>
{t.recipientLabel}: {[invoice.issuer.companyName, invoice.issuer.legalForm].filter(Boolean).join(" ")}
</Text>
{invoice.issuer.bankName ? (
<Text style={styles.giroDetails}>{t.bankLabel}: {invoice.issuer.bankName}</Text>
) : null}
<Text style={styles.giroDetails}>{t.ibanLabel}: {invoice.issuer.iban}</Text>
{invoice.issuer.bic ? (
<Text style={styles.giroDetails}>{t.bicLabel}: {invoice.issuer.bic}</Text>
) : null}
<Text style={styles.giroDetails}>
{t.amountLabel}: {formatMoney(invoice.totals.gross, cur, invoice.language)}
</Text>
<Text style={styles.giroDetails}>
{t.referenceLabel}: {invoice.number}
</Text>
</View>
</View>
)}
<View style={styles.closing}>
<Text>{t.closing}</Text>
</View>
<Footer issuer={invoice.issuer} language={invoice.language} />
<Text
style={styles.pageIndicator}
render={({ pageNumber, totalPages }) => `${pageNumber}/${totalPages}`}
fixed
/>
</Page>
</Document>
);
}
function senderInline(issuer: IssuerData): string {
return [
[issuer.companyName, issuer.legalForm].filter(Boolean).join(" "),
issuer.addressLine1,
[issuer.postalCode, issuer.city].filter(Boolean).join(" "),
]
.filter(Boolean)
.join(" - ");
}
function Header(_args: { issuer: IssuerData }) {
// Deprecated: header rendering is now inlined in InvoiceDocument so the
// logo and meta block can share a single row at the top of the page.
return null;
}
void Header;
function Recipient({ recipient }: { recipient: RecipientData }) {
const lines: string[] = [];
if (recipient.company) lines.push(recipient.company);
if (recipient.name && recipient.name !== recipient.company) lines.push(recipient.name);
if (recipient.addressLine1) lines.push(recipient.addressLine1);
if (recipient.addressLine2) lines.push(recipient.addressLine2);
const cityLine = [recipient.postalCode, recipient.city].filter(Boolean).join(" ");
if (cityLine) lines.push(cityLine);
if (recipient.countryCode) lines.push(recipient.countryCode);
return (
<View>
{lines.map((l, i) => (
<Text key={i} style={i === 0 ? styles.recipientName : undefined}>
{l}
</Text>
))}
</View>
);
}
function LineRow({
line,
language,
currency,
}: {
line: InvoiceLine;
language: InvoiceLanguage;
currency: string;
}) {
const t = getStrings(language);
return (
<View style={styles.tableRow}>
<Text style={styles.colPos}>{line.position}</Text>
<View style={[styles.colDescription, styles.descriptionCell]}>
{line.imageDataUrl ? (
<Image src={line.imageDataUrl} style={styles.productIcon} />
) : (
<View style={styles.productIconPlaceholder} />
)}
<View style={styles.descriptionText}>
<Text style={styles.itemTitle}>{line.title}</Text>
{line.sku ? <Text style={styles.itemSku}>SKU: {line.sku}</Text> : null}
</View>
</View>
<Text style={styles.colQty}>{formatQuantity(line.quantity, t.pieceUnit, language)}</Text>
<View style={styles.colUnit}>
{line.originalUnitPriceNet != null ? (
<Text style={styles.unitOriginalStrike}>
{formatMoney(line.originalUnitPriceNet, currency, language)}
</Text>
) : null}
<Text>{formatMoney(line.unitPriceNet, currency, language)}</Text>
</View>
<Text style={styles.colTotal}>{formatMoney(line.totalNet, currency, language)}</Text>
</View>
);
}
function Footer({ issuer, language }: { issuer: IssuerData; language: InvoiceLanguage }) {
const t = getStrings(language);
return (
<View style={styles.footer} fixed>
<View style={styles.footerCol}>
<Text style={styles.footerHeading}>{t.addressHeading}</Text>
<Text>{[issuer.companyName, issuer.legalForm].filter(Boolean).join(" ")}</Text>
{issuer.addressLine1 ? <Text>{issuer.addressLine1}</Text> : null}
{issuer.addressLine2 ? <Text>{issuer.addressLine2}</Text> : null}
<Text>{[issuer.postalCode, issuer.city].filter(Boolean).join(" ")}</Text>
{issuer.countryCode ? <Text>{issuer.countryCode}</Text> : null}
</View>
<View style={styles.footerCol}>
<Text style={styles.footerHeading}>{t.contactHeading}</Text>
{issuer.phone ? <Text>{t.phoneLabel}: {issuer.phone}</Text> : null}
{issuer.email ? (
<Text>
{t.emailLabel}: <Link src={`mailto:${issuer.email}`}>{issuer.email}</Link>
</Text>
) : null}
{issuer.website ? (
<Text>
{t.webLabel}: <Link src={normaliseWebUrl(issuer.website)}>{issuer.website}</Link>
</Text>
) : null}
</View>
<View style={styles.footerCol}>
<Text style={styles.footerHeading}>{t.legalHeading}</Text>
{issuer.registrationCourt ? (
<Text>{t.legalCourtLabel}: {issuer.registrationCourt}</Text>
) : null}
{issuer.registrationNo ? <Text>{t.fnLabel}: {issuer.registrationNo}</Text> : null}
{issuer.vatId ? <Text>{t.vatIdLabel}: {issuer.vatId}</Text> : null}
{issuer.taxNumber ? <Text>{t.taxNumberLabel}: {issuer.taxNumber}</Text> : null}
{issuer.ownerName ? <Text>{t.ownerLabel}: {issuer.ownerName}</Text> : null}
</View>
<View style={styles.footerCol}>
<Text style={styles.footerHeading}>{t.bankHeading}</Text>
{issuer.bankName ? <Text>{t.bankLabel}: {issuer.bankName}</Text> : null}
{issuer.iban ? <Text>{t.ibanLabel}: {issuer.iban}</Text> : null}
{issuer.bic ? <Text>{t.bicLabel}: {issuer.bic}</Text> : null}
{pickFooterNote(issuer, language) ? <Text>{pickFooterNote(issuer, language)}</Text> : null}
</View>
</View>
);
}
/**
* Picks the footer note for the rendered language. English falls back to the
* German `footerNote` when `footerNoteEn` is empty (so existing single-language
* setups keep working). German always uses `footerNote`.
*/
function pickFooterNote(issuer: { footerNote: string; footerNoteEn: string }, language: InvoiceLanguage): string {
if (language === "en") {
return issuer.footerNoteEn?.trim() || issuer.footerNote || "";
}
return issuer.footerNote || "";
}
/**
* Make a website URL safe for `<Link src="...">` — adds an `https://` scheme
* when the user typed something like `linumiq.com` or `www.linumiq.com`.
*/
function normaliseWebUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) return trimmed;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed.replace(/^\/\//, "")}`;
}
/**
* Turn a Shopify payment-gateway machine name (e.g. `shopify_payments`,
* `manual`, `bogus`) or a built-in manual-payment template name (e.g.
* `Bank Deposit`, `Money Order`) into the localized customer-facing label
* shown on the invoice. The Shopify Admin API only exposes English
* template names — see `InvoiceStrings.paymentGatewayLabels` for the
* rationale.
*
* Lookup is keyed on the *normalized* name (lowercased, separators
* collapsed). Unknown gateways fall back to a title-cased rendering
* of the raw name so we never silently print empty meta-rows.
*/
function prettifyGatewayName(
name: string,
labels: Record<string, string>,
): string {
const key = name.trim().toLowerCase().replace(/[_\-]+/g, " ").replace(/\s+/g, " ");
if (labels[key]) return labels[key];
return key
.split(" ")
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}