From 770c6fd16ab40168930692e3ed2a992a637960b7 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Fri, 8 May 2026 10:40:19 +0200 Subject: [PATCH] many updates :-) --- app/routes/app._index.tsx | 196 ++++++++++--- app/routes/app.invoices.tsx | 274 +++++++++++++----- app/routes/app.settings.tsx | 118 ++++++-- app/services/invoice/composeInvoice.ts | 2 + app/services/invoice/email.server.ts | 13 +- .../invoice/generateInvoice.server.tsx | 4 + app/services/invoice/i18n.ts | 4 +- .../invoice/loadOrderForInvoice.server.ts | 14 +- app/services/invoice/logoCache.server.ts | 56 ++++ app/services/invoice/pdf/InvoiceDocument.tsx | 53 +++- .../invoice/productImageCache.server.ts | 81 ++++++ app/services/invoice/types.ts | 5 + .../migration.sql | 51 ++++ prisma/schema.prisma | 3 + scripts/regenerate-invoice.ts | 71 +++++ scripts/render-sample.ts | 82 +++++- 16 files changed, 876 insertions(+), 151 deletions(-) create mode 100644 app/services/invoice/productImageCache.server.ts create mode 100644 prisma/migrations/20260430190648_add_footer_note_en/migration.sql create mode 100644 scripts/regenerate-invoice.ts diff --git a/app/routes/app._index.tsx b/app/routes/app._index.tsx index 7c29916..ffbfb45 100644 --- a/app/routes/app._index.tsx +++ b/app/routes/app._index.tsx @@ -7,15 +7,25 @@ import db from "../db.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { const { session } = await authenticate.admin(request); - const [settings, recent] = await Promise.all([ + const [settings, recent, counts] = await Promise.all([ db.shopSettings.findUnique({ where: { shopDomain: session.shop } }), db.invoice.findMany({ where: { shopDomain: session.shop }, orderBy: [{ issuedAt: "desc" }], - take: 10, + take: 8, + }), + db.invoice.groupBy({ + by: ["status"], + where: { shopDomain: session.shop }, + _count: { _all: true }, }), ]); + const total = counts.reduce((acc, row) => acc + row._count._all, 0); + const issuedCount = counts.find((c) => c.status === "issued")?._count._all ?? 0; + const sentCount = counts.find((c) => c.status === "sent")?._count._all ?? 0; + const cancelledCount = counts.find((c) => c.status === "cancelled")?._count._all ?? 0; + const settingsConfigured = !!( settings && settings.companyName && @@ -25,12 +35,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { return { settingsConfigured, + metrics: { total, issuedCount, sentCount, cancelledCount }, recent: recent.map((i) => ({ id: i.id, number: i.invoiceNumber, kind: i.kind, orderName: i.orderName, version: i.version, + status: i.status, sentAt: i.sentAt?.toISOString() ?? null, cancelledAt: i.cancelledAt?.toISOString() ?? null, issuedAt: i.issuedAt.toISOString(), @@ -39,54 +51,168 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; }; +const dateFmt = new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", +}); + +function formatDate(iso: string | null): string { + if (!iso) return "—"; + return dateFmt.format(new Date(iso)); +} + +interface RecentInvoice { + id: string; + number: string; + kind: string; + orderName: string; + version: number; + status: string; + sentAt: string | null; + cancelledAt: string | null; + issuedAt: string; + pdfUrl: string; +} + +function statusBadge(invoice: RecentInvoice): { + tone: "success" | "info" | "critical" | "warning"; + label: string; +} { + if (invoice.cancelledAt) return { tone: "critical", label: "Cancelled" }; + if (invoice.kind === "storno") return { tone: "warning", label: "Storno" }; + if (invoice.sentAt) return { tone: "success", label: "Sent" }; + return { tone: "info", label: "Issued" }; +} + export default function Index() { - const { settingsConfigured, recent } = useLoaderData(); + const { settingsConfigured, metrics, recent } = useLoaderData(); return ( {!settingsConfigured && ( - Complete your company, bank and numbering details so generated - invoices are legally compliant.{" "} - Open settings + + Complete your company, bank and numbering details so generated + invoices are legally compliant. + + + Open settings → + )} - - - Generates Austrian-compliant PDF invoices for your Shopify orders. - Trigger from the order page (Generate invoice action), via Shopify - Flow, or in bulk from the Invoices page. PDFs are stored on - Shopify Files and linked to each order via metafields. - + + + + + + + - + {recent.length === 0 ? ( - No invoices generated yet. + + + No invoices yet + + Generate your first invoice from the Invoices page or directly + from a Shopify order. + + Open invoices → + + ) : ( - - {recent.map((i) => ( - - {i.kind === "storno" ? "Storno " : ""} - {i.number} — order {i.orderName} (v{i.version}) - {i.cancelledAt - ? " — cancelled" - : i.sentAt - ? " — sent" - : ""} - {i.pdfUrl ? ( - <> - {" "} - [PDF] - - ) : null} - - ))} - + + + Invoice + Order + Issued + Status + PDF + + + {recent.map((invoice) => { + const badge = statusBadge(invoice); + return ( + + + + {invoice.number} + {invoice.version > 1 ? ( + v{invoice.version} + ) : null} + + + {invoice.orderName} + {formatDate(invoice.issuedAt)} + + {badge.label} + + + + {invoice.pdfUrl ? ( + + Open + + ) : ( + + )} + + + + ); + })} + + )} - Open invoices page + + + View all invoices → + + + + + + + + LinumIQ Invoice generates Austrian-compliant PDF invoices for your + Shopify orders. PDFs are stored on Shopify Files and linked to + each order via metafields. + + + Trigger generation from the order page (Generate invoice action), + via Shopify Flow, or in bulk from the Invoices page. + + ); } + +function Metric({ + label, + value, + tone, +}: { + label: string; + value: number; + tone?: "info" | "success" | "critical"; +}) { + const valueTone = tone ?? "neutral"; + return ( + + + {label} + + {value} + + + + ); +} diff --git a/app/routes/app.invoices.tsx b/app/routes/app.invoices.tsx index 85ee1e1..c7cb8af 100644 --- a/app/routes/app.invoices.tsx +++ b/app/routes/app.invoices.tsx @@ -6,12 +6,15 @@ import db from "../db.server"; interface RecentOrder { id: string; // gid + numericId: string; name: string; createdAt: string; totalPrice: string; currency: string; + customerName: string; hasInvoice: boolean; invoiceNumber?: string; + invoiceVersion?: number; invoiceSent?: boolean; invoiceCancelled?: boolean; pdfUrl?: string; @@ -26,21 +29,26 @@ const RECENT_ORDERS_QUERY = `#graphql createdAt displayFinancialStatus totalPriceSet { shopMoney { amount currencyCode } } + customer { firstName lastName } } } } `; +type Filter = "all" | "missing" | "with"; + export const loader = async ({ request }: LoaderFunctionArgs) => { const { admin, session } = await authenticate.admin(request); const url = new URL(request.url); - const filter = url.searchParams.get("filter") ?? "all"; + const filterParam = (url.searchParams.get("filter") ?? "all") as Filter; + const filter: Filter = ["all", "missing", "with"].includes(filterParam) + ? filterParam + : "all"; - // Recent orders from Shopify (first 25). let orders: RecentOrder[] = []; try { - const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 25 } }); + const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 50 } }); const json = (await res.json()) as { data?: { orders?: { @@ -49,6 +57,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { name: string; createdAt: string; totalPriceSet?: { shopMoney: { amount: string; currencyCode: string } }; + customer?: { firstName: string | null; lastName: string | null } | null; }>; }; }; @@ -56,7 +65,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const nodes = json.data?.orders?.nodes ?? []; const orderIds = nodes.map((n) => n.id); - // Look up which of these orders already have a non-cancelled invoice. const invoices = await db.invoice.findMany({ where: { shopDomain: session.shop, @@ -72,14 +80,20 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { orders = nodes.map((n) => { const inv = latestByOrder.get(n.id); + const customer = n.customer + ? [n.customer.firstName, n.customer.lastName].filter(Boolean).join(" ").trim() + : ""; return { id: n.id, + numericId: n.id.replace(/^.*\//, ""), name: n.name, createdAt: n.createdAt, totalPrice: n.totalPriceSet?.shopMoney.amount ?? "", currency: n.totalPriceSet?.shopMoney.currencyCode ?? "EUR", + customerName: customer || "Guest", hasInvoice: !!inv && !inv.cancelledAt, invoiceNumber: inv?.invoiceNumber, + invoiceVersion: inv?.version, invoiceSent: !!inv?.sentAt, invoiceCancelled: !!inv?.cancelledAt, pdfUrl: inv?.pdfUrl, @@ -89,103 +103,207 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { console.warn("Failed to load recent orders:", err); } + const allCount = orders.length; + const withCount = orders.filter((o) => o.hasInvoice).length; + const missingCount = allCount - withCount; + if (filter === "missing") orders = orders.filter((o) => !o.hasInvoice); if (filter === "with") orders = orders.filter((o) => o.hasInvoice); - return { orders, filter }; + return { + orders, + filter, + counts: { all: allCount, with: withCount, missing: missingCount }, + }; }; +const dateFmt = new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", +}); +const moneyFmt = new Intl.NumberFormat("de-AT", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function formatMoney(amount: string, currency: string): string { + const n = Number(amount); + if (!Number.isFinite(n)) return `${amount} ${currency}`; + return `${moneyFmt.format(n)} ${currency}`; +} + export default function InvoicesPage() { - const { orders, filter } = useLoaderData(); + const { orders, filter, counts } = useLoaderData(); const navigation = useNavigation(); const isLoading = navigation.state !== "idle"; return ( - - - Generate or view the latest invoice for each order. For sent - invoices, use Cancel & reissue to issue a Stornorechnung followed - by a fresh invoice. - + + + + + Generate the invoice for an order, regenerate an unsent draft, + or cancel-and-reissue a sent one. Newest orders appear first. + - - All - Missing invoice - Has invoice - + + + All + + + Missing invoice + + + Has invoice + + + + {isLoading ? ( - Loading… + + + + Loading… + + ) : orders.length === 0 ? ( - No orders match the current filter. + + + No orders match this filter + + Try a different filter or wait for new orders. + + + ) : ( - - {orders.map((o) => ( - - ))} - + + + Order + Customer + Date + Total + Invoice + Actions + + + {orders.map((order) => ( + + ))} + + )} - - - Use the buttons next to each order to generate one at a time. (A - true multi-select bulk run will be added once the admin block - extension is installed.) - - - Tip: filter by Missing invoice to see orders that still - need one. - + + + + + Buttons trigger the same generation pipeline used by the order + page action and Shopify Flow. PDFs are uploaded to Shopify Files + and linked back to the order via metafields. + + + Tip: filter by Missing invoice to find orders that still + need one. + + ); } -function OrderRow({ order }: { order: RecentOrder }) { - const numericId = order.id.replace(/^.*\//, ""); - const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>(); - const isBusy = fetcher.state !== "idle"; - +function FilterChip({ + to, + active, + count, + children, +}: { + to: string; + active: boolean; + count: number; + children: React.ReactNode; +}) { return ( - - - {order.name} - {order.totalPrice} {order.currency} - {order.hasInvoice ? ( - - ✓ {order.invoiceNumber} - {order.invoiceCancelled - ? " (cancelled)" - : order.invoiceSent - ? " (sent)" - : ""} - {order.pdfUrl ? ( - <> - {" — "} - PDF - - ) : null} - - ) : ( - — no invoice yet - )} - - - {order.hasInvoice - ? order.invoiceSent - ? "Cancel & reissue" - : "Regenerate" - : "Generate"} - - {order.hasInvoice && order.invoiceSent && ( - - )} - - {fetcher.data?.error && ( - {fetcher.data.error} - )} - - + + + {children} ({count}) + + + ); +} + +function OrderRow({ order }: { order: RecentOrder }) { + const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>(); + const isBusy = fetcher.state !== "idle"; + const isCancelReissue = order.hasInvoice && order.invoiceSent; + const buttonLabel = !order.hasInvoice + ? "Generate" + : order.invoiceSent + ? "Cancel & reissue" + : "Regenerate"; + + return ( + + + + {order.name} + + + {order.customerName} + {dateFmt.format(new Date(order.createdAt))} + {formatMoney(order.totalPrice, order.currency)} + + {order.hasInvoice ? ( + + + {order.invoiceNumber} + {order.invoiceCancelled ? ( + Cancelled + ) : order.invoiceSent ? ( + Sent + ) : ( + Issued + )} + {order.invoiceVersion && order.invoiceVersion > 1 ? ( + v{order.invoiceVersion} + ) : null} + + {fetcher.data?.error ? ( + {fetcher.data.error} + ) : null} + + ) : ( + + )} + + + + {order.pdfUrl ? ( + + PDF + + ) : null} + + {isCancelReissue ? ( + + ) : null} + + {isBusy ? "Working…" : buttonLabel} + + + + + ); } diff --git a/app/routes/app.settings.tsx b/app/routes/app.settings.tsx index bdda619..b8ca3bf 100644 --- a/app/routes/app.settings.tsx +++ b/app/routes/app.settings.tsx @@ -8,6 +8,11 @@ import { isValidIban, normaliseIban, } from "../services/invoice/validation"; +import { + STORED_LOGO_SENTINEL, + deleteStoredLogo, + storeUploadedLogo, +} from "../services/invoice/logoCache.server"; interface SettingsFieldErrors { vatId?: string; @@ -16,6 +21,7 @@ interface SettingsFieldErrors { smtpPort?: string; paymentTermDays?: string; invoiceSeed?: string; + logo?: string; } export const loader = async ({ request }: LoaderFunctionArgs) => { @@ -25,7 +31,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { update: {}, create: { shopDomain: session.shop }, }); - return { settings }; + let logoPreviewDataUrl: string | null = null; + if (settings.logoUrl === STORED_LOGO_SENTINEL) { + const cached = await db.logoCache.findUnique({ where: { shopDomain: session.shop } }); + if (cached) { + logoPreviewDataUrl = `data:${cached.contentType};base64,${Buffer.from(cached.bytes).toString("base64")}`; + } + } + return { settings, logoPreviewDataUrl }; }; export const action = async ({ request }: ActionFunctionArgs) => { @@ -72,6 +85,32 @@ export const action = async ({ request }: ActionFunctionArgs) => { errors.invoiceSeed = "Must be a non-negative number."; } + // --- Logo handling -------------------------------------------------------- + // The settings page allows three actions on the logo: + // 1. Upload a new file (multipart `logoFile`) — stored in `LogoCache`. + // 2. Remove the current logo (`removeLogo=on`). + // 3. Provide an external URL via the `logoUrl` field. + // If a file is uploaded it wins over a manually-entered URL. + let resolvedLogoUrl = str("logoUrl"); + const removeLogo = bool("removeLogo"); + const logoFile = form.get("logoFile"); + const hasUpload = + logoFile && typeof logoFile === "object" && "size" in logoFile && (logoFile as File).size > 0; + + if (removeLogo && !hasUpload) { + await deleteStoredLogo(session.shop); + resolvedLogoUrl = ""; + } else if (hasUpload) { + const file = logoFile as File; + const buf = Buffer.from(await file.arrayBuffer()); + const stored = await storeUploadedLogo(session.shop, buf, file.type); + if (!stored.ok) { + errors.logo = stored.error ?? "Failed to store uploaded logo."; + } else { + resolvedLogoUrl = STORED_LOGO_SENTINEL; + } + } + if (Object.keys(errors).length > 0) { return { ok: false, errors, savedAt: null as string | null }; } @@ -102,8 +141,9 @@ export const action = async ({ request }: ActionFunctionArgs) => { defaultLanguage: str("defaultLanguage", "de") === "en" ? "en" : "de", paymentTermDays: paymentTermDays ?? 14, footerNote: str("footerNote"), + footerNoteEn: str("footerNoteEn"), kleinunternehmer: bool("kleinunternehmer"), - logoUrl: str("logoUrl"), + logoUrl: resolvedLogoUrl, smtpHost: str("smtpHost"), smtpPort: smtpPort ?? 587, smtpSecure: bool("smtpSecure"), @@ -124,30 +164,38 @@ export const action = async ({ request }: ActionFunctionArgs) => { }; export default function SettingsRoute() { - const { settings } = useLoaderData(); + const { settings, logoPreviewDataUrl } = useLoaderData(); const actionData = useActionData(); const nav = useNavigation(); const isSaving = nav.state === "submitting"; const errors = actionData?.errors ?? {}; + const hasStoredLogo = settings.logoUrl === STORED_LOGO_SENTINEL; + // Show the URL field only when not using a stored upload — keeps the UI + // simpler and avoids the sentinel value leaking into the input. + const visibleLogoUrl = hasStoredLogo ? "" : settings.logoUrl; return ( - - Configure issuer details, bank, numbering, language and SMTP. These - values are written into every PDF invoice this app generates and must - match your legal records. - + + + Configure issuer details, bank, numbering, language and SMTP. These + values are written into every PDF invoice this app generates and must + match your legal records. + + {actionData?.ok && ( - Settings saved. + + Your changes are now live and will be used for the next invoice. + )} {actionData && !actionData.ok && ( - - Please fix the errors highlighted below. + + Some fields below need attention before settings can be saved. )} -
+ @@ -252,8 +300,37 @@ export default function SettingsRoute() { { value: "en", label: "English (en)" }, ]} /> - - + + + + + + + + + The logo is rendered in the top-right corner of every invoice + PDF. Upload an image (PNG, JPEG, WebP or GIF, max 5 MB) or + provide a publicly reachable HTTPS URL — uploads take precedence. + + {errors.logo && {errors.logo}} + {logoPreviewDataUrl && ( + + Current logo: + Current invoice logo + + + )} + Upload a new logo: + + @@ -284,11 +361,14 @@ export default function SettingsRoute() { - - - Save settings - - + + + {isSaving ? Saving… : null} + + Save settings + + +
); diff --git a/app/services/invoice/composeInvoice.ts b/app/services/invoice/composeInvoice.ts index a61ae93..3e7d581 100644 --- a/app/services/invoice/composeInvoice.ts +++ b/app/services/invoice/composeInvoice.ts @@ -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; diff --git a/app/services/invoice/email.server.ts b/app/services/invoice/email.server.ts index 914d875..d9c797f 100644 --- a/app/services/invoice/email.server.ts +++ b/app/services/invoice/email.server.ts @@ -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 = `

Dear customer,

` + `

Please find attached invoice ${escapeHtml(invoiceNumber)}.

` + - `

Kind regards,
${escapeHtml(company)}

`; + `

Thank you for your purchase.

`; 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 = - `

Sehr geehrte Damen und Herren,

` + + `

Hallo,

` + `

anbei finden Sie die Rechnung ${escapeHtml(invoiceNumber)}.

` + - `

Mit freundlichen Grüßen,
${escapeHtml(company)}

`; + `

Danke für deinen Einkauf.

`; return { text, html }; } diff --git a/app/services/invoice/generateInvoice.server.tsx b/app/services/invoice/generateInvoice.server.tsx index 2577d91..0a29271 100644 --- a/app/services/invoice/generateInvoice.server.tsx +++ b/app/services/invoice/generateInvoice.server.tsx @@ -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 && diff --git a/app/services/invoice/i18n.ts b/app/services/invoice/i18n.ts index ac30cbe..3b23a8c 100644 --- a/app/services/invoice/i18n.ts +++ b/app/services/invoice/i18n.ts @@ -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.", diff --git a/app/services/invoice/loadOrderForInvoice.server.ts b/app/services/invoice/loadOrderForInvoice.server.ts index a563c9b..1de9086 100644 --- a/app/services/invoice/loadOrderForInvoice.server.ts +++ b/app/services/invoice/loadOrderForInvoice.server.ts @@ -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 }, }; } diff --git a/app/services/invoice/logoCache.server.ts b/app/services/invoice/logoCache.server.ts index 978f5d7..99b0dae 100644 --- a/app/services/invoice/logoCache.server.ts +++ b/app/services/invoice/logoCache.server.ts @@ -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 { + 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 { + await db.logoCache.deleteMany({ where: { shopDomain } }); +} diff --git a/app/services/invoice/pdf/InvoiceDocument.tsx b/app/services/invoice/pdf/InvoiceDocument.tsx index 4211e80..63042ab 100644 --- a/app/services/invoice/pdf/InvoiceDocument.tsx +++ b/app/services/invoice/pdf/InvoiceDocument.tsx @@ -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) { {t.closing} - - {invoice.issuer.ownerName || invoice.issuer.companyName} -