Looking to hire Laravel developers? Try LaraJobs

laravel-redsys-subscriptions maintained by mariodevv

Description
Suscripciones y cobros recurrentes con Redsys para Laravel. Tokenizacion COF/MIT y firma HMAC SHA-512.
Author
Last update
2026/09/15 20:59 (dev-master)
License
Links
Downloads
1

Comments
comments powered by Disqus

Laravel Redsys Subscriptions

Packagist Descargas PHP Licencia Tests

Suscripciones y cobros recurrentes con Redsys para Laravel.

Redsys no aloja suscripciones: solo guarda una referencia de tarjeta. El ciclo de facturación —renovaciones, reintentos, tarjetas caducadas, autenticaciones pendientes— lo pone este paquete.

Estado: beta. Alta con 3DS, cobro recurrente y notificación servidor-a-servidor verificados contra el entorno de pruebas de Redsys. Sin probar en producción, y la API pública puede cambiar antes de la 1.0.

$subscription = $user->newSubscription(amountInCents: 1500, interval: 'monthly');

return response($subscription->cardRegistrationForm());   // el titular pasa el 3DS una vez
// y a partir de ahí se cobra solo

Qué pone el paquete: la máquina de estados, los reintentos con espera, el historial de cada intento, el cambio de tarjeta, un panel para operar y la clasificación de los códigos de error de Redsys —que es donde se rompe la facturación cuando se hace a ojo.

Qué no: cupones, prorrateo, periodos de prueba, facturas, multidivisa…


Instalación

composer require mariodevv/laravel-redsys-subscriptions
php artisan migrate

Laravel 12 y 13, PHP 8.2 o superior. En la 13 no hace falta forzar nada: creagia/redsys-php pide guzzle ^7, la 13 acepta ^7.8.2 || ^8.0, y composer resuelve la 7.15.

Laravel 11 se quedó fuera: su soporte de seguridad terminó y Composer bloquea todas sus versiones por avisos, así que declararlo sería prometer algo que nadie puede instalar.

REDSYS_MERCHANT_CODE=
REDSYS_TERMINAL=1
REDSYS_SECRET_KEY=
REDSYS_PRODUCTION=false

El paquete no trae credenciales por defecto: sin REDSYS_SECRET_KEY no firma. Las tuyas las da tu banco. Para probar sin comercio propio, las del entorno de pruebas están publicadas en la documentación de Redsys.

php artisan vendor:publish --tag=redsys-subscriptions-config

Si ya usas creagia/laravel-redsys

Conviven. Este paquete publica config/redsys-subscriptions.php y cuelga sus rutas de redsys/subscriptions/, precisamente para no pisar el config/redsys.php ni el prefijo redsys/ de aquel. REDSYS_MERCHANT_CODE y REDSYS_TERMINAL son las mismas variables en los dos; nuestra REDSYS_SECRET_KEY es el mismo valor que su REDSYS_KEY.

Uso

use MarioDevv\RedsysSubscriptions\Billable;

class User extends Authenticatable
{
    use Billable;
}
// 1. Crear la suscripción (queda incompleta hasta que haya tarjeta)
$subscription = $user->newSubscription(amountInCents: 1500, interval: 'monthly');

// 2. Registrar la tarjeta: el titular pasa por Redsys y por el 3DS una vez
return response($subscription->cardRegistrationForm());

// 3. A partir de ahí se cobra solo
// php artisan redsys:charge-subscriptions

El formulario se autoenvía a Redsys. Cuando el titular vuelve, la suscripción ya está en active con la tarjeta guardada.

El alta de tarjeta

El paquete registra dos rutas:

Ruta Quién la llama Qué hace
POST /redsys/subscriptions/notify Redsys, servidor a servidor Guarda la tarjeta y activa
GET|POST /redsys/subscriptions/return/{id} El navegador del titular Redirige a return_url

Manda la notificación, no el navegador. Es la única que llega siempre, firmada y completa: si el titular cierra la pestaña al volver, la suscripción se activa igual.

Esto importa por una casilla del panel de Redsys, «Enviar parámetros en las URLs». En producción mucha gente la tiene en NO, y entonces el titular vuelve a urlOK con un GET pelado: ni pedido, ni referencia, ni firma. Por eso la suscripción se identifica en esa ruta por el id de la URL, y por eso ese id no basta para escribir nada: hace falta que lleguen parámetros firmados y que su Ds_Order sea el de esa suscripción.

Con la casilla en los parámetros llegan también por el navegador, y ese camino acaba en la misma función: quien llegue primero activa, el segundo no hace nada. De ahí que en local, donde Redsys no puede alcanzar tu máquina para notificar, el alta funcione igualmente.

