Integración de Pasarelas de Pago LATAM: Abstracción Multi-Gateway en PHP

Descargo de responsabilidad: Este artículo es material educativo de integración de sistemas financieros. Las APIs de pasarelas de pago cambian frecuentemente; consultar siempre la documentación oficial vigente de cada proveedor antes de implementar. El manejo incorrecto de credenciales o flujos de pago puede resultar en pérdidas financieras directas. El uso de este contenido es bajo exclusiva responsabilidad del desarrollador u operador.

1. El Problema del Vendor Lock-in en Pagos LATAM

Integrar una sola pasarela de pagos parece eficiente al inicio, pero en LATAM se convierte rápidamente en riesgo operativo. Cada país tiene preferencias locales distintas: México domina con OXXO y SPEI, Colombia con PSE y Nequi, Perú con Yape y BCP, Brasil con PIX. Ninguna pasarela cubre todos los métodos con igual calidad, precio y tasa de aprobación.

Más crítico aún: cuando tu única pasarela sufre una caída regional (evento común en LATAM), tu plataforma deja de facturar completamente. La solución arquitectónica es la abstracción multi-gateway: una capa intermedia que normaliza las diferencias entre proveedores y permite cambiar de gateway sin modificar lógica de negocio, habilitando failover automático y optimización dinámica de rutas de pago.

2. Patrón Adapter: Normalización de Interfaces Heterogéneas

Cada pasarela tiene su propia API, formatos de respuesta, códigos de error y mecanismos de autenticación. El patrón Adapter encapsula estas diferencias detrás de una interfaz común:

<?php
declare(strict_types=1);

namespace App\Payments\Contracts;

/**
 * Interfaz unificada para todas las pasarelas de pago
 * Cualquier adapter debe implementar este contrato
 */
interface PaymentGatewayInterface 
{
    /**
     * Procesa cargo único
     * @return PaymentResult Objeto normalizado independiente del proveedor
     */
    public function charge(PaymentRequest $request): PaymentResult;
    
    /**
     * Reembolso total o parcial
     */
    public function refund(string $transactionId, int $amountCents): RefundResult;
    
    /**
     * Consulta estado de transacción
     */
    public function getTransactionStatus(string $transactionId): TransactionStatus;
    
    /**
     * Verifica disponibilidad del gateway (health check)
     */
    public function isAvailable(): bool;
}

/**
 * DTO normalizado de resultado de pago
 * Independiente del formato nativo de cada pasarela
 */
readonly class PaymentResult 
{
    public function __construct(
        public string $transactionId,
        public PaymentStatus $status,
        public int $amountCents,
        public string $currency,
        public string $gatewayName,
        public ?string $gatewayRawResponse = null,
        public ?string $errorCode = null,
        public ?string $errorMessage = null
    ) {}
    
    public function isSuccess(): bool 
    {
        return $this->status === PaymentStatus::APPROVED;
    }
}

enum PaymentStatus: string 
{
    case APPROVED = 'approved';
    case DECLINED = 'declined';
    case PENDING = 'pending';      // Esperando confirmación asíncrona
    case ERROR = 'error';          // Fallo técnico, no rechazo bancario
    case CANCELLED = 'cancelled';
}
PasarelaCódigo Éxito NativoCódigo Rechazo NativoNormalizado a
Stripesucceededcard_declined / insufficient_fundsAPPROVED / DECLINED
Mercado Pagoapprovedrejected / cc_rejected_bad_filled_security_codeAPPROVED / DECLINED
KushkiAPPROVEDDECLINED / INSUFFICIENT_FUNDSAPPROVED / DECLINED
dLocalPAIDREJECTED / EXPIREDAPPROVED / DECLINED

3. Implementación de Adapters Específicos

Cada adapter traduce la interfaz unificada al lenguaje nativo de la pasarela. Ejemplo simplificado para Stripe:

<?php
declare(strict_types=1);

namespace App\Payments\Adapters;

use App\Payments\Contracts\{PaymentGatewayInterface, PaymentRequest, PaymentResult, PaymentStatus};

