El problema

Mis mecenas de Patreon tienen un descuento en el portal de licencias que depende de lo que aportan. Para calcularlo, el portal consulta una tabla DBF de mecenas que yo actualizaba a mano: entraba en Patreon, descargaba el CSV de miembros y lo subía a un importador.

Entre una descarga y la siguiente la tabla quedaba desactualizada. Un mecenas nuevo pagaba la tarifa completa hasta que yo volvía a bajar el CSV, y uno que subía de tier seguía con el descuento del tier anterior. Todo dependía de que me acordara.

Patreon ofrece webhooks, así que la solución era que su backend avisara al mío cada vez que alguien entra, se va o cambia de tier, y que la DBF se actualizara sola.

Lo que envía Patreon

Me bastan seis eventos: members:create, members:update, members:delete y las tres variantes de members:pledge:*. Cada llamada es un POST con dos cabeceras: X-Patreon-Event indica qué ha pasado, y X-Patreon-Signature lleva el HMAC-MD5 en hexadecimal del cuerpo, calculado con un secreto que Patreon te da al crear el webhook.

El cuerpo sigue el formato JSON:API: el miembro va en data, y el usuario y los tiers en un array included, enlazados por id. Dos detalles de la documentación influyeron en el diseño:

  • El email puede llegar nulo. Hace falta un permiso concreto en la campaña, y el propio mecenas puede ocultarlo. El id de usuario, en cambio, siempre viene.
  • La firma no incluye marca de tiempo, así que no hay ventana contra repeticiones como en otras pasarelas. Recibir el mismo evento dos veces tiene que dar igual.

Firmar sobre los bytes originales

La firma se calcula sobre los bytes del cuerpo, y en VFP el cuerpo llega después de cruzar COM, que lo convierte a CP1252. Lo expliqué en la nota anterior: con un nombre turco, polaco o con un emoji, la firma calculada sobre el texto no coincide. Por eso este webhook verifica la firma con req.GetRawBodyBase64(), que conserva los bytes tal como llegaron, y no con req.GetRawBody().

El webhook complementa al CSV

La tabla de mecenas tiene 32 campos y el evento de Patreon trae unos 14: estado, importe, tier y fechas de cobro. La dirección, el Discord, la moneda o la frecuencia de cobro solo vienen en el CSV.

Por eso el webhook no toca ningún campo que el evento no traiga. Un dato desactualizado es un problema menor que uno borrado. En un alta por webhook esos campos quedan vacíos hasta que pase el siguiente CSV. El importador sigue ahí para la actualización completa, pero ya no hace falta esperarlo para que un mecenas nuevo tenga su descuento.

Tomé otras tres decisiones del mismo estilo:

  • Emparejar primero por id de usuario y después por email. El id no cambia y siempre viene; el email puede faltar o cambiar.
  • Una baja no borra la fila. El mecenas pasa a «Former patron» con importe cero, igual que una licencia revocada se desactiva sin borrarse.
  • La antigüedad no se reescribe. Si la fila ya tiene la fecha en que empezó como mecenas, un evento posterior no la cambia.
El upsert: emparejar y escribir solo lo que trae el evento
* Emparejar: USERID primero, email en minúsculas después.
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

* Lo que trae el evento se escribe; lo que no trae, no se toca.
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()

Como el upsert solo asigna valores y no suma nada, aplicar el mismo evento dos veces deja la fila igual. Eso cubre la falta de marca de tiempo en la firma.

La tabla pertenece a otro proyecto

La DBF de mecenas es de otro proyecto que corre en el mismo proceso de FoxServer, y su importador del CSV la abre en exclusiva. Ya tuve un problema con esto: una consulta del portal la dejaba abierta y el importador fallaba con el error 1705 hasta reiniciar el servidor.

El webhook la abre compartida, bloquea solo el registro que modifica y la cierra en el FINALLY, y solo si la abrió esa misma llamada. Si en ese momento el importador la tiene en exclusiva, no se puede escribir, y en ese caso Patreon tiene que reintentar en vez de perder el evento.

Códigos de respuesta

En los webhooks de pago se suele contestar siempre 200, porque un reintento que genere una licencia duplicada es peor que un evento que se queda en el log. Aquí hice lo contrario, porque el upsert no duplica nada si se repite:

