Un cliente necesitaba cobrar cuotas mensuales. Tenía TPV con su banco, o sea Redsys, y la pregunta parecía de trámite: ¿cómo se hacen suscripciones con esto?
No se hacen. Redsys guarda una referencia de tarjeta, y eso lo hace bien, pero el ciclo de facturación no existe en ningún sitio hasta que lo escribes tú. Laravel Cashier tampoco te saca de ahí: habla Stripe y Paddle, y ninguno de los dos es el TPV que te ha dado tu banco.
Así que lo escribí, y lo he publicado: laravel-redsys-subscriptions.
Es beta. El alta con 3DS, la notificación servidor-a-servidor y el cobro recurrente están verificados contra el entorno de pruebas de Redsys. Nadie lo ha ejecutado todavía con dinero de verdad, y la API pública puede cambiar antes de la 1.0.
Instalar
php artisan migrateREDSYS_MERCHANT_CODE=REDSYS_TERMINAL=1REDSYS_SECRET_KEY=REDSYS_PRODUCTION=falseLaravel 12 y 13, PHP 8.2 o superior. Las credenciales te las da tu banco. Para
probar sin comercio propio, las del entorno de pruebas están publicadas en la
documentación de Redsys. Sin REDSYS_SECRET_KEY el paquete no firma: no trae
ninguna clave por defecto.
Usar
use MarioDevv\RedsysSubscriptions\Billable;
class User extends Authenticatable{ use Billable;}$subscription = $user->newSubscription( amountInCents: 1500, interval: 'monthly',);
// el titular pasa por Redsys y por el 3DS una vezreturn response($subscription->cardRegistrationForm());El formulario se autoenvía a Redsys. La suscripción nace incomplete y pasa a
active cuando hay tarjeta.
Quien la activa es la notificación servidor a servidor, no la vuelta del
navegador. Es la única que llega siempre, firmada y completa: si el titular
cierra la pestaña, la suscripción se activa igual. Si tu aplicación va detrás de
un proxy, configura TrustProxies o esa notificación nunca llegará.
De ahí en adelante cobra un comando, que va al scheduler una vez al día:
php artisan redsys:charge-subscriptionsCobra las que vencen hoy y no toca el resto.
Qué pasa en cada cobro
Cobrar con una tarjeta guardada es una petición HTTP y se resuelve en una tarde. Lo que cuesta es decidir qué hacer con lo que Redsys contesta, y eso viene resuelto:
| Respuesta | Estado | Qué hace |
|---|---|---|
0000–0099 | active | Cobrado. Reinicia el contador de fallos. |
0195 | past_due_sca | El banco pide autenticar esa cuota. Ni reintenta ni cancela. |
SIS0321, 0101, 0191… | canceled | La tarjeta está muerta. Toca pedir otra. |
SIS0042, 0904… | sin cambios | Es tu configuración. Aborta el pase entero. |
9997, 0912… | sin cambios | Transitorio. Reintenta sin gastar intento. |
| Otra denegación | past_due | Hasta 3 intentos, uno cada 3 días. |
Las dos filas de en medio son las que cuestan dinero, y son las que casi todo el mundo mete en el mismo saco que una denegación.
Un fallo de comercio le sale igual a todas tus suscripciones. Si tratas un
SIS0042 —una firma mal calculada— como si fuera una tarjeta denegada, le sumas
un fallo a cada cliente, y en tres pases del cron has cancelado tu cartera
entera por una variable de entorno mal puesta. Aquí no suma fallo y el comando
para el pase en cuanto ve uno.
Un emisor caído tampoco es culpa del titular: un 9997 no puede gastarle uno de
sus tres intentos a alguien cuya tarjeta está perfectamente.
Y el 0195 no es un rechazo. El banco quiere que el titular autentique esa
cuota concreta; la tarjeta sigue viva y volverá a cobrar el mes que viene.
Enterarte de lo que hizo el cron a las tres de la mañana
Cada intento dispara un evento, salga bien o mal:
Event::listen(function (SubscriptionCharged $event) { $titular = $event->subscription->billable;
match ($event->charge->outcome) { ChargeOutcome::Declined => $titular->notify(new CobroFallido($event->charge)), ChargeOutcome::ScaRequired => $titular->notify(new AutenticaTuPago($event->subscription)), ChargeOutcome::TokenDead => $titular->notify(new CambiaLaTarjeta($event->subscription->cardUpdateLink())), ChargeOutcome::Authorized => $titular->notify(new Recibo($event->charge)), };});Es un evento y no seis a propósito: los seis te obligarían a registrar seis
listeners para hacer un match que cabe en uno. Cuando llega, el estado ya está
aplicado, así que $event->subscription->status te dice si además se ha
quedado cancelada.
Cuando hay que cambiar la tarjeta
$link = $subscription->cardUpdateLink(); // firmado, caduca en 7 díasMándaselo al titular o cópialo desde el panel. La suscripción no se rehace: conserva su número, su importe y su historial. El enlace va firmado y con caducidad a propósito, porque quien lo abra deja su tarjeta asociada a esa suscripción.
El panel
Cuando un cliente llama diciendo que le habéis cobrado dos veces, alguien tiene que poder mirarlo sin abrir una consola de base de datos. El resumen no lista estados, lista decisiones: cada fila dice por qué está ahí y qué se puede hacer con ella sin salir de la pantalla.
Debajo, el listado completo, que se busca por número, pedido, últimos cuatro dígitos o referencia:
Vive en /redsys-subscriptions y viene cerrado: fuera de local no entra nadie
hasta que defines el Gate viewRedsysSubscriptions. Ahí están los datos de pago
de todos tus clientes.
Probarlo sin comercio propio
El repositorio trae un laboratorio en Docker. No forma parte del paquete: solo lo ve quien clona el repositorio, y es una aplicación Laravel de verdad, así que lo que falle ahí falla igual en la tuya.
REDSYS_MERCHANT_CODE=... REDSYS_TERMINAL=... REDSYS_SECRET_KEY=... \ docker compose upEn http://127.0.0.1:8000 das de alta una tarjeta contra el entorno de pruebas
de Redsys, ves la suscripción pasar de incomplete a active, adelantas el
vencimiento y lanzas el comando para ver la máquina de estados moverse.
Qué no hace
Cupones, prorrateo, periodos de prueba, facturas en PDF, multidivisa, Bizum y preautorizaciones. Abre un issue si necesitas alguno.
El protocolo tampoco lo escribí yo: lo pone
creagia/redsys-php. Este paquete
añade la capa de suscripciones encima.
Qué está verificado y qué no
Contra el sandbox de Redsys: el alta con 3DS entera, la notificación, el cobro
recurrente con la exención MIT, los tres intervalos, y los códigos 0000,
SIS0321, SIS0042 y SIS0051.
Solo con tests: el 0195 —lo vi una vez en el sandbox y no he conseguido
volver a forzarlo— y el resto del catálogo, que son unos 30 códigos de respuesta
y 683 SISxxxx que no se pueden provocar desde fuera.
Y producción, que no la ha tocado nadie. Eso, y forzar un 0195, es lo que
separa esto de una 1.0. Prefiero decirlo yo a que lo descubra alguien.
laravel-redsys-subscriptions en GitHub · MIT · Laravel 12 y 13, PHP 8.2+
Si lo pruebas en producción antes que yo, cuéntamelo en un issue.