many updates :-)

This commit is contained in:
Gerhard Scheikl
2026-05-08 10:40:19 +02:00
parent 5b2aa5d62b
commit 770c6fd16a
16 changed files with 876 additions and 151 deletions
+2
View File
@@ -118,6 +118,7 @@ function mapIssuer(s: ShopSettings): IssuerData {
iban: s.iban,
bic: s.bic,
footerNote: s.footerNote,
footerNoteEn: s.footerNoteEn,
};
}
@@ -181,6 +182,7 @@ function mapLinesAndTotals(order: RawOrderForInvoice): {
quantity: qty,
unitPriceNet: round2(unitNet),
totalNet: round2(lineNet),
imageUrl: li.imageUrl ?? undefined,
});
netSum += lineNet;
+6 -7
View File
@@ -175,26 +175,25 @@ function renderEmailBody({
invoiceNumber: string;
language: "de" | "en";
}): { text: string; html: string } {
const company = settings.companyName || "your supplier";
if (language === "en") {
const text =
`Dear customer,\n\n` +
`Please find attached invoice ${invoiceNumber}.\n\n` +
`Kind regards,\n${company}`;
`Thank you for your purchase.`;
const html =
`<p>Dear customer,</p>` +
`<p>Please find attached invoice <strong>${escapeHtml(invoiceNumber)}</strong>.</p>` +
`<p>Kind regards,<br/>${escapeHtml(company)}</p>`;
`<p>Thank you for your purchase.</p>`;
return { text, html };
}
const text =
`Sehr geehrte Damen und Herren,\n\n` +
`Hallo,\n\n` +
`anbei finden Sie die Rechnung ${invoiceNumber}.\n\n` +
`Mit freundlichen Grüßen,\n${company}`;
`Danke für deinen Einkauf.`;
const html =
`<p>Sehr geehrte Damen und Herren,</p>` +
`<p>Hallo,</p>` +
`<p>anbei finden Sie die Rechnung <strong>${escapeHtml(invoiceNumber)}</strong>.</p>` +
`<p>Mit freundlichen Grüßen,<br/>${escapeHtml(company)}</p>`;
`<p>Danke für deinen Einkauf.</p>`;
return { text, html };
}
@@ -7,6 +7,7 @@ import { composeInvoice } from "./composeInvoice";
import { buildGiroCodeDataUrl } from "./girocode";
import { loadOrderForInvoice } from "./loadOrderForInvoice.server";
import { getLogoDataUrl } from "./logoCache.server";
import { attachLineItemImages } from "./productImageCache.server";
import { allocateInvoiceNumber } from "./numbering.server";
import { InvoiceDocument } from "./pdf/InvoiceDocument";
import type { InvoiceViewModel } from "./types";
@@ -76,6 +77,9 @@ export async function generateInvoice(
const logoDataUrl = await getLogoDataUrl(shopDomain, settings.logoUrl);
if (logoDataUrl) viewModel.issuer.logoDataUrl = logoDataUrl;
// Product images for each line (best-effort, parallel, in-process cache).
await attachLineItemImages(viewModel.lines);
// GiroCode (only for unpaid + IBAN configured + enabled).
if (
settings.giroCodeEnabled &&
+2 -2
View File
@@ -66,7 +66,7 @@ const de: InvoiceStrings = {
salutationGeneric: "Sehr geehrte Damen und Herren,",
thankYouLine:
"vielen Dank für Ihren Auftrag. Wir erlauben uns, Ihnen folgende Leistungen in Rechnung zu stellen:",
closing: "Mit freundlichen Grüßen",
closing: "Danke für deinen Einkauf",
paymentTerms: (days, due) =>
`Bitte überweisen Sie den Rechnungsbetrag innerhalb von ${days} Tagen, spätestens bis zum ${due}, auf das unten angegebene Konto. Bei Fragen zur Rechnung stehen wir Ihnen gerne zur Verfügung.`,
paymentTermsImmediate:
@@ -116,7 +116,7 @@ const en: InvoiceStrings = {
salutationGeneric: "Dear Sir or Madam,",
thankYouLine:
"Thank you for your order. We hereby invoice you for the following:",
closing: "Kind regards",
closing: "Thank you for your purchase.",
paymentTerms: (days, due) =>
`Please transfer the invoice amount within ${days} days, no later than ${due}, to the bank account shown below.`,
paymentTermsImmediate: "The invoice amount is due immediately upon receipt.",
@@ -57,6 +57,7 @@ export interface RawLineItem {
quantity: number;
originalUnitPriceSet: { shopMoney: RawMoney };
taxLines: RawTaxLine[];
imageUrl: string | null;
}
export interface RawTaxLine {
@@ -119,6 +120,7 @@ const QUERY = `#graphql
sku
quantity
originalUnitPriceSet { shopMoney { amount currencyCode } }
image { url altText }
taxLines {
title
rate
@@ -219,7 +221,17 @@ export async function loadOrderForInvoice(
totalTaxSet: order.totalTaxSet,
totalPriceSet: order.totalPriceSet,
taxLines: order.taxLines || [],
lineItems: (order.lineItems?.edges || []).map((e) => e.node),
lineItems: (order.lineItems?.edges || []).map((e) => {
const node = e.node as unknown as RawLineItem & { image?: { url: string | null } | null };
return {
title: node.title,
sku: node.sku,
quantity: node.quantity,
originalUnitPriceSet: node.originalUnitPriceSet,
taxLines: node.taxLines,
imageUrl: node.image?.url ?? null,
};
}),
purchasingEntity: { company: purchasingCompany },
};
}
+56
View File
@@ -3,6 +3,13 @@ import db from "../../db.server";
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB cap
const STALE_AFTER_MS = 24 * 60 * 60 * 1000; // re-fetch once a day at most
/**
* Sentinel value stored in `ShopSettings.logoUrl` when the logo was uploaded
* directly through the settings UI (rather than fetched from a remote URL).
* The actual bytes live in `LogoCache` for that shop.
*/
export const STORED_LOGO_SENTINEL = "stored://shop-logo";
/**
* Returns a `data:` URL for the shop's logo bytes, fetching from the
* configured URL on first use (or when stale) and persisting to the
@@ -15,6 +22,12 @@ export async function getLogoDataUrl(
if (!logoUrl) return undefined;
const cached = await db.logoCache.findUnique({ where: { shopDomain } });
// Locally uploaded logo: bytes live in LogoCache, no HTTP fetch.
if (logoUrl === STORED_LOGO_SENTINEL) {
return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined;
}
const isFresh =
cached &&
cached.sourceUrl === logoUrl &&
@@ -65,3 +78,46 @@ function guessContentType(url: string): string {
if (lower.endsWith(".webp")) return "image/webp";
return "image/png";
}
const ALLOWED_LOGO_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
export interface StoreUploadedLogoResult {
ok: boolean;
error?: string;
contentType?: string;
byteLength?: number;
}
/**
* Persists an uploaded logo file directly into `LogoCache`. Caller is
* responsible for setting `ShopSettings.logoUrl = STORED_LOGO_SENTINEL`.
*/
export async function storeUploadedLogo(
shopDomain: string,
bytes: Buffer,
contentType: string,
): Promise<StoreUploadedLogoResult> {
const ct = (contentType || "").toLowerCase();
if (!ALLOWED_LOGO_MIME.has(ct)) {
return { ok: false, error: `Unsupported image type "${contentType || "unknown"}". Use PNG, JPEG, WebP or GIF.` };
}
if (bytes.byteLength === 0) {
return { ok: false, error: "Uploaded file is empty." };
}
if (bytes.byteLength > MAX_BYTES) {
return { ok: false, error: `File too large (${(bytes.byteLength / 1024 / 1024).toFixed(2)} MB). Max is ${MAX_BYTES / 1024 / 1024} MB.` };
}
const bytesU8 = new Uint8Array(bytes);
await db.logoCache.upsert({
where: { shopDomain },
create: { shopDomain, sourceUrl: STORED_LOGO_SENTINEL, bytes: bytesU8, contentType: ct, etag: "" },
update: { sourceUrl: STORED_LOGO_SENTINEL, bytes: bytesU8, contentType: ct, etag: "", fetchedAt: new Date() },
});
return { ok: true, contentType: ct, byteLength: bytes.byteLength };
}
export async function deleteStoredLogo(shopDomain: string): Promise<void> {
await db.logoCache.deleteMany({ where: { shopDomain } });
}
+46 -7
View File
@@ -111,6 +111,26 @@ const styles = StyleSheet.create({
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",
},
@@ -123,6 +143,9 @@ const styles = StyleSheet.create({
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",
@@ -346,9 +369,6 @@ export function InvoiceDocument({ invoice }: DocProps) {
<View style={styles.closing}>
<Text>{t.closing}</Text>
<Text style={{ fontFamily: "Helvetica-Bold", marginTop: 4 }}>
{invoice.issuer.ownerName || invoice.issuer.companyName}
</Text>
</View>
<Footer issuer={invoice.issuer} language={invoice.language} />
@@ -416,9 +436,16 @@ function LineRow({
return (
<View style={styles.tableRow}>
<Text style={styles.colPos}>{line.position}</Text>
<View style={styles.colDescription}>
<Text style={styles.itemTitle}>{line.title}</Text>
{line.sku ? <Text style={styles.itemSku}>SKU: {line.sku}</Text> : null}
<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>
<Text style={styles.colUnit}>{formatMoney(line.unitPriceNet, currency, language)}</Text>
@@ -460,8 +487,20 @@ function Footer({ issuer, language }: { issuer: IssuerData; language: InvoiceLan
{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}
{issuer.footerNote ? <Text>{issuer.footerNote}</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 || "";
}
@@ -0,0 +1,81 @@
/**
* Fetches product images for invoice line items and returns them as
* `data:` URLs ready to embed in the PDF.
*
* Uses a simple in-process LRU-ish Map keyed by URL. Images are typically
* served from Shopify's CDN so re-fetching is cheap, but caching avoids
* hammering the network when regenerating an invoice multiple times.
*/
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB cap per image
const CACHE_MAX_ENTRIES = 200;
const cache = new Map<string, string>(); // url -> data URL
function rememberInCache(url: string, dataUrl: string) {
if (cache.size >= CACHE_MAX_ENTRIES) {
// Drop the oldest entry (Map preserves insertion order).
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
}
cache.set(url, dataUrl);
}
function guessContentType(url: string, headerCt: string | null): string {
if (headerCt && headerCt.startsWith("image/")) return headerCt;
const lower = url.toLowerCase();
if (lower.includes(".jpg") || lower.includes(".jpeg")) return "image/jpeg";
if (lower.includes(".webp")) return "image/webp";
if (lower.includes(".gif")) return "image/gif";
return "image/png";
}
export async function fetchProductImageDataUrl(url: string): Promise<string | undefined> {
if (!url) return undefined;
const hit = cache.get(url);
if (hit) return hit;
// Request a small Shopify CDN variant when possible to keep the PDF lean.
// Shopify image URLs accept a `width=` query param; fall back to the original URL.
const requestUrl = url.includes("cdn.shopify.com") && !/[?&](width|height|crop)=/.test(url)
? `${url}${url.includes("?") ? "&" : "?"}width=128`
: url;
let res: Response;
try {
res = await fetch(requestUrl);
} catch (err) {
console.warn(`Product image fetch failed for ${url}:`, err);
return undefined;
}
if (!res.ok) {
console.warn(`Product image HTTP ${res.status} for ${url}`);
return undefined;
}
const buf = await res.arrayBuffer();
if (buf.byteLength === 0 || buf.byteLength > MAX_BYTES) return undefined;
const contentType = guessContentType(url, res.headers.get("content-type"));
// @react-pdf supports png/jpeg natively; webp/gif are unreliable. Skip those.
if (contentType !== "image/png" && contentType !== "image/jpeg") return undefined;
const b64 = Buffer.from(buf).toString("base64");
const dataUrl = `data:${contentType};base64,${b64}`;
rememberInCache(url, dataUrl);
return dataUrl;
}
/**
* Resolves images for every line in parallel, mutating `imageDataUrl` in place.
* Failures are swallowed (the row simply renders without an icon).
*/
export async function attachLineItemImages(
lines: { imageUrl?: string; imageDataUrl?: string }[],
): Promise<void> {
await Promise.all(
lines.map(async (line) => {
if (!line.imageUrl) return;
const dataUrl = await fetchProductImageDataUrl(line.imageUrl);
if (dataUrl) line.imageDataUrl = dataUrl;
}),
);
}
+5
View File
@@ -64,6 +64,7 @@ export interface IssuerData {
/** Optional pre-fetched logo bytes as a data URL. */
logoDataUrl?: string;
footerNote: string;
footerNoteEn: string;
}
export interface RecipientData {
@@ -87,6 +88,10 @@ export interface InvoiceLine {
totalNet: number;
/** Optional SKU for display under the title. */
sku?: string;
/** Source URL of the product/variant image (Shopify CDN). */
imageUrl?: string;
/** `data:` URL with base64-encoded image bytes — attached by the generator. */
imageDataUrl?: string;
}
export type NoticeKind = "reverseCharge" | "export" | "kleinunternehmer";