Skip to content

Arquitectura de Medios de Pago

Estado: Planificado

Resumen

Sistema de integracion con medios de pago online para el portal de clientes multi-tenant. Arquitectura basada en el patron Adapter que abstrae las diferencias entre gateways detras de una interfaz estandar. Cada tenant tiene un gateway configurado en deploy time.

El pago es automatico: cuando el webhook confirma aprobacion, el sistema crea el recibo en CtaCte sin intervencion manual.

Gateways soportados:

GatewayEstadoDocumentacion
Pago TIC (PayPerTIC)Primer adapterpaypertic.md
MercadoPagoPlanificadomercadopago.md

Arquitectura del Adapter

El sistema utiliza Adapter + Factory para desacoplar la logica de negocio de las particularidades de cada gateway. El backend trabaja exclusivamente con DTOs estandar; cada adapter traduce desde/hacia la API del proveedor.

mermaid
flowchart TD
    subgraph Portal["Portal de Clientes"]
        FE["Frontend (Docker tenant)"]
    end

    subgraph Backend["Backend Compartido"]
        API["POST /portal/pagos/iniciar"]
        WH["POST /portal/pagos/webhook"]
        Factory["PaymentGatewayFactory"]
        SVC["PaymentGatewayService"]
    end

    subgraph Adapters["Adapters"]
        IF["PaymentGatewayAdapter\n(interfaz estandar)"]
        PT["PagoTicAdapter"]
        MP["MercadoPagoAdapter"]
        FUTURE["FuturoAdapter"]
    end

    subgraph Gateways["Gateways Externos"]
        PTAPI["Pago TIC API"]
        MPAPI["MercadoPago API"]
    end

    FE --> API
    WH --> SVC
    API --> Factory
    Factory --> IF
    IF --> PT
    IF --> MP
    IF --> FUTURE
    PT --> PTAPI
    MP --> MPAPI
    SVC --> Factory

Interfaz Estandar

Todos los adapters implementan PaymentGatewayAdapter. El backend nunca interactua con APIs de gateway directamente; siempre pasa por esta interfaz.

php
interface PaymentGatewayAdapter
{
    /**
     * Crea un pago en el gateway externo.
     * Retorna URL de checkout para redirigir al cliente.
     */
    public function createPayment(PaymentRequest $request): PaymentResponse;

    /**
     * Procesa la notificacion (webhook) del gateway.
     * Normaliza la respuesta a un resultado estandar.
     */
    public function processWebhook(array $headers, array $body): WebhookResult;

    /**
     * Valida que el webhook provenga realmente del gateway (firma, token, etc.).
     */
    public function validateWebhook(array $headers, array $body): bool;

    /**
     * Consulta el estado actual de un pago en el gateway.
     */
    public function getPaymentStatus(string $externalId): PaymentStatusResult;

    /**
     * Cancela un pago pendiente/emitido.
     */
    public function cancelPayment(string $externalId, string $reason): CancelResult;

    /**
     * Solicita devolucion (refund) de un pago aprobado.
     */
    public function refundPayment(string $externalId, RefundRequest $request): RefundResult;
}

DTOs Estandar

PaymentRequest

Datos necesarios para crear un pago en cualquier gateway.

php
class PaymentRequest
{
    public string $externalId;         // Referencia unica del sistema (portal_payment.id)
    public float $amount;              // Monto total
    public string $currency;           // "ARS"
    public array $items;               // [{amount, description, reference}]
    public PayerData $payer;           // Datos del pagador
    public string $notificationUrl;    // URL del webhook
    public string $returnUrl;          // URL post-pago exitoso
    public string $backUrl;            // URL boton "volver"
    public ?string $dueDate;           // Fecha vencimiento (ISO 8601, opcional)
    public ?array $metadata;           // JSON libre (opcional)
}

class PayerData
{
    public string $name;
    public string $email;
    public string $dniCuit;            // DNI o CUIT del pagador
    public ?string $externalReference; // Referencia del pagador en el sistema
}

PaymentResponse

Resultado de crear un pago.

php
class PaymentResponse
{
    public string $gatewayPaymentId;   // ID del pago en el gateway (UUID, preference_id, etc.)
    public string $checkoutUrl;        // URL para redirigir al cliente al checkout
    public string $status;             // Estado inicial del pago
    public float $finalAmount;         // Monto total incluyendo comisiones del gateway
}