Una firma que no cuadra devuelve 403 y no toca la base de datos: ni guarda ni lo anota como pago fallido. Una versión de firma desconocida tampoco se adivina. Se aceptan HMAC_SHA256_V1 y HMAC_SHA512_V2, la que tenga configurada tu TPV.

Un alta denegada deja la suscripción en incomplete y el titular puede reintentarla.

Programa el comando en routes/console.php:

Schedule::command('redsys:charge-subscriptions')->dailyAt('03:00');

Si tu aplicación va detrás de un proxy

Configura TrustProxies, o las altas no se activarán nunca.

La URL de notificación no se configura: el paquete la construye con route(), y Laravel construye URLs absolutas con el esquema y el host de la petición. Si quien termina el TLS es un proxy, un balanceador o un CDN, a Laravel le llega la petición en claro y manda a Redsys una merchantUrl con http://.

Redsys no sigue redirecciones: recibe el 307 de tu proxy hacia HTTPS, lo da por fallido, y la notificación no llega. Tu aplicación no registra ningún error, porque desde su lado no ha pasado nada. Las suscripciones se quedan en incomplete para siempre.

En bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: '*');   // o la lista de IPs de tu proxy
})

Comprueba que lo tienes bien mirando qué sale en Ajustes como «Notificación de Redsys»: si empieza por http:// y tu sitio es HTTPS, esto te va a morder.

No hace falta withoutOverlapping(). El comando coge un lock él solo, así que dos pases que se pisen no cobran dos veces al mismo cliente: el segundo se salta y lo dice. Usa los locks atómicos de Laravel, así que si tu caché es database, necesitas la tabla cache_locks — la que trae Laravel de serie.

Ver el paquete funcionando

El repositorio trae un laboratorio en docker/, que no forma parte del paquete: solo lo ve quien lo clona. Es una aplicación Laravel de verdad, no un banco de pruebas, así que lo que falle aquí falla igual en la tuya.

REDSYS_MERCHANT_CODE=... REDSYS_TERMINAL=... REDSYS_SECRET_KEY=... \
  docker compose up

La primera vez crea la aplicación y engancha el paquete con un repositorio de tipo path, así que lo que edites en el repositorio se ve al recargar, sin reinstalar nada. Tarda un par de minutos; las siguientes arranca en segundos.

En http://127.0.0.1:8000 puedes dar de alta una tarjeta contra el entorno de pruebas de Redsys, ver la suscripción pasar de incomplete a active con los últimos cuatro dígitos, adelantar el vencimiento y lanzar redsys:charge-subscriptions para ver la máquina de estados moverse. El panel está en /redsys-subscriptions.

Sin credenciales arranca igual, pero avisa: el paquete no trae ninguna por defecto.

docker compose down -v   # tirar la aplicación y empezar de cero

Y contra el Redsys de verdad, para comprobar que la tabla de errores dice lo que Redsys dice y no lo que creemos:

docker compose exec lab php /demo/verify-redsys.php

Prueba lo que se puede forzar sin un titular delante: la firma incorrecta y la referencia muerta. Lo que necesita 3DS se comprueba a mano dando de alta una tarjeta en el laboratorio.

Para probar la notificación, entra al laboratorio por la URL del túnel, no por 127.0.0.1. La merchantUrl que viaja a Redsys se construye con el host de la petición: si entras por localhost, Redsys recibe http://127.0.0.1:8000/... y no puede llamarte. Es el mismo motivo por el que en producción hace falta TrustProxies.

Los tests también corren ahí, que es lo cómodo si tu PHP no trae pdo_sqlite:

docker compose exec lab sh -c 'cd /package && vendor/bin/phpunit'

El alta se completa entera en localhost si tu comercio tiene «Enviar parámetros en las URLs» en SÍ. Con la casilla en NO hace falta exponer la aplicación, porque la referencia solo llega por la notificación.

Probar la notificación servidor-a-servidor

Es lo único que no se puede comprobar en local: Redsys no alcanza tu máquina. El laboratorio trae un túnel opcional para eso.

NGROK_AUTHTOKEN=... NGROK_DOMAIN=lo-tuyo.ngrok-free.app   APP_URL=https://lo-tuyo.ngrok-free.app   docker compose --profile tunel up

Luego pega en tu panel de Redsys, como URL de notificación:

https://lo-tuyo.ngrok-free.app/redsys/subscriptions/notify

Tres cosas que cuestan una tarde si no se saben:

  • APP_URL es obligatorio y tiene que ser el dominio público. La URL de notificación se construye con route(), así que si la aplicación se sigue creyendo en localhost le manda eso a Redsys como merchantUrl y no llega nada, con el túnel levantado y todo. El laboratorio avisa al arrancar.
  • Usa un dominio fijo, de los que da ngrok en el plan gratuito. Con una dirección aleatoria hay que volver a pegarla en el panel de Redsys en cada arranque.
  • El docker compose up normal no necesita nada de esto: el túnel va en un perfil aparte y quien no lo use ni se entera.

