Mario Pérez
← Back to blog

This article is not yet translated to English. Showing the Spanish version.

Redsys no aloja suscripciones: el ciclo lo pones tú

· 5 min read· Updated
#laravel#php#redsys#pagos#open source
mariodevv/laravel-redsys-subscriptionsbeta
composer require mariodevv/laravel-redsys-subscriptions
MIT · Laravel 12 y 13 · PHP 8.2 o superiorGitHub ↗

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 migrate
.env
REDSYS_MERCHANT_CODE=
REDSYS_TERMINAL=1
REDSYS_SECRET_KEY=
REDSYS_PRODUCTION=false

Laravel 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

app/Models/User.php
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 vez
return 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-subscriptions

Cobra 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:

RespuestaEstadoQué hace
0000–0099activeCobrado. Reinicia el contador de fallos.
0195past_due_scaEl banco pide autenticar esa cuota. Ni reintenta ni cancela.
SIS0321, 0101, 0191…canceledLa tarjeta está muerta. Toca pedir otra.
SIS0042, 0904…sin cambiosEs tu configuración. Aborta el pase entero.
9997, 0912…sin cambiosTransitorio. Reintenta sin gastar intento.
Otra denegaciónpast_dueHasta 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ías

Má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.

Bloque «Necesitan atención»: una suscripción reintentando en su intento 2 de 3, y otra con 0195, «el banco pide que autentique», cada una con su importe y botones para cobrar ahora o dar de baja

Debajo, el listado completo, que se busca por número, pedido, últimos cuatro dígitos o referencia:

Listado de suscripciones con chips de filtro por estado —todas, activa, reintentando, pendiente del titular, cancelada, sin terminar— y una tabla con titular, estado, importe y tarjeta

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 up

En 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.