Files
linumiq-invoice/app/services/invoice/pdf/InvoiceDocument.tsx
T
Gerhard Scheikl 01b4734477 security hardening
2026-05-31 09:35:31 +02:00

723 lines
24 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";
/**
* Returns true only for syntactically valid http(s) URLs. Used to gate
* carrier/fulfillment-supplied tracking URLs before embedding them as PDF
* link annotations, so non-http schemes (javascript:, file:, data:, …) can't
* be smuggled into the document.
*/
function isHttpUrl(value: string): boolean {
try {
const u = new URL(value);
return u.protocol === "https:" || u.protocol === "http:";
} catch {
return false;
}
}
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 && isHttpUrl(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>
{/* No salutation here on purpose — this is an invoice, not a
* letter. Dropping the line saves vertical space and avoids
* the formal/informal "Hallo," vs "Dear Sir or Madam" framing
* that doesn't belong on a tax document. */}
<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>
{invoice.refundedAmount > 0 && (
<>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{t.refundedLabel}</Text>
<Text style={styles.totalValue}>
{formatMoney(-invoice.refundedAmount, cur, invoice.language)}
</Text>
</View>
<View style={[styles.totalRow, { borderTopWidth: 0.5, borderTopColor: TABLE_BORDER, marginTop: 4, paddingTop: 4 }]}>
<Text style={styles.totalLabelBlue}>
{invoice.requiresPayment ? t.outstandingLabel : t.finalAmountLabel}
</Text>
<Text style={styles.totalValueBoldBlue}>
{formatMoney(invoice.totals.gross - invoice.refundedAmount, 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.requiresPayment && (
<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.requiresPayment && (
<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}: <Link src={toTelUrl(issuer.phone)}>{issuer.phone}</Link>
</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(/^\/\//, "")}`;
}
/**
* Build a `tel:` URL from a free-form phone string. RFC 3966 allows only
* digits and a leading `+`, so we strip everything else (spaces, parens,
* dashes, slashes, dots, internal letters). The display string above the
* link keeps the human-readable formatting.
*/
function toTelUrl(phone: string): string {
const cleaned = phone.replace(/[^\d+]/g, "");
// Keep only a single leading '+' if present.
const normalized = cleaned.startsWith("+")
? "+" + cleaned.slice(1).replace(/\+/g, "")
: cleaned.replace(/\+/g, "");
return `tel:${normalized}`;
}
/**
* 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(" ");
}