Blog ·
Stripe en hosting: cuándo un pago es propio
Con éxito no es liquidado, una disputa no es un reembolso y los reintentos son un ajuste del panel. Facturar hosting de forma recurrente con Stripe.
Por Provibr
Facturar hosting no es comercio electrónico. Una tienda cobra una vez y envía una caja; aquí se cobra cada mes y entre medias hay que mantener una máquina en marcha. Esa diferencia aparece en un único sitio, y es el sitio donde toda integración se equivoca primero: decidir cuándo un pago es lo bastante real como para seguir entregando servicio.
Esto es con lo que nos topamos al construir la facturación recurrente sobre Stripe. Nada de ello es exótico, y casi todo es invisible hasta el mes en que un pago se revierte.
«Con éxito» no es «liquidado»
Una compra correcta no es dinero. La tarjeta e iDEAL son lo bastante instantáneos como para poder fingir lo contrario. El adeudo directo SEPA no lo es, y una transferencia bancaria tampoco.
Un pago SEPA se queda en processing después de la compra, sin nada recibido, durante días. Normalmente acaba llegando. A veces no. Si la integración trata la compra como el momento de la verdad, se ha aprovisionado un servidor sobre la base de una intención.
Así que el modelo de estados no es un booleano. Acabamos con seis fases, y exactamente una de ellas significa «entregar»:
| Fase | Qué significa | ¿Entregar? |
|---|---|---|
| pending | Iniciado, nada recibido todavía | No |
| settled | Dinero recibido y nuestro | Sí |
| failed | No va a ocurrir | No |
| disputed | El cliente lo está impugnando | No, pero puede volver |
| reversed | Retrocedido o reembolsado | No, de forma permanente |
| unknown | No pudimos determinarlo | No |
Hacer que «entregable» signifique precisa y únicamente settled no cuesta nada de escribir y compra la propiedad de que ninguna otra parte del código pueda tener opinión al respecto. Todo lo demás hace una sola pregunta.
El orden de los eventos no está garantizado: hacer ilegales las transiciones ilegales
Los webhooks llegan desordenados. No a menudo, pero llegan, y el fallo es silencioso: un evento rancio sobrescribe un estado más nuevo, y el servicio de un cliente o se detiene sin motivo o sigue funcionando después de una retrocesión.
La defensa barata consiste en escribir qué transiciones son imposibles y rechazarlas. De settled no se vuelve a pending, settled no puede pasar a failed, pending no puede saltar directamente a disputed, y nada sale nunca de reversed ni de failed. Un evento tardío que provocaría una de esas se descarta en lugar de aplicarse.
La segunda defensa es arquitectónica, y es la decisión que conservaríamos en cualquier integración de pagos.
El webhook es un acelerador, no una fuente de verdad. El nuestro verifica la firma, deduplica y empuja un identificador de organización a una cola. Eso es todo lo que hace. Después, un sondeador recupera el estado actual desde Stripe antes de que nada cambie. La consecuencia compensa la fontanería: un webhook perdido nunca es un pago perdido, es un pago más lento. La entrega no depende de un mecanismo de entrega ajeno.
La verificación de la firma tiene tres detalles fáciles de hacer sutilmente mal:
- Comparar en tiempo constante, pero comprobar antes las longitudes, porque la comparación en tiempo constante lanza una excepción con longitudes distintas en lugar de devolver falso.
- Aceptar varias firmas en la cabecera. Así es como funciona la rotación de secretos, y una implementación que lee solo la primera se rompe el día de la rotación.
- Rechazar una firma no hexadecimal antes de interpretarla como hexadecimal, porque el análisis hexadecimal trunca en silencio en el primer carácter inválido y una firma de dos caracteres se comparará tan tranquila contra nada.
También deduplicamos reclamando el identificador del evento antes de publicar el aviso. Eso tiene una pega conocida: se reclama, falla la publicación y el aviso se pierde. Es aceptable precisamente porque el sondeo es la fuente de verdad, lo cual ilustra bien cómo una decisión arquitectónica paga un compromiso en otro lugar.
El cobro de impagados es un ajuste del panel, no un parámetro de la API
Si un pago mensual falla, algo tiene que reintentarlo y algo tiene que decidir cuándo se detiene el servicio. Escribimos primero esa lógica y después borramos casi toda.
Los reintentos inteligentes de Stripe eligen el momento a partir de sus propios datos, y en qué se convierte una suscripción después de que hayan fallado todos los reintentos se configura en el panel. Es un ajuste para toda la cuenta y no algo que se pase por suscripción, y el modo de prueba y el modo real se configuran por separado, que es una buena manera de llevarse una sorpresa en producción tras una prueba en verde.
Eso deja de este lado un solo trabajo: traducir el estado de la suscripción a lo que el cliente tiene permitido usar.
| Estado de la suscripción | Qué concedemos | Por qué |
|---|---|---|
| active, trialing | Acceso completo | |
| past_due | Gracia: un aviso, nada detenido | Stripe sigue reintentando |
| unpaid | Suspendida | Los reintentos se agotaron |
| canceled | Suspendida | |
| incomplete | Acceso completo, sin restricción | El primer pago nunca llegó a salir bien |
| paused | Acceso completo |
La fila past_due se guía por el estado y no por el reloj, y esa es la simplificación que conviene aceptar. Una vez que el procesador es dueño del calendario de reintentos, «cuántos días lleva fallando esto» deja de ser una pregunta propia. Hacerla igualmente significa poner un segundo temporizador a competir con un calendario que no se ve.
La fila incomplete es la que muerde. Significa una suscripción cuyo primerísimo pago nunca salió adelante, que no es lo mismo que un cliente que pagaba y dejó de pagar. Tratarlos igual suspende retroactivamente a un cliente en marcha por culpa de un evento sobre una suscripción que nunca arrancó.
Y la suspensión es una pausa, nunca un borrado. Volver es una acción que ejecuta una persona. Nada dentro de una máquina de estados de facturación debería poder destruir los datos de un cliente, por muy convencida que esté.
El fallo que devolvió el dinero y dejó el servicio en marcha
Este es el fallo que más nos enseñó, y no tenía nada que ver con el proveedor de pagos.
Una retrocesión puso correctamente el estado de licencia del cliente en bloqueado. En cinco minutos volvía a estar activo. El cliente tenía su dinero y sus servidores.
La causa era que un trabajo en segundo plano que recalculaba otra cosa, el exceso de recursos, lanzaba un UPDATE sobre esa misma columna cada cinco minutos sin cláusula WHERE. Tres piezas de código distintas componían el mismo campo, cada una correcta por su cuenta, y ganaba la que se ejecutaba más a menudo.
El arreglo fue estructural y no un parche: una única función compone esa columna, tomando tres suelos como entrada, uno de facturación, uno de exceso y uno de reversiones, y nada más la escribe. La regla general merece enunciarse en abstracto, porque se aplica mucho más allá de la facturación. Si más de un escritor calcula la misma columna, esa columna no es una decisión. Es una carrera, y se resolverá a favor del trabajo que tenga el intervalo más corto.
Una disputa no es un reembolso, y un reembolso parcial no es media entrega
Son dos distinciones que la facturación de hosting necesita y que ninguna guía genérica de pagos hará por nadie.
Una disputa abierta no es una pérdida. Se puede ganar. El pago deja de contar como entregado, pero eso es una afirmación sobre el pago y no sobre la licencia: una disputa abierta se asigna a gracia, así que el cliente conserva su servicio y llega una notificación. Suspender a alguien que va a ganar su disputa es una conversación de soporte que nadie quiere. Una retrocesión definitiva sí es una pérdida, y esa se asigna a suspensión.
Un reembolso parcial es el caso incómodo. Tienta tratarlo como una entrega parcial, y tal cosa no existe. Un mes de suscripción se entrega o no se entrega. Así que un reembolso parcial saca el pago de settled por completo, y la entrega se detiene. Esa es la lectura honesta, aunque suene dura, porque la alternativa es inventarse una fracción de servicio que el producto no puede prestar de verdad.
Quien empuje notas de crédito a un sistema contable, un aviso más. En el nuestro una nota de crédito no es un recurso aparte: es una factura con importes negativos, y la llamada que la duplica copia el campo de referencia del original. Esa referencia es exactamente la clave sobre la que busca una comprobación de idempotencia, así que una implementación ingenua encuentra el original en la siguiente pasada, concluye que no se ha abonado nada y emite una segunda nota de crédito. Dinero devuelto dos veces. Todo documento generado necesita su propia ancla, derivada del evento que lo provocó.
Qué es aquí una proforma, y por qué el contador tiene dos prefijos
Una proforma es el documento que produce la propia plataforma, con su propio número, y es lo que el cliente ve en el panel. La factura legal se crea en el sistema contable con su propia serie. Sin ese sistema contable conectado, la proforma es el único documento que hay, razón por la cual tiene que estar numerada como es debido en vez de tratarse como un recibo.
Dos detalles que salieron de hacerlo:
El número se rellena a cuatro dígitos como mínimo, no como anchura fija. El documento diez mil recibe cinco dígitos. Truncar para mantener el formato ordenado daría el mismo número a dos documentos, y un índice único lo hará saber en el peor momento posible.
Un contador, dos prefijos. Cuando cambió el prefijo, la secuencia continuó en lugar de reiniciarse. Dos contadores habrían producido un documento nuevo cuyo número se diferencia de uno antiguo solo en un prefijo de dos letras, cosa que una base de datos ve como única y quien lee un extracto bancario no.
Y una recarga de saldo prepago no lleva IVA en su proforma, porque una recarga es un pago anticipado y no un servicio. El IVA corresponde a la factura de aquello en lo que se acabe gastando el saldo. Hacerlo al revés significa cobrar impuestos dos veces sobre el mismo dinero.
Qué se pudo medir y qué no
Lo más útil de todo esto es el reloj de pruebas. Un ciclo de facturación mensual se puede recorrer de principio a fin en minutos: crear un pago SEPA, confirmar que processing no activa absolutamente nada, adelantar el reloj más allá de la ventana de liquidación, verlo liquidarse y comprobar que la activación ocurre exactamente una vez y es idempotente en una segunda pasada. Sin él, el ciclo mensual es lo único que no se puede ensayar, y su primera ejecución real ocurre en producción.
Dos límites honestos sobre todo lo anterior. Esto se ha medido contra el entorno de pruebas de Stripe y un webhook real entregado por la red, no contra una base amplia de clientes reales. Y una lección que se generaliza más allá de los pagos: una bandera de funcionalidad sobre un trabajador y un botón no protege un esquema de base de datos. Un despliegue puso una vez en marcha código que leía columnas cuya migración no se había ejecutado, y una página de administración sin relación con aquello devolvió un error de servidor a todo el mundo, porque la bandera estaba apagada y la consulta no.
Esto salió de construir el lado de facturación de Provibr, donde los proveedores de alojamiento gestionan su propia infraestructura desde un único panel y el agente solo conecta hacia fuera.