SituaciónRespuestaMotivo
Aplicado, o un evento que no es de miembros200No hay nada que repetir
Un cuerpo que nunca se podrá aplicar200Reintentarlo no lo arregla, y queda en el log
Sin firma o con firma que no coincide401Patreon reintenta y su panel muestra el contador de fallos, que es lo que me avisa de que el secreto está mal
Tabla bloqueada, falta configuración o error interno500Patreon reintenta más tarde y el evento no se pierde
Patreon.prg: qué contestar
llOk = loSvc.Process(lcTrigger, lcJson)
IF llOk
    res.status(200).json(THIS.ToJson(loRes))
    EXIT
ENDIF

IF INLIST(loSvc.cLastReason, "json-invalido", "sin-usuario")
    * No se va a poder aplicar nunca: 200 para que Patreon no lo repita.
    THIS.LogWarning("Patreon webhook: no aplicable ({0})", loSvc.cLastReason)
    res.status(200).json(THIS.ToJson(loRes))
ELSE
    * Bloqueada, sin configuración o error: que Patreon reintente.
    THIS.LogWarning("Patreon webhook: se pide reintento ({0})", loSvc.cLastReason)
    res.status(500).json(THIS.ToJson(loRes))
ENDIF

El email con mayúsculas

Al revisar el servicio que calcula el descuento vi que buscaba con SEEK en minúsculas sobre un índice alltrim(email), que no aplica LOWER. Si el email estaba guardado con mayúsculas, el SEEK no lo encontraba, y la búsqueda de respaldo con LOCATE solo se ejecutaba cuando faltaba el índice, no cuando el SEEK fallaba. Había tres filas así, las tres de mecenas gratuitos, así que no se había perdido dinero. Pero el webhook iba a escribir los emails tal como los envía Patreon.

Ahora el webhook guarda siempre el email en minúsculas, y el servicio del descuento hace una búsqueda en minúsculas antes de dar al mecenas por no encontrado. No reindexé la tabla, porque es de otro proyecto y un cambio de índice hecho desde fuera también afectaría a su importador.

Pruebas

Hay 69 tests de FoxProof repartidos en cuatro ficheros. Cubren el parseo del JSON:API, los casos del upsert sobre un cursor, la firma, que la tabla real quede cerrada después de escribir, el caso en que otro proceso la tiene en exclusiva, los códigos de respuesta del controlador y la búsqueda con mayúsculas contra el mismo índice que tiene la tabla de producción.

La prueba final la hizo Patreon. Su panel de webhooks tiene un botón Send test para cada evento, que envía un miembro ficticio firmado con tu secreto. Envié un members:create: llegó desde el robot de Patreon, la firma coincidió y se dio de alta la fila. Después envié un members:delete, y esa misma fila pasó a ex mecenas con importe cero sin borrarse. Luego eliminé a mano la fila de prueba.

Tuve cuidado con el orden de los pasos. El día 1 de cada mes Patreon cobra a todos los mecenas y envía un members:update por cada uno. Si el webhook está registrado contra una URL que no responde, eso son cientos de fallos seguidos y Patreon lo pausa. Por eso primero desplegué el endpoint, que mientras no tenía secreto contestaba con un error de configuración, y después di de alta el webhook en Patreon.

En el despliegue hubo un fallo sin consecuencias. El script comprobaba que la ruta nueva devolviera el error esperado mientras no hubiera secreto. La ruta respondió bien, pero PowerShell no leyó el cuerpo de la respuesta 500 y la comprobación la dio por fallida, así que el script deshizo el despliegue, que era lo previsto en ese caso. Lo repetí comprobando con curl y funcionó a la primera. El servicio se cortó tres veces, alrededor de un segundo cada vez.

Lo que aprendí

  • Un webhook no tiene que sustituir al proceso manual. Con adelantarlo basta, y si no escribe lo que no sabe, no introduce errores nuevos.
  • Si repetir un evento no hace daño, conviene devolver 500 cuando algo falla, para que se reintente, en vez de 200 y perderlo.
  • Un fallo de firma tiene que verse en algún sitio. Con un 401, el panel de Patreon me avisa; con un 200 habría pasado semanas sin enterarme.

Desde ahora, cuando alguien se hace mecenas, el siguiente pago en el portal ya lleva su descuento. El día 1 será la primera vez que la tabla se actualice sola.