WebhookResult

Resultado normalizado del procesamiento de un webhook.

php
class WebhookResult
{
    public string $gatewayPaymentId;   // ID del pago en el gateway
    public string $externalId;         // Nuestra referencia (portal_payment.id)
    public PaymentStatus $status;      // Estado estandar
    public float $amount;              // Monto pagado
    public ?string $paymentDate;       // Fecha de pago (ISO 8601)
    public array $rawResponse;         // Respuesta cruda del gateway (para debug/audit)
}

PaymentStatusResult

Resultado de consultar el estado de un pago.

php
class PaymentStatusResult
{
    public PaymentStatus $status;      // Estado estandar
    public float $amount;              // Monto
    public ?string $paymentDate;       // Fecha de pago
    public array $rawResponse;         // Respuesta cruda del gateway
}

CancelResult

Resultado de cancelar un pago.

php
class CancelResult
{
    public bool $success;
    public string $status;             // Estado resultante
}

RefundRequest / RefundResult

Datos y resultado de una devolucion.

php
class RefundRequest
{
    public string $type;               // "online", "partial", etc.
    public ?string $reason;            // Motivo de la devolucion
    public ?array $metadata;           // JSON libre
}

class RefundResult
{
    public string $refundId;           // ID de la devolucion en el gateway
    public string $status;             // "approved", "rejected"
    public float $amount;              // Monto devuelto
    public ?array $feeDetails;         // Detalle de comisiones
}

Tabla de Mapeo de Campos

Cada adapter traduce entre los campos estandar y los del gateway. Esta tabla documenta las correspondencias:

Campo EstandarMercadoPagoPago TIC
checkoutUrlinit_pointform_url
gatewayPaymentIdpreference.idid (UUID)
externalIdexternal_referenceexternal_transaction_id
itemsitems[]details[]
items[].amountitems[].unit_pricedetails[].amount
items[].descriptionitems[].titledetails[].concept_description
notificationUrlnotification_urlnotification_url
returnUrlback_urls.successreturn_url
backUrlback_urls.failureback_url
payer.namepayer.namepayer.name
payer.emailpayer.emailpayer.email
payer.dniCuitpayer.identification.numberpayer.identification.number
currencycurrency_idcurrency_id
dueDateN/A (no aplica en Checkout Pro)due_date (ISO 8601)
metadatametadatametadata

Estados de Pago

Enum estandar

php
enum PaymentStatus: string
{
    case PENDING   = 'pending';
    case ISSUED    = 'issued';
    case APPROVED  = 'approved';
    case REJECTED  = 'rejected';
    case REFUNDED  = 'refunded';
    case CANCELLED = 'cancelled';
}

Maquina de estados

mermaid
stateDiagram-v2
    [*] --> PENDING : Pago creado

    PENDING --> ISSUED : Gateway confirma emision
    PENDING --> APPROVED : Pago completado
    PENDING --> REJECTED : Pago rechazado
    PENDING --> CANCELLED : Cancelado antes de pagar

    ISSUED --> APPROVED : Pago completado
    ISSUED --> REJECTED : Pago rechazado
    ISSUED --> CANCELLED : Cancelado

    APPROVED --> REFUNDED : Devolucion aprobada

    REJECTED --> [*]
    CANCELLED --> [*]
    REFUNDED --> [*]

Mapeo de estados por gateway

Estado EstandarMercadoPagoPago TIC
PENDINGpendingpending
ISSUEDN/Aissued
APPROVEDapprovedapproved
REJECTEDrejectedrejected
REFUNDEDrefundedrefunded
CANCELLEDcancelledcancelled

Cada adapter mapea los estados del gateway al enum estandar. El backend trabaja exclusivamente con PaymentStatus.

PaymentGatewayFactory

La factory instancia el adapter correcto segun la configuracion del tenant.

php
class PaymentGatewayFactory
{
    /**
     * Crea el adapter correspondiente al gateway del tenant.
     *
     * @param string $gateway  Identificador del gateway ("paypertic", "mercadopago")
     * @param array  $config   Configuracion del gateway (credenciales, URLs, etc.)
     */
    public static function create(string $gateway, array $config): PaymentGatewayAdapter
    {
        return match ($gateway) {
            'paypertic'   => new PagoTicAdapter($config),
            'mercadopago' => new MercadoPagoAdapter($config),
            default       => throw new \InvalidArgumentException(
                "Gateway no soportado: {$gateway}"
            ),
        };
    }
}

