Zahlungs-Webhooks kommen spät und doppelt
Die Rückleitung von der Bezahlseite ist keine Bestätigung. Ein einzelner Webhook auch nicht. Was ein Zahlungsanbieter über Zustellung wirklich zusagt, und wie man darauf etwas Korrektes baut.
Ein Zahlungsanbieter sagt Ihnen, dass er Ereignisse zuverlässig zustellt. Das stimmt, und es ist eine engere Zusage, als die meisten Teams darin lesen. Es heißt: Das Ereignis kommt irgendwann an, wahrscheinlich mehr als einmal, möglicherweise Stunden nach dem, was es beschreibt, und nicht zwingend in der Reihenfolge, in der die Dinge passiert sind.
Jede Entwurfsentscheidung unten folgt daraus, diesen Satz wörtlich zu nehmen.
Die Rückleitung ist Oberfläche, kein Ergebnis
Wenn der Kunde von der Bezahlseite zurückkommt, erhält Ihre Anwendung eine Anfrage, die im Kern sagt: Der Kunde ist wieder da. Sie sagt nicht, dass die Zahlung erfolgreich war, und sie wird nicht vom Anbieter geschickt - sie kommt vom Browser des Kunden, und der kann geschlossen, neu geladen oder von einem Handy ersetzt werden, dem im Aufzug der Akku ausgeht.
Nutzen Sie die Rückleitung für genau eine Aufgabe: einen Bildschirm zeigen. Lesen Sie den aktuellen Zustand der Zahlung beim Anbieter, zeigen Sie, was Sie wissen, und sagen Sie es, wenn Sie es noch nicht wissen. Was die Rückleitung niemals tun darf, ist "bezahlt" in Ihre Datenbank schreiben.
Bestätigt wird die Zahlung durch das Ereignis, und das Ereignis kommt über einen Kanal, auf den der Kunde keinen Einfluss hat.
Prüfen Sie die Bytes, nicht das Objekt
Die Signatur ist ein Hash über den rohen Anfragekörper mit einem geteilten Geheimnis, plus einem Zeitstempel, damit eine alte Zustellung nicht wiedereingespielt werden kann. Zwei Dinge gehen schief, und beide sehen aus wie ein falscher Schlüssel:
public function handle(Request $request): Response
{
$payload = $request->getContent(); // roh, nicht $request->all()
try {
$event = Webhook::constructEvent(
$payload,
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret'),
);
} catch (SignatureVerificationException) {
return response()->noContent(400);
}
ProcessPaymentEvent::dispatch($event->id, $payload);
return response()->noContent(200);
}Das erste ist Parsen vor dem Hashen. $request->all() gibt Ihnen ein Array, und
dessen erneute Kodierung erzeugt andere Bytes als angekommen sind - andere
Schlüsselreihenfolge, anderes Unicode-Escaping, andere Zahlenformatierung.
Hashen Sie die Zeichenkette.
Das zweite ist die CSRF-Middleware, die die Anfrage verwirft, bevor irgendetwas davon läuft, denn ein Zahlungsanbieter hat keine Session und kein Token. Die Route gehört außerhalb der Web-Middleware-Gruppe, und liegt sie darin, ist der Fehler eine 419, die der Anbieter drei Tage lang wiederholt.
Beachten Sie, was der Handler nach der Prüfung tut: fast nichts. Er übergibt die Arbeit an eine Queue und kehrt zurück. Anbieter erwarten eine schnelle Bestätigung, und die Arbeit inline zu erledigen macht aus einer langsamen Abfrage einen Wiederholungssturm.
Falsche Reihenfolge ist der Normalfall
Zwei Ereignisse zu einer Zahlung können in falscher Folge ankommen. Eine Belastung gelingt und wird neunzig Sekunden später erstattet; das Erstattungsereignis überholt das Erfolgsereignis; Ihr Handler verarbeitet die Erstattung gegen eine Bestellung, die noch nicht bezahlt ist, hält das für unsinnig und tut nichts. Jetzt ist die Bestellung für immer bezahlt.
Beheben Sie das nicht über Reihenfolge. Beheben Sie es, indem jeder Handler die Welt beschreibt statt einen Übergang:
- Falsch: "bei Erstattung, setze Status von eingezogen auf erstattet."
- Richtig: "bei jedem Ereignis zu dieser Zahlung, hole ihren aktuellen Zustand beim Anbieter und gleiche meine Zeile daran an."
Das Ereignis wird zum Signal nachzusehen, und seine Ankunftsreihenfolge hört auf zu zählen. Es kostet einen API-Aufruf pro Ereignis und entfernt eine ganze Fehlerklasse, die sonst in Produktion von einem verwirrten Buchhalter gefunden wird.
Wo Sie wirklich nicht nachladen können, speichern Sie den Ereigniszeitstempel des Anbieters an der Zeile und ignorieren alles, was älter ist als das bereits Angewandte.
Doppelt ist ebenfalls der Normalfall
Jeder Anbieter wiederholt bei allem, was keine 2xx ist, und eine Zustellung, die nach erfolgreichem Code in einen Timeout lief, wird auch wiederholt. Dasselbe Ereignis landet zweimal.
Speichern Sie die Ereignis-ID des Anbieters mit einem eindeutigen Index, fügen Sie sie vor der Verarbeitung ein, und lassen Sie die Datenbank das Duplikat ablehnen. Das ist der ganze Mechanismus, und er ist verlässlicher als zu prüfen, ob die Zeile schon existiert, denn eine Prüfung gefolgt von einem Insert ist ein Wettlauf zweier Worker, und bei Wiederholungsstürmen sind beide Worker real.
Und dann trauen Sie dem Kanal gar nicht mehr
Alles oben macht den Webhook-Pfad korrekt. Es macht ihn nicht vollständig, denn ein Endpunkt, der während eines Deploys vier Stunden weg war, hat Ereignisse verpasst, und nach Ablauf des Wiederholungsfensters sind sie fort.
Lassen Sie also einen Abgleichsjob laufen. Fragen Sie den Anbieter einmal täglich nach jeder Zahlung, die sich seit Ihrem letzten erfolgreichen Lauf geändert hat, und vergleichen Sie sie mit Ihren Zeilen. Melden Sie die Unterschiede, statt sie still zu korrigieren - eine Abweichung bedeutet meist einen Fehler, und ein Job, der Ihre Daten leise repariert, versteckt diesen Fehler ein Jahr lang.
Das ist dieselbe Form wie bei den Buchhaltungsanbindungen, die wir bauen: ein Ereignisstrom für Aktualität, ein periodischer Abruf für Richtigkeit, und der Abruf ist das, worauf Sie sich verlassen können. Bei Zahlungen gibt es einen zweiten Grund dafür: Irgendwann fragt jemand aus der Buchhaltung, warum die Monatssumme des Anbieters und Ihre Datenbank auseinandergehen, und "tun sie nicht" ist eine deutlich bessere Antwort als eine Untersuchung.
Wenn Sie das zusammen mit dem Bestellmodell aufsetzen, entscheidet wie die Zahlung modelliert ist die Hälfte dessen, was diese Handler tun müssen, und das klärt man besser zuerst.
