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

+11 -1
View File
@@ -9,6 +9,7 @@ import { requireAdmin } from "./_shared";
import { fail, succeed } from "@/lib/flash";
import { normalizeContactEmail } from "@/lib/contact-email";
import { settingsPathForGroup } from "@/lib/settings-tabs";
import { INVOICE_COPY_KEY } from "@/lib/invoice-mail";
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."
);
}
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 { requireAdmin, str, strOrNull, fail } from "./_shared";
import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events";
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
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 } },
items: { select: { productId: true, quantity: true, productName: true } },
returnRequest: { select: { status: true } },
invoice: { select: { cancellation: { select: { number: true } } } },
},
});
if (!previous) return null;
@@ -159,6 +161,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
// Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne
// sie wird nichts veraendert.
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) {
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") {
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.
let mailSent: boolean | undefined;
@@ -307,6 +321,15 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
stripeCheckoutSessionId: result.stripeCheckoutSessionId,
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) {
await logAdminAction(admin, {
+14 -1
View File
@@ -29,7 +29,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
payment: true,
shipment: true,
pickup: true,
invoice: true,
invoice: { include: { cancellation: { select: { number: true } } } },
customer: { select: { email: true, firstName: true, lastName: true } },
discountCode: { select: { code: true } },
},
@@ -279,6 +279,19 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
>
{t.adminOrderDetailPage.downloadPdf}
</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>
) : (
<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 { recordOrderReturn } from "@/lib/apicore-stock/stock-events";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
import { isLocale, defaultLocale } from "@/i18n/config";
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));
// 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, {
action: "return_request.refund",
entityType: "ReturnRequest",