Uso en el servicio

php
class PaymentGatewayService
{
    public function initPayment(string $tenantId, PaymentRequest $request): PaymentResponse
    {
        // 1. Obtener configuracion del tenant
        $tenantConfig = $this->getTenantGatewayConfig($tenantId);

        // 2. Instanciar adapter correcto
        $adapter = PaymentGatewayFactory::create(
            $tenantConfig['payment_gateway'],
            $tenantConfig['payment_gateway_config']
        );

        // 3. Crear pago (el adapter traduce a la API del gateway)
        return $adapter->createPayment($request);
    }
}

Seleccion de Gateway por Tenant

Cada tenant tiene un gateway configurado en deploy time. La seleccion NO es dinamica en runtime.

Configuracion

Backend (ini.sistemas):

sql
ALTER TABLE ini.sistemas
  ADD COLUMN payment_gateway VARCHAR(50) DEFAULT 'none',
  ADD COLUMN payment_gateway_config JSONB;

Ejemplo Pago TIC:

json
{
  "payment_gateway": "paypertic",
  "payment_gateway_config": {
    "bearer_token": "eyJhbGciOiJIUzI1NiIs...",
    "api_url": "https://api.paypertic.com",
    "environment": "production"
  }
}

Ejemplo MercadoPago:

json
{
  "payment_gateway": "mercadopago",
  "payment_gateway_config": {
    "access_token": "APP_USR-xxx",
    "webhook_secret": "secret",
    "environment": "production"
  }
}

Frontend (.env de Docker):

env
VITE_PAYMENT_GATEWAY=paypertic

Esta variable es informacional para el frontend (mostrar logos, textos especificos). La logica real de seleccion ocurre en el backend.

Credenciales

Cada gateway tiene sus propias credenciales en el backend:

GatewayVariables
Pago TICPAYPERTIC_BEARER_TOKEN, PAYPERTIC_API_URL
MercadoPagoMERCADOPAGO_ACCESS_TOKEN, MERCADOPAGO_WEBHOOK_SECRET

Alternativamente, las credenciales pueden almacenarse en ini.sistemas.payment_gateway_config para configuracion per-tenant.

Flujo de Pago Estandar

Independientemente del gateway, el flujo es siempre el mismo:

mermaid
sequenceDiagram
    participant C as Cliente
    participant FE as Frontend (Docker tenant)
    participant BE as Backend (compartido)
    participant GW as Gateway (Pago TIC / MercadoPago)
    participant CC as CtaCte

    C->>FE: Selecciona facturas, click "Pagar"
    FE->>BE: POST /portal/pagos/iniciar

    BE->>BE: PaymentGatewayFactory.create(tenant_gateway)
    BE->>GW: adapter.createPayment(PaymentRequest)
    GW-->>BE: PaymentResponse {checkoutUrl, gatewayPaymentId}

    BE->>BE: Guardar en portal_payments (status=PENDING)
    BE-->>FE: {payment_id, redirect_url, payment_method}
    FE->>C: Redirige a checkoutUrl del gateway

    C->>GW: Completa el pago en el checkout
    GW-->>C: Redirige a returnUrl (/pagar/exito)

    Note over GW,BE: Asincrono - webhook

    GW->>BE: POST /portal/pagos/webhook
    BE->>BE: adapter.validateWebhook(headers, body)
    BE->>BE: adapter.processWebhook(headers, body)

    alt WebhookResult.status == APPROVED
        BE->>BE: Verificar idempotencia (external_id)
        BE->>CC: Crear recibo automatico
        BE->>BE: Actualizar portal_payments (status=APPROVED, recibo_id)
    else WebhookResult.status == REJECTED
        BE->>BE: Actualizar portal_payments (status=REJECTED)
    else WebhookResult.status == PENDING
        BE->>BE: Esperar siguiente webhook
    end

Endpoints

