Oscar Coleto

SOLID en Laravel (2/5): Principio Open/Closed con Stripe

Introducción

Bienvenido al segundo artículo de nuestra serie SOLID en Laravel. En este artículo exploraremos el Principio Open/Closed (OCP), que establece:

“Las clases deben estar abiertas para extensión, pero cerradas para modificación”

Esto significa que debes poder agregar nueva funcionalidad sin cambiar el código existente.

El Problema: Código Cerrado para Extensión

Imagina que inicialmente solo aceptabas pagos con Stripe, pero ahora necesitas soportar PayPal, y luego Mercado Pago. Así es como muchos desarrolladores lo implementan:

<?php

namespace App\Services;

use Stripe\Stripe;
use Stripe\PaymentIntent;
use PayPal\Rest\ApiContext;
use PayPal\Api\Payment as PayPalPayment;
use MercadoPago\SDK as MercadoPagoSDK;

class PaymentService
{
    public function processPayment(float $amount, string $provider, array $data)
    {
        if ($provider === 'stripe') {
            return $this->processStripePayment($amount, $data);
        }
        elseif ($provider === 'paypal') {
            return $this->processPayPalPayment($amount, $data);
        }
        elseif ($provider === 'mercadopago') {
            return $this->processMercadoPagoPayment($amount, $data);
        }

        throw new \Exception('Provider not supported');
    }

    private function processStripePayment(float $amount, array $data)
    {
        Stripe::setApiKey(config('services.stripe.secret'));

        $paymentIntent = PaymentIntent::create([
            'amount' => $amount * 100,
            'currency' => 'usd',
            'payment_method' => $data['payment_method'],
            'confirmation_method' => 'manual',
            'confirm' => true,
        ]);

        return [
            'success' => $paymentIntent->status === 'succeeded',
            'transaction_id' => $paymentIntent->id,
            'provider' => 'stripe',
        ];
    }

    private function processPayPalPayment(float $amount, array $data)
    {
        $apiContext = new ApiContext(
            new \PayPal\Auth\OAuthTokenCredential(
                config('services.paypal.client_id'),
                config('services.paypal.secret')
            )
        );

        // Lógica de PayPal...
        $payment = new PayPalPayment();
        // ... código de configuración ...

        return [
            'success' => true,
            'transaction_id' => $payment->getId(),
            'provider' => 'paypal',
        ];
    }

    private function processMercadoPagoPayment(float $amount, array $data)
    {
        MercadoPagoSDK::setAccessToken(config('services.mercadopago.token'));

        // Lógica de Mercado Pago...

        return [
            'success' => true,
            'transaction_id' => 'mp_xxx',
            'provider' => 'mercadopago',
        ];
    }

    public function refund(string $transactionId, string $provider)
    {
        if ($provider === 'stripe') {
            return $this->refundStripe($transactionId);
        }
        elseif ($provider === 'paypal') {
            return $this->refundPayPal($transactionId);
        }
        elseif ($provider === 'mercadopago') {
            return $this->refundMercadoPago($transactionId);
        }

        throw new \Exception('Provider not supported');
    }

    // Más métodos con if/else para cada provider...
}

¿Por qué esto viola el OCP?

Cada vez que quieres agregar un nuevo proveedor de pagos:

  • Modificas la clase PaymentService
  • ❌ Agregas más if/else (aumenta complejidad ciclomática)
  • Riesgo de bugs: Un cambio puede romper proveedores existentes
  • Difícil de testear: Necesitas mockear todos los proveedores
  • Violación del OCP: La clase no está cerrada para modificación

La Solución: Aplicando OCP

Vamos a usar interfaces y polimorfismo para poder agregar nuevos proveedores sin modificar código existente:

1. Definir la Interfaz

<?php

namespace App\Contracts;

interface PaymentGatewayInterface
{
    public function charge(float $amount, array $paymentData): PaymentResult;

    public function refund(string $transactionId, ?float $amount = null): RefundResult;

    public function createCustomer(array $customerData): string;

    public function getTransactionStatus(string $transactionId): string;
}

2. Objetos de Valor para Resultados

<?php

namespace App\ValueObjects;

class PaymentResult
{
    public function __construct(
        public readonly bool $success,
        public readonly string $transactionId,
        public readonly string $status,
        public readonly ?string $errorMessage = null,
        public readonly array $metadata = []
    ) {}

    public static function success(string $transactionId, string $status, array $metadata = []): self
    {
        return new self(
            success: true,
            transactionId: $transactionId,
            status: $status,
            metadata: $metadata
        );
    }

    public static function failed(string $errorMessage): self
    {
        return new self(
            success: false,
            transactionId: '',
            status: 'failed',
            errorMessage: $errorMessage
        );
    }
}

