feat(invoice): Stornorechnung und Rechnungskopie an Admin-Adresse

Rechnungskopie: unter Einstellungen > Firma > "Rechnungskopie an" laesst sich
eine Adresse hinterlegen; jede Rechnung und Stornorechnung geht zusaetzlich
als eigene Mail mit derselben E-Rechnung dorthin (lib/invoice-mail.ts).

Stornorechnung: eine Rechnung wird nie geloescht. Bei Stornierung im Admin,
erstatteter Retoure oder voller Erstattung bei Stripe entsteht genau eine
Stornorechnung ST-JJJJ-NNNN (eigener lueckenloser Nummernkreis) mit allen
Positionen und Bezug auf die Rechnung, als E-Rechnung (Gutschrift 381) an
die Kundschaft und als Kopie. Gleichzeitige Ausloeser (Admin-Storno und
Stripe-Webhook derselben Erstattung) enden bei einer Stornorechnung.
Bestellungen mit Stornorechnung lassen sich nicht wieder oeffnen.

ERP-Export: GET /api/erp/rechnungen?art=storno liefert Stornorechnungen mit
Bezug (cancels), Rechnungen tragen cancelledBy; PDFs auch fuer ST-Nummern.
Admin-Bestelldetail zeigt die Stornorechnung mit Download.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 06:19:42 +02:00
1 parent 508ff24aa7
commit 4d1bcbbdb7
22 files changed
+809 -16

No files matched your search