Panel

El paquete trae un panel para operar en producción, en /redsys-subscriptions:

  • Resumen — ingresos al mes, activas, sin cobrar, altas sin terminar, quién necesita atención y qué se cobra a continuación.
  • Suscripciones — listado buscable por nº, pedido, últimos cuatro dígitos o referencia, filtrable por estado. Los cuatro dígitos solo están si tu comercio tiene activado el envío del número enmascarado en su TPV: Redsys no lo manda por defecto, ni en la notificación ni en la respuesta del cobro. Desde aquí se da de baja y se lanza un cobro en el momento, que es lo que hace falta cuando un cliente llama diciendo que ya tiene saldo.
  • Ficha de una suscripción — la tarjeta guardada, el cobro y el historial de intentos, cada uno con el código que devolvió Redsys.
  • Ajustes — qué comercio, terminal y entorno está usando el paquete, y a qué direcciones responde. La clave de firma no se imprime nunca.

Viene cerrado. Fuera de local no entra nadie hasta que definas el Gate, en un ServiceProvider:

use Illuminate\Support\Facades\Gate;
use MarioDevv\RedsysSubscriptions\Authorize;

Gate::define(Authorize::GATE, fn ($user) => $user->isAdmin());

Un panel con los datos de pago de todos tus clientes no puede quedarse abierto porque alguien se olvidara de configurarlo. La ruta y el middleware se cambian en config/redsys-subscriptions.php, y las vistas se adaptan con:

php artisan vendor:publish --tag=redsys-subscriptions-views

Historial de cobros

Cada intento deja una fila, salga bien o mal. El estado dice cómo está la suscripción ahora; el historial dice cómo ha llegado hasta ahí, que es lo que hace falta cuando un cliente pregunta por un cargo.

$subscription->charges;          // el más reciente primero

$charge->order;                  // el pedido de ese intento
$charge->outcome;                // ChargeOutcome
$charge->response_code;          // '0180', 'SIS0321'…
$charge->responseLabel();        // «0180 · Denegada»

El importe se congela en la fila: si mañana subes el precio, lo que se cobró aquel día no cambia. Y el alta de tarjeta es la primera línea del historial, porque también fue un cobro.

Enterarte de lo que pasa

Cada intento de cobro dispara un evento. Es lo único que tu aplicación no sabe por su cuenta: cuando llama a cancel() ya se ha enterado, pero lo que hizo el cron a las tres de la mañana no lo ve nadie.

use MarioDevv\RedsysSubscriptions\ChargeOutcome;
use MarioDevv\RedsysSubscriptions\Events\SubscriptionCharged;

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)),
    };
});

El estado ya está aplicado cuando llega el evento, así que $event->subscription->status te dice si además se ha quedado cancelada. El alta de tarjeta también lo dispara, porque también fue un cobro.

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.

Cambiar la tarjeta

Cuando Redsys mata la referencia con SIS0321, o el emisor pide autenticación con 0195, el titular tiene que registrar otra tarjeta. La suscripción no se rehace: conserva su número, su importe y su historial.

$link = $subscription->cardUpdateLink();   // firmado, caduca en 7 días
$link = $subscription->cardUpdateLink(30); // o los días que quieras

Mándaselo al titular, o cópialo desde la ficha del panel. Abrirlo le lleva al formulario de Redsys; cuando vuelve, la suscripción queda activa con la tarjeta nueva.

El enlace va firmado y con caducidad a propósito: quien lo abra deja su tarjeta asociada a esa suscripción, así que una dirección adivinable sería una forma de pagar con la cuenta de otro.

Bajas

$subscription->cancel();      // al final del periodo ya pagado
$subscription->cancelNow();   // de inmediato

$subscription->onGracePeriod(); // dada de baja, pero aún le queda periodo
$subscription->valid();         // activa, o en ese periodo de gracia
$user->subscribed();            // lo mismo, desde el titular

cancel() deja de cobrar desde ya, pero el acceso le dura hasta la fecha que le quedaba: le cobraste el mes entero y cortarle el día que se da de baja es quedarte su dinero. Para dar acceso mira valid(), no active().

Estados

Las transiciones no son de manual: salen de observar qué responde Redsys.

Respuesta de Redsys Estado Qué hacer
00000099 active Nada. Se reinicia el contador de fallos.
0195 past_due_sca Enviar al titular a reautenticar. No reintentar. No cancelar.
SIS0321, 0101, 0191, 0125, 0106, 0202, 9093, 9253 canceled La tarjeta está muerta. Mandar un cardUpdateLink().
SIS0042, SIS0026, SIS0028, SIS0430, 0904, 9104, 9218, 9256 sin cambios Es tu configuración, no la tarjeta. El pase se detiene.
0909, 0912, 9912, 9997, 9998, 9999, 0913, SIS0051, SIS0001 sin cambios Transitorio. Se reintenta sin gastar intento.
Otra denegación past_due Reintentar, hasta 3 veces, cada 3 días.

