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

+138 -1
View File
@@ -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).
+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 }
);
+145 -6
View File
@@ -62,9 +62,14 @@ async function storeInvoiceDocument(invoiceNumber: string): Promise<Buffer | nul
/**
* Verarbeitet Stripe-Webhook-Events. In Stripe muss ein Webhook-Endpoint auf
* /api/checkout/webhook mit den Events "checkout.session.completed",
* "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed"
* und "checkout.session.expired" angelegt und STRIPE_WEBHOOK_SECRET gesetzt
* werden. Die beiden async-Events sind fuer Zahlungsmethoden mit verzoegerter
* "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed",
* "checkout.session.expired", "charge.refunded" und "charge.dispute.created"
* angelegt und STRIPE_WEBHOOK_SECRET gesetzt
* werden. Die beiden letzten betreffen Geld, das den Shop wieder verlaesst,
* ohne dass hier jemand etwas angeklickt haette — eine Erstattung von Hand im
* Stripe-Dashboard und eine Rueckbuchung durch die Bank. Beide waren bisher
* ausschliesslich im Stripe-Dashboard zu sehen (siehe recordRefund und
* recordDispute). Die beiden async-Events sind fuer Zahlungsmethoden mit verzoegerter
* Bestaetigung (SEPA-Lastschrift, teils Klarna) noetig — dort ist die
* Checkout-Sitzung bereits abgeschlossen, aber die Zahlung selbst noch nicht
* (siehe fulfillOrder/failOrder unten). "expired" faengt den Fall ab, dass
@@ -129,9 +134,123 @@ export async function POST(request: Request) {
await failOrder(session, "delete");
}
if (event.type === "charge.refunded") {
await recordRefund(event.data.object as Stripe.Charge);
}
if (event.type === "charge.dispute.created") {
await recordDispute(event.data.object as Stripe.Dispute);
}
return NextResponse.json({ received: true });
}
/** Die PaymentIntent-ID eines Stripe-Objekts, gleich ob sie als ID oder als eingebettetes Objekt kommt. */
function paymentIntentIdOf(value: string | { id: string } | null | undefined): string | null {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
/**
* Haelt eine Erstattung fest, die AUSSERHALB dieser Anwendung ausgeloest
* wurde — typischerweise von Hand im Stripe-Dashboard.
*
* Der Weg ueber den Retouren-Bereich und die Stornierung im Admin-Bereich
* schreiben den Zahlungsstatus selbst mit. Eine Erstattung direkt bei Stripe
* tat das bisher nicht: die Zahlung stand im Shop weiter auf "SUCCEEDED", und
* jede spaetere Auswertung zaehlte einen Umsatz mit, den es nicht mehr gab.
*
* Der Bestellstatus bleibt unangetastet. Ob eine Erstattung eine Stornierung
* bedeutet, eine Retoure oder eine Kulanzgutschrift bei gelieferter Ware,
* steht an diesem Ereignis nicht — und Ware automatisch in den Bestand
* zurueckzubuchen, die vielleicht nie zurueckkommt, waere schlimmer als sie
* von Hand einzubuchen.
*/
async function recordRefund(charge: Stripe.Charge): Promise<void> {
const paymentIntentId = paymentIntentIdOf(charge.payment_intent);
if (!paymentIntentId) return;
const payment = await prisma.payment.findFirst({
where: { stripePaymentIntentId: paymentIntentId },
select: { id: true, status: true, order: { select: { orderNumber: true } } },
});
if (!payment) {
console.warn(
`[checkout] Erstattung zu PaymentIntent ${paymentIntentId} erhalten, zu der es hier keine Zahlung gibt — nichts zu tun.`
);
return;
}
// "refunded" ist nur bei einer VOLLSTAENDIGEN Erstattung wahr. Eine
// Teilerstattung darf die Zahlung nicht auf "REFUNDED" setzen: der
// Restbetrag ist weiterhin eingezogen, und eine Auswertung wuerde ihn sonst
// ganz abschreiben.
if (!charge.refunded) {
console.warn(
`[checkout] Teilerstattung bei Bestellung ${payment.order.orderNumber}: ${charge.amount_refunded} von ${charge.amount} Cent. Der Zahlungsstatus bleibt "${payment.status}" — bitte die Rechnung von Hand korrigieren.`
);
return;
}
if (payment.status === "REFUNDED") return;
await prisma.payment.update({ where: { id: payment.id }, data: { status: "REFUNDED" } });
console.info(
`[checkout] Bestellung ${payment.order.orderNumber}: vollstaendige Erstattung ueber ${charge.amount_refunded} Cent vermerkt.`
);
}
/**
* Vermerkt eine Rueckbuchung (Chargeback) an der Bestellung.
*
* Eine Rueckbuchung hat eine Frist: Stripe verlangt die Stellungnahme
* innerhalb weniger Tage, sonst gilt sie als verloren. Bisher kam davon im
* Shop nichts an — sie war ausschliesslich im Stripe-Dashboard zu sehen, und
* nur wer dort nachsieht, haette sie rechtzeitig bemerkt.
*
* Automatisch entschieden wird hier nichts: Status und Lagerbestand bleiben,
* wie sie sind. Der Vermerk landet in den Notizen der Bestellung, wo er im
* Admin-Bereich neben allem anderen zu dieser Bestellung steht.
*/
async function recordDispute(dispute: Stripe.Dispute): Promise<void> {
const paymentIntentId = paymentIntentIdOf(dispute.payment_intent);
if (!paymentIntentId) return;
const payment = await prisma.payment.findFirst({
where: { stripePaymentIntentId: paymentIntentId },
select: { orderId: true, order: { select: { orderNumber: true, notes: true } } },
});
if (!payment) {
console.error(
`[checkout] RUECKBUCHUNG zu PaymentIntent ${paymentIntentId} erhalten, zu der es hier keine Zahlung gibt (Dispute ${dispute.id}). Bitte im Stripe-Dashboard pruefen.`
);
return;
}
console.error(
`[checkout] RUECKBUCHUNG bei Bestellung ${payment.order.orderNumber}: ${dispute.amount} Cent, Grund "${dispute.reason}" (Dispute ${dispute.id}). Fristgebundene Stellungnahme im Stripe-Dashboard noetig.`
);
const note = `[${new Date().toISOString()}] Rueckbuchung ueber ${dispute.amount} Cent, Grund "${dispute.reason}" (Stripe-Dispute ${dispute.id}). Stellungnahme im Stripe-Dashboard ist fristgebunden.`;
try {
await prisma.order.update({
where: { id: payment.orderId },
// Anhaengen statt ersetzen: bestehende Notizen der Imkerei duerfen
// dabei nicht verlorengehen.
data: { notes: payment.order.notes ? `${payment.order.notes}\n${note}` : note },
});
} catch (error: unknown) {
// Nicht weiterwerfen: der Logeintrag oben ist die wichtigere Meldung, und
// ein 500er wuerde Stripe nur zu Wiederholungen veranlassen, die denselben
// Vermerk erneut anhaengen.
console.error(
`[checkout] Rueckbuchungs-Vermerk an Bestellung ${payment.order.orderNumber} konnte nicht gespeichert werden:`,
error
);
}
}
async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
const orderId = session.metadata?.orderId;
if (!orderId) return;
@@ -164,9 +283,29 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
if (!firstTime) {
// Entweder ein Retry, der die Bestellung bereits "BEZAHLT" vorfindet
// (siehe Kommentar oben bei fulfillOrder), oder ein anderer Endstatus
// (z.B. "STORNIERT") — im zweiten Fall nichts weiter tun.
const existing = await tx.order.findUnique({ where: { id: orderId }, select: { status: true } });
if (!existing || existing.status !== "BEZAHLT") return null;
// (z.B. "STORNIERT").
const existing = await tx.order.findUnique({
where: { id: orderId },
select: { status: true, orderNumber: true },
});
if (!existing) {
// Die Bestellung ist weg, das Geld ist da. Moeglich, wenn eine als
// abgelaufen aufgeloeste Bestellung danach doch noch bezahlt wurde.
console.error(
`[checkout] Zahlung eingegangen fuer eine nicht mehr vorhandene Bestellung (Order ${orderId}, Session ${session.id}). Der Betrag liegt bei Stripe und muss von Hand erstattet oder zugeordnet werden.`
);
return null;
}
if (existing.status !== "BEZAHLT") {
// Frueher ein stilles "return null": die Zahlung war eingezogen, im
// Shop entstand aber weder Rechnung noch Bestaetigung noch ein
// Logeintrag. Genau der Fall, der niemandem auffaellt, bis sich die
// Kundschaft meldet.
console.error(
`[checkout] Zahlung eingegangen fuer Bestellung ${existing.orderNumber}, die auf "${existing.status}" steht (Session ${session.id}). Es wird nichts ausgeliefert und nichts berechnet — bitte pruefen und gegebenenfalls erstatten.`
);
return null;
}
}
const updated = await tx.order.findUniqueOrThrow({
+50 -3
View File
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckoutForm } from "./checkout-form";
import { useCartStore, type CartItem } from "@/store/cart-store";
@@ -7,10 +7,15 @@ import { I18nProvider } from "@/i18n/i18n-provider";
import type { ShippingTierLike } from "@/lib/shipping-calc";
import type { BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
// Geteilte Instanz statt eines frischen vi.fn() je Aufruf: nur so laesst sich
// pruefen, dass NICHT navigiert wurde.
const routerPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
useRouter: () => ({ push: routerPush }),
}));
const fetchMock = vi.fn();
const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }];
const bulkDiscountTiers: BulkDiscountTierLike[] = [];
@@ -36,6 +41,13 @@ function renderCheckout(items: CartItem[]) {
beforeEach(() => {
useCartStore.setState({ items: [], isOpen: false });
routerPush.mockClear();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("CheckoutForm", () => {
@@ -55,4 +67,39 @@ describe("CheckoutForm", () => {
expect(screen.queryByText("7,00 €")).not.toBeInTheDocument();
expect(screen.getByText("kostenlos")).toBeInTheDocument();
});
/**
* Regression: Antwortet /api/checkout mit 502 (Stripe nicht erreichbar),
* schickte das Formular frueher auf die Erfolgsseite mit dem Hinweis "wir
* melden uns". Es gab zu dieser Bestellung aber gar keine Zahlungssitzung —
* sie war auf keinem Weg bezahlbar, und die Seite versprach etwas, das
* niemand einloesen konnte.
*/
test("shows the server error instead of the success page when the payment session cannot be created", async () => {
const userEventInstance = userEvent.setup();
const serverError =
"Die Zahlung konnte nicht gestartet werden — es wurde nichts abgebucht. Bitte versuche es in ein paar Minuten erneut oder melde dich bei uns.";
fetchMock.mockResolvedValue({
ok: false,
status: 502,
json: async () => ({ error: serverError }),
} as Response);
renderCheckout([item()]);
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
await userEventInstance.type(screen.getByLabelText(/e-mail/i), "[email protected]");
await userEventInstance.type(screen.getByLabelText(/straße|strasse/i), "Wiesenweg 1");
await userEventInstance.type(screen.getByLabelText(/plz|postleitzahl/i), "86450");
await userEventInstance.type(screen.getByLabelText(/ort|stadt/i), "Altenmünster");
await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i }));
await waitFor(() => expect(screen.getByText(serverError)).toBeInTheDocument());
// Das Formular navigiert inzwischen gar nicht mehr — diese Zusicherung
// faellt erst dann wieder auf, wenn jemand die Weiterleitung auf die
// Erfolgsseite erneut einbaut. Genau dafuer steht sie hier.
expect(routerPush).not.toHaveBeenCalled();
});
});
+5 -6
View File
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react";
import { useRouter } from "next/navigation";
import { Container, SectionHeading, Button } from "@/components/ui";
import { TrustBadges } from "@/components/trust-badges";
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
@@ -61,7 +60,6 @@ export function CheckoutForm({
const hasHydrated = useCartStore((s) => s.hasHydrated);
const subtotal = cartTotalCents(items);
const totalQuantity = cartItemCount(items);
const router = useRouter();
// Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit
// Token-Rueckgabe statt eines versteckten Feldes.
@@ -179,10 +177,11 @@ export function CheckoutForm({
const data = await response.json();
if (!response.ok) {
if (data.orderNumber) {
router.push(`/checkout/success?order=${data.orderNumber}&pending=1`);
return;
}
// Auch der 502er (Stripe nicht erreichbar) landet hier als normale
// Fehlermeldung. Frueher fuehrte er auf die Erfolgsseite mit dem
// Hinweis "wir melden uns" — eine Bestellung ohne Zahlungssitzung ist
// aber auf keinem Weg bezahlbar, und die Seite versprach damit etwas,
// das niemand einloesen konnte (siehe api/checkout/route.ts).
setError(data.error ?? t.checkoutPage.genericError);
setSubmitting(false);
return;