Rename invoice-settings to company-settings, add its own Live Preview, and refine invoice PDF layout

Company data now has its own Payload admin group and a live in-browser
PDF preview (react-pdf's PDFViewer) instead of just a plain settings
form. Invoice header is a brand-colored rule instead of a filled band,
and the footer is now pinned to the page bottom instead of following
content flow.
This commit is contained in:
Marco
2026-07-22 11:11:58 +00:00
parent f144ad25f2
commit e50d43ea44
8 changed files with 261 additions and 75 deletions
+72 -32
View File
@@ -43,8 +43,8 @@ Preview components should ever point somewhere else). `DISCOUNT_SERVICE_SECRET`
must match the value set on the Payload backend). `ORDER_SERVICE_SECRET`
(no safe default — required for `/api/checkout` to persist an order in
Payload at all, for `/api/account/verify-email` to look up a customer
by their verification token, and for `getInvoiceSettings()` to read the
`invoice-settings` collection (seller data for invoice PDFs); must match
by their verification token, and for `getCompanySettings()` to read the
`company-settings` collection (seller data for invoice PDFs); must match
the value set on the Payload backend — also used there for the same
header). `SMTP_USER`/`SMTP_PASSWORD`
(no safe default — required for `app/lib/alertAdmin.ts`'s critical-failure
@@ -92,7 +92,7 @@ API directly and reuse Next.js's fetch cache instead of an extra round trip.
Most reads are public (`access.read: () => true`); writes are always
admin-gated in the Payload admin UI at `payload.mk360.de/admin`. A growing
subset below is **not** public-read at all (`discount-codes`, `orders`,
`customers`, `number-ranges`, `invoice-settings`) — each row says so and
`customers`, `number-ranges`, `company-settings`) — each row says so and
explains what does have access instead.
| Collection (slug) | Used for | Key fields |
@@ -114,19 +114,20 @@ explains what does have access instead.
| `customers` | Storefront accounts — register/login/order-history, a second `auth: true` collection separate from the Payload admin's own `users` login | `customerNumber`, `firstName`/`lastName`/`email`, one default address, `cart` (server-side mirror), `emailVerified` (non-blocking). **Not public-read** — see "Orders & customer accounts" below |
| `number-ranges` | Admin-configurable prefix + running counter for customer/order/invoice numbers — one row per tenant | `customerPrefix`/`customerNext`/`customerPadding`, `orderPrefix`/`orderNext`/`orderPadding`, `invoicePrefix`/`invoiceNext`/`invoicePadding`. **Admin-only**, no frontend read at all — internal to the two `beforeChange` hooks that assign these numbers |
| `email-templates` | Editable subject/heading/body/footer for all 6 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button |
| `invoice-settings` | Structured seller data for invoice PDFs (see "Invoice PDFs" below) — one row per tenant | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankDetails`. **Not public-read** — admin or `ORDER_SERVICE_SECRET` |
| `company-settings` | Structured business data for invoice PDFs *and* every email footer's company line (see "Invoice PDFs" below) — one row per tenant, own **Company** admin group (not Commerce — this is business identity, not a storefront concern) | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankDetails`. **Not public-read** — admin or `ORDER_SERVICE_SECRET`. Has a Live Preview button — see "Company Settings & Live Preview" below |
All of the above (except `media`, `users`, `tenants`) are grouped in the
Payload admin sidebar under **Commerce** (`products`, `discount-codes`,
`orders`, `customers`, `number-ranges`, `email-templates`,
`invoice-settings`, `shipping-methods`, `shipping-settings`,
All of the above except `company-settings`, `media`, `users`, `tenants`
are grouped in the Payload admin sidebar under **Commerce** (`products`,
`discount-codes`, `orders`, `customers`, `number-ranges`,
`email-templates`, `shipping-methods`, `shipping-settings`,
`payment-methods`, `trust-badges`, `cart-trust-badges`) or **Content**
(`posts`, `categories`, `legal-pages`, `werkzeuge-cards`, `testimonials`);
`media`/`users`/`tenants` sit under **Platform**`users` and `tenants`
are hidden from non-super-admins' nav entirely, and every tenant-scoped
collection's own "assigned tenant" field is hidden from non-super-admins
in the edit view (though not yet the list view's column — see the infra
README's Payload CMS section for why that one's a harder fix).
(`posts`, `categories`, `legal-pages`, `werkzeuge-cards`, `testimonials`).
`company-settings` sits in its own **Company** group; `media`/`users`/`tenants`
sit under **Platform**`users` and `tenants` are hidden from
non-super-admins' nav entirely, and every tenant-scoped collection's own
"assigned tenant" field is hidden from non-super-admins in the edit view
(though not yet the list view's column — see the infra README's Payload
CMS section for why that one's a harder fix).
**What an admin can actually configure without a code deploy, at a
glance:** product/shipping/payment catalog data and `active` toggles
@@ -135,7 +136,7 @@ discount codes, all page content (blog/legal/testimonials/trust badges),
delivery-time disclosure (`shipping-settings`), order/customer/invoice
numbering schemes (`number-ranges`), all 6 email wordings
(`email-templates`, with Live Preview), and invoice seller data,
bank details, and VAT rate (`invoice-settings` — also what every email's
bank details, and VAT rate (`company-settings` — also what every email's
footer company line is sourced from). What still requires a code change:
adding a new *field* to any collection (needs a migration), payment
processing itself (not built), and anything structural in
@@ -150,7 +151,10 @@ which does need a deploy of the Payload service.
`posts`, `legal-pages`, and `testimonials` support Payload's Live Preview —
opening a document in the Payload admin shows this app's real rendered page
in an iframe, updating as you type, no save required.
in an iframe, updating as you type, no save required. `email-templates`
also has Live Preview, but against a synthetic page + sample data rather
than one of these three's own real page — different enough to cover
separately, see "Email templates & Live Preview" further below.
- **`app/api/preview/route.ts`** — validates `PAYLOAD_PREVIEW_SECRET`
(matching value required on the Payload side too, or this route 401s) and
@@ -272,10 +276,10 @@ Both resolved server-side in `/api/checkout/route.ts`, at the same point
prices are already being re-derived from live Payload data (never trusted
from the client):
- **Tax rate**: `product.taxRatePercent ?? invoiceSettings.taxRatePercent`
- **Tax rate**: `product.taxRatePercent ?? companySettings.taxRatePercent`
— a product's own override if set, otherwise the tenant-wide default
from `invoice-settings` (fetched alongside the product catalog,
`Promise.all([fetchProductsBySlug(), getInvoiceSettings()])`). Snapshotted
from `company-settings` (fetched alongside the product catalog,
`Promise.all([fetchProductsBySlug(), getCompanySettings()])`). Snapshotted
onto `orders.items[].taxRatePercent` at order creation — see the Payload
README's "Per-product tax rates" section for why this has to be a
snapshot, not a live lookup.
@@ -305,13 +309,16 @@ inbox, not only in `/konto/bestellungen`.
failure there would be one more way to silently lose the attachment for
no real design benefit; brand color/spacing still carries the visual
identity via `StyleSheet`.
- **Layout**: a brand-tinted header band (wordmark + "RECHNUNG" label,
not a plain title line), seller/buyer addresses, invoice
number/date/order-reference/USt-IdNr. shown as small bordered "meta
boxes" rather than a plain text row, a rounded/bordered item table with
alternating row shading, and a shaded summary card for the totals —
deliberately closer to the site's own card-based UI language than a
generic invoice template.
- **Layout**: a header separated by a bold brand-colored rule (not a
filled color band — a plain line reads cleaner than a solid block of
color across the top) with the wordmark + "RECHNUNG" label, seller/buyer
addresses, invoice number/date/order-reference/USt-IdNr. shown as small
bordered "meta boxes" rather than a plain text row, a rounded/bordered
item table with alternating row shading, and a shaded summary card for
the totals — deliberately closer to the site's own card-based UI
language than a generic invoice template. The footer is pinned to the
bottom of the page (`position: absolute` + react-pdf's `fixed` prop),
not just wherever the content flow happens to end.
- **"Bereits beglichen" badge**: shown next to the meta boxes whenever
`order.paymentMethodTitle` is anything other than `"Überweisung"` (bank
transfer) — Kreditkarte and PayPal both settle at checkout, so the
@@ -319,11 +326,11 @@ inbox, not only in `/konto/bestellungen`.
"Überweisung" is the one method named explicitly as the exception,
rather than hardcoding a list of "immediate" titles that would need
updating every time a new payment method is added in Payload).
- **Bank details**: `invoice-settings.bankDetails`, when set, prints in
- **Bank details**: `company-settings.bankDetails`, when set, prints in
the footer as "Bankverbindung (für Überweisung): …" — for the case a
customer paid (or still needs to pay) by bank transfer and needs the
account details to do so. Currently seeded with a placeholder IBAN/BIC,
same caveat as the rest of `invoice-settings`' seller data below.
same caveat as the rest of `company-settings`' seller data below.
- **Per-tax-rate summary**: line items are grouped by their own
snapshotted `taxRatePercent` (see the Payload README's "Per-product tax
rates" section) and the summary prints one "Netto (X%)" / "zzgl. X%
@@ -338,7 +345,7 @@ inbox, not only in `/konto/bestellungen`.
the Payload README's "Product bundles" section).
- **`app/lib/invoiceData.ts`** — `generateInvoicePdf(order, seller)` /
`generateCorrectionInvoicePdf(kind, order, seller)`, the render
entrypoints every caller below goes through. `seller` (`invoice-settings`
entrypoints every caller below goes through. `seller` (`company-settings`
data) is passed in rather than fetched inside these functions, so a
caller that also needs it for something else in the same request (e.g.
`orderEmail.ts`'s email footer, see "Configurable company data" below)
@@ -372,9 +379,9 @@ inbox, not only in `/konto/bestellungen`.
disk/S3/Media — the underlying data is already durable in Postgres, and
deterministic regeneration needs no cleanup or storage cost, same
reasoning already applied to the original invoice.
- **`invoice-settings`** (Payload collection, structured seller data —
- **`company-settings`** (Payload collection, structured seller data —
name/address/`vatId`/`taxRatePercent`/`bankDetails`) is fetched via
`getInvoiceSettings()`/`getSellerForInvoice()`, authenticated the same
`getCompanySettings()`/`getSellerForInvoice()`, authenticated the same
way as order creation (`x-order-service-secret` header,
`ORDER_SERVICE_SECRET`) since it's not public-read (holds bank details)
but does need to be reachable from this app's own server-side code, not
@@ -391,6 +398,39 @@ inbox, not only in `/konto/bestellungen`.
amount per rate, gross total — all on the PDF, not just the summary the
confirmation email's HTML already shows.
### Company Settings & Live Preview
`company-settings` has a Live Preview button too, like `email-templates`
— but instead of an HTML page, it's a **live, in-browser rendered PDF**:
opening the document in the Payload admin shows the actual invoice layout
updating as the admin edits `sellerName`/address/`taxRatePercent`/
`bankDetails`, no save required.
- **`app/company-settings-preview/page.tsx`** + **`components/LiveCompanySettingsPreviewClient.tsx`**
— same entrypoint pattern as `/email-preview/[type]` (Draft Mode via
`app/api/preview/route.ts`, never a real visitor destination), but no
`[type]` segment — there's only one kind of document here, unlike the 6
email types.
- **`@react-pdf/renderer`'s `<PDFViewer>`** (not `renderToBuffer()`) is
what makes this a *live* preview rather than a static download — it's a
browser-only component that renders a `Document` straight into an
embedded PDF viewer `<iframe>`, re-rendering whenever its props change.
Paired with `useLivePreview()`'s live `data` (same postMessage mechanism
as the email templates preview), editing a field in the admin re-renders
the actual PDF in real time — no server round-trip per keystroke.
Dynamically imported with `{ ssr: false }` (`next/dynamic`) since it
touches the DOM directly; the HTML-string email previews elsewhere don't
need that since they're just `dangerouslySetInnerHTML`.
- Renders `InvoiceDocument` (exported from `app/lib/invoicePdf.tsx`
specifically for this — everywhere else only the async
`renderInvoicePdf()` buffer-generator is used) against a fixed
`SAMPLE_INVOICE_ORDER` — same "no real document to preview against
generically" reasoning as `email-templates`' own `SAMPLE_ORDER`.
- No draft/published distinction here, unlike `email-templates`:
`company-settings` has no content-versioning concept, it's just the
current row — the page's initial (pre-postMessage) fetch is the same
live data `getCompanySettings()` always returns.
## Orders & customer accounts
An account is required to buy — there is no guest checkout. Registration
@@ -600,7 +640,7 @@ mechanism as Posts/LegalPages/Testimonials (`useLivePreview()` from
`<table>`s, no flexbox) — just matched by eye.
- **Footer company line is configurable, not hardcoded.** `emailShell()`
takes a required `companyLine` parameter ("<sellerName> · <sellerEmail>")
`orderEmail.ts` fetches `invoice-settings` once (`getSellerForInvoice()`)
`orderEmail.ts` fetches `company-settings` once (`getSellerForInvoice()`)
and derives it from there, same admin-editable business data the invoice
PDFs already use, rather than a literal `"einfach produktiv ·
admin@mk360.de"` string. The Payload-side sends (password-reset, the 4
+3 -3
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import type { CartItem } from "../../lib/cart";
import { getShippingMethods, getPaymentMethods, getInvoiceSettings } from "../../lib/payload";
import { getShippingMethods, getPaymentMethods, getCompanySettings } from "../../lib/payload";
import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
import { createOrder } from "../../lib/orderServer";
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
@@ -102,8 +102,8 @@ export async function POST(request: Request) {
}
// Re-price everything server-side — never trust client-submitted prices.
const [productsBySlug, invoiceSettings] = await Promise.all([fetchProductsBySlug(), getInvoiceSettings()]);
const defaultTaxRate = invoiceSettings?.taxRatePercent ?? 19;
const [productsBySlug, companySettings] = await Promise.all([fetchProductsBySlug(), getCompanySettings()]);
const defaultTaxRate = companySettings?.taxRatePercent ?? 19;
const items: {
productId: number;
productName: string;
@@ -0,0 +1,37 @@
"use client";
import dynamic from "next/dynamic";
import { useLivePreview } from "@payloadcms/live-preview-react";
import { InvoiceDocument, SAMPLE_INVOICE_ORDER } from "../../lib/invoicePdf";
import type { CompanySettings } from "../../lib/payload";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// @react-pdf/renderer's PDFViewer renders into an <iframe> via direct DOM
// access — it has to be excluded from the server render pass entirely
// (ssr: false), unlike the HTML-string email previews elsewhere in this
// app, which can render server-side fine since they're just
// dangerouslySetInnerHTML.
const PDFViewer = dynamic(() => import("@react-pdf/renderer").then((mod) => mod.PDFViewer), { ssr: false });
// Same useLivePreview() mechanism as LiveEmailPreviewClient.tsx — connects
// to the Payload admin's iframe via postMessage and updates `data` as the
// admin edits company-settings fields, no save required. Renders through
// the exact same InvoiceDocument component the real invoice PDF uses
// (app/lib/invoicePdf.tsx), against a fixed sample order
// (SAMPLE_INVOICE_ORDER) — there's no "current" real order to preview
// against generically, same reasoning as the email-templates preview's
// own SAMPLE_ORDER.
export function LiveCompanySettingsPreviewClient({ initialSettings }: { initialSettings: CompanySettings }) {
const { data } = useLivePreview<CompanySettings>({
initialData: initialSettings,
serverURL: PAYLOAD_URL,
depth: 0,
});
return (
<PDFViewer style={{ width: "100%", height: "100vh", border: "none" }}>
<InvoiceDocument order={SAMPLE_INVOICE_ORDER} seller={data} />
</PDFViewer>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { draftMode } from "next/headers";
import { getCompanySettings, type CompanySettings } from "../lib/payload";
import { LiveCompanySettingsPreviewClient } from "./components/LiveCompanySettingsPreviewClient";
export const metadata: Metadata = {
title: "Firmendaten-Vorschau",
robots: { index: false, follow: false },
};
const FALLBACK: CompanySettings = {
sellerName: "",
sellerStreet: "",
sellerZip: "",
sellerCity: "",
sellerCountry: "",
sellerEmail: "",
vatId: "",
taxRatePercent: 19,
bankDetails: null,
};
// Entered exclusively via CompanySettings.ts's admin.livePreview.url (a
// Payload-admin-only iframe target, see buildPreviewUrl()/api/preview) —
// not a page a real visitor would ever land on. Unlike email-templates
// there's no draft/published distinction here (company-settings has no
// content-versioning concept, it's just the current row) — the initial
// fetch is the same live data getCompanySettings() always returns,
// useLivePreview() takes over from there as the admin edits fields.
export default async function CompanySettingsPreviewPage() {
await draftMode();
const initialSettings = (await getCompanySettings()) ?? FALLBACK;
return <LiveCompanySettingsPreviewClient initialSettings={initialSettings} />;
}
+30 -11
View File
@@ -14,17 +14,26 @@ import { formatDate } from "./format";
// immutable once set, so re-rendering from the order's own stored data
// always reproduces the identical document.
const BRAND = "#f6a701";
const BRAND_TINT = "#fdf1d9";
const TEXT_MUTED = "#6b6b69";
const BORDER = "#e5e0d8";
const BG_MUTED = "#f8f5f1";
const styles = StyleSheet.create({
page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" },
headerBand: { backgroundColor: BRAND_TINT, padding: 32, flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
// A rule, not a filled band — a bold brand-colored line rather than a
// plain 1pt gray divider.
headerBand: {
padding: 32,
paddingBottom: 24,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderBottomWidth: 3,
borderBottomColor: BRAND,
},
wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 },
kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 },
body: { padding: 32 },
body: { padding: 32, paddingBottom: 90 },
refLine: { fontSize: 10, color: TEXT_MUTED, marginBottom: 24 },
addressRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 24 },
addressBlock: { width: "45%" },
@@ -52,7 +61,17 @@ const styles = StyleSheet.create({
grandTotalRow: { flexDirection: "row", justifyContent: "space-between", paddingTop: 8, marginTop: 6, borderTopWidth: 1, borderTopColor: BORDER },
grandTotalLabel: { fontSize: 12, fontFamily: "Helvetica-Bold" },
grandTotalValue: { fontSize: 12, fontFamily: "Helvetica-Bold" },
footer: { borderTopWidth: 1, borderTopColor: BORDER, paddingTop: 12, fontSize: 8, color: TEXT_MUTED },
footer: {
position: "absolute",
bottom: 32,
left: 32,
right: 32,
borderTopWidth: 1,
borderTopColor: BORDER,
paddingTop: 12,
fontSize: 8,
color: TEXT_MUTED,
},
});
function formatPrice(amount: number): string {
@@ -215,14 +234,14 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
</View>
</View>
</View>
</View>
<View style={styles.footer}>
<Text>
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
{seller.vatId}
</Text>
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
</View>
<View style={styles.footer} fixed>
<Text>
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
{seller.vatId}
</Text>
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
</View>
</Page>
</Document>
+10 -10
View File
@@ -1,15 +1,15 @@
import { getInvoiceSettings, type InvoiceSettings } from "./payload";
import { getCompanySettings, type CompanySettings } from "./payload";
import { renderInvoicePdf, type InvoiceOrder } from "./invoicePdf";
import { renderCorrectionInvoicePdf, type CorrectionInvoiceKind, type CorrectionInvoiceOrder } from "./correctionInvoicePdf";
export type { InvoiceSettings };
export type { CompanySettings };
// Fetched once by callers that need seller data for more than one purpose
// in the same request (e.g. orderEmail.ts also needs it for the email
// footer's companyLine) — avoids a second identical invoice-settings round
// trip, unlike calling getInvoiceSettings() again inside each generator.
export async function getSellerForInvoice(): Promise<InvoiceSettings | null> {
return getInvoiceSettings();
// footer's companyLine) — avoids a second identical company-settings round
// trip, unlike calling getCompanySettings() again inside each generator.
export async function getSellerForInvoice(): Promise<CompanySettings | null> {
return getCompanySettings();
}
// Shared by app/lib/orderEmail.ts (checkout — attaches to the confirmation
@@ -18,9 +18,9 @@ export async function getSellerForInvoice(): Promise<InvoiceSettings | null> {
// later always matches what they were emailed. `seller` is passed in
// rather than fetched here, so a caller that already has it (see above)
// doesn't fetch it twice.
export async function generateInvoicePdf(order: InvoiceOrder, seller: InvoiceSettings | null): Promise<Buffer | null> {
export async function generateInvoicePdf(order: InvoiceOrder, seller: CompanySettings | null): Promise<Buffer | null> {
if (!seller) {
console.error("generateInvoicePdf: no invoice-settings row found for tenant");
console.error("generateInvoicePdf: no company-settings row found for tenant");
return null;
}
return renderInvoicePdf(order, seller);
@@ -35,10 +35,10 @@ export async function generateInvoicePdf(order: InvoiceOrder, seller: InvoiceSet
export async function generateCorrectionInvoicePdf(
kind: CorrectionInvoiceKind,
order: CorrectionInvoiceOrder,
seller: InvoiceSettings | null,
seller: CompanySettings | null,
): Promise<Buffer | null> {
if (!seller) {
console.error("generateCorrectionInvoicePdf: no invoice-settings row found for tenant");
console.error("generateCorrectionInvoicePdf: no company-settings row found for tenant");
return null;
}
return renderCorrectionInvoicePdf(kind, order, seller);
+68 -12
View File
@@ -14,7 +14,6 @@ import { formatDate } from "./format";
// (src/lib/correctionInvoicePdf.tsx in the backend repo, kept visually in
// sync with this file by eye — not shared code, two separate deployments).
const BRAND = "#f6a701";
const BRAND_TINT = "#fdf1d9";
const TEXT_MUTED = "#6b6b69";
const BORDER = "#e5e0d8";
const BG_MUTED = "#f8f5f1";
@@ -23,10 +22,21 @@ const SUCCESS_TINT = "#e7f5eb";
const styles = StyleSheet.create({
page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" },
headerBand: { backgroundColor: BRAND_TINT, padding: 32, flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
// A rule, not a filled band — a bold brand-colored line with a thin
// muted second line underneath, rather than a plain 1pt gray divider.
headerBand: {
padding: 32,
paddingBottom: 24,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderBottomWidth: 3,
borderBottomColor: BRAND,
},
headerRuleThin: { height: 1, backgroundColor: BORDER, marginHorizontal: 32 },
wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 },
kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 },
body: { padding: 32 },
body: { padding: 32, paddingBottom: 90 },
addressRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 24 },
addressBlock: { width: "45%" },
addressLabel: { fontSize: 8, color: TEXT_MUTED, marginBottom: 4, textTransform: "uppercase" },
@@ -55,7 +65,20 @@ const styles = StyleSheet.create({
grandTotalRow: { flexDirection: "row", justifyContent: "space-between", paddingTop: 8, marginTop: 6, borderTopWidth: 1, borderTopColor: BORDER },
grandTotalLabel: { fontSize: 12, fontFamily: "Helvetica-Bold" },
grandTotalValue: { fontSize: 12, fontFamily: "Helvetica-Bold" },
footer: { borderTopWidth: 1, borderTopColor: BORDER, paddingTop: 12, fontSize: 8, color: TEXT_MUTED },
// `fixed` (below, on the element) + absolute positioning — always pinned
// to the bottom of the page regardless of how much content is above it,
// rather than just following wherever the content flow happens to end.
footer: {
position: "absolute",
bottom: 32,
left: 32,
right: 32,
borderTopWidth: 1,
borderTopColor: BORDER,
paddingTop: 12,
fontSize: 8,
color: TEXT_MUTED,
},
});
function formatPrice(amount: number): string {
@@ -98,6 +121,32 @@ export type InvoiceSeller = {
bankDetails?: string | null;
};
// Used only by /company-settings-preview's Live Preview — a fixed sample
// order so the admin sees a realistic-looking invoice while editing
// company-settings fields, without depending on any real order existing.
export const SAMPLE_INVOICE_ORDER: InvoiceOrder = {
orderNumber: "#EP-0001-A7K2",
invoiceNumber: "RE-0001",
invoiceIssuedAt: new Date().toISOString(),
customerFirstName: "Max",
customerLastName: "Mustermann",
deliveryMethod: "address",
street: "Musterweg 5",
zip: "10115",
city: "Berlin",
country: "Deutschland",
paymentMethodTitle: "Kreditkarte",
items: [
{ productName: "ToDo-Karten Set", quantity: 1, unitPrice: 12.9, taxRatePercent: 19, bundleContents: null },
{ productName: "Wochenplaner Überblick", quantity: 2, unitPrice: 14.9, taxRatePercent: 19, bundleContents: null },
],
subtotal: 42.7,
shippingCost: 0,
discountAmount: 5,
discountCode: "WILLKOMMEN10",
total: 37.7,
};
// "Überweisung" (bank transfer) is the only payment method on this shop
// that ISN'T settled immediately — Kreditkarte/PayPal both capture at
// checkout. Rather than hardcode a list of "immediate" method titles
@@ -131,7 +180,13 @@ function groupByTaxRate(order: InvoiceOrder, defaultRate: number): { rate: numbe
.sort((a, b) => b.rate - a.rate);
}
function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller: InvoiceSeller }) {
// Exported (not just used internally by renderInvoicePdf below) so
// /company-settings-preview's client component can mount it directly with
// @react-pdf/renderer's browser-side <PDFViewer> — a live, in-browser
// rendered PDF that re-renders as the admin edits company-settings fields
// via Payload's postMessage-based useLivePreview(), no server round-trip
// needed for each keystroke the way an HTML preview would.
export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller: InvoiceSeller }) {
const rateGroups = groupByTaxRate(order, seller.taxRatePercent);
const paid = isPaidImmediately(order.paymentMethodTitle);
const deliveryLine =
@@ -244,13 +299,14 @@ function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller: Invoi
</View>
</View>
<View style={styles.footer}>
<Text>
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
{seller.vatId}
</Text>
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
</View>
</View>
<View style={styles.footer} fixed>
<Text>
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
{seller.vatId}
</Text>
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
</View>
</Page>
</Document>
+7 -7
View File
@@ -643,7 +643,7 @@ export async function getEmailTemplate(
return data.docs?.[0] ?? null;
}
export type InvoiceSettings = {
export type CompanySettings = {
sellerName: string;
sellerStreet: string;
sellerZip: string;
@@ -658,19 +658,19 @@ export type InvoiceSettings = {
// Server-only in practice (only ever called from app/lib/invoiceData.ts),
// but kept in this file rather than a "use server"-only module since every
// other Payload fetcher lives here too — no client component imports it.
// invoice-settings' read access is admin-only plus this same service
// secret (see InvoiceSettings.ts) — it holds bank details, not something
// company-settings' read access is admin-only plus this same service
// secret (see CompanySettings.ts) — it holds bank details, not something
// to leave publicly readable like Products/ShippingSettings.
export async function getInvoiceSettings(): Promise<InvoiceSettings | null> {
export async function getCompanySettings(): Promise<CompanySettings | null> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/invoice-settings?${params}`, {
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
cache: "no-store",
});
if (!res.ok) {
console.error(`getInvoiceSettings: Payload returned ${res.status} ${res.statusText}`);
console.error(`getCompanySettings: Payload returned ${res.status} ${res.statusText}`);
return null;
}
const data: { docs?: InvoiceSettings[] } = await res.json();
const data: { docs?: CompanySettings[] } = await res.json();
return data.docs?.[0] ?? null;
}