class StripeAdapter implements PaymentGatewayInterface 
{
    private string $apiKey;
    private string $endpoint = 'https://api.stripe.com/v1';

    public function __construct(string $apiKey) 
    {
        $this->apiKey = $apiKey;
    }

    public function charge(PaymentRequest $request): PaymentResult 
    {
        $payload = http_build_query([
            'amount' => $request->amountCents,
            'currency' => strtolower($request->currency),
            'payment_method' => $request->paymentMethodToken,
            'confirm' => 'true',
            'idempotency_key' => $request->idempotencyKey,
            'metadata[merchant_id]' => $request->merchantId,
        ]);

        $response = $this->post('/payment_intents', $payload);
        $data = json_decode($response['body'], true);

        // Traducción de estados nativos a enum unificado
        $statusMap = [
            'succeeded' => PaymentStatus::APPROVED,
            'requires_action' => PaymentStatus::PENDING,
            'canceled' => PaymentStatus::CANCELLED,
        ];

        $status = $statusMap[$data['status']] ?? PaymentStatus::ERROR;
        
        $errorCode = null;
        $errorMessage = null;
        if ($status === PaymentStatus::DECLINED || $status === PaymentStatus::ERROR) {
            $errorCode = $data['last_payment_error']['code'] ?? 'unknown';
            $errorMessage = $data['last_payment_error']['message'] ?? 'Unknown error';
        }

        return new PaymentResult(
            transactionId: $data['id'] ?? '',
            status: $status,
            amountCents: $data['amount'] ?? $request->amountCents,
            currency: strtoupper($data['currency'] ?? $request->currency),
            gatewayName: 'stripe',
            gatewayRawResponse: $response['body'],
            errorCode: $errorCode,
            errorMessage: $errorMessage
        );
    }

    public function isAvailable(): bool 
    {
        // Health check ligero: verificar conectividad sin procesar pago
        try {
            $response = $this->get('/account');
            return $response['http_code'] === 200;
        } catch (\Throwable) {
            return false;
        }
    }

    // ... refund() y getTransactionStatus() omitidos por brevedad
    
    private function post(string $path, string $payload): array 
    {
        $ch = curl_init($this->endpoint . $path);
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $payload,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $this->apiKey,
                'Content-Type: application/x-www-form-urlencoded'
            ]
        ]);
        
        $body = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        
        return ['body' => $body, 'http_code' => $httpCode];
    }
}

4. Router Inteligente: Selección Dinámica de Gateway

Tener múltiples adapters es inútil sin un router que decida cuál usar en tiempo real. La selección puede basarse en múltiples factores:

  • Geografía del cliente: PIX → dLocal/Ebanx en Brasil; OXXO → Conekta/Stripe en México; PSE → PayU/Mercado Pago en Colombia.
  • Método de pago solicitado: Tarjeta → Stripe/Kushki; Transferencia bancaria → gateway local; Wallet → pasarela con mejor integración.
  • Disponibilidad en tiempo real: Si health check de gateway primario falla, router redirige automáticamente al secundario.
  • Costo por transacción: Para métodos donde múltiples gateways compiten, elegir el de menor fee dinámicamente.
  • Tasa de aprobación histórica: ML simple basado en últimos 1000 intentos por bin/card_type/gateway. Elegir el con mayor approval rate.
<?php
declare(strict_types=1);

namespace App\Payments;

use App\Payments\Contracts\{PaymentGatewayInterface, PaymentRequest, PaymentResult};

class SmartPaymentRouter 
{
    /** @var array<string, PaymentGatewayInterface> */
    private array $gateways;
    private array $routingRules;

    public function __construct(array $gateways, array $routingRules) 
    {
        $this->gateways = $gateways;
        $this->routingRules = $routingRules;
    }