3. Implementación para Stripe

<?php

namespace App\Services\PaymentGateways;

use App\Contracts\PaymentGatewayInterface;
use App\ValueObjects\PaymentResult;
use App\ValueObjects\RefundResult;
use Stripe\Stripe;
use Stripe\PaymentIntent;
use Stripe\Customer;
use Stripe\Refund;

class StripeGateway implements PaymentGatewayInterface
{
    public function __construct()
    {
        Stripe::setApiKey(config('services.stripe.secret'));
    }

    public function charge(float $amount, array $paymentData): PaymentResult
    {
        try {
            $paymentIntent = PaymentIntent::create([
                'amount' => $amount * 100,
                'currency' => $paymentData['currency'] ?? 'usd',
                'payment_method' => $paymentData['payment_method'],
                'confirmation_method' => 'manual',
                'confirm' => true,
            ]);

            return PaymentResult::success(
                transactionId: $paymentIntent->id,
                status: $paymentIntent->status,
                metadata: [
                    'client_secret' => $paymentIntent->client_secret,
                ]
            );
        } catch (\Exception $e) {
            return PaymentResult::failed($e->getMessage());
        }
    }

    public function refund(string $transactionId, ?float $amount = null): RefundResult
    {
        try {
            $refundData = ['payment_intent' => $transactionId];

            if ($amount) {
                $refundData['amount'] = $amount * 100;
            }

            $refund = Refund::create($refundData);

            return RefundResult::success(
                refundId: $refund->id,
                amount: $refund->amount / 100
            );
        } catch (\Exception $e) {
            return RefundResult::failed($e->getMessage());
        }
    }

    public function createCustomer(array $customerData): string
    {
        $customer = Customer::create([
            'email' => $customerData['email'],
            'name' => $customerData['name'],
        ]);

        return $customer->id;
    }

    public function getTransactionStatus(string $transactionId): string
    {
        $paymentIntent = PaymentIntent::retrieve($transactionId);
        return $paymentIntent->status;
    }
}

4. Implementación para PayPal

<?php

namespace App\Services\PaymentGateways;

use App\Contracts\PaymentGatewayInterface;
use App\ValueObjects\PaymentResult;
use App\ValueObjects\RefundResult;

class PayPalGateway implements PaymentGatewayInterface
{
    private $apiContext;

    public function __construct()
    {
        $this->apiContext = new \PayPal\Rest\ApiContext(
            new \PayPal\Auth\OAuthTokenCredential(
                config('services.paypal.client_id'),
                config('services.paypal.secret')
            )
        );
    }

    public function charge(float $amount, array $paymentData): PaymentResult
    {
        try {
            // Lógica específica de PayPal
            $payment = new \PayPal\Api\Payment();
            // ... configuración ...

            $payment->create($this->apiContext);

            return PaymentResult::success(
                transactionId: $payment->getId(),
                status: $payment->getState(),
            );
        } catch (\Exception $e) {
            return PaymentResult::failed($e->getMessage());
        }
    }

    public function refund(string $transactionId, ?float $amount = null): RefundResult
    {
        // Implementación de reembolso PayPal
    }

    public function createCustomer(array $customerData): string
    {
        // PayPal no requiere crear clientes de la misma forma
        return $customerData['email'];
    }

    public function getTransactionStatus(string $transactionId): string
    {
        // Implementación de consulta de estado
    }
}

5. Factory para Seleccionar Gateway

<?php

namespace App\Services;

use App\Contracts\PaymentGatewayInterface;
use App\Services\PaymentGateways\StripeGateway;
use App\Services\PaymentGateways\PayPalGateway;
use App\Services\PaymentGateways\MercadoPagoGateway;

class PaymentGatewayFactory
{
    public function make(string $provider): PaymentGatewayInterface
    {
        return match($provider) {
            'stripe' => app(StripeGateway::class),
            'paypal' => app(PayPalGateway::class),
            'mercadopago' => app(MercadoPagoGateway::class),
            default => throw new \InvalidArgumentException("Gateway {$provider} not supported")
        };
    }
}

6. Servicio de Pagos Simplificado

<?php

namespace App\Services;

use App\Contracts\PaymentGatewayInterface;
use App\ValueObjects\PaymentResult;

class PaymentService
{
    public function __construct(
        private PaymentGatewayFactory $gatewayFactory
    ) {}

    public function processPayment(
        float $amount,
        string $provider,
        array $paymentData
    ): PaymentResult {
        $gateway = $this->gatewayFactory->make($provider);
        return $gateway->charge($amount, $paymentData);
    }

    public function refund(
        string $transactionId,
        string $provider,
        ?float $amount = null
    ): RefundResult {
        $gateway = $this->gatewayFactory->make($provider);
        return $gateway->refund($transactionId, $amount);
    }
}

