91c1a74c1b
Two related bugs surfaced when a paid order was partially refunded
(Shopify flips `displayFinancialStatus` to PARTIALLY_REFUNDED as soon
as *any* refund is posted, even a small one):
1. The status row showed "Erstattet" / "Refunded" even though the
customer paid in full and the merchant kept the difference. The
correct status is "Bezahlt" / "Paid" — only when the refund equals
(or, defensively, exceeds) the gross is the order genuinely
refunded.
2. The final row beneath the new refund block was labelled "Offener
Betrag" / "Outstanding amount", falsely suggesting the customer
still owes the kept portion. For an order that has been refunded
but is no longer owing anything, that row is just the final amount
the merchant kept — "Endbetrag" / "Total".
Truth table now implemented:
displayFinancialStatus | refunded | paymentStatus | final-row label
-----------------------+--------------+---------------+-----------------
PAID | 0 | paid | (no refund rows)
PAID | >0 | paid | Endbetrag
PARTIALLY_REFUNDED | < gross | paid (NEW) | Endbetrag (NEW)
PARTIALLY_REFUNDED | == gross | refunded | Endbetrag
REFUNDED | == gross | refunded | Endbetrag
PARTIALLY_PAID | 0 | partial | (no refund rows)
PARTIALLY_PAID | >0 (exotic) | partial | Offener Betrag
PENDING/AUTHORIZED/etc | 0 | unpaid | (no refund rows)
storno / offer | 0 (forced) | n/a | n/a
Implementation:
- composeInvoice.ts: after computing refundedAmount, reclassify
paymentStatus="refunded" → "paid" when 0 < refundedAmount <
totals.gross. requiresPayment is derived from paymentStatus, so
it correctly stays false for partial-refund-on-paid (no GiroCode,
no payment terms — nothing is owed).
- i18n.ts: new `finalAmountLabel` ("Endbetrag" / "Total") in both
languages.
- InvoiceDocument.tsx: the final-row label now picks
outstandingLabel vs. finalAmountLabel based on requiresPayment,
so PARTIALLY_PAID with a refund still says "Offener Betrag"
while PARTIALLY_REFUNDED says "Endbetrag".
Verification: render-sample now runs four refund scenarios — paid +
no refund (regression guard), full refund (status=Erstattet, final
row=Endbetrag 0,00 EUR), partial refund on a paid order (status=
Bezahlt, final row=Endbetrag, no Erstattet), and PARTIALLY_REFUNDED
with refund==gross (status stays refunded). tsc / smoke / tests /
build all green.
708 lines
23 KiB
TypeScript
708 lines
23 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>
|
|
|
|
{/* 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(" ");
|
|
}
|