Das Problem
Meine Unterstützer auf Patreon bekommen im Lizenzportal einen Rabatt, der von ihrem Beitrag abhängt. Um ihn zu berechnen, liest das Portal eine DBF-Tabelle der Unterstützer, die ich von Hand aktuell hielt: Ich meldete mich bei Patreon an, lud die CSV-Datei der Mitglieder herunter und spielte sie in einen Importer ein.
Zwischen zwei Downloads veraltete die Tabelle. Ein neuer Unterstützer zahlte den vollen Preis, bis ich die CSV-Datei wieder herunterlud, und wer eine Stufe höher ging, behielt den Rabatt der vorherigen Stufe. Alles hing davon ab, dass ich daran dachte.
Patreon bietet Webhooks an. Die Lösung war also, dass ihr Backend meines benachrichtigt, sobald jemand dazukommt, geht oder die Stufe wechselt, und dass sich die DBF selbst aktualisiert.
Was Patreon schickt
Sechs Ereignisse reichen mir: members:create, members:update, members:delete und die drei Varianten von members:pledge:*. Jeder Aufruf ist ein POST mit zwei Headern: X-Patreon-Event sagt, was passiert ist, und X-Patreon-Signature enthält den hexadezimalen HMAC-MD5 des Bodys, berechnet mit einem Geheimnis, das Patreon beim Anlegen des Webhooks vergibt.
Der Body folgt dem Format JSON:API: Das Mitglied steht in data, der Benutzer und die Stufen in einem Array included, verknüpft über ihre ID. Zwei Details aus der Dokumentation haben den Entwurf bestimmt:
- Die E-Mail-Adresse kann leer ankommen. Dafür braucht es eine bestimmte Berechtigung in der Kampagne, und der Unterstützer kann sie verbergen. Die Benutzer-ID kommt dagegen immer an.
- Die Signatur enthält keinen Zeitstempel, es gibt also kein Fenster gegen Wiederholungen wie bei anderen Zahlungsanbietern. Dasselbe Ereignis zweimal zu empfangen, darf keinen Unterschied machen.
Über die ursprünglichen Bytes signieren
Die Signatur wird über die Bytes des Bodys berechnet, und in VFP kommt der Body erst an, nachdem er über COM gegangen ist, das ihn nach CP1252 umwandelt. Das habe ich im vorherigen Beitrag erklärt: Bei einem türkischen oder polnischen Namen oder einem Emoji stimmt eine Signatur über den Text nicht. Deshalb prüft dieser Webhook die Signatur mit req.GetRawBodyBase64(), das die Bytes so bewahrt, wie sie ankamen, und nicht mit req.GetRawBody().
Der Webhook ergänzt die CSV-Datei
Die Tabelle der Unterstützer hat 32 Felder, und ein Ereignis von Patreon bringt etwa 14 mit: Status, Betrag, Stufe und Abrechnungsdaten. Adresse, Discord, Währung oder Abrechnungsintervall kommen nur mit der CSV-Datei.
Deshalb fasst der Webhook kein Feld an, das das Ereignis nicht mitbringt. Ein veralteter Wert ist ein kleineres Problem als ein gelöschter. Bei einer Neuanlage über den Webhook bleiben diese Felder leer, bis die nächste CSV-Datei eingespielt wird. Der Importer bleibt für die vollständige Aktualisierung, aber ein neuer Unterstützer muss nicht mehr auf ihn warten, um seinen Rabatt zu bekommen.
Drei weitere Entscheidungen folgen derselben Linie:
- Zuerst über die Benutzer-ID abgleichen, danach über die E-Mail-Adresse. Die ID ändert sich nicht und kommt immer an; die E-Mail-Adresse kann fehlen oder sich ändern.
- Eine Kündigung löscht die Zeile nicht. Der Unterstützer wird zu «Former patron» mit dem Betrag null, so wie eine widerrufene Lizenz deaktiviert und nicht gelöscht wird.
- Das Anfangsdatum der Unterstützung wird nicht überschrieben. Kennt die Zeile es schon, ändert ein späteres Ereignis es nicht.
* Abgleich: zuerst USERID, danach die E-Mail in Kleinbuchstaben.
llFound = .F.
IF !EMPTY(loEv.cUserId)
LOCATE FOR ALLTRIM(patrons.userid) == loEv.cUserId
llFound = FOUND()
ENDIF
IF !llFound AND !EMPTY(loEv.cEmail)
LOCATE FOR LOWER(ALLTRIM(patrons.email)) == loEv.cEmail
llFound = FOUND()
ENDIF
IF !llFound
APPEND BLANK
ELSE
SET REPROCESS TO 3 SECONDS
IF !RLOCK()
THIS.cLastReason = "bloqueada"
EXIT
ENDIF
ENDIF
* Was das Ereignis mitbringt, wird geschrieben; was nicht, bleibt unberührt.
IF loEv.lPledgeKnown
REPLACE patrons.pledgeamount WITH loEv.nPledge
ENDIF
IF loEv.lTierKnown
REPLACE patrons.tier WITH loEv.cTier
ENDIF
IF !EMPTY(loEv.tSince) AND EMPTY(patrons.patronagesince)
REPLACE patrons.patronagesince WITH loEv.tSince
ENDIF
REPLACE patrons.lastupdated WITH DATETIME()Da das Upsert nur Werte zuweist und nichts aufaddiert, lässt dasselbe Ereignis, zweimal angewendet, die Zeile unverändert. Das gleicht den fehlenden Zeitstempel in der Signatur aus.
Die Tabelle gehört einem anderen Projekt
Die DBF der Unterstützer gehört einem anderen Projekt, das im selben FoxServer-Prozess läuft, und dessen CSV-Importer öffnet sie exklusiv. Das hat mir schon einmal ein Problem gemacht: Eine Abfrage des Portals ließ sie offen, und der Importer scheiterte mit Fehler 1705, bis der Server neu gestartet wurde.
Der Webhook öffnet sie gemeinsam, sperrt nur den Datensatz, den er ändert, und schließt sie im FINALLY, und zwar nur, wenn derselbe Aufruf sie geöffnet hat. Hat der Importer sie in diesem Moment exklusiv geöffnet, lässt sich nichts schreiben, und dann muss Patreon es erneut versuchen, statt das Ereignis zu verlieren.
Antwortcodes
Zahlungs-Webhooks antworten üblicherweise immer mit 200, weil eine Wiederholung, die eine doppelte Lizenz erzeugt, schlimmer ist als ein Ereignis, das im Log bleibt. Hier habe ich es umgekehrt gemacht, weil das Upsert bei einer Wiederholung nichts verdoppelt:
| Situation | Antwort | Grund |
|---|---|---|
| Angewendet, oder ein Ereignis, das keine Mitglieder betrifft | 200 | Nichts zu wiederholen |
| Ein Body, der sich nie anwenden lässt | 200 | Eine Wiederholung ändert daran nichts, und er bleibt im Log |
| Keine Signatur, oder eine, die nicht stimmt | 401 | Patreon versucht es erneut, und sein Dashboard zeigt die Zahl der Fehlschläge, die mir sagt, dass das Geheimnis falsch ist |
| Tabelle gesperrt, Konfiguration fehlt oder interner Fehler | 500 | Patreon versucht es später erneut, und das Ereignis geht nicht verloren |
llOk = loSvc.Process(lcTrigger, lcJson)
IF llOk
res.status(200).json(THIS.ToJson(loRes))
EXIT
ENDIF
IF INLIST(loSvc.cLastReason, "json-invalido", "sin-usuario")
* Lässt sich nie anwenden: 200, damit Patreon es nicht wiederholt.
THIS.LogWarning("Patreon webhook: no aplicable ({0})", loSvc.cLastReason)
res.status(200).json(THIS.ToJson(loRes))
ELSE
* Gesperrt, ohne Konfiguration oder Fehler: Patreon soll es erneut versuchen.
THIS.LogWarning("Patreon webhook: se pide reintento ({0})", loSvc.cLastReason)
res.status(500).json(THIS.ToJson(loRes))
ENDIFDie großgeschriebene E-Mail-Adresse
Beim Durchsehen des Dienstes, der den Rabatt berechnet, fiel mir auf, dass er mit einem SEEK in Kleinbuchstaben über einen Index alltrim(email) suchte, der kein LOWER anwendet. War die E-Mail-Adresse mit Großbuchstaben gespeichert, fand das SEEK sie nicht, und das LOCATE als Rückfall lief nur, wenn der Index fehlte, nicht wenn das SEEK nichts fand. Drei Zeilen waren so, alle von kostenlosen Mitgliedern, es war also kein Geld verloren gegangen. Aber der Webhook würde die E-Mail-Adressen so schreiben, wie Patreon sie schickt.
Jetzt speichert der Webhook die E-Mail-Adresse immer in Kleinbuchstaben, und der Rabattdienst sucht in Kleinbuchstaben, bevor er einen Unterstützer als nicht gefunden meldet. Die Tabelle habe ich nicht neu indiziert, weil sie einem anderen Projekt gehört und eine Indexänderung von außen auch dessen Importer betreffen würde.
Tests
Es gibt 69 FoxProof-Tests in vier Dateien. Sie decken das Parsen des JSON:API ab, die Fälle des Upserts über einen Cursor, die Signatur, das Schließen der echten Tabelle nach dem Schreiben, den Fall, dass ein anderer Prozess sie exklusiv geöffnet hat, die Antwortcodes des Controllers und die Suche mit Großbuchstaben gegen denselben Index, den die Produktionstabelle hat.
Den letzten Test hat Patreon gemacht. Sein Webhook-Dashboard hat für jedes Ereignis eine Schaltfläche Send test, die ein fiktives Mitglied schickt, signiert mit Ihrem Geheimnis. Ich schickte ein members:create: Es kam vom Roboter von Patreon, die Signatur stimmte, und die Zeile wurde angelegt. Danach schickte ich ein members:delete, und dieselbe Zeile wurde zu einem ehemaligen Unterstützer mit dem Betrag null, ohne gelöscht zu werden. Anschließend entfernte ich die Testzeile von Hand.
Bei der Reihenfolge war ich vorsichtig. Am 1. jedes Monats belastet Patreon alle Unterstützer und schickt für jeden ein members:update. Ist der Webhook auf eine URL eingetragen, die nicht antwortet, sind das Hunderte Fehlschläge hintereinander, und Patreon setzt ihn aus. Deshalb habe ich zuerst den Endpunkt ausgerollt, der ohne Geheimnis mit einem Konfigurationsfehler antwortete, und erst danach den Webhook bei Patreon eingetragen.
Beim Ausrollen gab es einen Fehler ohne Folgen. Das Skript prüfte, ob die neue Route den erwarteten Fehler zurückgab, solange es kein Geheimnis gab. Die Route antwortete richtig, aber PowerShell las den Body der 500-Antwort nicht, und die Prüfung wertete das als Fehlschlag. Also machte das Skript das Deployment rückgängig, wie für diesen Fall vorgesehen. Ich wiederholte es mit einer Prüfung über curl, und es klappte auf Anhieb. Der Dienst stand dreimal still, jeweils etwa eine Sekunde.
Was ich gelernt habe
- Ein Webhook muss den manuellen Ablauf nicht ersetzen. Es reicht, ihm zuvorzukommen, und wenn er nicht schreibt, was er nicht weiß, bringt er keine neuen Fehler mit.
- Wenn eine Wiederholung keinen Schaden anrichtet, ist es besser, bei einem Fehler 500 zu antworten, damit es erneut versucht wird, als 200 zu antworten und das Ereignis zu verlieren.
- Ein Signaturfehler muss irgendwo sichtbar werden. Mit einem 401 warnt mich das Dashboard von Patreon; mit einem 200 hätte ich es wochenlang nicht bemerkt.
Ab jetzt trägt die nächste Zahlung im Portal den Rabatt bereits, sobald jemand Unterstützer wird. Am 1. des Monats wird sich die Tabelle zum ersten Mal selbst aktualisieren.