7. Uso en el Controlador

<?php

namespace App\Http\Controllers;

use App\Services\PaymentService;
use Illuminate\Http\Request;

class PaymentController extends Controller
{
    public function __construct(
        private PaymentService $paymentService
    ) {}

    public function charge(Request $request)
    {
        $result = $this->paymentService->processPayment(
            amount: $request->amount,
            provider: $request->provider, // 'stripe', 'paypal', etc.
            paymentData: $request->payment_data
        );

        if ($result->success) {
            return response()->json([
                'success' => true,
                'transaction_id' => $result->transactionId,
            ]);
        }

        return response()->json([
            'success' => false,
            'error' => $result->errorMessage,
        ], 400);
    }
}

Agregando un Nuevo Gateway (Sin Modificar Código Existente)

Ahora, para agregar Mercado Pago, solo creas una nueva clase:

<?php

namespace App\Services\PaymentGateways;

use App\Contracts\PaymentGatewayInterface;
use App\ValueObjects\PaymentResult;

class MercadoPagoGateway implements PaymentGatewayInterface
{
    public function __construct()
    {
        \MercadoPago\SDK::setAccessToken(config('services.mercadopago.token'));
    }

    public function charge(float $amount, array $paymentData): PaymentResult
    {
        try {
            $payment = new \MercadoPago\Payment();
            $payment->transaction_amount = $amount;
            $payment->token = $paymentData['token'];
            // ... resto de configuración

            $payment->save();

            return PaymentResult::success(
                transactionId: $payment->id,
                status: $payment->status,
            );
        } catch (\Exception $e) {
            return PaymentResult::failed($e->getMessage());
        }
    }

    // ... resto de métodos
}

Y registrarlo en el Factory:

// Solo modificas esta línea en PaymentGatewayFactory
'mercadopago' => app(MercadoPagoGateway::class),

Comparación

Antes (Violando OCP)

  • ❌ Modificabas PaymentService por cada nuevo gateway
  • ❌ Cadenas de if/else creciendo infinitamente
  • ❌ Riesgo de romper gateways existentes
  • ❌ 200+ líneas en una clase

Después (Aplicando OCP)

  • PaymentService nunca cambia
  • ✅ Nuevos gateways = nueva clase (extensión)
  • ✅ Gateways existentes no se tocan
  • ✅ Cada gateway ~50 líneas, aislado y testeable

Beneficios

1. Agregar Funcionalidad Sin Riesgo

// Antes: Miedo de romper algo
// Después: Nueva clase, cero riesgo
php artisan make:gateway CryptoGateway

2. Testing Simplificado

// Mockea solo la interfaz
public function test_payment_processes_successfully()
{
    $mockGateway = Mockery::mock(PaymentGatewayInterface::class);
    $mockGateway->shouldReceive('charge')
        ->andReturn(PaymentResult::success('txn_123', 'succeeded'));

    $service = new PaymentService(new PaymentGatewayFactory());
    // ...
}

3. Configuración Dinámica

// En config/payments.php
return [
    'default_gateway' => env('PAYMENT_GATEWAY', 'stripe'),
    'gateways' => [
        'stripe' => StripeGateway::class,
        'paypal' => PayPalGateway::class,
        'mercadopago' => MercadoPagoGateway::class,
    ],
];

Casos de Uso Reales

A/B Testing de Gateways

class PaymentGatewaySelector
{
    public function selectGateway(User $user): string
    {
        // Testing: 50% Stripe, 50% PayPal
        return $user->id % 2 === 0 ? 'stripe' : 'paypal';
    }
}

Failover Automático

public function processWithFailover(float $amount, array $data): PaymentResult
{
    $providers = ['stripe', 'paypal', 'mercadopago'];

    foreach ($providers as $provider) {
        $result = $this->processPayment($amount, $provider, $data);

        if ($result->success) {
            return $result;
        }
    }

    throw new AllGatewaysFailedException();
}

Conclusión

El Principio Open/Closed te permite:

  • ✅ Extender funcionalidad sin modificar código existente
  • ✅ Reducir riesgo de bugs en código probado
  • ✅ Facilitar el testing con interfaces
  • ✅ Escalar tu aplicación de forma sostenible

Regla de oro: Si te encuentras modificando una clase cada vez que agregas una feature, probablemente estás violando OCP.

En el próximo artículo exploraremos el Principio de Sustitución de Liskov, donde veremos cómo asegurar que las implementaciones sean intercambiables.


Artículo anterior: SOLID en Laravel (1/5): Principio de Responsabilidad Única

Próximo artículo: SOLID en Laravel (3/5): Principio de Sustitución de Liskov