/* 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 (
{invoice.kind === "storno" && (
{t.stornoInvoice}
{invoice.cancelsNumber ? ` — ${t.stornoReference(invoice.cancelsNumber)}` : ""}
)}
{invoice.issuer.logoDataUrl ? (
) : (
)}
{invoice.kind === "offer" ? t.offerDate : t.invoiceDate}
{formatDate(invoice.invoiceDate, invoice.language)}
{invoice.kind !== "offer" && (
{t.deliveryDate}
{formatDate(invoice.deliveryDate, invoice.language)}
)}
{invoice.recipientVatId ? (
{t.customerVatId}
{invoice.recipientVatId}
) : null}
{invoice.kind === "invoice" && invoice.paymentGatewayNames.length > 0 && (
{t.paymentMethodLabel}
{invoice.paymentGatewayNames.map((n) => prettifyGatewayName(n, t.paymentGatewayLabels)).join(", ")}
)}
{invoice.kind === "invoice" && (
{t.paymentStatusLabel}
{getPaymentStatusLabel(invoice.paymentStatus, t)}
)}
{invoice.kind === "invoice" && invoice.discountCodes.length > 0 ? (
{t.discountCodeLabel}
{invoice.discountCodes.join(", ")}
) : null}
{invoice.kind === "invoice" && invoice.isPickup ? (
{t.pickupLocationLabel}
{invoice.pickupLocationName ?? t.pickupLabel}
) : invoice.kind === "invoice" && invoice.shippingMethod ? (
{t.shippingMethodLabel}
{invoice.shippingMethod}
) : null}
{invoice.kind === "invoice" && invoice.tracking.map((tr) => (
{t.trackingLabel}
{tr.company ? ` (${tr.company})` : ""}
{tr.url ? (
{tr.number}
) : (
{tr.number}
)}
))}
{senderInline(invoice.issuer)}
{invoice.separateShippingAddress ? (
{t.shippingAddressHeading}
) : null}
{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}`
: ""}
{/* 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. */}
{t.thankYouLine}
{t.position}
{t.description}
{t.quantity}
{t.unitPrice}
{t.totalPrice}
{invoice.lines.map((line) => (
))}
{t.netTotal}
{formatMoney(invoice.totals.net, cur, invoice.language)}
{invoice.totals.vatBreakdown.map((v) => (
{t.vatLine(formatTaxRate(v.ratePct, invoice.language))}
{formatMoney(v.tax, cur, invoice.language)}
))}
{t.grossTotal}
{formatMoney(invoice.totals.gross, cur, invoice.language)}
{invoice.refundedAmount > 0 && (
<>
{t.refundedLabel}
{formatMoney(-invoice.refundedAmount, cur, invoice.language)}
{t.outstandingLabel}
{formatMoney(invoice.totals.gross - invoice.refundedAmount, cur, invoice.language)}
>
)}
{invoice.notices.length > 0 && (
{invoice.notices.map((n) => (
{n.kind === "reverseCharge" && t.reverseChargeNotice}
{n.kind === "export" && t.exportNotice}
{n.kind === "kleinunternehmer" && t.kleinunternehmerNotice}
))}
)}
{invoice.kind === "offer" ? (
{invoice.dueDate ? t.offerValidUntil(formatDate(invoice.dueDate, invoice.language)) : null}
) : invoice.requiresPayment && (
{invoice.dueDate
? t.paymentTerms(
Math.max(0, Math.round((invoice.dueDate.getTime() - invoice.invoiceDate.getTime()) / 86400000)),
formatDate(invoice.dueDate, invoice.language),
)
: t.paymentTermsImmediate}
)}
{invoice.giroCodePngDataUrl && invoice.requiresPayment && (
{t.giroCodeCaption}
{t.recipientLabel}: {[invoice.issuer.companyName, invoice.issuer.legalForm].filter(Boolean).join(" ")}
{invoice.issuer.bankName ? (
{t.bankLabel}: {invoice.issuer.bankName}
) : null}
{t.ibanLabel}: {invoice.issuer.iban}
{invoice.issuer.bic ? (
{t.bicLabel}: {invoice.issuer.bic}
) : null}
{t.amountLabel}: {formatMoney(invoice.totals.gross, cur, invoice.language)}
{t.referenceLabel}: {invoice.number}
)}
{t.closing}
`${pageNumber}/${totalPages}`}
fixed
/>
);
}
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 (
{lines.map((l, i) => (
{l}
))}
);
}
function LineRow({
line,
language,
currency,
}: {
line: InvoiceLine;
language: InvoiceLanguage;
currency: string;
}) {
const t = getStrings(language);
return (
{line.position}
{line.imageDataUrl ? (
) : (
)}
{line.title}
{line.sku ? SKU: {line.sku} : null}
{formatQuantity(line.quantity, t.pieceUnit, language)}
{line.originalUnitPriceNet != null ? (
{formatMoney(line.originalUnitPriceNet, currency, language)}
) : null}
{formatMoney(line.unitPriceNet, currency, language)}
{formatMoney(line.totalNet, currency, language)}
);
}
function Footer({ issuer, language }: { issuer: IssuerData; language: InvoiceLanguage }) {
const t = getStrings(language);
return (
{t.addressHeading}
{[issuer.companyName, issuer.legalForm].filter(Boolean).join(" ")}
{issuer.addressLine1 ? {issuer.addressLine1} : null}
{issuer.addressLine2 ? {issuer.addressLine2} : null}
{[issuer.postalCode, issuer.city].filter(Boolean).join(" ")}
{issuer.countryCode ? {issuer.countryCode} : null}
{t.contactHeading}
{issuer.phone ? (
{t.phoneLabel}: {issuer.phone}
) : null}
{issuer.email ? (
{t.emailLabel}: {issuer.email}
) : null}
{issuer.website ? (
{t.webLabel}: {issuer.website}
) : null}
{t.legalHeading}
{issuer.registrationCourt ? (
{t.legalCourtLabel}: {issuer.registrationCourt}
) : null}
{issuer.registrationNo ? {t.fnLabel}: {issuer.registrationNo} : null}
{issuer.vatId ? {t.vatIdLabel}: {issuer.vatId} : null}
{issuer.taxNumber ? {t.taxNumberLabel}: {issuer.taxNumber} : null}
{issuer.ownerName ? {t.ownerLabel}: {issuer.ownerName} : null}
{t.bankHeading}
{issuer.bankName ? {t.bankLabel}: {issuer.bankName} : null}
{issuer.iban ? {t.ibanLabel}: {issuer.iban} : null}
{issuer.bic ? {t.bicLabel}: {issuer.bic} : null}
{pickFooterNote(issuer, language) ? {pickFooterNote(issuer, language)} : null}
);
}
/**
* 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 `` — 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 {
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(" ");
}