fix: Bestell- und Abrechnungsprozess vor Go-Live abdichten

Fuenf Luecken zwischen Checkout, Zahlung und Stornierung geschlossen.

1. Bestellung ohne Stripe-Sitzung blieb dauerhaft haengen
   Schlug stripe.checkout.sessions.create fehl, blieb die Bestellung auf
   "NEU" ohne stripeCheckoutSessionId stehen. Damit griff kein einziger
   Aufraeumweg: der Abbruch-Klick braucht die Sitzungs-ID, der CronJob
   filterte solche Bestellungen sogar aktiv heraus, und ein Stripe-Event
   kann es ohne Sitzung nicht geben. Der reservierte Lagerbestand blieb
   fuer immer blockiert, und die Oberflaeche schickte die Kundschaft dabei
   auf die Erfolgsseite ("wir melden uns") — obwohl es keinen Weg gab,
   diese Bestellung je zu bezahlen.

   Die Route loest die Bestellung jetzt selbst auf (Bestand zurueck,
   Status STORNIERT mit Notiz) und antwortet mit einem ehrlichen Fehler,
   der ausdruecklich sagt, dass nichts abgebucht wurde. Laesst sich die
   Sitzungs-ID nicht speichern, wird die Sitzung sofort wieder
   geschlossen und derselbe Weg gegangen — eine "NEU"-Bestellung ohne
   Sitzungs-ID kann so nicht mehr entstehen. Der CronJob raeumt
   Altbestaende trotzdem ab (Filter entfernt).

2. Kaufabbrueche standen als Bestellungen im Kundenkonto
   getOrdersForCustomer lieferte auch den Status "NEU" aus. Ein gerade
   abgebrochener Kauf stand damit bis zu 45 Minuten lang mit
   Bestellnummer und Summe in der Liste und sah aus wie ein zustande
   gekommener Kauf. "NEU" wird dort jetzt ausgeblendet; in der
   Bestellverfolgung bleibt die Bestellung auffindbar, heisst aber
   "Zahlung ausstehend" statt "Neu".

3. Admin-Storno einer bezahlten Bestellung erstattete kein Geld
   Status wurde gesetzt, Ware zurueckgebucht, der Betrag blieb beim Shop.
   Der Storno erstattet jetzt ueber die Stripe Refunds API und setzt
   Payment.status auf REFUNDED.

4. Admin-Storno schloss die offene Stripe-Sitzung nicht
   Die Kundschaft konnte danach noch bezahlen; der Webhook fand die
   Bestellung nicht mehr auf "NEU" und brach still ab. Die Sitzung wird
   jetzt geschlossen, und der Webhook protokolliert eine Zahlung auf eine
   nicht mehr offene Bestellung laut, statt sie zu verschweigen.

5. Erstattungen und Rueckbuchungen kamen im Shop nicht an
   charge.refunded und charge.dispute.created werden jetzt verarbeitet:
   eine volle Erstattung aus dem Dashboard setzt Payment auf REFUNDED,
   eine Teilerstattung wird protokolliert, eine Rueckbuchung landet als
   Vermerk an der Bestellung. preflight prueft beide Abonnements mit.

Scheitert ein Stripe-Aufruf beim Storno, wird die Stornierung nicht
zurueckgenommen — der offene Punkt erscheint als Fehlermeldung im
Admin-Bereich und als order.cancel_incomplete im Audit-Log.

tsc: 0 Fehler. vitest: 294/294 gruen (ein Regressionstest fuer den
502-Fall neu). eslint: sauber.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-18 16:57:18 +02:00
1 parent dda0a66c6c
commit 1fa8dc9ce2
11 files changed
+481 -35

No files matched your search