    public function processPayment(PaymentRequest $request): PaymentResult 
    {
        // Obtener lista priorizada de gateways según reglas
        $candidates = $this->resolveCandidates($request);
        
        foreach ($candidates as $gatewayName) {
            $gateway = $this->gateways[$gatewayName] ?? null;
            
            if (!$gateway || !$gateway->isAvailable()) {
                continue; // Saltar gateway no disponible
            }
            
            try {
                $result = $gateway->charge($request);
                
                // Si fue aprobado o rechazado legítimo (no error técnico), retornar
                if ($result->status !== \App\Payments\Contracts\PaymentStatus::ERROR) {
                    return $result;
                }
                
                // Error técnico: intentar siguiente gateway (failover)
                error_log(sprintf(
                    '[PAYMENT_ROUTER] Gateway %s failed with ERROR for merchant %s, trying next candidate',
                    $gatewayName,
                    $request->merchantId
                ));
                
            } catch (\Throwable $e) {
                error_log(sprintf(
                    '[PAYMENT_ROUTER] Exception in %s: %s',
                    $gatewayName,
                    $e->getMessage()
                ));
                // Continuar con siguiente candidato
            }
        }
        
        // Todos los gateways fallaron
        return new PaymentResult(
            transactionId: '',
            status: \App\Payments\Contracts\PaymentStatus::ERROR,
            amountCents: $request->amountCents,
            currency: $request->currency,
            gatewayName: 'none',
            errorCode: 'ALL_GATEWAYS_FAILED',
            errorMessage: 'No payment gateway available'
        );
    }

    private function resolveCandidates(PaymentRequest $request): array 
    {
        // Lógica de routing basada en país, método, monto, etc.
        // Retorna array ordenado por prioridad: ['stripe', 'kushki', 'mercadopago']
        // Implementación específica según reglas de negocio
        return $this->routingRules[$request->country][$request->paymentMethod] 
               ?? ['stripe']; // Default fallback
    }
}

5. Gestión Segura de Credenciales Multi-Gateway

Cada gateway requiere sus propias credenciales (API keys, certificados, webhooks secrets). Almacenarlas en variables de entorno planas o código fuente es inaceptable en producción financiera:

  • Vault/Infisical: Secrets cifrados con rotación automática. Aplicación fetch por identidad de servicio, no por credencial estática.
  • Encriptación por gateway: Cada conjunto de credenciales cifrado con clave independiente. Compromiso de una no expone las demás.
  • Auditoría de acceso: Log inmutable de quién/qué accedió a cada credencial y cuándo. Requisito PCI-DSS y regulaciones LATAM.
  • Separación por entorno: Keys de test/sandbox nunca coexisten con producción en mismo almacén. Namespaces o vaults separados obligatorios.

6. Testing de Integraciones Sin Dinero Real

Probar integraciones de pago en staging sin gastar dinero real ni arriesgar datos de producción requiere estrategia deliberada:

  • Sandbox oficiales: Todas las pasarelas LATAM ofrecen entornos sandbox con tarjetas de prueba y simuladores de escenarios (aprobado, rechazado, timeout, 3DS requerido).
  • Mock adapters para tests unitarios: Implementar MockPaymentGateway que retorna resultados configurables sin llamar APIs reales. Tests rápidos y determinísticos.
  • Contract testing: Validar que tus adapters cumplen el contrato de la interfaz unificada incluso cuando la API del proveedor cambia. Detectar breaking changes antes de deploy.
  • Simulador de red: Inyectar latencia, timeouts y errores de red en tests de integración para validar comportamiento del router y circuit breakers sin depender de sandbox externo.

7. Conclusión y Recursos Oficiales

La abstracción multi-gateway transforma la dependencia de un solo proveedor en ventaja competitiva operativa. El patrón Adapter + Smart Router permite failover automático, optimización de costos y expansión geográfica sin reescrituras masivas. En LATAM, donde la fragmentación de métodos de pago es la norma, esta arquitectura no es lujo: es requisito de supervivencia comercial.

Documentación oficial de pasarelas LATAM:


Nota técnica: Este artículo utiliza marcado Schema.org Article coexistente con el nodo FinancialService corporativo. Los metadatos semánticos se indexan automáticamente vía The SEO Framework.

Deja un comentario