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:
1 parent
508ff24aa7
commit
4d1bcbbdb7
22 files changed
+809
-16
No files matched your search
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user