Files
linumiq-invoice/app/services/invoice/pdf/InvoiceDocument.tsx
T
Gerhard Scheikl 2a4a7fd983 fix(invoice): unify customer-facing remittance reference with the printed invoice number
Two related fixes around the order/invoice number:

1) The thank-you page and the customer-account order page were showing
   the bare Shopify order name (e.g. '#1034') as the payment reference,
   while the PDF (and its GiroCode QR) used the canonical invoice
   number (e.g. 'RE-1034'). Banks treat each unique reference as a
   separate payment, and several reject the '#' character outright \u2014
   so customers who pasted the thank-you reference into their banking
   app ended up with a payment the shop couldn't reconcile.

   New shared helper resolveOrderRemittance() (services/invoice/
   remittance.server.ts) returns the single source of truth for the
   reference: latest non-cancelled Invoice row for the order, falling
   back to '${prefix}${orderNumber}' when no PDF has been generated yet.
   Both /api/public/payment-info and /api/public/girocode.png now route
   through it, so the thank-you page, the customer-account page and the
   GiroCode QR are guaranteed to match the PDF byte-for-byte.

2) Drop the redundant '\u00b7 Bestellnummer: #1004' suffix from the PDF
   title when the invoice number's trailing digits already match the
   Shopify order name (default 'order_number' numbering mode). In that
   mode the two strings carry identical numeric content and the suffix
   only adds noise; sequential mode (RE-7 vs #1004) keeps the suffix.

- New smoke assertion verifies the suppression triggers on
  invoiceNumber='RE-1004' + orderName='#1004' and that the invoice
  number itself is still shown.
- Both endpoints now also query 'Order.number' (already covered by
  read_orders) so the fallback path can build the prefix+order-number
  string without requiring the Invoice row.
2026-05-15 15:51:10 +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(prettifyGatewayName).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`) into something a customer can read on the invoice. We
* keep this purely cosmetic — the underlying value is preserved for any
* downstream automation.
*/
function prettifyGatewayName(name: string): string {
const known: Record<string, string> = {
manual: "Manual",
bogus: "Bogus (Test)",
shopify_payments: "Shopify Payments",
paypal: "PayPal",
cash_on_delivery: "Cash on delivery",
"cash-on-delivery": "Cash on delivery",
};
const key = name.trim().toLowerCase();
if (known[key]) return known[key];
// Replace separators and title-case each word.
return key
.split(/[_\s-]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}