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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user