+19
View File
@@ -458,6 +458,25 @@ Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine
Vier Stellen halten die Nummern gleich lang und damit sortierbar. Gibt es in einem Jahr mehr als 9999 Rechnungen, wächst die Nummer mit, statt abgeschnitten zu werden. Vier Stellen halten die Nummern gleich lang und damit sortierbar. Gibt es in einem Jahr mehr als 9999 Rechnungen, wächst die Nummer mit, statt abgeschnitten zu werden.
### E-Rechnung
Jedes Rechnungs-PDF ist eine hybride E-Rechnung nach **ZUGFeRD 2.x / Factur-X, Profil EN 16931**: ein PDF/A-3b mit eingebettetem `factur-x.xml` (UN/CEFACT CII D16B). Das XML ist der maßgebliche Teil; Buchhaltungsprogramme (ERPNext, DATEV, lexoffice …) lesen es direkt. Erzeugt in `lib/einvoice/cii.ts` (XML) und `lib/einvoice/facturx-pdf.ts` (PDF/A, XMP-Metadaten, Anhang).
- **Summen:** Netto und Steuer je Satz kommen unverändert aus der Aufstellung des PDFs. Die Positionen tragen ihren Nettoanteil (centgenau verteilt), Versand und Rabatt stehen als Zu- und Abschlag je Satz. Bei Bruttopreisen kann ein Prüfprogramm, das die Steuer aus dem Netto nachrechnet, einen Cent Unterschied melden; EN 16931 lässt das ausdrücklich zu (BR-S-09/BR-CO-17), der Steuerbetrag entspricht dem aus dem Bruttopreis herausgerechneten.
- **Schriften:** PDF/A verlangt eingebettete Schriften. Statt Helvetica/Times werden Liberation Sans/Serif aus `assets/fonts` eingebettet (gleiche Laufweiten, SIL Open Font License); das Dockerfile kopiert das Verzeichnis ins Image.
- **Ohne USt-IdNr.** (Einstellungen → Firma) oder bei Positionen ohne Steuersatz entsteht nur das PDF, mit Warnung im Log.
- Geprüft mit XSD und CEN-Schematron EN 16931 sowie Mustang (veraPDF): PDF/A-3b konform, XML gültig.
### Rechnungskopie
Unter Einstellungen → Firma → **„Rechnungskopie an“** lässt sich eine Adresse hinterlegen (z. B. die Buchhaltung). Jede Rechnung und jede Stornorechnung geht dann zusätzlich als eigene Mail mit derselben E-Rechnung dorthin (`lib/invoice-mail.ts`). Leer = keine Kopie.
### Stornorechnung
Eine ausgestellte Rechnung wird nie gelöscht oder geändert. Wird eine Bestellung mit Rechnung storniert (Admin), eine Retoure erstattet oder die Zahlung bei Stripe voll erstattet, entsteht **eine** Stornorechnung `ST-<Jahr>-<laufende Nummer>` (eigener Nummernkreis, Tabelle `cancellation_counters`) mit allen Positionen der Rechnung, negativen Beträgen auf dem PDF und Bezug auf die Rechnung (XML: Gutschrift 381 mit Verweis). Sie geht als E-Rechnung an die Kundschaft und als Kopie an die hinterlegte Adresse. Laufen Admin-Stornierung und Stripe-Webhook derselben Erstattung gleichzeitig, bleibt es bei einer Stornorechnung (eindeutige `invoiceId`, die zweite Transaktion rollt samt Zähler zurück). Eine Bestellung mit Stornorechnung lässt sich nicht wieder öffnen. Teilerstattungen bei Stripe erzeugen keine Stornorechnung (Hinweis im Log).
ERPNext holt Stornorechnungen über `GET /api/erp/rechnungen?seit=…&art=storno` und bucht sie als Rechnungskorrektur gegen die Ausgangsrechnung; die PDFs liegen unter `/api/erp/rechnungen/ST-…/pdf`.
## Health-Endpunkte ## Health-Endpunkte
Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe: Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe:
@@ -0,0 +1,35 @@
-- Stornorechnungen: Gegenbeleg mit eigenem Nummernkreis (ST-JJJJ-NNNN) statt
-- Loeschen oder Aendern einer Rechnung. Rein additiv.
-- CreateTable
CREATE TABLE "cancellation_counters" (
"year" INTEGER NOT NULL,
"lastNumber" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "cancellation_counters_pkey" PRIMARY KEY ("year")
);
-- CreateTable
CREATE TABLE "cancellation_invoices" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"number" TEXT NOT NULL,
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"totalCents" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"pdfPath" TEXT,
CONSTRAINT "cancellation_invoices_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "cancellation_invoices_invoiceId_key" ON "cancellation_invoices"("invoiceId");
-- CreateIndex
CREATE UNIQUE INDEX "cancellation_invoices_number_key" ON "cancellation_invoices"("number");
-- CreateIndex
CREATE INDEX "cancellation_invoices_issuedAt_idx" ON "cancellation_invoices"("issuedAt");
-- AddForeignKey
ALTER TABLE "cancellation_invoices" ADD CONSTRAINT "cancellation_invoices_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+37 -1
View File
@@ -710,11 +710,47 @@ model Invoice {
/// einem oeffentlich abrufbaren Pfad. /// einem oeffentlich abrufbaren Pfad.
pdfPath String? pdfPath String?
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
cancellation CancellationInvoice?
@@map("invoices") @@map("invoices")
} }
/// Eigener fortlaufender Nummernkreis der Stornorechnungen (ST-JJJJ-NNNN),
/// je Kalenderjahr — aus demselben Grund eine Zeile mit Sperre wie
/// InvoiceCounter.
model CancellationCounter {
year Int @id
lastNumber Int @default(0)
@@map("cancellation_counters")
}
/// Stornorechnung: hebt eine Rechnung vollstaendig auf. Eine Rechnung wird
/// nie geloescht oder geaendert (§ 14 UStG, GoBD); wird die Bestellung
/// storniert oder voll erstattet, entsteht stattdessen dieser Gegenbeleg mit
/// eigener Nummer und Bezug auf die Rechnung (lib/cancellation-invoice.ts).
/// Hoechstens einer je Rechnung. ERPNext uebernimmt ihn als
/// Rechnungskorrektur, die Ausgangsrechnung steht dort dann auf
/// "Credit Note Issued".
model CancellationInvoice {
id String @id @default(cuid())
invoiceId String @unique
number String @unique
issuedAt DateTime @default(now())
/// Betrag der aufgehobenen Rechnung (brutto, positiv).
totalCents Int
/// STORNIERUNG (Admin), RETOURE (Retoure erstattet) oder ERSTATTUNG (volle Erstattung bei Stripe).
reason String
/// Wie Invoice.pdfPath, relativ zu INVOICE_DIR.
pdfPath String?
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
@@index([issuedAt])
@@map("cancellation_invoices")
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Marketing / Kontakt // Marketing / Kontakt
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+11 -1
View File
@@ -9,6 +9,7 @@ import { requireAdmin } from "./_shared";
import { fail, succeed } from "@/lib/flash"; import { fail, succeed } from "@/lib/flash";
import { normalizeContactEmail } from "@/lib/contact-email"; import { normalizeContactEmail } from "@/lib/contact-email";
import { settingsPathForGroup } from "@/lib/settings-tabs"; import { settingsPathForGroup } from "@/lib/settings-tabs";
import { INVOICE_COPY_KEY } from "@/lib/invoice-mail";
const COMPANY_EMAIL_KEY = "company.email"; const COMPANY_EMAIL_KEY = "company.email";
@@ -64,7 +65,16 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
"Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. [email protected], info [at] wabenhain.com oder info [á] wabenhain.com." "Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. [email protected], info [at] wabenhain.com oder info [á] wabenhain.com."
); );
} }
updates.push({ key: def.key, valueDe: def.key === COMPANY_EMAIL_KEY ? valueDe.trim() : valueDe, valueEn: null }); // Die Rechnungskopie geht an genau diese Adresse: leer (keine Kopie) oder
// eine echte Adresse, sonst verschwaenden die Kopien stillschweigend.
if (def.key === INVOICE_COPY_KEY && valueDe.trim() !== "" && normalizeContactEmail(valueDe) === null) {
return fail(
settingsPathForGroup(group),
"„Rechnungskopie an“ ist keine gültige E-Mail-Adresse. Bitte z. B. [email protected] eintragen oder das Feld leer lassen."
);
}
const trimmed = def.key === COMPANY_EMAIL_KEY || def.key === INVOICE_COPY_KEY;
updates.push({ key: def.key, valueDe: trimmed ? valueDe.trim() : valueDe, valueEn: null });
} }
} }
+23
View File
@@ -20,6 +20,7 @@ import { isLocale, defaultLocale } from "@/i18n/config";
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client"; import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
import { requireAdmin, str, strOrNull, fail } from "./_shared"; import { requireAdmin, str, strOrNull, fail } from "./_shared";
import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events"; import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events";
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule"; import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash"; import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
@@ -151,6 +152,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
payment: { select: { status: true, stripePaymentIntentId: true } }, payment: { select: { status: true, stripePaymentIntentId: true } },
items: { select: { productId: true, quantity: true, productName: true } }, items: { select: { productId: true, quantity: true, productName: true } },
returnRequest: { select: { status: true } }, returnRequest: { select: { status: true } },
invoice: { select: { cancellation: { select: { number: true } } } },
}, },
}); });
if (!previous) return null; if (!previous) return null;
@@ -159,6 +161,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
// Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne // Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne
// sie wird nichts veraendert. // sie wird nichts veraendert.
const reopening = needsConfirmToChangeStatus(previous.status, status); const reopening = needsConfirmToChangeStatus(previous.status, status);
// Mit Stornorechnung ist die Rechnung aufgehoben und das Geld erstattet;
// ein Wiederoeffnen waere ein Verkauf ohne Rechnung. Dann bitte eine neue
// Bestellung anlegen.
if (reopening && previous.invoice?.cancellation) {
return { ...previous, shipment: null, cancelledNow: false, blocked: "cancellation-invoice" as const };
}
if (reopening && !confirmedReopen) { if (reopening && !confirmedReopen) {
return { ...previous, shipment: null, cancelledNow: false, blocked: "needs-confirm" as const }; return { ...previous, shipment: null, cancelledNow: false, blocked: "needs-confirm" as const };
} }
@@ -278,6 +286,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
if (result.blocked === "changed") { if (result.blocked === "changed") {
return fail(detailPath, "Die Bestellung wurde zwischenzeitlich geändert. Bitte die Seite neu laden und erneut versuchen."); return fail(detailPath, "Die Bestellung wurde zwischenzeitlich geändert. Bitte die Seite neu laden und erneut versuchen.");
} }
if (result.blocked === "cancellation-invoice") {
return fail(
detailPath,
`Zu dieser Bestellung gibt es die Stornorechnung ${result.invoice?.cancellation?.number}. Sie kann nicht wieder geöffnet werden — bitte eine neue Bestellung anlegen.`
);
}
// undefined = keine E-Mail faellig; false = faellig, aber nicht versendet. // undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
let mailSent: boolean | undefined; let mailSent: boolean | undefined;
@@ -307,6 +321,15 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
stripeCheckoutSessionId: result.stripeCheckoutSessionId, stripeCheckoutSessionId: result.stripeCheckoutSessionId,
payment: result.payment, payment: result.payment,
}); });
// Hatte die Bestellung eine Rechnung, wird sie durch eine
// Stornorechnung aufgehoben (nie geloescht) — auch wenn die Erstattung
// oben scheiterte: verkauft ist dann trotzdem nichts mehr.
try {
await issueCancellationInvoice(orderId, "STORNIERUNG");
} catch (error: unknown) {
console.error(`[order-cancel] Stornorechnung fuer ${result.orderNumber} fehlgeschlagen:`, error);
stornoWarnings.push("Die Stornorechnung konnte nicht erstellt werden — bitte die Stornierung erneut auslösen oder den Support informieren.");
}
if (stornoWarnings.length > 0) { if (stornoWarnings.length > 0) {
await logAdminAction(admin, { await logAdminAction(admin, {
+14 -1
View File
@@ -29,7 +29,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
payment: true, payment: true,
shipment: true, shipment: true,
pickup: true, pickup: true,
invoice: true, invoice: { include: { cancellation: { select: { number: true } } } },
customer: { select: { email: true, firstName: true, lastName: true } }, customer: { select: { email: true, firstName: true, lastName: true } },
discountCode: { select: { code: true } }, discountCode: { select: { code: true } },
}, },
@@ -279,6 +279,19 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
> >
{t.adminOrderDetailPage.downloadPdf} {t.adminOrderDetailPage.downloadPdf}
</a> </a>
{order.invoice.cancellation && (
<>
<span className="mt-3">
{t.invoicePage.cancellationTitle} {order.invoice.cancellation.number}
</span>
<a
href={`/rechnung/${order.invoice.cancellation.number}/pdf`}
className="link-draw text-sm text-heading"
>
{t.adminOrderDetailPage.downloadPdf}
</a>
</>
)}
</div> </div>
) : ( ) : (
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p> <p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
+10
View File
@@ -11,6 +11,7 @@ import { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@
import { restockOrderItems } from "@/lib/inventory"; import { restockOrderItems } from "@/lib/inventory";
import { recordOrderReturn } from "@/lib/apicore-stock/stock-events"; import { recordOrderReturn } from "@/lib/apicore-stock/stock-events";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule"; import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
function str(formData: FormData, key: string): string { function str(formData: FormData, key: string): string {
@@ -122,6 +123,15 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
}); });
scheduleApiCoreStockSync(returnRequest.order.items.map((item) => item.productId)); scheduleApiCoreStockSync(returnRequest.order.items.map((item) => item.productId));
// Voll erstattet: die Rechnung wird durch eine Stornorechnung aufgehoben.
// Ein Fehler hier nimmt die Erstattung nicht zurueck; die Stornorechnung
// holt spaetestens der Stripe-Webhook der Erstattung nach.
try {
await issueCancellationInvoice(returnRequest.orderId, "RETOURE");
} catch (error: unknown) {
console.error(`[return-refund] Stornorechnung fuer ${returnRequest.order.orderNumber} fehlgeschlagen:`, error);
}
await logAdminAction(admin, { await logAdminAction(admin, {
action: "return_request.refund", action: "return_request.refund",
entityType: "ReturnRequest", entityType: "ReturnRequest",
@@ -58,6 +58,15 @@ vi.mock("@/lib/invoice-storage", () => ({
storeInvoicePdf: vi.fn(async () => undefined), storeInvoicePdf: vi.fn(async () => undefined),
})); }));
// Kopie an die Admin-Adresse und Stornorechnung haben eigene Tests; hier
// zaehlt, dass der Webhook sie anstoesst.
const sendInvoiceCopy = vi.fn(async () => true);
vi.mock("@/lib/invoice-mail", () => ({ sendInvoiceCopy: (...args: unknown[]) => sendInvoiceCopy(...(args as [])) }));
const issueCancellationInvoice = vi.fn(async () => ({ number: "ST-2026-0001", created: true }));
vi.mock("@/lib/cancellation-invoice", () => ({
issueCancellationInvoice: (...args: unknown[]) => issueCancellationInvoice(...(args as [])),
}));
/** /**
* Minimaler Prisma-Ersatz. `$transaction` reicht dieselbe Instanz als `tx` * Minimaler Prisma-Ersatz. `$transaction` reicht dieselbe Instanz als `tx`
* weiter — der Handler verlaesst sich auf die Atomaritaet der Datenbank, und * weiter — der Handler verlaesst sich auf die Atomaritaet der Datenbank, und
@@ -219,6 +228,10 @@ describe("Stripe-Webhook", () => {
// Die Rechnung liegt der Bestaetigung bei — Gastbestellungen haben kein // Die Rechnung liegt der Bestaetigung bei — Gastbestellungen haben kein
// Konto, in dem sie sonst zu finden waere. // Konto, in dem sie sonst zu finden waere.
expect(sendMail.mock.calls[0][0].attachments?.[0]?.filename).toBe("RE-2026-0001.pdf"); expect(sendMail.mock.calls[0][0].attachments?.[0]?.filename).toBe("RE-2026-0001.pdf");
// Kopie derselben E-Rechnung an die im Admin hinterlegte Adresse.
expect(sendInvoiceCopy).toHaveBeenCalledWith(
expect.objectContaining({ title: "Rechnung", number: "RE-2026-0001", customerEmail: paidOrder.email })
);
}); });
test("zaehlt einen Gutschein bei mehrfach zugestelltem Ereignis nur einmal", async () => { test("zaehlt einen Gutschein bei mehrfach zugestelltem Ereignis nur einmal", async () => {
@@ -352,6 +365,26 @@ describe("Stripe-Webhook", () => {
}); });
}); });
test("hebt die Rechnung bei vollstaendiger Erstattung per Stornorechnung auf", async () => {
// Auch wenn die Zahlung schon REFUNDED ist (Erstattung aus dem Admin):
// der Aufruf ist idempotent und findet die Stornorechnung nur wieder.
db.payment.findFirst.mockResolvedValue({
id: "pay_1",
status: "REFUNDED",
orderId: ORDER_ID,
order: { orderNumber: paidOrder.orderNumber },
});
constructEvent.mockReturnValue({
type: "charge.refunded",
data: { object: { payment_intent: "pi_test_1", refunded: true, amount: 2680, amount_refunded: 2680 } },
});
await post();
expect(issueCancellationInvoice).toHaveBeenCalledWith(ORDER_ID, "ERSTATTUNG");
expect(db.payment.update).not.toHaveBeenCalled();
});
test("laesst den Zahlungsstatus bei einer TEILerstattung unveraendert", async () => { test("laesst den Zahlungsstatus bei einer TEILerstattung unveraendert", async () => {
// Der Restbetrag ist weiterhin eingezogen — auf "REFUNDED" gesetzt // Der Restbetrag ist weiterhin eingezogen — auf "REFUNDED" gesetzt
// wuerde eine Auswertung den ganzen Umsatz abschreiben. // wuerde eine Auswertung den ganzen Umsatz abschreiben.
@@ -376,6 +409,7 @@ describe("Stripe-Webhook", () => {
expect(db.payment.update).not.toHaveBeenCalled(); expect(db.payment.update).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Teilerstattung")); expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Teilerstattung"));
expect(issueCancellationInvoice).not.toHaveBeenCalled();
}); });
test("haengt eine Rueckbuchung an die Notizen der Bestellung an, ohne bestehende zu verlieren", async () => { test("haengt eine Rueckbuchung an die Notizen der Bestellung an, ohne bestehende zu verlieren", async () => {
+20 -1
View File
@@ -11,6 +11,8 @@ import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
import { formatInvoiceNumber } from "@/lib/invoice-number"; import { formatInvoiceNumber } from "@/lib/invoice-number";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
import type Stripe from "stripe"; import type Stripe from "stripe";
import { sendInvoiceCopy } from "@/lib/invoice-mail";
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
import { recordOrderSale } from "@/lib/apicore-stock/stock-events"; import { recordOrderSale } from "@/lib/apicore-stock/stock-events";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule"; import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
@@ -174,7 +176,7 @@ async function recordRefund(charge: Stripe.Charge): Promise<void> {
const payment = await prisma.payment.findFirst({ const payment = await prisma.payment.findFirst({
where: { stripePaymentIntentId: paymentIntentId }, where: { stripePaymentIntentId: paymentIntentId },
select: { id: true, status: true, order: { select: { orderNumber: true } } }, select: { id: true, status: true, orderId: true, order: { select: { orderNumber: true } } },
}); });
if (!payment) { if (!payment) {
console.warn( console.warn(
@@ -194,6 +196,12 @@ async function recordRefund(charge: Stripe.Charge): Promise<void> {
return; return;
} }
// Volle Erstattung: die Rechnung ist damit aufgehoben, es entsteht die
// Stornorechnung. Auch wenn die Zahlung schon auf REFUNDED steht — dann kam
// die Erstattung aus der Stornierung im Admin oder der Retoure, und dieser
// Aufruf findet die dort angelegte Stornorechnung nur wieder.
await issueCancellationInvoice(payment.orderId, "ERSTATTUNG");
if (payment.status === "REFUNDED") return; if (payment.status === "REFUNDED") return;
await prisma.payment.update({ where: { id: payment.id }, data: { status: "REFUNDED" } }); await prisma.payment.update({ where: { id: payment.id }, data: { status: "REFUNDED" } });
@@ -456,6 +464,17 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
} }
: {}), : {}),
}); });
// Kopie der E-Rechnung an die im Admin hinterlegte Adresse (lib/invoice-mail.ts).
if (invoicePdf) {
await sendInvoiceCopy({
title: "Rechnung",
number: invoice.invoiceNumber,
orderNumber: order.orderNumber,
customerName: `${order.firstName} ${order.lastName}`.trim(),
customerEmail: order.email,
pdf: invoicePdf,
});
}
} catch (error: unknown) { } catch (error: unknown) {
// Laut sichtbar machen (statt wie zuvor unbehandelt bis in den // Laut sichtbar machen (statt wie zuvor unbehandelt bis in den
// Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er // Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er
@@ -1,11 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { erpExportAuthorized, erpInvoicePdf, isInvoiceNumber } from "@/lib/erp-export"; import { erpExportAuthorized, erpInvoicePdf, isErpDocumentNumber } from "@/lib/erp-export";
import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { rateLimit, getClientIp } from "@/lib/rate-limit";
const FAILED_ATTEMPTS_PER_WINDOW = 20; const FAILED_ATTEMPTS_PER_WINDOW = 20;
const FAILED_WINDOW_MS = 15 * 60 * 1000; const FAILED_WINDOW_MS = 15 * 60 * 1000;
/** Rechnungs-PDF fuer ERPNext (Anhang an der Ausgangsrechnung). Nur mit ERP_EXPORT_TOKEN. */ /** Rechnungs- oder Stornorechnungs-PDF fuer ERPNext (Anhang am Beleg). Nur mit ERP_EXPORT_TOKEN. */
export async function GET(request: Request, { params }: { params: Promise<{ invoiceNumber: string }> }) { export async function GET(request: Request, { params }: { params: Promise<{ invoiceNumber: string }> }) {
if (!erpExportAuthorized(request.headers.get("authorization"))) { if (!erpExportAuthorized(request.headers.get("authorization"))) {
const ip = await getClientIp(); const ip = await getClientIp();
@@ -13,7 +13,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ invo
return NextResponse.json({ error: "Nicht berechtigt." }, { status }); return NextResponse.json({ error: "Nicht berechtigt." }, { status });
} }
const { invoiceNumber } = await params; const { invoiceNumber } = await params;
const pdf = isInvoiceNumber(invoiceNumber) ? await erpInvoicePdf(invoiceNumber) : null; const pdf = isErpDocumentNumber(invoiceNumber) ? await erpInvoicePdf(invoiceNumber) : null;
if (!pdf) { if (!pdf) {
return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 }); return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 });
} }
+7 -3
View File
@@ -1,12 +1,13 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { erpExportAuthorized, listErpInvoices, parseErpQuery } from "@/lib/erp-export"; import { erpExportAuthorized, listErpCancellations, listErpInvoices, parseErpQuery } from "@/lib/erp-export";
import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { rateLimit, getClientIp } from "@/lib/rate-limit";
const FAILED_ATTEMPTS_PER_WINDOW = 20; const FAILED_ATTEMPTS_PER_WINDOW = 20;
const FAILED_WINDOW_MS = 15 * 60 * 1000; const FAILED_WINDOW_MS = 15 * 60 * 1000;
/** /**
* Rechnungen fuer ERPNext, aelteste zuerst: GET /api/erp/rechnungen?seit=<ISO>&limit=<1-200>. * Rechnungen fuer ERPNext, aelteste zuerst: GET /api/erp/rechnungen?seit=<ISO>&limit=<1-200>,
* Stornorechnungen mit &art=storno.
* Nur lesend und nur mit ERP_EXPORT_TOKEN, siehe lib/erp-export.ts. * Nur lesend und nur mit ERP_EXPORT_TOKEN, siehe lib/erp-export.ts.
*/ */
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -19,7 +20,10 @@ export async function GET(request: Request) {
if (!query) { if (!query) {
return NextResponse.json({ error: "seit (ISO-Zeitpunkt) und limit (1-200) pruefen." }, { status: 400 }); return NextResponse.json({ error: "seit (ISO-Zeitpunkt) und limit (1-200) pruefen." }, { status: 400 });
} }
const invoices = await listErpInvoices(query.since, query.limit); const invoices =
query.kind === "storno"
? await listErpCancellations(query.since, query.limit)
: await listErpInvoices(query.since, query.limit);
return NextResponse.json( return NextResponse.json(
{ invoices, hasMore: invoices.length === query.limit }, { invoices, hasMore: invoices.length === query.limit },
{ headers: { "Cache-Control": "no-store" } } { headers: { "Cache-Control": "no-store" } }
@@ -3,6 +3,8 @@ import { getCurrentCustomer } from "@/lib/auth";
import { getInvoiceForCustomer, getInvoiceByNumber } from "@/lib/account-data"; import { getInvoiceForCustomer, getInvoiceByNumber } from "@/lib/account-data";
import { generateInvoicePdf } from "@/lib/invoice-pdf"; import { generateInvoicePdf } from "@/lib/invoice-pdf";
import { readStoredInvoicePdf } from "@/lib/invoice-storage"; import { readStoredInvoicePdf } from "@/lib/invoice-storage";
import { cancellationPdf, isCancellationNumber } from "@/lib/cancellation-invoice";
import { prisma } from "@/lib/prisma";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
export async function GET( export async function GET(
@@ -15,6 +17,24 @@ export async function GET(
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 }); return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
} }
// Stornorechnung (ST-…): dieselbe Besitzpruefung ueber die Bestellung der
// aufgehobenen Rechnung, dann das abgelegte bzw. neu erzeugte PDF.
if (isCancellationNumber(invoiceNumber)) {
const owned =
customer.role === "ADMIN" ||
(await prisma.cancellationInvoice.count({ where: { number: invoiceNumber, invoice: { order: { customerId: customer.id } } } })) > 0;
const pdf = owned ? await cancellationPdf(invoiceNumber) : null;
if (!pdf) {
return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 });
}
return new NextResponse(new Uint8Array(pdf), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${invoiceNumber}.pdf"`,
},
});
}
const invoice = const invoice =
customer.role === "ADMIN" customer.role === "ADMIN"
? await getInvoiceByNumber(invoiceNumber) ? await getInvoiceByNumber(invoiceNumber)
+125
View File
@@ -0,0 +1,125 @@
/**
* Tests fuer die Stornorechnung: hoechstens eine je Rechnung, auch wenn
* Admin-Stornierung und Stripe-Webhook derselben Erstattung gleichzeitig
* ankommen, und sie geht an die Kundschaft und als Kopie hinaus.
*/
import { describe, test, expect, vi, beforeEach } from "vitest";
const tx = {
cancellationCounter: { upsert: vi.fn() },
cancellationInvoice: { create: vi.fn() },
};
const db = {
invoice: { findUnique: vi.fn() },
cancellationInvoice: { findUnique: vi.fn(), update: vi.fn() },
$transaction: vi.fn(async (run: (client: typeof tx) => Promise<unknown>) => run(tx)),
};
vi.mock("@/lib/prisma", () => ({ prisma: db }));
const generateInvoicePdf = vi.fn(async () => Buffer.from("storno-pdf"));
vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: (...args: unknown[]) => generateInvoicePdf(...(args as [])) }));
const storeInvoicePdf = vi.fn(async () => undefined);
vi.mock("@/lib/invoice-storage", () => ({
invoiceRelativePath: vi.fn(() => "2026/musterfrau-erika/ST-2026-0003.pdf"),
storeInvoicePdf: (...args: unknown[]) => storeInvoicePdf(...(args as [])),
readStoredInvoicePdf: vi.fn(async () => null),
}));
const sendMail = vi.fn(async () => true);
vi.mock("@/lib/mail", () => ({ sendMail: (...args: unknown[]) => sendMail(...(args as [])) }));
vi.mock("@/lib/email-templates", () => ({
cancellationInvoiceEmail: vi.fn(async () => ({ subject: "Stornorechnung", html: "<p>x</p>", text: "x" })),
}));
const sendInvoiceCopy = vi.fn(async () => true);
vi.mock("@/lib/invoice-mail", () => ({ sendInvoiceCopy: (...args: unknown[]) => sendInvoiceCopy(...(args as [])) }));
const { issueCancellationInvoice, isCancellationNumber } = await import("./cancellation-invoice");
const order = {
orderNumber: "WH-2026-0042",
email: "[email protected]",
firstName: "Erika",
lastName: "Musterfrau",
locale: "de",
items: [],
};
const loaded = {
number: "ST-2026-0003",
issuedAt: new Date("2026-10-14T09:00:00Z"),
pdfPath: null,
invoice: { invoiceNumber: "RE-2026-0042", issuedAt: new Date("2026-10-12T10:00:00Z"), totalCents: 5375, order },
};
beforeEach(() => {
vi.clearAllMocks();
db.invoice.findUnique.mockResolvedValue({ id: "inv1", totalCents: 5375, cancellation: null });
tx.cancellationCounter.upsert.mockResolvedValue({ lastNumber: 3 });
tx.cancellationInvoice.create.mockImplementation(async ({ data }: { data: { number: string } }) => ({ number: data.number }));
db.cancellationInvoice.findUnique.mockResolvedValue(loaded);
});
describe("issueCancellationInvoice", () => {
test("ohne Rechnung gibt es nichts aufzuheben", async () => {
db.invoice.findUnique.mockResolvedValue(null);
expect(await issueCancellationInvoice("order1", "STORNIERUNG")).toBeNull();
expect(db.$transaction).not.toHaveBeenCalled();
});
test("legt die Stornorechnung mit eigener Nummer an und verschickt sie mit Kopie", async () => {
// Act
const result = await issueCancellationInvoice("order1", "STORNIERUNG");
// Assert
const year = new Date().getFullYear();
expect(result).toEqual({ number: `ST-${year}-0003`, created: true });
expect(tx.cancellationInvoice.create).toHaveBeenCalledWith(
expect.objectContaining({ data: { invoiceId: "inv1", number: `ST-${year}-0003`, totalCents: 5375, reason: "STORNIERUNG" } })
);
expect(generateInvoicePdf).toHaveBeenCalledWith(
expect.objectContaining({ invoiceNumber: "RE-2026-0042", cancellation: { number: "ST-2026-0003", issuedAt: loaded.issuedAt } })
);
expect(storeInvoicePdf).toHaveBeenCalledOnce();
expect(sendMail).toHaveBeenCalledWith(
expect.objectContaining({ to: "[email protected]", attachments: [expect.objectContaining({ filename: `ST-${year}-0003.pdf` })] })
);
expect(sendInvoiceCopy).toHaveBeenCalledWith(expect.objectContaining({ title: "Stornorechnung", orderNumber: "WH-2026-0042" }));
});
test("zweiter Ausloeser findet die vorhandene Stornorechnung, ohne erneut zu verschicken", async () => {
db.invoice.findUnique.mockResolvedValue({ id: "inv1", totalCents: 5375, cancellation: { number: "ST-2026-0001" } });
expect(await issueCancellationInvoice("order1", "ERSTATTUNG")).toEqual({ number: "ST-2026-0001", created: false });
expect(db.$transaction).not.toHaveBeenCalled();
expect(sendMail).not.toHaveBeenCalled();
});
test("gleichzeitig angelegt: die zweite Transaktion scheitert eindeutig und liefert die erste", async () => {
// Arrange: die Datenbank lehnt die zweite Stornorechnung zur selben Rechnung ab.
tx.cancellationInvoice.create.mockRejectedValue(Object.assign(new Error("Unique constraint failed"), { code: "P2002" }));
db.cancellationInvoice.findUnique.mockResolvedValue({ number: "ST-2026-0001" });
// Act
const result = await issueCancellationInvoice("order1", "ERSTATTUNG");
// Assert
expect(result).toEqual({ number: "ST-2026-0001", created: false });
expect(sendMail).not.toHaveBeenCalled();
});
test("ein Fehler beim Verschicken laesst die Stornorechnung bestehen", async () => {
generateInvoicePdf.mockRejectedValueOnce(new Error("Schrift fehlt"));
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
const result = await issueCancellationInvoice("order1", "RETOURE");
expect(result?.created).toBe(true);
expect(errors).toHaveBeenCalled();
errors.mockRestore();
});
});
test("isCancellationNumber erkennt nur ST-Nummern", () => {
expect(isCancellationNumber("ST-2026-0001")).toBe(true);
expect(isCancellationNumber("RE-2026-0001")).toBe(false);
expect(isCancellationNumber("ST-2026-0001/../x")).toBe(false);
});
+165
View File
@@ -0,0 +1,165 @@
import { prisma } from "@/lib/prisma";
import { formatCancellationNumber } from "@/lib/invoice-number";
import { generateInvoicePdf } from "@/lib/invoice-pdf";
import { invoiceRelativePath, readStoredInvoicePdf, storeInvoicePdf } from "@/lib/invoice-storage";
import { isUniqueConstraintError } from "@/lib/harvest-slug";
import { sendMail } from "@/lib/mail";
import { cancellationInvoiceEmail } from "@/lib/email-templates";
import { sendInvoiceCopy } from "@/lib/invoice-mail";
import { isLocale, defaultLocale } from "@/i18n/config";
/**
* Stornorechnung: der Gegenbeleg, wenn eine Bestellung mit Rechnung
* storniert oder voll erstattet wird.
*
* Eine ausgestellte Rechnung wird nie geloescht oder geaendert (§ 14 UStG,
* GoBD). Stattdessen entsteht EINE Stornorechnung je Rechnung, mit eigener
* fortlaufender Nummer (ST-JJJJ-NNNN), allen Positionen der Rechnung und
* Bezug auf sie. Sie geht als E-Rechnung an die Kundschaft und als Kopie an
* die Admin-Adresse; ERPNext holt sie ueber den ERP-Export ab und bucht sie
* als Rechnungskorrektur gegen die Ausgangsrechnung.
*
* Ausloeser (alle idempotent ueber denselben Weg):
* - Stornierung im Admin (STORNIERUNG)
* - Erstattung einer Retoure (RETOURE)
* - volle Erstattung direkt bei Stripe (ERSTATTUNG, Webhook charge.refunded)
* Eine Stornierung im Admin loest selbst eine Stripe-Erstattung aus, deren
* Webhook gleichzeitig hier ankommen kann. Beide Wege fuehren zu derselben
* einen Stornorechnung: die zweite scheitert am eindeutigen invoiceId und
* rollt samt Zaehler zurueck, es entsteht also auch keine Nummernluecke.
*/
export type CancellationReason = "STORNIERUNG" | "RETOURE" | "ERSTATTUNG";
export interface CancellationResult {
number: string;
/** false: es gab sie schon (zweiter Ausloeser derselben Erstattung). */
created: boolean;
}
const CANCELLATION_NUMBER_PATTERN = /^ST-\d{4}-\d{4,}$/;
export function isCancellationNumber(value: string): boolean {
return CANCELLATION_NUMBER_PATTERN.test(value);
}
/**
* Legt die Stornorechnung zur Rechnung der Bestellung an und verschickt sie.
* null, wenn die Bestellung keine Rechnung hat (nie bezahlt) — dann gibt es
* nichts aufzuheben.
*/
export async function issueCancellationInvoice(orderId: string, reason: CancellationReason): Promise<CancellationResult | null> {
const invoice = await prisma.invoice.findUnique({
where: { orderId },
select: { id: true, totalCents: true, cancellation: { select: { number: true } } },
});
if (!invoice) return null;
if (invoice.cancellation) return { number: invoice.cancellation.number, created: false };
let number: string;
try {
number = await prisma.$transaction(async (tx) => {
const year = new Date().getFullYear();
const counter = await tx.cancellationCounter.upsert({
where: { year },
create: { year, lastNumber: 1 },
update: { lastNumber: { increment: 1 } },
select: { lastNumber: true },
});
const created = await tx.cancellationInvoice.create({
data: {
invoiceId: invoice.id,
number: formatCancellationNumber(year, counter.lastNumber),
totalCents: invoice.totalCents,
reason,
},
select: { number: true },
});
return created.number;
});
} catch (error: unknown) {
if (!isUniqueConstraintError(error)) throw error;
const existing = await prisma.cancellationInvoice.findUnique({ where: { invoiceId: invoice.id }, select: { number: true } });
if (!existing) throw error;
return { number: existing.number, created: false };
}
await deliverCancellationInvoice(number);
return { number, created: true };
}
async function loadCancellation(number: string) {
return prisma.cancellationInvoice.findUnique({
where: { number },
include: { invoice: { include: { order: { include: { items: true, discountCode: { select: { code: true } } } } } } },
});
}
type LoadedCancellation = NonNullable<Awaited<ReturnType<typeof loadCancellation>>>;
function renderCancellationPdf(cancellation: LoadedCancellation): Promise<Buffer> {
const { invoice } = cancellation;
return generateInvoicePdf({
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale,
invoiceNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt,
totalCents: invoice.totalCents,
order: invoice.order,
cancellation: { number: cancellation.number, issuedAt: cancellation.issuedAt },
});
}
/**
* PDF erzeugen und ablegen, an die Kundschaft und als Kopie verschicken.
* Wirft nicht: die Stornorechnung steht bereits fest, und ihr PDF laesst sich
* jederzeit ueber cancellationPdf() neu erzeugen. Ein Fehler hier darf die
* Stornierung nicht als gescheitert erscheinen lassen.
*/
async function deliverCancellationInvoice(number: string): Promise<void> {
try {
const cancellation = await loadCancellation(number);
if (!cancellation) return;
const { order } = cancellation.invoice;
const pdf = await renderCancellationPdf(cancellation);
try {
const relativePath = invoiceRelativePath({
year: cancellation.issuedAt.getFullYear(),
lastName: order.lastName,
firstName: order.firstName,
invoiceNumber: number,
});
await storeInvoicePdf(relativePath, pdf);
await prisma.cancellationInvoice.update({ where: { number }, data: { pdfPath: relativePath } });
} catch (error: unknown) {
console.error(`[cancellation] ${number} konnte nicht abgelegt werden:`, error);
}
const attachment = { filename: `${number}.pdf`, content: pdf, contentType: "application/pdf" };
const { subject, html, text } = await cancellationInvoiceEmail({
locale: isLocale(order.locale) ? order.locale : defaultLocale,
orderNumber: order.orderNumber,
firstName: order.firstName,
cancellationNumber: number,
invoiceNumber: cancellation.invoice.invoiceNumber,
});
await sendMail({ to: order.email, subject, html, text, attachments: [attachment] });
await sendInvoiceCopy({
title: "Stornorechnung",
number,
orderNumber: order.orderNumber,
customerName: `${order.firstName} ${order.lastName}`.trim(),
customerEmail: order.email,
pdf,
});
} catch (error: unknown) {
console.error(`[cancellation] ${number} konnte nicht erzeugt oder verschickt werden:`, error);
}
}
/** Das PDF der Stornorechnung: abgelegt, sonst neu erzeugt. null, wenn es die Nummer nicht gibt. */
export async function cancellationPdf(number: string): Promise<Buffer | null> {
const cancellation = await loadCancellation(number);
if (!cancellation) return null;
const stored = cancellation.pdfPath ? await readStoredInvoicePdf(cancellation.pdfPath) : null;
return stored ?? (await renderCancellationPdf(cancellation));
}
+2
View File
@@ -74,6 +74,8 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
{ key: "company.street", group: "company", labelDe: "Straße", labelEn: "Street", type: "text", localized: false, getDefault: () => siteConfig.address.street }, { key: "company.street", group: "company", labelDe: "Straße", labelEn: "Street", type: "text", localized: false, getDefault: () => siteConfig.address.street },
{ key: "company.zip", group: "company", labelDe: "PLZ", labelEn: "ZIP", type: "text", localized: false, getDefault: () => siteConfig.address.zip }, { key: "company.zip", group: "company", labelDe: "PLZ", labelEn: "ZIP", type: "text", localized: false, getDefault: () => siteConfig.address.zip },
{ key: "company.city", group: "company", labelDe: "Ort", labelEn: "City", type: "text", localized: false, getDefault: () => siteConfig.address.city }, { key: "company.city", group: "company", labelDe: "Ort", labelEn: "City", type: "text", localized: false, getDefault: () => siteConfig.address.city },
// Kopie jeder Rechnung und Stornorechnung (lib/invoice-mail.ts). Leer = keine Kopie.
{ key: "invoice.copyEmail", group: "company", labelDe: "Rechnungskopie an (E-Mail-Adresse; jede Rechnung und Stornorechnung geht als E-Rechnung zusätzlich an diese Adresse, leer = keine Kopie)", labelEn: "Invoice copy to (email address; every invoice and cancellation invoice is also sent as an e-invoice to this address, empty = no copy)", type: "text", localized: false, getDefault: () => "" },
// --- Liefergebiet --- // --- Liefergebiet ---
// Steuert dreierlei zugleich: die Laenderauswahl im Checkout, die // Steuert dreierlei zugleich: die Laenderauswahl im Checkout, die
+20
View File
@@ -101,6 +101,12 @@ interface EmailStrings {
button: string; button: string;
subject: (orderNumber: string) => string; subject: (orderNumber: string) => string;
}; };
cancellationInvoice: {
preheader: (orderNumber: string) => string;
heading: (firstName: string) => string;
intro: (cancellationNumber: string, invoiceNumber: string, orderNumber: string) => string;
subject: (cancellationNumber: string, orderNumber: string) => string;
};
} }
const de: EmailStrings = { const de: EmailStrings = {
@@ -202,6 +208,13 @@ const de: EmailStrings = {
button: "ERNEUT BESTELLEN", button: "ERNEUT BESTELLEN",
subject: (orderNumber) => `Zahlung zu Bestellung ${orderNumber} fehlgeschlagen — Wabenhain`, subject: (orderNumber) => `Zahlung zu Bestellung ${orderNumber} fehlgeschlagen — Wabenhain`,
}, },
cancellationInvoice: {
preheader: (orderNumber) => `Stornorechnung zu deiner Bestellung ${orderNumber}.`,
heading: (firstName) => `Deine Stornorechnung, ${firstName}`,
intro: (cancellationNumber, invoiceNumber, orderNumber) =>
`Anbei findest du die Stornorechnung <strong>${cancellationNumber}</strong>. Sie hebt die Rechnung <strong>${invoiceNumber}</strong> zu deiner Bestellung <strong>${orderNumber}</strong> vollständig auf. Bitte lege sie zu deinen Unterlagen.`,
subject: (cancellationNumber, orderNumber) => `Stornorechnung ${cancellationNumber} zu Bestellung ${orderNumber} — Wabenhain`,
},
}; };
const en: EmailStrings = { const en: EmailStrings = {
@@ -303,6 +316,13 @@ const en: EmailStrings = {
button: "ORDER AGAIN", button: "ORDER AGAIN",
subject: (orderNumber) => `Payment for order ${orderNumber} failed — Wabenhain`, subject: (orderNumber) => `Payment for order ${orderNumber} failed — Wabenhain`,
}, },
cancellationInvoice: {
preheader: (orderNumber) => `Cancellation invoice for your order ${orderNumber}.`,
heading: (firstName) => `Your cancellation invoice, ${firstName}`,
intro: (cancellationNumber, invoiceNumber, orderNumber) =>
`Please find attached the cancellation invoice <strong>${cancellationNumber}</strong>. It fully reverses invoice <strong>${invoiceNumber}</strong> for your order <strong>${orderNumber}</strong>. Please keep it with your records.`,
subject: (cancellationNumber, orderNumber) => `Cancellation invoice ${cancellationNumber} for order ${orderNumber} — Wabenhain`,
},
}; };
const emailStrings: Record<Locale, EmailStrings> = { de, en }; const emailStrings: Record<Locale, EmailStrings> = { de, en };
+25
View File
@@ -375,6 +375,31 @@ export async function returnRefundedEmail(order: OrderStatusUpdateInput): Promis
}; };
} }
interface CancellationInvoiceEmailInput extends OrderStatusUpdateInput {
cancellationNumber: string;
invoiceNumber: string;
}
/** Begleitmail zur Stornorechnung (PDF als Anhang, siehe lib/cancellation-invoice.ts). */
export async function cancellationInvoiceEmail(input: CancellationInvoiceEmailInput): Promise<EmailContent> {
const s = getEmailStrings(input.locale).cancellationInvoice;
const intro = (escape: (value: string) => string) =>
s.intro(escape(input.cancellationNumber), escape(input.invoiceNumber), escape(input.orderNumber));
const html = await emailLayout(
input.locale,
s.preheader(input.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(input.firstName))}</h1>
<p style="margin:0;line-height:1.5;">${intro(escapeHtml)}</p>
`
);
return {
subject: s.subject(input.cancellationNumber, input.orderNumber),
html,
text: intro((value) => value).replace(/<\/?strong>/g, ""),
};
}
export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> { export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).paymentFailed; const s = getEmailStrings(order.locale).paymentFailed;
// Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne // Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne
+55 -2
View File
@@ -8,14 +8,22 @@
import { describe, test, expect, vi, beforeEach } from "vitest"; import { describe, test, expect, vi, beforeEach } from "vitest";
const findMany = vi.fn(); const findMany = vi.fn();
vi.mock("@/lib/prisma", () => ({ prisma: { invoice: { findMany: (...args: unknown[]) => findMany(...args) } } })); const findCancellations = vi.fn();
vi.mock("@/lib/prisma", () => ({
prisma: {
invoice: { findMany: (...args: unknown[]) => findMany(...args) },
cancellationInvoice: { findMany: (...args: unknown[]) => findCancellations(...args) },
},
}));
vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: vi.fn(async () => Buffer.from("pdf")) })); vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: vi.fn(async () => Buffer.from("pdf")) }));
vi.mock("@/lib/invoice-storage", () => ({ readStoredInvoicePdf: vi.fn(async () => null) })); vi.mock("@/lib/invoice-storage", () => ({ readStoredInvoicePdf: vi.fn(async () => null) }));
import { import {
erpExportAuthorized, erpExportAuthorized,
erpVatLines, erpVatLines,
isErpDocumentNumber,
isInvoiceNumber, isInvoiceNumber,
listErpCancellations,
listErpInvoices, listErpInvoices,
parseErpQuery, parseErpQuery,
toErpInvoice, toErpInvoice,
@@ -42,18 +50,29 @@ describe("erpExportAuthorized", () => {
describe("parseErpQuery und isInvoiceNumber", () => { describe("parseErpQuery und isInvoiceNumber", () => {
test("verlangt einen gueltigen Zeitpunkt und begrenzt das Limit", () => { test("verlangt einen gueltigen Zeitpunkt und begrenzt das Limit", () => {
expect(parseErpQuery(new URLSearchParams("seit=2026-01-01T00:00:00Z&limit=50"))).toEqual({ since: new Date("2026-01-01T00:00:00Z"), limit: 50 }); expect(parseErpQuery(new URLSearchParams("seit=2026-01-01T00:00:00Z&limit=50"))).toEqual({
since: new Date("2026-01-01T00:00:00Z"),
limit: 50,
kind: "rechnung",
});
expect(parseErpQuery(new URLSearchParams("seit=2026-01-01"))?.limit).toBe(200); expect(parseErpQuery(new URLSearchParams("seit=2026-01-01"))?.limit).toBe(200);
expect(parseErpQuery(new URLSearchParams("seit=gestern"))).toBeNull(); expect(parseErpQuery(new URLSearchParams("seit=gestern"))).toBeNull();
expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&limit=0"))).toBeNull(); expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&limit=0"))).toBeNull();
expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&limit=201"))).toBeNull(); expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&limit=201"))).toBeNull();
}); });
test("liefert Stornorechnungen nur mit art=storno", () => {
expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&art=storno"))?.kind).toBe("storno");
expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&art=alles"))).toBeNull();
});
test("erkennt nur Rechnungsnummern des Shops", () => { test("erkennt nur Rechnungsnummern des Shops", () => {
expect(isInvoiceNumber("RE-2026-0001")).toBe(true); expect(isInvoiceNumber("RE-2026-0001")).toBe(true);
expect(isInvoiceNumber("RE-2026-12345")).toBe(true); expect(isInvoiceNumber("RE-2026-12345")).toBe(true);
expect(isInvoiceNumber("../RE-2026-0001")).toBe(false); expect(isInvoiceNumber("../RE-2026-0001")).toBe(false);
expect(isInvoiceNumber("RE-26-1")).toBe(false); expect(isInvoiceNumber("RE-26-1")).toBe(false);
expect(isErpDocumentNumber("ST-2026-0001")).toBe(true);
expect(isErpDocumentNumber("ST-2026-0001.pdf")).toBe(false);
}); });
}); });
@@ -172,5 +191,39 @@ describe("listErpInvoices", () => {
expect(findMany).toHaveBeenCalledWith( expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { issuedAt: { gte: since } }, orderBy: [{ issuedAt: "asc" }, { id: "asc" }], take: 50 }) expect.objectContaining({ where: { issuedAt: { gte: since } }, orderBy: [{ issuedAt: "asc" }, { id: "asc" }], take: 50 })
); );
expect(result[0]).toMatchObject({ type: "invoice", cancelledBy: null });
});
});
describe("listErpCancellations", () => {
beforeEach(() => findCancellations.mockReset());
test("liefert die Stornorechnung mit Positionen der Rechnung und Bezug", async () => {
// Arrange
findCancellations.mockResolvedValue([
{
id: "st1",
number: "ST-2026-0002",
issuedAt: new Date("2026-10-05T08:00:00Z"),
totalCents: 3620,
reason: "STORNIERUNG",
invoice: { ...record, cancellation: { number: "ST-2026-0002" } },
},
]);
// Act
const [erp] = await listErpCancellations(new Date("2026-10-01T00:00:00Z"), 10);
// Assert
expect(erp).toMatchObject({
type: "cancellation",
invoiceNumber: "ST-2026-0002",
issuedAt: "2026-10-05T08:00:00.000Z",
totalCents: 3620,
reason: "STORNIERUNG",
cancels: { invoiceNumber: "RE-2026-0007", issuedAt: "2026-10-01T10:00:00.000Z" },
});
expect(erp.items).toHaveLength(2);
expect(erp.vat?.reduce((sum, line) => sum + line.grossCents, 0)).toBe(3620);
}); });
}); });
+51 -4
View File
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
import { generateInvoicePdf } from "@/lib/invoice-pdf"; import { generateInvoicePdf } from "@/lib/invoice-pdf";
import { readStoredInvoicePdf } from "@/lib/invoice-storage"; import { readStoredInvoicePdf } from "@/lib/invoice-storage";
import { vatLinesWithShares, type VatLineWithShares } from "@/lib/vat"; import { vatLinesWithShares, type VatLineWithShares } from "@/lib/vat";
import { cancellationPdf, isCancellationNumber } from "@/lib/cancellation-invoice";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
/** /**
@@ -37,12 +38,27 @@ export function isInvoiceNumber(value: string): boolean {
return INVOICE_NUMBER_PATTERN.test(value); return INVOICE_NUMBER_PATTERN.test(value);
} }
/** `seit` (ISO-Zeitpunkt, einschliesslich) und `limit` (1 bis MAX_PAGE_SIZE); null bei ungueltigen Werten. */ /** Rechnung (RE-…) oder Stornorechnung (ST-…). */
export function parseErpQuery(params: URLSearchParams): { since: Date; limit: number } | null { export function isErpDocumentNumber(value: string): boolean {
return isInvoiceNumber(value) || isCancellationNumber(value);
}
/** Welche Belege: "rechnung" (Vorgabe, wie bisher) oder "storno" (Stornorechnungen). */
export type ErpDocumentKind = "rechnung" | "storno";
/**
* `seit` (ISO-Zeitpunkt, einschliesslich), `limit` (1 bis MAX_PAGE_SIZE) und
* `art` (rechnung/storno); null bei ungueltigen Werten. Stornorechnungen
* kommen bewusst nur auf ausdrueckliche Nachfrage: ein aelterer Import, der
* sie nicht kennt, saehe sie sonst als weitere Rechnung.
*/
export function parseErpQuery(params: URLSearchParams): { since: Date; limit: number; kind: ErpDocumentKind } | null {
const since = new Date(params.get("seit") ?? ""); const since = new Date(params.get("seit") ?? "");
const limit = Number(params.get("limit") ?? MAX_PAGE_SIZE); const limit = Number(params.get("limit") ?? MAX_PAGE_SIZE);
const kind = params.get("art") ?? "rechnung";
if (Number.isNaN(since.getTime()) || !Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) return null; if (Number.isNaN(since.getTime()) || !Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) return null;
return { since, limit }; if (kind !== "rechnung" && kind !== "storno") return null;
return { since, limit, kind };
} }
const invoiceInclude = { const invoiceInclude = {
@@ -53,6 +69,7 @@ const invoiceInclude = {
discountCode: { select: { code: true } }, discountCode: { select: { code: true } },
}, },
}, },
cancellation: { select: { number: true } },
} satisfies Prisma.InvoiceInclude; } satisfies Prisma.InvoiceInclude;
type ErpInvoiceRecord = Prisma.InvoiceGetPayload<{ include: typeof invoiceInclude }>; type ErpInvoiceRecord = Prisma.InvoiceGetPayload<{ include: typeof invoiceInclude }>;
@@ -66,9 +83,12 @@ export const erpVatLines = vatLinesWithShares;
export function toErpInvoice(invoice: ErpInvoiceRecord) { export function toErpInvoice(invoice: ErpInvoiceRecord) {
const { order } = invoice; const { order } = invoice;
return { return {
type: "invoice" as "invoice" | "cancellation",
invoiceNumber: invoice.invoiceNumber, invoiceNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt.toISOString(), issuedAt: invoice.issuedAt.toISOString(),
totalCents: invoice.totalCents, totalCents: invoice.totalCents,
/** Nummer der Stornorechnung, falls die Rechnung aufgehoben ist. */
cancelledBy: invoice.cancellation?.number ?? null,
order: { order: {
id: order.id, id: order.id,
orderNumber: order.orderNumber, orderNumber: order.orderNumber,
@@ -121,11 +141,38 @@ export async function listErpInvoices(since: Date, limit: number): Promise<ErpIn
return invoices.map(toErpInvoice); return invoices.map(toErpInvoice);
} }
/**
* Stornorechnungen fuer ERPNext, aelteste zuerst. Gleiche Form wie die
* Rechnung (Positionen und Steuer je Satz der aufgehobenen Rechnung, positive
* Betraege), dazu `cancels` mit Nummer und Datum der Rechnung. ERPNext bucht
* daraus die Rechnungskorrektur gegen die Ausgangsrechnung.
*/
export async function listErpCancellations(since: Date, limit: number) {
const cancellations = await prisma.cancellationInvoice.findMany({
where: { issuedAt: { gte: since } },
orderBy: [{ issuedAt: "asc" }, { id: "asc" }],
take: limit,
include: { invoice: { include: invoiceInclude } },
});
return cancellations.map((cancellation) => ({
...toErpInvoice(cancellation.invoice),
type: "cancellation" as const,
invoiceNumber: cancellation.number,
issuedAt: cancellation.issuedAt.toISOString(),
totalCents: cancellation.totalCents,
cancelledBy: null,
cancels: { invoiceNumber: cancellation.invoice.invoiceNumber, issuedAt: cancellation.invoice.issuedAt.toISOString() },
reason: cancellation.reason,
}));
}
/** /**
* Das PDF so, wie die Kundschaft es bekommen hat: zuerst das abgelegte * Das PDF so, wie die Kundschaft es bekommen hat: zuerst das abgelegte
* Dokument, nur ohne Ablage neu erzeugt (wie /rechnung/<nummer>/pdf). * Dokument, nur ohne Ablage neu erzeugt (wie /rechnung/<nummer>/pdf). Gilt
* fuer Rechnungen (RE-…) und Stornorechnungen (ST-…).
*/ */
export async function erpInvoicePdf(invoiceNumber: string): Promise<Buffer | null> { export async function erpInvoicePdf(invoiceNumber: string): Promise<Buffer | null> {
if (isCancellationNumber(invoiceNumber)) return cancellationPdf(invoiceNumber);
const invoice = await prisma.invoice.findUnique({ const invoice = await prisma.invoice.findUnique({
where: { invoiceNumber }, where: { invoiceNumber },
include: { order: { include: { items: true, discountCode: { select: { code: true } } } } }, include: { order: { include: { items: true, discountCode: { select: { code: true } } } } },
+56
View File
@@ -0,0 +1,56 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
const values: Record<string, string> = {};
vi.mock("@/lib/content", () => ({
getContentValues: vi.fn(async (keys: string[]) => Object.fromEntries(keys.map((key) => [key, values[key] ?? ""]))),
}));
const sendMail = vi.fn(async () => true);
vi.mock("@/lib/mail", () => ({ sendMail: (...args: unknown[]) => sendMail(...(args as [])) }));
const { INVOICE_COPY_KEY, getInvoiceCopyAddress, invoiceCopyContent, sendInvoiceCopy } = await import("./invoice-mail");
const copy = {
title: "Rechnung",
number: "RE-2026-0042",
orderNumber: "WH-2026-0042",
customerName: "Erika <Musterfrau>",
customerEmail: "[email protected]",
pdf: Buffer.from("pdf"),
};
beforeEach(() => {
vi.clearAllMocks();
delete values[INVOICE_COPY_KEY];
});
describe("Rechnungskopie", () => {
test("ohne hinterlegte Adresse keine Kopie", async () => {
expect(await getInvoiceCopyAddress()).toBeNull();
expect(await sendInvoiceCopy(copy)).toBeNull();
expect(sendMail).not.toHaveBeenCalled();
});
test("schickt die E-Rechnung als Anhang an die hinterlegte Adresse", async () => {
// Arrange: verschleiert eingetragen wie die Firmen-E-Mail.
values[INVOICE_COPY_KEY] = " buchhaltung [at] wabenhain.com ";
// Act
const sent = await sendInvoiceCopy(copy);
// Assert
expect(sent).toBe(true);
expect(sendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: "[email protected]",
subject: "Kopie: Rechnung RE-2026-0042 (Bestellung WH-2026-0042)",
attachments: [expect.objectContaining({ filename: "RE-2026-0042.pdf", contentType: "application/pdf" })],
})
);
});
test("maskiert Kundendaten im HTML-Teil", () => {
const { html, text } = invoiceCopyContent(copy);
expect(html).toContain("Erika &lt;Musterfrau&gt;");
expect(text).toContain("EN 16931");
});
});
+67
View File
@@ -0,0 +1,67 @@
import { getContentValues } from "@/lib/content";
import { normalizeContactEmail } from "@/lib/contact-email";
import { escapeHtml } from "@/lib/escape-html";
import { sendMail } from "@/lib/mail";
/**
* Kopie jeder Rechnung und Stornorechnung an eine im Admin hinterlegte
* Adresse (Einstellungen > Firma > "Rechnungskopie an"), etwa fuer die
* Buchhaltung. Die Kopie ist eine eigene Mail mit demselben PDF (E-Rechnung
* mit eingebettetem XML), nicht ein BCC der Kundenmail: so steht im Betreff,
* welcher Beleg es ist, und die Kundenmail bleibt unveraendert.
*/
export const INVOICE_COPY_KEY = "invoice.copyEmail";
/** Die hinterlegte Adresse, oder null, wenn keine (gueltige) eingetragen ist. */
export async function getInvoiceCopyAddress(): Promise<string | null> {
try {
const values = await getContentValues([INVOICE_COPY_KEY], "de");
const raw = values[INVOICE_COPY_KEY]?.trim() ?? "";
return raw ? normalizeContactEmail(raw) : null;
} catch (error: unknown) {
console.error("[invoice-mail] Adresse fuer die Rechnungskopie nicht lesbar:", error);
return null;
}
}
export interface InvoiceCopy {
/** "Rechnung" oder "Stornorechnung". */
title: string;
number: string;
orderNumber: string;
customerName: string;
customerEmail: string;
pdf: Buffer;
}
/** Betreff und Text der Kopie (ohne Versand, testbar). */
export function invoiceCopyContent(copy: Omit<InvoiceCopy, "pdf">): { subject: string; text: string; html: string } {
const subject = `Kopie: ${copy.title} ${copy.number} (Bestellung ${copy.orderNumber})`;
const text =
`${copy.title} ${copy.number} zur Bestellung ${copy.orderNumber} an ${copy.customerName} <${copy.customerEmail}>.\n` +
"Anbei die E-Rechnung (PDF/A-3 mit eingebettetem XML nach EN 16931, ZUGFeRD/Factur-X).";
const html = text
.split("\n")
.map((line) => `<p style="margin:0 0 8px;line-height:1.5;">${escapeHtml(line)}</p>`)
.join("");
return { subject, text, html };
}
/**
* Schickt die Kopie. null: keine Adresse hinterlegt; sonst, ob der
* Mailserver die Mail angenommen hat. Wirft nie (wie sendMail): eine
* fehlende Kopie darf keine Bestellung oder Stornierung aufhalten.
*/
export async function sendInvoiceCopy(copy: InvoiceCopy): Promise<boolean | null> {
const to = await getInvoiceCopyAddress();
if (!to) return null;
const { subject, text, html } = invoiceCopyContent(copy);
return sendMail({
to,
subject,
text,
html,
attachments: [{ filename: `${copy.number}.pdf`, content: copy.pdf, contentType: "application/pdf" }],
});
}
+10
View File
@@ -22,3 +22,13 @@ const SEQUENCE_DIGITS = 4;
export function formatInvoiceNumber(year: number, sequence: number): string { export function formatInvoiceNumber(year: number, sequence: number): string {
return `RE-${year}-${String(sequence).padStart(SEQUENCE_DIGITS, "0")}`; return `RE-${year}-${String(sequence).padStart(SEQUENCE_DIGITS, "0")}`;
} }
/**
* Bildet "ST-2026-0001": Stornorechnungen haben einen eigenen fortlaufenden
* Nummernkreis (Tabelle cancellation_counters). Mehrere Nummernkreise sind
* zulaessig, solange jeder fuer sich lueckenlos ist; so bleibt auch die
* RE-Folge ohne Spruenge.
*/
export function formatCancellationNumber(year: number, sequence: number): string {
return `ST-${year}-${String(sequence).padStart(SEQUENCE_DIGITS, "0")}`;
}