POST /portal/pagos/iniciar       — Crear pago (requiere JWT)
POST /portal/pagos/webhook       — Recibir notificacion del gateway (sin JWT)
GET  /portal/pagos/{id}/estado   — Consultar estado de un pago
POST /portal/pagos/{id}/cancelar — Cancelar un pago pendiente
POST /portal/pagos/{id}/devolver — Solicitar devolucion

Resolucion de Tenant en Webhooks (ADR-011)

El webhook llega al backend compartido sin JWT ni contexto de tenant. La resolucion funciona asi:

  1. El gateway envia una notificacion con el external_id (que corresponde a external_transaction_id en Pago TIC o external_reference en MercadoPago).
  2. El backend busca en portal_payments por external_id.
  3. La fila contiene tenant_id y sucursal_id, almacenados al momento de crear el pago (cuando el usuario tenia JWT con contexto de tenant).
  4. Con tenant_id se resuelve la DB via ini.sistema; con sucursal_id se resuelve el schema.
  5. Se procesa el webhook en el contexto correcto del tenant.

Este patron evita exponer informacion de tenant en URLs y no depende de configuracion DNS.

Base de Datos

Tabla portal_payments

sql
CREATE TABLE portal_payments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id VARCHAR(50) NOT NULL,
  sucursal_id VARCHAR(50) NOT NULL,
  cliente_id INTEGER NOT NULL,
  payment_method VARCHAR(50) NOT NULL,
  amount DECIMAL(12,2) NOT NULL,
  status VARCHAR(20) NOT NULL,       -- PaymentStatus enum
  external_id VARCHAR(255),           -- Referencia del pago en el gateway
  external_response JSONB,            -- Respuesta cruda del gateway
  facturas_pagadas JSONB NOT NULL,
  recibo_id UUID,
  payment_date TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW(),
  FOREIGN KEY (cliente_id) REFERENCES ordcon(codigo)
);

CREATE INDEX idx_portal_payments_cliente ON portal_payments(cliente_id);
CREATE INDEX idx_portal_payments_external ON portal_payments(external_id);
CREATE INDEX idx_portal_payments_status ON portal_payments(status);
CREATE INDEX idx_portal_payments_tenant ON portal_payments(tenant_id);

Implementar un Nuevo Gateway

Paso 1: Crear Adapter

Implementar PaymentGatewayAdapter con los 6 metodos:

  • createPayment() — Crear pago, retornar URL de checkout
  • processWebhook() — Normalizar la notificacion del gateway
  • validateWebhook() — Validar autenticidad (firma, token, etc.)
  • getPaymentStatus() — Consultar estado actual
  • cancelPayment() — Cancelar pago pendiente
  • refundPayment() — Solicitar devolucion

Paso 2: Mapear estados

Definir la tabla de mapeo entre estados del gateway y PaymentStatus enum.

Paso 3: Registrar en Factory

Agregar el nuevo case al match en PaymentGatewayFactory.

Paso 4: Configurar Tenant

sql
UPDATE ini.sistemas
SET
  payment_gateway = 'nuevo_gateway',
  payment_gateway_config = '{"api_key": "xxx"}'::jsonb
WHERE codigo = 'tenant_codigo';

Paso 5: Configurar Webhook

La URL del webhook sigue siendo la misma del backend compartido. El nuevo adapter implementa su propia logica de validacion en validateWebhook().

Seguridad

  • Validacion de webhook: Cada adapter implementa su propia validacion (HMAC-SHA256, Bearer token, etc.)
  • HTTPS obligatorio: Todos los webhooks requieren SSL/TLS
  • Idempotencia: Verificar external_id antes de procesar (evitar doble acreditacion)
  • Credenciales seguras: En ini.sistemas o variables de entorno (nunca en codigo)
  • Audit logging: Registrar todos los pagos, webhooks y cambios de estado

Testing

Unit Tests

php
class PaymentGatewayServiceTest extends TestCase
{
    public function testSeleccionarAdapterSegunTenant() { /* ... */ }
    public function testCrearReciboAutomaticoEnAprobacion() { /* ... */ }
    public function testIdempotenciaNoCreaDuplicados() { /* ... */ }
    public function testCancelPaymentSoloEnPending() { /* ... */ }
    public function testRefundPaymentSoloEnApproved() { /* ... */ }
}

Integration Tests por Gateway

Cada adapter tiene sus propios tests de integracion que validan el mapeo de campos y estados contra el sandbox del proveedor.

Ver tambien