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:
1 parent
dda0a66c6c
commit
1fa8dc9ce2
11 files changed
+481
-35
No files matched your search
@@ -7,7 +7,9 @@ import { sendMail } from "@/lib/mail";
|
||||
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
|
||||
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
||||
import { restockOrderItems } from "@/lib/inventory";
|
||||
import { getStripeClient } from "@/lib/stripe";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
|
||||
import { requireAdmin, str, strOrNull, fail } from "./_shared";
|
||||
|
||||
const ORDER_STATUSES = [
|
||||
@@ -21,6 +23,99 @@ const ORDER_STATUSES = [
|
||||
"STORNIERT",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Erledigt die Geldseite einer gerade ausgesprochenen Stornierung.
|
||||
*
|
||||
* Zwei Dinge, die eine Statusaenderung allein offen laesst:
|
||||
*
|
||||
* 1. WAR SCHON BEZAHLT — dann muss das Geld zurueck. Ohne diesen Schritt
|
||||
* stand die Bestellung als "storniert" da, die Ware lag wieder im Regal,
|
||||
* und der Betrag blieb trotzdem beim Shop. Erstattet wird der volle
|
||||
* Betrag; eine Teilerstattung ist ueber den Retouren-Bereich abzuwickeln,
|
||||
* wo sie hingehoert.
|
||||
*
|
||||
* 2. WAR NOCH OFFEN ("NEU") — dann ist die Stripe-Sitzung unter Umstaenden
|
||||
* noch bezahlbar. Sie muss geschlossen werden, sonst kann die Kundschaft
|
||||
* Minuten spaeter noch zahlen: der Webhook findet die Bestellung dann
|
||||
* nicht mehr im Status "NEU", bricht ab, und das Geld liegt bei Stripe,
|
||||
* ohne dass im Shop irgendetwas davon zu sehen waere.
|
||||
*
|
||||
* Fehler werden nicht geworfen, sondern als Klartext zurueckgegeben. Die
|
||||
* Stornierung ist an dieser Stelle schon geschrieben und die Ware
|
||||
* zurueckgebucht — sie deswegen abzubrechen wuerde nichts heilen. Was offen
|
||||
* bleibt, gehoert stattdessen sichtbar vor die Imkerei, damit sie es von Hand
|
||||
* erledigen kann.
|
||||
*
|
||||
* @returns leere Liste, wenn alles erledigt ist; sonst je offener Punkt einen
|
||||
* Satz fuer die Fehlermeldung im Admin-Bereich.
|
||||
*/
|
||||
async function settleCancelledOrder(order: {
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
previousStatus: OrderStatus;
|
||||
stripeCheckoutSessionId: string | null;
|
||||
payment: { status: PaymentStatus; stripePaymentIntentId: string | null } | null;
|
||||
}): Promise<string[]> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es
|
||||
// nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben
|
||||
// Betrags, und "PENDING" ist noch gar nicht abgeschlossen.
|
||||
if (order.payment?.status === "SUCCEEDED") {
|
||||
const paymentIntentId = order.payment.stripePaymentIntentId;
|
||||
if (!paymentIntentId) {
|
||||
warnings.push(
|
||||
"Zu dieser bezahlten Bestellung ist keine Stripe-Zahlung hinterlegt — die Erstattung muss von Hand angestossen werden."
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
await stripe.refunds.create({ payment_intent: paymentIntentId });
|
||||
await prisma.payment.update({
|
||||
where: { orderId: order.orderId },
|
||||
data: { status: "REFUNDED" },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[order-cancel] Erstattung fuer Bestellung ${order.orderNumber} (PaymentIntent ${paymentIntentId}) fehlgeschlagen:`,
|
||||
error
|
||||
);
|
||||
warnings.push(
|
||||
"Die Erstattung ueber Stripe ist fehlgeschlagen — bitte im Stripe-Dashboard von Hand erstatten."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (order.previousStatus === "NEU" && order.stripeCheckoutSessionId) {
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
|
||||
// Nur eine offene Sitzung laesst sich schliessen. "complete" bedeutet,
|
||||
// dass in der Zwischenzeit doch bezahlt wurde — dann ist das hier ein
|
||||
// Wettlauf, den die Zahlung gewonnen hat, und er gehoert gemeldet statt
|
||||
// stillschweigend uebergangen.
|
||||
if (session.status === "open") {
|
||||
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
|
||||
} else if (session.status === "complete") {
|
||||
warnings.push(
|
||||
"Achtung: Diese Bestellung wurde bei Stripe zwischenzeitlich doch bezahlt. Bitte den Zahlungseingang pruefen und die Erstattung von Hand anstossen."
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[order-cancel] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geschlossen werden:`,
|
||||
error
|
||||
);
|
||||
warnings.push(
|
||||
"Die offene Zahlungssitzung konnte nicht geschlossen werden — sie laeuft von selbst ab, bis dahin ist eine Zahlung noch moeglich."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
export async function updateOrderStatusAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const orderId = str(formData, "orderId");
|
||||
@@ -39,11 +134,19 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
email: true,
|
||||
firstName: true,
|
||||
locale: true,
|
||||
stripeCheckoutSessionId: true,
|
||||
payment: { select: { status: true, stripePaymentIntentId: true } },
|
||||
items: { select: { productId: true, quantity: true } },
|
||||
},
|
||||
});
|
||||
if (!previous) return null;
|
||||
|
||||
// Nur wahr, wenn DIESER Aufruf die Bestellung tatsaechlich storniert hat.
|
||||
// Steuert weiter unten Erstattung und Schliessen der Zahlungssitzung —
|
||||
// beides darf bei einem zweiten Klick auf eine bereits stornierte
|
||||
// Bestellung nicht noch einmal passieren.
|
||||
let cancelledNow = false;
|
||||
|
||||
if (status === "STORNIERT") {
|
||||
// Atomarer Claim statt einfachem update(): verhindert, dass zwei
|
||||
// schnell aufeinanderfolgende Stornierungs-Versuche (z.B. zwei offene
|
||||
@@ -58,6 +161,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
data: { status },
|
||||
});
|
||||
if (claim.count === 1) {
|
||||
cancelledNow = true;
|
||||
await restockOrderItems(tx, previous.items);
|
||||
}
|
||||
} else {
|
||||
@@ -88,7 +192,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
return { ...previous, shipment };
|
||||
return { ...previous, shipment, cancelledNow };
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
@@ -103,6 +207,39 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
detail: `${result.orderNumber}: ${result.status} → ${status}`,
|
||||
});
|
||||
|
||||
// Die Geldseite der Stornierung — bewusst NACH der Transaktion: ein
|
||||
// Stripe-Aufruf dauert ein Vielfaches einer Datenbankoperation und wuerde
|
||||
// die Zeilensperren auf Bestellung und Lagerbestand so lange halten.
|
||||
//
|
||||
// Bis hierher hat die Stornierung nur den Status gesetzt und die Ware
|
||||
// zurueckgebucht. Ohne die beiden folgenden Schritte blieb eine
|
||||
// Stornierung auf halbem Weg stehen: bei einer bezahlten Bestellung
|
||||
// behielt der Shop das Geld, bei einer noch offenen blieb die
|
||||
// Zahlungssitzung bezahlbar.
|
||||
if (result.cancelledNow) {
|
||||
const stornoWarnings = await settleCancelledOrder({
|
||||
orderId,
|
||||
orderNumber: result.orderNumber,
|
||||
previousStatus: result.status,
|
||||
stripeCheckoutSessionId: result.stripeCheckoutSessionId,
|
||||
payment: result.payment,
|
||||
});
|
||||
|
||||
if (stornoWarnings.length > 0) {
|
||||
await logAdminAction(admin, {
|
||||
action: "order.cancel_incomplete",
|
||||
entityType: "Order",
|
||||
entityId: orderId,
|
||||
detail: `${result.orderNumber}: ${stornoWarnings.join(" | ")}`,
|
||||
});
|
||||
// Die Stornierung selbst steht und wird NICHT zurueckgenommen — die
|
||||
// Ware ist bereits wieder im Bestand und die Absicht war eindeutig.
|
||||
// Aber der offene Rest muss auf dem Bildschirm landen und nicht nur
|
||||
// im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen.
|
||||
fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach
|
||||
// der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht
|
||||
// rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts).
|
||||
|
||||
Reference in new issue
Block a user