723 lines
24 KiB
TypeScript
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(" ");
|
|
}
|