40ee895719
When a Shopify order has been (partially or fully) refunded the PDF
now mirrors the order-page totals block:
Gesamtbetrag brutto 629,95 EUR
Zurückerstattet -629,95 EUR
Offener Betrag 0,00 EUR
So the customer immediately sees that nothing is owed any more, even
though the original invoice gross stays unchanged for tax-document
correctness (the refund is itemised as a separate row, not subtracted
from the line totals).
Plumbing:
- GraphQL: added `totalRefundedSet` to OrderForInvoice query.
- RawOrderForInvoice: new optional `totalRefundedSet` field
(null for drafts/offers — they never have refunds).
- InvoiceViewModel: new `refundedAmount: number` (gross, in the
same currency as `totals.gross`). Always present, 0 for storno
and offer documents and for non-refunded invoices.
- composeInvoice parses the gross refund out of `totalRefundedSet`
(defensive parseFloat, clamped to >= 0).
- InvoiceDocument renders the two extra rows under `grossTotal`
only when `refundedAmount > 0`. Uses the existing total-row
styles for visual consistency.
- i18n: added `refundedLabel` ("Zurückerstattet" / "Refunded") and
`outstandingLabel` ("Offener Betrag" / "Outstanding amount") to
both languages.
Verification: render-sample fixture now mirrors the full gross as
refunded and asserts the PDF text contains "Zurückerstattet",
"Offener Betrag", and "0,00 EUR" as the final outstanding row, on
top of the previous suppressions (no GiroCode, no payment terms).
tsc / smoke / tests / build all green.
706 lines
23 KiB
TypeScript
706 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}>{t.outstandingLabel}</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(" ");
|
|
}
|