+62 -12
View File
@@ -12,6 +12,7 @@ import { verifyTurnstileToken } from "@/lib/turnstile";
import { getShippingCostCents } from "@/lib/shipping";
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
import { releaseUnpaidOrder } from "@/lib/order-release";
import { getBaseUrl } from "@/lib/url";
import { vatRateForProduct } from "@/lib/vat";
import { getLocale } from "@/i18n/locale";
@@ -433,13 +434,20 @@ export async function POST(request: Request) {
metadata: { orderId: order.id, orderNumber: order.orderNumber },
});
// Best-effort: stripeCheckoutSessionId wird aktuell von keinem Lesepfad
// ausgewertet (fulfillOrder im Webhook identifiziert die Bestellung ueber
// session.metadata.orderId, nicht ueber dieses Feld) — schlaegt nur
// dieser Schreibzugriff fehl, waere es falsch, die bereits erstellte,
// echte und bezahlbare Stripe-Session deswegen zu verwerfen und der
// Kundschaft einen Fehler zu zeigen (Risiko: Retry legt eine zweite
// Bestellung samt zweiter Bestandsreservierung an).
// KEIN Best-effort mehr: ohne hinterlegte Sitzungs-ID laesst sich die
// Bestellung auf keinem Weg mehr aufloesen. Der Abbruch-Klick
// (checkout/cancel) braucht sie, um bei Stripe nachzusehen, und der
// Aufraeumlauf (scripts/release-stale-orders.ts) findet ohne sie keine
// Sitzung, deren Ablauf er pruefen koennte. Die Bestellung bliebe als
// "NEU" stehen und haette den reservierten Lagerbestand dauerhaft
// blockiert — unbemerkt, weil sie im Admin-Bereich wie jede andere
// offene Bestellung aussieht.
//
// Schlaegt das Speichern fehl, wird deshalb die gerade erzeugte Sitzung
// sofort wieder geschlossen und die Bestellung aufgeloest (aeusseres
// catch). Dass dabei eine bezahlbare Sitzung verlorengeht, ist folgenlos:
// ihre URL steht ausschliesslich in der Antwort, die wir daraufhin NICHT
// ausliefern — niemand ausser diesem Prozess hat sie je gesehen.
try {
await prisma.order.update({
where: { id: order.id },
@@ -447,25 +455,67 @@ export async function POST(request: Request) {
});
} catch (error: unknown) {
console.error(
`Speichern von stripeCheckoutSessionId fuer Bestellung ${order.orderNumber} fehlgeschlagen (Session ${session.id} bleibt trotzdem gueltig):`,
`Speichern von stripeCheckoutSessionId fuer Bestellung ${order.orderNumber} fehlgeschlagen — Session ${session.id} wird geschlossen und die Bestellung aufgeloest:`,
error
);
// Eigenes try/catch: scheitert auch das Schliessen, bleibt der
// urspruengliche Fehler der wichtigere. Die Sitzung laeuft dann
// spaetestens nach CHECKOUT_SESSION_TTL_MINUTES von selbst ab.
try {
await stripe.checkout.sessions.expire(session.id);
} catch (expireError: unknown) {
console.error(`Schliessen der Session ${session.id} fehlgeschlagen:`, expireError);
}
throw error;
}
return NextResponse.json({ url: session.url });
} catch (error: unknown) {
// Bestellung bleibt als "NEU" erhalten, damit sie im Admin-Bereich manuell
// nachbearbeitet werden kann, auch wenn Stripe (noch) nicht konfiguriert ist.
// Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client
// zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten
// und dieser Endpunkt ist ohne Authentifizierung erreichbar.
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`Checkout-Session fuer Bestellung ${order.orderNumber} fehlgeschlagen:`, message);
// Diese Bestellung ist nie bezahlbar geworden: es gibt keine Sitzung, zu
// der die Kundschaft geschickt werden koennte, und damit auch kein
// Ereignis von Stripe, das den reservierten Bestand je wieder freigeben
// wuerde. Er muss deshalb hier zurueck — sonst fehlt die Ware dauerhaft
// im Bestand, ohne dass irgendwo ein Fehler sichtbar waere.
//
// "cancel" statt "delete": die Bestellung bleibt als "STORNIERT" mit
// Notiz stehen. Anders als ein weggeklickter Warenkorb ist das ein
// Vorgang, dem die Imkerei nachgehen will — hier wollte jemand kaufen
// und ist an der Zahlungsanbindung gescheitert.
try {
const released = await releaseUnpaidOrder(order.id, "cancel");
if (released) {
await prisma.order.update({
where: { id: order.id },
data: {
notes:
"Automatisch storniert: Die Stripe-Zahlungssitzung konnte nicht erstellt werden, die Bestellung war nie bezahlbar. Der Lagerbestand wurde zurueckgebucht.",
},
});
}
} catch (releaseError: unknown) {
// Laut, aber nicht weiterwerfen: die Kundschaft bekommt unten ohnehin
// eine Fehlermeldung, und ein zusaetzlicher 500er wuerde nur die
// Ursache verdecken. Der Aufraeumlauf greift als zweites Netz.
console.error(
`Bestellung ${order.orderNumber} konnte nach dem Stripe-Fehler nicht aufgeloest werden — Lagerbestand bleibt vorerst reserviert:`,
releaseError
);
}
// Bewusst OHNE orderNumber: frueher schickte die Oberflaeche daraufhin auf
// die Erfolgsseite ("wir melden uns"). Das war eine Zusage, die niemand
// einloesen konnte — es gab keine Sitzung, keinen Zahlungsweg und keine
// Moeglichkeit, die Bestellung nachtraeglich zu bezahlen.
return NextResponse.json(
{
error:
"Die Zahlungsabwicklung ist aktuell nicht verfügbar. Deine Bestellung wurde vorgemerkt, wir melden uns bei dir.",
orderNumber: order.orderNumber,
"Die Zahlung konnte nicht gestartet werden — es wurde nichts abgebucht. Bitte versuche es in ein paar Minuten erneut oder melde dich bei uns.",
},
{ status: 502 }
);