ronu / payment-gateways
One payment contract, three gateways (Mercado Pago Orders, Stripe PaymentIntents, PayPal Orders v2) plus durable payment orchestration on PostgreSQL — testable against the Payment Emulator Lab (Plaza, Vector, Harbor).
Requires
- php: ^8.2
- ext-json: *
- ext-openssl: *
- guzzlehttp/guzzle: ^7.8
- psr/log: ^2.0 || ^3.0
- psr/simple-cache: ^2.0 || ^3.0
Requires (Dev)
- laravel/framework: ^12.0
- laravel/pint: ^1.18
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
- ronu/laravel-app-context: 1.3.1
- vlucas/phpdotenv: ^5.6.1
Suggests
- ext-pdo_pgsql: Orchestration: the PostgreSQL store (PostgresStore)
- illuminate/support: Laravel bridge: PaymentGatewaysServiceProvider and the payment-gateways config
- laravel/framework: ^12.0 for the orchestration Laravel bridge (Orchestration\Laravel\PaymentServiceProvider, registered explicitly)
- ronu/laravel-app-context: ^1.3.1 for payments:setup partner provisioning (config or separate-table Eloquent driver)
Provides
None
Conflicts
- ronu/payment-orchestration: *
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 20:47:58 UTC
README
💳 ronu/payment-gateways
Un contrato de pagos para cualquier pasarela.
One payment contract for any gateway.
Mercado Pago · Stripe · PayPal — el mismo código, en el emulador y en producción.
Instalar · Configurar · Cobrar · Webhooks · Probar · Nueva pasarela · Orquestación · Cambios · Plan
La versión 0.8.0-alpha.3 incluye payments:setup: prepara el
partner y las cuentas del emulador local sin editar configuración generada. La apertura de ventas
requiere --emulator explícito y un entorno distinto de producción.
✨ Por qué
| 🔌 Un contrato | Tu código habla de Payment, Money, Refund y WebhookEvent, nunca de preference, payment_intent ni purchase_units. |
| 🧪 Emulador → real sin tocar código | Probado de punta a punta contra el Payment Emulator Lab (Plaza, Vector, Harbor). Para ir a producción cambias environment y las credenciales. |
| 🛒 Página del proveedor o formulario propio | Checkout Pro, Checkout Session y aprobación de PayPal, o los tokens de Brick y Elements. |
| 🔐 Seguro por defecto | Idempotencia en todas las escrituras, firmas de webhook verificadas sobre el cuerpo crudo, y si falta un secreto el webhook se rechaza. |
| 🧩 Extensible | Una pasarela nueva se registra con GatewayFactory::extend(), sin tocar la biblioteca. |
| 💰 Dinero sin floats | Importes en unidades menores con moneda ISO-4217; cada driver lo convierte al formato que pide su API. |
📦 Instalar
composer require ronu/payment-gateways:0.8.0-alpha.3
Disponible en Packagist. La línea 0.8 sigue en alpha; el comando fija la versión explícitamente.
PHP ≥ 8.2 con ext-json y ext-openssl. El núcleo no depende de ningún framework; el puente de Laravel se registra solo.
⚙️ Configurar
Todas las pasarelas usan las mismas secciones. Si falta o sobra algo, falla
al construir el gateway y el mensaje dice qué clave es (credentials.secret_key is required).
use Ronu\PaymentGateways\GatewayFactory; $gateway = GatewayFactory::make('tienda', [ 'driver' => 'mercadopago', // mercadopago | stripe | paypal | uno tuyo 'environment' => 'emulator', // emulator | sandbox | live 'credentials' => ['access_token' => getenv('MERCADOPAGO_ACCESS_TOKEN')], 'webhooks' => ['secret' => getenv('MERCADOPAGO_WEBHOOK_SECRET')], 'options' => ['currency' => 'ARS'], 'emulator' => ['api_url' => 'http://localhost:8080', 'checkout_url' => 'http://localhost:3001'], 'http' => ['timeout' => 15, 'connect_timeout' => 5, 'retries' => 2], ]);
Claves por pasarela
| Sección | Mercado Pago | Stripe | PayPal |
|---|---|---|---|
credentials |
access_token |
secret_key |
client_id + client_secret (o access_token) |
webhooks |
secret, tolerance_seconds (600) |
secret, tolerance_seconds (300) |
id, cert_hosts, certificate, tolerance_seconds (300) |
options |
currency, ticket_method, notification_url, statement_descriptor |
api_version (fíjala) |
brand_name, locale, shipping_preference |
emulator |
api_url, checkout_url, scenario |
api_url, scenario |
api_url |
Un null (un env() sin definir) se ignora y deja el valor por defecto.
base_url sustituye el host del entorno (proxies, mocks).
Presets: atajos con valores por defecto
GatewayFactory::make('plaza', ['credentials' => […], 'emulator' => ['api_url' => …]]); GatewayFactory::make('cobros', ['preset' => 'stripe', 'credentials' => […]]); // cualquier nombre
| Driver | Emulador | Sandbox | Live |
|---|---|---|---|
mercadopago |
plaza |
mercadopago_sandbox |
mercadopago |
stripe |
vector |
stripe_test |
stripe |
paypal |
harbor |
paypal_sandbox |
paypal |
Caché, logs y cliente HTTP
Son objetos, así que van aparte de la configuración, en Services:
use Ronu\PaymentGateways\Services; GatewayFactory::make('paypal', $config, new Services( http: $guzzle, // tu cliente (proxies, middlewares) cache: $psr16, // el token OAuth de PayPal compartido entre procesos logger: $psr3, // una línea por llamada: método, ruta, estado, duración ));
Los logs nunca incluyen cabeceras, cuerpos ni la query string. En Laravel, la caché y el
log de la aplicación se pasan solos (log_channel elige el canal).
Laravel
php artisan vendor:publish --tag=payment-gateways-config
config/payment-gateways.php trae las tres pasarelas comentadas. Cada una elige
su entorno con MERCADOPAGO_ENVIRONMENT, STRIPE_ENVIRONMENT y PAYPAL_ENVIRONMENT.
app(\Ronu\PaymentGateways\Contracts\Gateway::class); // la de 'default' app(\Ronu\PaymentGateways\Laravel\PaymentGatewaysManager::class)->gateway('paypal'); // una por nombre
🛒 Tres formas de cobrar
| Mercado Pago | Stripe | PayPal | |
|---|---|---|---|
A. Página del proveedor · createCheckout() |
Checkout Pro | Checkout Session | Aprobación de la orden |
B. Formulario incrustado · createPayment() + PaymentMethod |
Card Payment Brick | Elements (pm_…) o clientSecret |
JS SDK con el id de la orden |
Ticket · createPayment() sin método |
Pago Fácil, Rapipago… | — | — |
A · Página del proveedor (recomendado)
Tu servidor nunca ve la tarjeta y el frontend solo redirige.
use Ronu\PaymentGateways\{CreatePayment, CheckoutStatus, Money, Payer}; $checkout = $gateway->createCheckout(new CreatePayment( amount: Money::fromDecimal('1500.50', 'ARS'), // el importe lo decide tu servidor reference: 'ORDER-123', // tu id, único por checkout idempotencyKey: 'checkout-ORDER-123', description: 'Reserva', returnUrl: 'https://shop.example/pago/vuelta', // obligatorio en Stripe y PayPal cancelUrl: 'https://shop.example/carrito', payer: Payer::fromFullName('Ana Pérez', 'ana@example.test', '30111222'), )); return redirect($checkout->url); // guarda $checkout->id // De vuelta en returnUrl, o al llegar el webhook: $checkout = $gateway->getCheckout($checkoutId); if ($checkout?->status === CheckoutStatus::Completed) { $payment = $gateway->getPayment($checkout->paymentId); }
Stripe: pon
{CHECKOUT_SESSION_ID}enreturnUrlpara recibir el id de la sesión. Checkout Pro de Mercado Pago cobra en el momento (no admite captura manual).
B · Formulario incrustado
use Ronu\PaymentGateways\PaymentMethod; // Mercado Pago: el token y el payment_method_id que devuelve el Brick $gateway->createPayment(new CreatePayment(…, paymentMethod: PaymentMethod::card($token, 'visa', installments: 3))); // Stripe: se confirma en el servidor con el PaymentMethod de Elements… $gateway->createPayment(new CreatePayment(…, returnUrl: $url3ds, paymentMethod: PaymentMethod::card('pm_…'))); // …o se crea sin token y el navegador lo confirma con $payment->clientSecret
🔁 El flujo común
sequenceDiagram
participant S as Tu servidor
participant G as Gateway
participant P as Proveedor
participant B as Comprador
S->>G: createCheckout() / createPayment()
G->>P: POST (con clave de idempotencia)
G-->>S: Checkout / Payment + URL
S-->>B: redirige
B->>P: paga
P-->>S: webhook firmado
S->>G: parseWebhook(headers, cuerpo crudo)
S->>G: getPayment(paymentIdForEvent)
alt approved / authorized
S->>G: capture()
end
S->>S: matches(referencia, importe) → confirmar el pedido una vez
Loading
$event = $gateway->parseWebhook($request->headers(), $request->rawBody()); // InvalidWebhookSignature ⇒ 401 if ($events->alreadyProcessed($event)) { // las pasarelas reenvían: responde 200 y para return response('', 200); } $payment = $gateway->getPayment($gateway->paymentIdForEvent($event)); // el evento solo avisa: se relee el pago if ($payment->status->awaitsMerchant()) { // PayPal aprobado o captura manual $payment = $gateway->capture($payment->id, idempotencyKey: "capture-{$payment->id}"); } if ($payment->status === PaymentStatus::Captured && $payment->matches('ORDER-123', $importe)) { // confirmar el pedido, una sola vez } $events->markProcessed($event); // después de que todo salió bien $gateway->refund($payment->id, Money::fromDecimal('500', 'ARS'), 'refund-ORDER-123-1'); // null = todo
$events es un Contracts\EventDeduplicator: CacheDeduplicator (PSR-16, compartido
entre procesos; en Laravel ya viene registrado) o InMemoryDeduplicator.
Eventos comunes: $event->kind
Cada driver traduce el tipo de evento del proveedor ($event->type) a un EventType
común. Úsalo para decidir qué hacer, nunca como el estado del pago: ese se relee siempre.
EventType |
Mercado Pago | Stripe | PayPal |
|---|---|---|---|
PaymentUpdated |
payment.*, order.updated |
otros payment_intent.* / charge.* |
otros PAYMENT.* |
PaymentApproved |
— | — | CHECKOUT.ORDER.APPROVED |
PaymentAuthorized |
— | payment_intent.amount_capturable_updated |
PAYMENT.AUTHORIZATION.CREATED |
PaymentCaptured |
order.processed |
payment_intent.succeeded |
PAYMENT.CAPTURE.COMPLETED |
PaymentFailed |
order.failed |
payment_intent.payment_failed |
PAYMENT.CAPTURE.DENIED |
PaymentCanceled |
order.canceled, order.expired |
payment_intent.canceled |
PAYMENT.AUTHORIZATION.VOIDED |
PaymentRefunded |
order.refunded |
charge.refunded, refund.* |
PAYMENT.CAPTURE.REFUNDED |
DisputeOpened / DisputeClosed |
chargebacks |
charge.dispute.created / .closed |
CUSTOMER.DISPUTE.CREATED / .RESOLVED |
CheckoutCompleted / CheckoutExpired |
— | checkout.session.completed / .expired |
— |
PayPal también puede verificar la firma con su API en vez de con el certificado:
'webhooks' => ['id' => …, 'verification' => 'remote'].
Estados y errores
created · requires_action · processing · approved · authorized · captured · partially_refunded · refunded · canceled · failed · disputed · unknown
approved: el comprador aprobó y falta que cobres (capture()), como en PayPal.authorized: captura manual pendiente (capture()ocancel()).unknown: un estado que la biblioteca no conoce. No cumplas el pedido.
La biblioteca ya reintenta los fallos de red, 408, 429 y 5xx (http.retries, 2 por
defecto), con espera exponencial y respetando Retry-After, y reenvía la petición con la
misma clave de idempotencia. Una escritura sin clave solo se reenvía si la conexión no
llegó a abrirse.
Si aun así falla, GatewayException::$retryable dice si puedes volver a intentarlo más
tarde, con la misma clave. $providerCode trae el código de error del proveedor. Si
reutilizas una clave con otro cuerpo, la respuesta es un 409 y no se reintenta.
🧭 Capacidades
if ($gateway->supports(Capability::PartialCapture)) { … }
| Mercado Pago | Stripe | PayPal | |
|---|---|---|---|
HostedCheckout |
✅ | ✅ | ✅ |
EmbeddedCheckout |
✅ | ✅ | ✅ |
ManualCapture |
✅ Orders | ✅ | ✅ |
PartialCapture |
— | ✅ | ✅ manual |
PartialRefund |
✅ | ✅ | ✅ |
OfflinePayments |
✅ | — | — |
Detalles por pasarela
| Mercado Pago | Stripe | PayPal | |
|---|---|---|---|
| Idempotencia | X-Idempotency-Key |
Idempotency-Key |
PayPal-Request-Id |
| Formato | JSON, importes decimales | formulario, unidades menores | JSON, importes decimales |
| Ids | ORD… (Orders), numérico (Payments) |
pi_…, cs_… |
id de la orden |
| Cobro | automático o capture() |
automático o capture() |
siempre capture() tras aprobar |
| Webhook | x-signature: HMAC de id;request-id;ts |
stripe-signature: HMAC de t.cuerpo |
RSA-SHA256 de id|time|webhook_id|crc32(cuerpo) |
🧪 Probar tu aplicación
Testing\FakeMercadoPago es la Orders API en memoria: guarda órdenes, respeta
X-Idempotency-Key (misma clave → misma respuesta; otro cuerpo → 409) y responde
con los estados y errores de Mercado Pago. Sirve con plaza y con mercadopago.
$fake = new FakeMercadoPago(); $gateway = GatewayFactory::make('plaza', [...], $fake->client()); $payment = $gateway->createPayment($request); // action_required / waiting_payment $fake->pay($payment->id); // el comprador paga (o decline(), expire(), chargeBack()) [$headers, $body] = $fake->signedWebhook($payment->id, $secret, 'order.processed'); $gateway->parseWebhook($headers, $body); // firmado como lo firma Mercado Pago $fake->failNext(503); // la próxima llamada falla: prueba tus reintentos
En Laravel, Http::fake() no intercepta Guzzle. Liga el cliente del fake antes de
resolver la primera pasarela:
$this->app->instance(PaymentGatewaysServiceProvider::HTTP, $fake->client());
Las pasarelas usan ese cliente, la caché de la app y el canal de payment-gateways.log_channel.
🧩 Añadir otra pasarela
Implementa Contracts\Gateway (y Contracts\HostedCheckout si el proveedor tiene
página propia), regístrala y hazla pasar el kit de conformidad, la misma suite
que pasan los tres drivers:
GatewayFactory::extend('adyen', fn (GatewayConfig $config, Services $services) => new AdyenGateway($config, $services)); final class AdyenConformanceTest extends GatewayConformanceTestCase { /* gateway(), currency(), buyerPays() */ }
Paso a paso: docs/anadir-una-pasarela.md.
🏦 Orquestación de pagos
Ronu\PaymentGateways\Orchestration coordina el cobro de forma duradera sobre PostgreSQL. Persiste el
intento antes de llamar al proveedor, deja lo ambiguo como UNKNOWN sin reenviarlo, y confirma captura,
ledger y outbox juntos. También incluye workers de recuperación, refunds con reserva de saldo
(deshabilitados por defecto), disputas, eventos financieros y operación por consola. Antes era el paquete
ronu/payment-orchestration. Guía y migración: docs/orquestacion.md.
⚠️ Límites conocidos
- El emulador no tiene Checkout Pro ni Checkout Sessions. Con
environment: emulator,createCheckout()de Mercado Pago y Stripe usa las páginas propias de Plaza y Vector. Contra las APIs reales esos dos caminos solo están probados con respuestas simuladas según la documentación; aún no contra el sandbox (M10). - Checkout Pro:
getCheckout()busca el pago porexternal_reference, así que la referencia debe ser única por checkout. - Stripe:
getPayment()lee las devoluciones y las disputas del cargo (expand[]=latest_charge). Vector no expande el cargo: en el emulador el pago sigue encapturedtras devolver; usa elRefundque devuelverefund(). - PayPal: el certificado de firma solo se descarga de los hosts de
webhooks.cert_hosts. - Mercado Pago Orders: una devolución parcial nombra la transacción; la biblioteca la lee de la orden (una consulta más), no hace falta pasarla. Si la orden tiene varios pagos, devuelve sobre el primero.
- Emulador: cancelar un pago ya capturado en Plaza o Vector responde 500 (un proveedor real responde 400). El kit de conformidad lo marca como incompleto en el emulador.
🧪 Tests
El driver Mercado Pago implementa el contrato opcional RefundLookup para
consultar un reembolso individual de Orders mediante getRefund($orderId, $refundId).
Valida orden, transacción de pago, ID, importe y moneda; devuelve null si no
encuentra evidencia individual. Usa únicamente GET y no correlaciona por importe.
La consulta de refunds de Payments API numérica aún no está implementada.
Validación local: Plaza corregido conserva dos reembolsos del mismo importe,
sus IDs y su consulta posterior. No implica certificación sandbox/live.
composer test # unitarios y el kit de conformidad contra FakeMercadoPago composer analyse # PHPStan nivel 8 composer test:orchestration-postgres # orquestación contra un PostgreSQL aislado (ver docs/orquestacion.md) # Cada petición contra el OpenAPI oficial de su proveedor (los specs que guarda el lab) PAYMENT_CONTRACTS_DIR=../payment-emulator-lab/contracts composer test:contract # Contra un Payment Emulator Lab (crea sus propias aplicaciones): flujos + kit de conformidad de los tres drivers PAYMENT_EMULATOR_URL=http://localhost:8080 \ PAYMENT_EMULATOR_ADMIN_TOKEN=… \ PAYMENT_EMULATOR_SINK_PORT=8095 composer test:integrationMIT · Desarrollado por RonuSoftware · No está afiliado a Mercado Pago, Stripe ni PayPal; sus nombres solo identifican las APIs compatibles.