Las dos filas de en medio son las que más caro cuestan si se ignoran, 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. Tratado como denegación, una clave mal puesta te cancela la cartera entera en tres pases. Aquí no suma fallo, no cancela, no mueve la fecha, y el comando aborta el pase en cuanto ve uno.
  • Un emisor caído no es culpa del titular. No puede gastarle uno de sus tres intentos.

El valor guardado es el de la columna «Estado»; lo traducido es solo la etiqueta, y sale de Subscription::statusLabels().

Dos detalles que cuestan caro si se ignoran:

  • 0195 no es un rechazo de tarjeta. El emisor pide autenticación del titular para esa cuota. La tarjeta sigue viva y volverá a cobrar el mes siguiente. Cancelar aquí es tirar clientes que pagan.
  • Cada intento necesita su propio número de pedido. Redsys rechaza los repetidos con SIS0051, reintentos incluidos. Ese código cuenta como transitorio y no como fallo del titular: el choque es nuestro, no suyo.
  • Un fallo espera Subscription::RETRY_DAYS antes del siguiente intento. Reintentar el mismo día es gastar los tres contra el mismo saldo vacío; tres días dan margen a que entre una nómina.

Firma SHA-512

Soporta HMAC_SHA512_V2, la versión de firma que Redsys presenta hoy como actual:

use MarioDevv\RedsysSubscriptions\Sha512Signature;

$signature = Sha512Signature::sign($merchantParameters, $order, $secretKey);
$valid     = Sha512Signature::verify($received, $merchantParameters, $order, $secretKey);

Con esta versión Ds_MerchantParameters viaja en Base64URL; con base64 estándar Redsys responde SIS0430.

Estado y hoja de ruta

Lo que hay probado, y contra qué:

Contra el sandbox de Redsys Solo con tests
Alta de tarjeta COF, 3DS incluido sí, entera
Notificación servidor-a-servidor
Cobro MIT con la referencia guardada
Mensual, semanal y anual sí, las tres
0000, SIS0321, SIS0042, SIS0051
3DS denegado y pago cancelado
0195
El resto del catálogo de códigos
Verificación de firma, SHA-256 y SHA-512

El alta pasa el 3DS de verdad, la notificación llega y activa la suscripción sin que el titular vuelva del navegador, y el cobro recurrente sale autorizado sin volver a pedir autenticación, que es lo que tenía que demostrar la exención MIT.

Un 3DS denegado o un pago cancelado dejan la suscripción en incomplete sin inventarse una línea en el historial. La cancelación también llega por notificación y el paquete la ignora, que es lo que evita activar suscripciones que nadie ha pagado.

Lo que no está probado: producción, el 0195, y los ~30 códigos de respuesta y 683 SISxxxx restantes del catálogo. La mayoría no se pueden provocar desde fuera, así que su clasificación es criterio razonado y no observación.

Para la 1.0

Dos cosas, y ninguna es una función nueva:

  1. Un cobro en producción. Todo lo verificado lo está contra el sandbox. Cambia el endpoint y la clave, nada más, pero nadie lo ha ejecutado con dinero de verdad. Es lo único que separa esto de la 1.0.
  2. Forzar un 0195. Se vio en el sandbox en septiembre y no se ha podido reproducir. Es el caso del que más presume el paquete —no reintentar, no cancelar— y hoy descansa en una observación y en tests.

Mejoras conocidas

Ninguna bloquea la 1.0, todas salieron de usar el paquete de verdad:

  • past_due_sca corta el acceso. valid() mira active() o periodo de gracia, así que un titular al que el banco le pide autenticar pierde el servicio aunque su tarjeta siga viva y vaya a cobrar el mes que viene. Decisión de producto, no bug.
  • Un alta denegada no deja rastro. completeCheckout() sale sin registrar nada, así que soporte no puede ver que alguien lo intentó tres veces.
  • «Lanzar cobro ahora» es síncrono dentro de la petición HTTP. Con muchas vencidas hay que mandarlo a una cola.
  • Avisos de configuración en Ajustes: URL de notificación inalcanzable, entorno real sin clave, o el envío del número enmascarado desactivado —que es lo que deja la búsqueda por los cuatro dígitos sin funcionar y en silencio.

Puede que nunca

Cupones, prorrateo, periodos de prueba, facturas en PDF, multidivisa, Bizum y preautorizaciones. Abre un issue si necesitas alguno.

Créditos

El protocolo lo pone creagia/redsys-php. Este paquete solo añade la capa de suscripciones.

Licencia

MIT. Ver LICENSE.