Ein Checkout, der dem Browser nicht glaubt
Der Client bestätigt die Zahlung und darf die Bestellung niemals anlegen. Wo jedes Stück eines Checkouts in einer App-Router-Anwendung hingehört, und welche davon manipulierbar sind.
Ein Checkout hat drei Beteiligte: den Browser des Kunden, Ihre Anwendung und den Zahlungsanbieter. Ihn richtig zu bauen ist fast vollständig die Frage, wer von ihnen was behaupten darf, und der Browser darf sehr wenig behaupten.
In einer App-Router-Anwendung landet diese Aufteilung in bestimmten Dateien, und es lohnt sich, sie aufzuschreiben, bevor eine davon existiert.
Was der Server entscheidet
Der Browser des Kunden sendet Produkt-IDs und Mengen. Das ist der gesamte Umfang dessen, was er beitragen darf.
Preis, Steuer, Versand, Rabatt und Endbetrag werden serverseitig aus Ihren eigenen Daten berechnet, jedes Mal, auch bei der Anfrage, die die Zahlung anlegt. Nicht aus einem versteckten Feld gelesen, nicht aus einer clientseitigen Warenkorbsumme übernommen, nicht geglaubt, weil die vorige Anfrage richtig gerechnet hat.
// app/checkout/actions.ts
'use server'
export async function startCheckout(items: CartItem[]) {
const session = await auth()
const priced = await priceBasket(items) // Serverpreise, Serverregeln
const order = await db.order.create({
data: { userId: session.user.id, status: 'pending', total: priced.total },
})
const intent = await stripe.paymentIntents.create({
amount: priced.total, // kleinste Einheit, vom Server
currency: priced.currency,
metadata: { orderId: order.id },
automatic_payment_methods: { enabled: true },
})
return { clientSecret: intent.client_secret, orderId: order.id }
}Drei Dinge darin sind Absicht. Die Bestellung existiert vor der Zahlung, in einem ausstehenden Zustand, damit es immer etwas gibt, an das sich das Ergebnis hängen lässt. Ihre ID reist in den Metadaten der Zahlung mit, und genau das lässt einen vierzig Minuten später eintreffenden Webhook sie ohne Nachschlagetabelle finden. Und das Einzige, was an den Browser zurückgeht, ist ein Client Secret, das die Bestätigung genau dieser einen Zahlung erlaubt und sonst nichts.
Was der Client tut, und was nicht
Der Browser nimmt die Karte auf und bestätigt die Zahlung direkt bei Stripe. Die Karte berührt Ihren Server nie, und das hält Sie aus dem Regelwerk heraus, in das Sie der Umgang mit Kartendaten brächte.
Diese Bestätigung ist eine Client-Komponente - einer der wenigen Orte auf einer Handelsseite, an dem eine Client-Komponente wirklich nötig und nicht nur bequem ist, und die Ausnahme von einer engen Grenze wert. Sie braucht Stripes JavaScript, sie muss ein iframe einhängen, und sie muss auf die Bank des Kunden reagieren.
Was sie nicht tut, ist Ihrem Server mitzuteilen, dass die Zahlung geklappt hat. Der Confirm-Aufruf löst im Browser auf, und ein aufgelöstes Promise in einem Browser ist keine Tatsache über Geld. Es ist ein guter Grund, einen Erfolgsbildschirm zu zeigen; es ist kein Grund, eine Bestellung auszuliefern.
Das Ergebnis kommt woanders an
Ein Route Handler empfängt den Webhook, prüft die Signatur gegen den Rohtext und übergibt das Ereignis an etwas Dauerhaftes.
// app/api/stripe/webhook/route.ts
export async function POST(request: Request) {
const raw = await request.text() // rohe Bytes, kein json()
let event
try {
event = stripe.webhooks.constructEvent(
raw, request.headers.get('stripe-signature')!, process.env.STRIPE_WEBHOOK_SECRET!,
)
} catch {
return new Response(null, { status: 400 })
}
await recordAndEnqueue(event.id, raw) // außerhalb dieses Prozesses
return new Response(null, { status: 200 })
}request.json() würde die Signatur brechen, denn der Hash geht über die
gesendeten Bytes, und das erneute Kodieren eines Objekts reproduziert sie nicht.
Und der Handler bleibt absichtlich kurz: Auslieferung, E-Mail, Bestandsabzug und
Buchhaltung passieren nach der Antwort, auf etwas, das wiederholen kann.
Nicht in after, das den Callback nach dem Senden der Antwort ausführt, aber
weiterhin im selben Aufruf und innerhalb derselben maximalen Laufzeit wie die
Route - es lässt sich nicht wiederholen und übersteht das Verschwinden der
Instanz nicht. Das ist
dieselbe Grenze, an die jede Drittanbieter-Anbindung stößt,
und Zahlungen sind der Fall, in dem ihr Übertreten echtes Geld kostet.
Die Seite dazwischen
Zwischen Bestätigung und Webhook liegt eine Lücke, meist Sekunden und gelegentlich Minuten, und der Kunde sitzt währenddessen auf einer Seite.
Rendern Sie aus Ihrer eigenen Bestellzeile, nicht aus irgendetwas, das der Client gemeldet hat. Die Bestellung ist ausstehend, also sagt die Seite, dass die Zahlung bestätigt wird, und nennt die Bestellnummer. Landet der Webhook und die Zeile springt auf bezahlt, zeigt die Seite den Beleg.
Der billige Fehler ist, optimistisch Erfolg zu rendern, weil das Client-Promise aufgelöst hat. In der Entwicklung sieht das korrekt aus, wo der Webhook nach ein paar hundert Millisekunden da ist, und in Produktion erzeugt es einen Bestätigungsbildschirm für eine Zahlung, die im letzten Schritt abgelehnt wurde. Zeigen Sie, was Ihre Datenbank weiß; sie ist hier der einzige Beteiligte, den Sie kontrollieren.
Dieser Zwischenzustand ist ein echter Bildschirm mit echtem Text, und wie man ihn baut zählt bei einer Zahlung mehr als irgendwo sonst, denn das ist der Moment, in dem ein Kunde entscheidet, ob er es noch einmal versucht und zweimal zahlt.
Wo das die Architektur hinstellt
Die Next.js-Anwendung besitzt den Checkout-Bildschirm, die Preisbildung, die ausstehende Bestellung und den Webhook-Empfänger. Sie besitzt keine Wiederholungen, keinen Abgleich und nichts, was nach Zeitplan passieren muss. Das gehört einem Worker oder einem Backend-Dienst, aus demselben Grund wie immer: Ein Request endet, die Geschichte einer Zahlung nicht.
Unsere Handelsprojekte beginnen an dieser Grenze und nicht beim Design, denn ein Checkout, der schön ist und dem Browser glaubt, ist ein Checkout, der binnen eines Monats nach dem Start ausgenutzt wird.
