Generate invoice PDFs attached to order confirmation, and send emails on order status changes
Invoice PDFs (§14 UStG line items, tenant-configurable VAT rate) are now generated at checkout and attached to the confirmation email, plus available on demand from the order-detail page. Payload-side, orders now also email the customer on shipped/cancelled/return_requested/returned, with Stornorechnung/Gutschrift correction PDFs attached for the latter two so the original invoice's immutable number stays honest.
This commit is contained in:
@@ -424,6 +424,8 @@ export async function getCustomerOrders(token: string, customerId: number): Prom
|
||||
|
||||
export type CustomerOrderDetail = CustomerOrder & {
|
||||
id: number;
|
||||
invoiceNumber: string | null;
|
||||
invoiceIssuedAt: string | null;
|
||||
customerFirstName: string;
|
||||
customerLastName: string;
|
||||
customerEmail: string;
|
||||
|
||||
@@ -116,7 +116,12 @@ function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerT
|
||||
</body>`;
|
||||
}
|
||||
|
||||
export type OrderConfirmationItem = { productName: string; quantity: number; unitPrice: number };
|
||||
export type OrderConfirmationItem = {
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
export type OrderConfirmationData = {
|
||||
orderNumber: string;
|
||||
createdAt: string;
|
||||
@@ -132,8 +137,13 @@ export const SAMPLE_ORDER: OrderConfirmationData = {
|
||||
orderNumber: "#EP-0001-A7K2",
|
||||
createdAt: new Date().toISOString(),
|
||||
items: [
|
||||
{ productName: "ToDo-Karten – Set", quantity: 1, unitPrice: 12.9 },
|
||||
{ productName: "Wochenplaner – Überblick", quantity: 2, unitPrice: 14.9 },
|
||||
{
|
||||
productName: "ToDo-Karten – Set",
|
||||
quantity: 1,
|
||||
unitPrice: 12.9,
|
||||
imageUrl: "https://payload.mk360.de/api/media/file/product-todo-karten.png",
|
||||
},
|
||||
{ productName: "Wochenplaner – Überblick", quantity: 2, unitPrice: 14.9, imageUrl: null },
|
||||
],
|
||||
subtotal: 42.7,
|
||||
shippingCost: 0,
|
||||
@@ -146,7 +156,14 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
const rows = order.items
|
||||
.map(
|
||||
(item) => `<tr>
|
||||
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span></td>
|
||||
<td width="52" style="padding:10px 0;border-bottom:1px solid ${BORDER};">
|
||||
${
|
||||
item.imageUrl
|
||||
? `<img src="${item.imageUrl}" width="44" height="44" alt="" style="display:block;width:44px;height:44px;border-radius:6px;object-fit:cover;border:1px solid ${BORDER};" />`
|
||||
: `<div style="width:44px;height:44px;border-radius:6px;background:${BG_MUTED};"></div>`
|
||||
}
|
||||
</td>
|
||||
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span></td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;font-size:14px;color:${TEXT_PRIMARY};">${formatPrice(item.quantity * item.unitPrice)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
@@ -158,7 +175,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
const body = `
|
||||
${paragraphs(template.bodyText, "center")}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;background:${BG_MUTED};border-radius:8px;padding:20px;">
|
||||
<tr><td colspan="2" style="padding-bottom:10px;font-size:12px;color:${TEXT_MUTED};">Bestellnummer <strong style="color:${TEXT_PRIMARY};">${escapeHtml(order.orderNumber)}</strong> · ${formatDate(order.createdAt)}</td></tr>
|
||||
<tr><td colspan="3" style="padding-bottom:10px;font-size:12px;color:${TEXT_MUTED};">Bestellnummer <strong style="color:${TEXT_PRIMARY};">${escapeHtml(order.orderNumber)}</strong> · ${formatDate(order.createdAt)}</td></tr>
|
||||
${rows}
|
||||
</table>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;">
|
||||
@@ -177,6 +194,34 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
return emailShell("✓", escapeHtml(template.heading), body, template.footerText);
|
||||
}
|
||||
|
||||
// Icon shown per status — matches the Payload-side send exactly (see
|
||||
// STATUS_EMAIL in the backend repo's src/collections/Orders.ts), kept here
|
||||
// only for the Live Preview approximation (the real send happens from
|
||||
// Payload itself, not this repo — see that file's own comment on why the
|
||||
// two aren't pixel-identical, same established gap as password-reset).
|
||||
export const ORDER_STATUS_EMAIL_ICON: Record<string, string> = {
|
||||
"order-shipped": "→",
|
||||
"order-cancelled": "✕",
|
||||
"order-return-requested": "↩",
|
||||
"order-returned": "✓",
|
||||
};
|
||||
|
||||
export function renderOrderStatusHtml(template: EmailTemplateContent, icon: string, orderNumber: string, orderUrl: string): string {
|
||||
const body = `
|
||||
${paragraphs(template.bodyText, "center")}
|
||||
<p style="text-align:center;font-size:13px;color:${TEXT_MUTED};margin:0 0 4px;">Bestellnummer ${escapeHtml(orderNumber)}</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
|
||||
<tr>
|
||||
<td style="background:${BRAND};border-radius:6px;">
|
||||
<a href="${orderUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">Bestellung ansehen</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`;
|
||||
|
||||
return emailShell(icon, escapeHtml(template.heading), body, template.footerText);
|
||||
}
|
||||
|
||||
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string): string {
|
||||
const body = `
|
||||
${paragraphs(template.bodyText, "center")}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getInvoiceSettings } from "./payload";
|
||||
import { renderInvoicePdf, type InvoiceOrder } from "./invoicePdf";
|
||||
|
||||
// Shared by app/lib/orderEmail.ts (checkout — attaches to the confirmation
|
||||
// mail) and app/api/account/orders/[orderNumber]/invoice/route.ts (on-demand
|
||||
// download) — same seller lookup + render call both times, so what a
|
||||
// customer downloads later always matches what they were emailed.
|
||||
export async function generateInvoicePdf(order: InvoiceOrder): Promise<Buffer | null> {
|
||||
const seller = await getInvoiceSettings();
|
||||
if (!seller) {
|
||||
console.error("generateInvoicePdf: no invoice-settings row found for tenant");
|
||||
return null;
|
||||
}
|
||||
return renderInvoicePdf(order, seller);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import React from "react";
|
||||
import { Document, Page, View, Text, StyleSheet, renderToBuffer } from "@react-pdf/renderer";
|
||||
import { formatPrice, formatDate } from "./format";
|
||||
|
||||
// Generated synchronously in the checkout request (see app/lib/orderEmail.ts)
|
||||
// and attached to the order-confirmation email, plus available on-demand via
|
||||
// app/api/account/orders/[orderNumber]/invoice/route.ts — same render
|
||||
// function both times, so a re-download always matches what was emailed.
|
||||
//
|
||||
// Built-in Helvetica, not a registered web font — this renders inside the
|
||||
// checkout request's own fire-and-forget email step; a font-fetch failure
|
||||
// there is one more way to lose the invoice attachment for no real design
|
||||
// benefit. Same choice as the Payload-side correction-invoice PDF
|
||||
// (src/lib/correctionInvoicePdf.tsx in the backend repo) — brand color via
|
||||
// StyleSheet, not Georgia/Playfair.
|
||||
const BRAND = "#f6a701";
|
||||
const TEXT_MUTED = "#6b6b69";
|
||||
const BORDER = "#e5e0d8";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { padding: 48, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" },
|
||||
wordmark: { fontFamily: "Helvetica-Bold", fontSize: 13, marginBottom: 32 },
|
||||
kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 20, color: BRAND, marginBottom: 24 },
|
||||
addressRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 28 },
|
||||
addressBlock: { width: "45%" },
|
||||
addressLabel: { fontSize: 8, color: TEXT_MUTED, marginBottom: 4, textTransform: "uppercase" },
|
||||
addressLine: { fontSize: 10, lineHeight: 1.5 },
|
||||
metaRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 20 },
|
||||
metaLabel: { fontSize: 8, color: TEXT_MUTED },
|
||||
metaValue: { fontSize: 10, fontFamily: "Helvetica-Bold" },
|
||||
table: { marginTop: 8, borderTopWidth: 1, borderTopColor: BORDER },
|
||||
tableHeader: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: BORDER, paddingVertical: 6 },
|
||||
tableRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: BORDER, paddingVertical: 6 },
|
||||
colName: { flex: 3 },
|
||||
colQty: { flex: 1, textAlign: "right" },
|
||||
colPrice: { flex: 1, textAlign: "right" },
|
||||
colTotal: { flex: 1, textAlign: "right" },
|
||||
headerCell: { fontSize: 8, color: TEXT_MUTED, textTransform: "uppercase" },
|
||||
summary: { marginTop: 16, alignItems: "flex-end" },
|
||||
summaryRow: { flexDirection: "row", width: 220, justifyContent: "space-between", paddingVertical: 2 },
|
||||
summaryLabel: { fontSize: 10, color: TEXT_MUTED },
|
||||
summaryValue: { fontSize: 10 },
|
||||
grandTotalRow: {
|
||||
flexDirection: "row",
|
||||
width: 220,
|
||||
justifyContent: "space-between",
|
||||
paddingTop: 8,
|
||||
marginTop: 6,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: BORDER,
|
||||
},
|
||||
grandTotalLabel: { fontSize: 12, fontFamily: "Helvetica-Bold" },
|
||||
grandTotalValue: { fontSize: 12, fontFamily: "Helvetica-Bold" },
|
||||
footer: {
|
||||
position: "absolute",
|
||||
bottom: 40,
|
||||
left: 48,
|
||||
right: 48,
|
||||
fontSize: 8,
|
||||
color: TEXT_MUTED,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: BORDER,
|
||||
paddingTop: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export type InvoiceItem = { productName: string; quantity: number; unitPrice: number };
|
||||
|
||||
export type InvoiceOrder = {
|
||||
orderNumber: string;
|
||||
invoiceNumber: string;
|
||||
invoiceIssuedAt: string;
|
||||
customerFirstName: string;
|
||||
customerLastName: string;
|
||||
deliveryMethod: "address" | "packstation";
|
||||
street?: string | null;
|
||||
packstationNumber?: string | null;
|
||||
postNumber?: string | null;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
items: InvoiceItem[];
|
||||
subtotal: number;
|
||||
shippingCost: number;
|
||||
discountAmount: number;
|
||||
discountCode: string | null;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type InvoiceSeller = {
|
||||
sellerName: string;
|
||||
sellerStreet: string;
|
||||
sellerZip: string;
|
||||
sellerCity: string;
|
||||
sellerCountry: string;
|
||||
sellerEmail: string;
|
||||
vatId: string;
|
||||
taxRatePercent: number;
|
||||
bankDetails?: string | null;
|
||||
};
|
||||
|
||||
function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller: InvoiceSeller }) {
|
||||
const rate = seller.taxRatePercent;
|
||||
const grossTotal = order.total;
|
||||
const netTotal = grossTotal / (1 + rate / 100);
|
||||
const taxTotal = grossTotal - netTotal;
|
||||
const deliveryLine =
|
||||
order.deliveryMethod === "address" ? order.street : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`;
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<Text style={styles.wordmark}>einfach produktiv.</Text>
|
||||
<Text style={styles.kindLabel}>Rechnung</Text>
|
||||
|
||||
<View style={styles.addressRow}>
|
||||
<View style={styles.addressBlock}>
|
||||
<Text style={styles.addressLabel}>Von</Text>
|
||||
<Text style={styles.addressLine}>{seller.sellerName}</Text>
|
||||
<Text style={styles.addressLine}>{seller.sellerStreet}</Text>
|
||||
<Text style={styles.addressLine}>
|
||||
{seller.sellerZip} {seller.sellerCity}
|
||||
</Text>
|
||||
<Text style={styles.addressLine}>{seller.sellerCountry}</Text>
|
||||
</View>
|
||||
<View style={styles.addressBlock}>
|
||||
<Text style={styles.addressLabel}>An</Text>
|
||||
<Text style={styles.addressLine}>
|
||||
{order.customerFirstName} {order.customerLastName}
|
||||
</Text>
|
||||
<Text style={styles.addressLine}>{deliveryLine}</Text>
|
||||
<Text style={styles.addressLine}>
|
||||
{order.zip} {order.city}
|
||||
</Text>
|
||||
<Text style={styles.addressLine}>{order.country}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<View>
|
||||
<Text style={styles.metaLabel}>Rechnungs-Nr.</Text>
|
||||
<Text style={styles.metaValue}>{order.invoiceNumber}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.metaLabel}>Datum</Text>
|
||||
<Text style={styles.metaValue}>{formatDate(order.invoiceIssuedAt)}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.metaLabel}>Bestellnummer</Text>
|
||||
<Text style={styles.metaValue}>{order.orderNumber}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.metaLabel}>USt-IdNr.</Text>
|
||||
<Text style={styles.metaValue}>{seller.vatId}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={[styles.colName, styles.headerCell]}>Artikel</Text>
|
||||
<Text style={[styles.colQty, styles.headerCell]}>Menge</Text>
|
||||
<Text style={[styles.colPrice, styles.headerCell]}>Einzelpreis</Text>
|
||||
<Text style={[styles.colTotal, styles.headerCell]}>Betrag</Text>
|
||||
</View>
|
||||
{order.items.map((item, i) => (
|
||||
<View style={styles.tableRow} key={i}>
|
||||
<Text style={styles.colName}>{item.productName}</Text>
|
||||
<Text style={styles.colQty}>{item.quantity}</Text>
|
||||
<Text style={styles.colPrice}>{formatPrice(item.unitPrice)}</Text>
|
||||
<Text style={styles.colTotal}>{formatPrice(item.quantity * item.unitPrice)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.summary}>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Zwischensumme</Text>
|
||||
<Text style={styles.summaryValue}>{formatPrice(order.subtotal)}</Text>
|
||||
</View>
|
||||
{order.discountAmount > 0 && (
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Rabatt{order.discountCode ? ` (${order.discountCode})` : ""}</Text>
|
||||
<Text style={styles.summaryValue}>-{formatPrice(order.discountAmount)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Versand</Text>
|
||||
<Text style={styles.summaryValue}>{order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Netto</Text>
|
||||
<Text style={styles.summaryValue}>{formatPrice(netTotal)}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>zzgl. {rate}% MwSt.</Text>
|
||||
<Text style={styles.summaryValue}>{formatPrice(taxTotal)}</Text>
|
||||
</View>
|
||||
<View style={styles.grandTotalRow}>
|
||||
<Text style={styles.grandTotalLabel}>Gesamt</Text>
|
||||
<Text style={styles.grandTotalValue}>{formatPrice(grossTotal)}</Text>
|
||||
</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 }}>{seller.bankDetails}</Text> : null}
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderInvoicePdf(order: InvoiceOrder, seller: InvoiceSeller): Promise<Buffer> {
|
||||
return renderToBuffer(<InvoiceDocument order={order} seller={seller} />);
|
||||
}
|
||||
+63
-1
@@ -1,6 +1,26 @@
|
||||
import { transport } from "./mailer";
|
||||
import { getEmailTemplate } from "./payload";
|
||||
import { renderOrderConfirmationHtml, type OrderConfirmationData } from "./emailTemplates";
|
||||
import { generateInvoicePdf } from "./invoiceData";
|
||||
import { sendCriticalAlert } from "./alertAdmin";
|
||||
|
||||
// Superset of OrderConfirmationData (used for the HTML render) plus the
|
||||
// address/invoice fields the PDF needs but the email body doesn't —
|
||||
// avoids threading a second parallel "order" argument through this
|
||||
// function for what's ultimately one order.
|
||||
export type OrderConfirmationEmailData = OrderConfirmationData & {
|
||||
invoiceNumber: string;
|
||||
invoiceIssuedAt: string;
|
||||
customerFirstName: string;
|
||||
customerLastName: string;
|
||||
deliveryMethod: "address" | "packstation";
|
||||
street?: string | null;
|
||||
packstationNumber?: string | null;
|
||||
postNumber?: string | null;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
};
|
||||
|
||||
// Called from app/api/checkout/route.ts right after a successful
|
||||
// createOrder() — fire-and-forget, must never block or fail the checkout
|
||||
@@ -9,7 +29,15 @@ import { renderOrderConfirmationHtml, type OrderConfirmationData } from "./email
|
||||
// nobody's visited the Payload admin yet (mirrors seed-email-templates.ts's
|
||||
// defaults, kept in sync by hand — there are only two places this wording
|
||||
// lives, seeding it is a one-time setup step, not a runtime dependency).
|
||||
export async function sendOrderConfirmationEmail(order: OrderConfirmationData, customerEmail: string): Promise<boolean> {
|
||||
//
|
||||
// The invoice PDF is generated here too (not a separate fire-and-forget
|
||||
// step) so it can ride along as an attachment on this same send, per the
|
||||
// "always emailed, not just downloadable" checkout decision — but its own
|
||||
// failure must not sink the confirmation email itself, so it's wrapped in
|
||||
// its own try/catch and just sends without the attachment if generation
|
||||
// fails (still alerted, same severity as the frontend's own critical-error
|
||||
// path for this checkout flow).
|
||||
export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailData, customerEmail: string): Promise<boolean> {
|
||||
const template = (await getEmailTemplate("order-confirmation")) ?? {
|
||||
subject: "Bestellt! Deine Ruhe kann kommen 🎉",
|
||||
heading: "Geschafft!",
|
||||
@@ -18,11 +46,45 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationData, c
|
||||
};
|
||||
|
||||
const html = renderOrderConfirmationHtml(template, order);
|
||||
|
||||
let attachments: { filename: string; content: Buffer }[] | undefined;
|
||||
try {
|
||||
const pdf = await generateInvoicePdf({
|
||||
orderNumber: order.orderNumber,
|
||||
invoiceNumber: order.invoiceNumber,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt,
|
||||
customerFirstName: order.customerFirstName,
|
||||
customerLastName: order.customerLastName,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
street: order.street,
|
||||
packstationNumber: order.packstationNumber,
|
||||
postNumber: order.postNumber,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
country: order.country,
|
||||
items: order.items,
|
||||
subtotal: order.subtotal,
|
||||
shippingCost: order.shippingCost,
|
||||
discountAmount: order.discountAmount,
|
||||
discountCode: order.discountCode,
|
||||
total: order.total,
|
||||
});
|
||||
if (pdf) attachments = [{ filename: `Rechnung-${order.invoiceNumber}.pdf`, content: pdf }];
|
||||
else throw new Error("generateInvoicePdf returned null (missing invoice-settings?)");
|
||||
} catch (err) {
|
||||
sendCriticalAlert("Rechnungs-PDF konnte nicht erzeugt werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
invoiceNumber: order.invoiceNumber,
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
|
||||
await transport.sendMail({
|
||||
from: '"einfach produktiv" <admin@mk360.de>',
|
||||
to: customerEmail,
|
||||
subject: template.subject,
|
||||
html,
|
||||
attachments,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export type CreateOrderInput = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type CreatedOrder = { orderNumber: string; createdAt: string };
|
||||
export type CreatedOrder = { orderNumber: string; createdAt: string; invoiceNumber: string; invoiceIssuedAt: string };
|
||||
|
||||
export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder | null> {
|
||||
const tenantId = await resolveTenantId();
|
||||
@@ -99,6 +99,11 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: { doc: { orderNumber: string; createdAt: string } } = await res.json();
|
||||
return { orderNumber: data.doc.orderNumber, createdAt: data.doc.createdAt };
|
||||
const data: { doc: { orderNumber: string; createdAt: string; invoiceNumber: string; invoiceIssuedAt: string } } = await res.json();
|
||||
return {
|
||||
orderNumber: data.doc.orderNumber,
|
||||
createdAt: data.doc.createdAt,
|
||||
invoiceNumber: data.doc.invoiceNumber,
|
||||
invoiceIssuedAt: data.doc.invoiceIssuedAt,
|
||||
};
|
||||
}
|
||||
|
||||
+39
-1
@@ -604,7 +604,13 @@ export async function getLegalPage(type: LegalPageType, options?: { draft?: bool
|
||||
};
|
||||
}
|
||||
|
||||
export type EmailTemplateType = "order-confirmation" | "password-reset";
|
||||
export type EmailTemplateType =
|
||||
| "order-confirmation"
|
||||
| "password-reset"
|
||||
| "order-shipped"
|
||||
| "order-cancelled"
|
||||
| "order-return-requested"
|
||||
| "order-returned";
|
||||
|
||||
type PayloadEmailTemplate = {
|
||||
type: EmailTemplateType;
|
||||
@@ -636,3 +642,35 @@ export async function getEmailTemplate(
|
||||
const data: { docs?: PayloadEmailTemplate[] } = await res.json();
|
||||
return data.docs?.[0] ?? null;
|
||||
}
|
||||
|
||||
export type InvoiceSettings = {
|
||||
sellerName: string;
|
||||
sellerStreet: string;
|
||||
sellerZip: string;
|
||||
sellerCity: string;
|
||||
sellerCountry: string;
|
||||
sellerEmail: string;
|
||||
vatId: string;
|
||||
taxRatePercent: number;
|
||||
bankDetails: string | null;
|
||||
};
|
||||
|
||||
// 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
|
||||
// to leave publicly readable like Products/ShippingSettings.
|
||||
export async function getInvoiceSettings(): Promise<InvoiceSettings | null> {
|
||||
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/invoice-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}`);
|
||||
return null;
|
||||
}
|
||||
const data: { docs?: InvoiceSettings[] } = await res.json();
|
||||
return data.docs?.[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,20 @@
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const TENANT_SLUG = "einfach-produktiv";
|
||||
|
||||
export type RawProduct = { id: number; slug: string; name: string; price: number; active: boolean };
|
||||
export type RawProduct = {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
price: number;
|
||||
active: boolean;
|
||||
image: { url: string } | number | null;
|
||||
};
|
||||
|
||||
export async function fetchProductsBySlug(): Promise<Map<string, RawProduct>> {
|
||||
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "100" });
|
||||
// depth=1 so `image` resolves to { url } instead of just the media id —
|
||||
// needed for the order-confirmation email's product thumbnails
|
||||
// (app/lib/orderEmail.ts), not just re-pricing.
|
||||
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, depth: "1", limit: "100" });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { cache: "no-store" });
|
||||
const map = new Map<string, RawProduct>();
|
||||
if (!res.ok) return map;
|
||||
|
||||
Reference in New Issue
Block a user