feat: Rechnungen werden abgelegt, gesichert und mitversendet
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Die Rechnung entstand bisher nur als Datenbankzeile; das PDF wurde bei jedem Abruf neu gerendert und tauchte in keiner E-Mail auf. Das hatte drei Nachteile: Gastbestellungen haben kein Konto, in dem sie den Beleg finden koennten; eine Sicherung der Datenbank sicherte den Beleg nicht mit; und ein spaeter erneut gerendertes PDF konnte nach einer Aenderung an Firmendaten oder Layout anders aussehen als das, was die Kundschaft in Haenden hielt. Ablage jetzt unter INVOICE_DIR, nach Jahr und Kunde sortiert: invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf Das Jahr zuerst, weil sich die Aufbewahrungsfrist danach richtet — ein abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren. Der relative Pfad steht als Invoice.pdfPath in der Datenbank, damit eine spaetere Aenderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar macht. Dafuer wurde die nie gelesene Spalte "pdfUrl" umbenannt; eine URL war ohnehin das falsche Konzept. INVOICE_DIR ist bewusst ein ANDERES Verzeichnis als UPLOAD_DIR: app/uploads/[...path] liefert jede Datei unterhalb von UPLOAD_DIR ohne Anmeldung aus. Laegen Rechnungen dort, koennte jede Person, die den Pfad erraet, fremde Rechnungen samt Anschrift und Bestellinhalt herunterladen. Auf das Rechnungsverzeichnis zeigt keine oeffentliche Route; der Abruf laeuft weiter ueber /rechnung/<nummer>/pdf mit Besitzpruefung. Der Abruf liefert jetzt die abgelegte Datei aus und rendert nur neu, wenn keine da ist — also bei Rechnungen aus der Zeit davor. Versand: das PDF haengt an der Bestellbestaetigung. sendMail nimmt dafuer optionale Anhaenge entgegen, alle zehn bestehenden Aufrufe bleiben unveraendert. Schlaegt das Erzeugen oder Ablegen fehl, geht die Bestaetigung trotzdem raus und die Rechnung bleibt abrufbar: ein 500er an Stripe waere die falsche Antwort auf eine volle Platte, denn die Zahlung ist erfolgt und ein Wiederholungslauf aenderte daran nichts. Sicherung: der taegliche CronJob packt das Verzeichnis als rechnungen-<Zeitstempel>.tar.gz neben den Datenbank-Dump und haengt das Volume nur lesend ein. Vermerkt ist dort auch die Einschraenkung, dass wabenhain-invoices als ReadWriteOnce nur von Pods auf demselben Knoten einhaengbar ist — bei mehreren Knoten braucht es ReadWriteMany, sonst bleibt der Sicherungs-Pod lautlos in "Pending". Weiterhin offen und im README vermerkt: die Rechnungsnummer ist eindeutig, aber nicht fortlaufend (§ 14 Abs. 4 Nr. 4 UStG), und das PDF weist keine Umsatzsteuer aus. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c525735285
commit
70ede77dde
14 files changed
+442
-11
No files matched your search
@@ -6,9 +6,59 @@ import { sendMail } from "@/lib/mail";
|
||||
import { orderConfirmationEmail, paymentFailedEmail } from "@/lib/email-templates";
|
||||
import { restockOrderItems } from "@/lib/inventory";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { getInvoiceByNumber } from "@/lib/account-data";
|
||||
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
||||
import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* Erzeugt das Rechnungs-PDF, legt es nach Jahr und Kunde sortiert ab und
|
||||
* vermerkt den Pfad an der Rechnung. Gibt das Dokument zurueck, damit es der
|
||||
* Bestellbestaetigung angehaengt werden kann — oder null, wenn das nicht
|
||||
* geklappt hat.
|
||||
*
|
||||
* Bewusst mit eigener Fehlerbehandlung statt im umgebenden try/catch: dort
|
||||
* fuehrt jeder Fehler zu einem 500er an Stripe und damit zu einem erneuten
|
||||
* Zustellversuch. Fuer eine volle Platte oder ein nicht eingehaengtes Volume
|
||||
* waere das die falsche Antwort — die Zahlung ist erfolgt, die Bestellung
|
||||
* steht, und die Rechnung laesst sich jederzeit nachtraeglich abrufen
|
||||
* (/rechnung/<nummer>/pdf erzeugt sie dann neu). Ein Wiederholungslauf
|
||||
* wuerde daran nichts aendern, nur die Bestaetigungsmail verzoegern.
|
||||
*
|
||||
* Gerendert wird aus derselben Quelle wie der spaetere Abruf
|
||||
* (getInvoiceByNumber), damit abgelegtes, versendetes und spaeter
|
||||
* angezeigtes Dokument garantiert dasselbe sind.
|
||||
*/
|
||||
async function storeInvoiceDocument(invoiceNumber: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const invoice = await getInvoiceByNumber(invoiceNumber);
|
||||
if (!invoice) return null;
|
||||
|
||||
const pdf = await generateInvoicePdf({
|
||||
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt,
|
||||
totalCents: invoice.totalCents,
|
||||
order: invoice.order,
|
||||
});
|
||||
|
||||
const relativePath = invoiceRelativePath({
|
||||
year: invoice.issuedAt.getFullYear(),
|
||||
lastName: invoice.order.lastName,
|
||||
firstName: invoice.order.firstName,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
});
|
||||
await storeInvoicePdf(relativePath, pdf);
|
||||
await prisma.invoice.update({ where: { id: invoice.id }, data: { pdfPath: relativePath } });
|
||||
|
||||
return pdf;
|
||||
} catch (error: unknown) {
|
||||
console.error(`[invoice] ${invoiceNumber} konnte nicht abgelegt werden:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet Stripe-Webhook-Events. In Stripe muss ein Webhook-Endpoint auf
|
||||
* /api/checkout/webhook mit den Events "checkout.session.completed",
|
||||
@@ -150,7 +200,7 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.invoice.upsert({
|
||||
const invoice = await prisma.invoice.upsert({
|
||||
where: { orderId: order.id },
|
||||
create: {
|
||||
orderId: order.id,
|
||||
@@ -160,6 +210,8 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
update: {},
|
||||
});
|
||||
|
||||
const invoicePdf = await storeInvoiceDocument(invoice.invoiceNumber);
|
||||
|
||||
const { subject, html, text } = await orderConfirmationEmail({
|
||||
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||
orderNumber: order.orderNumber,
|
||||
@@ -181,7 +233,27 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
giftMessage: order.giftMessage,
|
||||
trackingUrl: `${getBaseUrl()}/bestellung-verfolgen?order=${encodeURIComponent(order.orderNumber)}&email=${encodeURIComponent(order.email)}`,
|
||||
});
|
||||
await sendMail({ to: order.email, subject, html, text });
|
||||
await sendMail({
|
||||
to: order.email,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
// Die Rechnung liegt der Bestaetigung bei, statt nur im Konto zu
|
||||
// warten: Gastbestellungen haben gar kein Konto, und ein Beleg, den
|
||||
// man suchen muss, wird nicht abgeheftet. Fehlt das PDF (siehe
|
||||
// storeInvoiceDocument), geht die Bestaetigung trotzdem raus.
|
||||
...(invoicePdf
|
||||
? {
|
||||
attachments: [
|
||||
{
|
||||
filename: `${invoice.invoiceNumber}.pdf`,
|
||||
content: invoicePdf,
|
||||
contentType: "application/pdf",
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// Laut sichtbar machen (statt wie zuvor unbehandelt bis in den
|
||||
// Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getInvoiceForCustomer, getInvoiceByNumber } from "@/lib/account-data";
|
||||
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
||||
import { readStoredInvoicePdf } from "@/lib/invoice-storage";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
|
||||
export async function GET(
|
||||
@@ -23,13 +24,24 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const pdf = await generateInvoicePdf({
|
||||
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt,
|
||||
totalCents: invoice.totalCents,
|
||||
order: invoice.order,
|
||||
});
|
||||
// Zuerst das abgelegte Dokument: eine Rechnung ist ein Beleg, und der muss
|
||||
// spaeter genauso aussehen wie das, was die Kundschaft per E-Mail bekommen
|
||||
// hat. Ein Neu-Rendern wuerde nach einer Aenderung an Firmendaten oder am
|
||||
// Layout ein ABWEICHENDES Dokument unter derselben Rechnungsnummer
|
||||
// ausliefern (siehe lib/invoice-storage.ts).
|
||||
//
|
||||
// Neu erzeugt wird nur, wenn keine Datei da ist: bei Rechnungen aus der
|
||||
// Zeit vor dieser Ablage, oder wenn das Ablegen seinerzeit fehlschlug.
|
||||
const stored = invoice.pdfPath ? await readStoredInvoicePdf(invoice.pdfPath) : null;
|
||||
const pdf =
|
||||
stored ??
|
||||
(await generateInvoicePdf({
|
||||
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt,
|
||||
totalCents: invoice.totalCents,
|
||||
order: invoice.order,
|
||||
}));
|
||||
|
||||
return new NextResponse(new Uint8Array(pdf), {
|
||||
headers: {
|
||||
|
||||
Reference in new issue
Block a user