Skip to content

PaymentGatewayService

Estado: Planificado

Ubicacion: Modules/Portal/Payment/Service/PaymentGatewayService.php

Responsabilidad

Servicio de integracion con medios de pago online. Utiliza el patron Adapter para soportar multiples gateways configurables por tenant. Cuando un pago es aprobado, la acreditacion en cuenta corriente es AUTOMATICA.

Arquitectura

PaymentGatewayService (Orquestador — gateway-agnostic)
    |
    +-- PaymentGatewayFactory (Selector de adapter por tenant)
    |     |
    |     +-- PagoTicAdapter (PayPerTIC — redirect via form_url)
    |     +-- MercadoPagoAdapter (redirect via init_point) [planificado]
    |     +-- ... (nuevos adapters sin tocar el service)
    |
    +-- ReciboRelationsService (Acreditacion automatica)
    +-- portal_payments (Tabla de pagos)

Patron de diseno: Factory + Adapter

  • Cada medio de pago implementa PaymentGatewayInterface con metodos estandarizados
  • PaymentGatewayFactory lee la configuracion del tenant (ini.sistema.payment_gateway) e instancia el adapter correcto
  • El Service es gateway-agnostic: invoca metodos del adapter sin saber cual gateway esta detras
  • Agregar un nuevo gateway = crear un nuevo adapter + registrarlo en la factory. No se modifica el service

PaymentGatewayFactory

Ubicacion: Modules/Portal/Payment/Factory/PaymentGatewayFactory.php

Responsabilidad: Leer la configuracion del gateway del tenant y devolver la instancia del adapter correspondiente.

php
class PaymentGatewayFactory
{
    public function create(string $gateway, array $config): PaymentGatewayInterface
    {
        return match ($gateway) {
            'paypertic' => new PagoTicAdapter($config),
            'mercadopago' => new MercadoPagoAdapter($config),
            default => throw new GatewayNotSupportedException($gateway),
        };
    }
}

Flujo de resolucion:

  1. El service lee ini.sistema.payment_gateway del tenant → obtiene el nombre del gateway (ej: paypertic)
  2. El service lee ini.sistema.payment_gateway_config → obtiene las credenciales como array
  3. Llama a PaymentGatewayFactory::create($gateway, $config) → recibe un adapter listo para usar
  4. A partir de ahi, el service solo trabaja con PaymentGatewayInterface, sin saber que adapter es

Metodos Principales

iniciarPago()

Proposito: Iniciar un pago online y obtener URL de checkout del gateway. Soporta seleccion libre de facturas con montos parciales.

Parametros:

  • portalUserId: ID del portal_user (extraido del JWT)
  • facturas: Array con las facturas a pagar [{id, monto}]monto puede ser parcial (<= factura.saldo)
  • total: Monto total a pagar (suma de los montos de las facturas seleccionadas)

Retorna:

json
{
  "payment_id": "uuid-payment-123",
  "redirect_url": "https://checkout.gateway.com/...",
  "payment_method": "paypertic"
}

La redirect_url proviene del adapter: en PagoTIC es el form_url, en MercadoPago seria el init_point. El service no conoce la diferencia.

Flujo:

  1. Resolver cliente_id desde portal_users.ordcon_id
  2. Obtener configuracion del gateway del tenant desde ini.sistema
  3. Validar que el tenant tenga un medio de pago configurado
  4. Crear adapter mediante PaymentGatewayFactory::create($gateway, $config)
  5. Validar facturas (existen, pertenecen al cliente)
  6. Validar montos parciales: cada monto debe ser > 0 y <= factura.saldo
  7. Validar que total coincide con suma de montos de facturas
  8. Generar payment_id unico (UUID)
  9. Construir PaymentRequest DTO con datos normalizados (payment_id, total, description, payer_email, webhook_url, return_url)
  10. Llamar adapter->createPayment($paymentRequest) → recibe external_id + redirect_url
  11. INSERT en portal_payments con estado pending, incluyendo gateway (nombre del adapter usado), tenant_id + sucursal_id, y facturas JSONB con los montos (parciales o completos) por factura
  12. Retornar URL de redireccion

Pago parcial: Cuando una factura se paga parcialmente (monto < saldo), el recibo creado por ReciboRelationsService cubre solo el monto parcial. El saldo restante queda como deuda pendiente en cuenta corriente. El campo facturas en portal_payments almacena el monto pagado por cada factura para trazabilidad.

procesarWebhook()

Proposito: Procesar notificaciones del gateway y acreditar automaticamente.

Parametros:

  • headers: Headers HTTP del webhook
  • body: Payload del webhook

Nota: No recibe gateway ni tenantContext como parametro. Ambos se resuelven internamente: el endpoint de webhook es gateway-agnostic (misma URL para todos los gateways). El service busca en portal_payments por external_id extraido del body, y de ahi obtiene el campo gateway (para saber que adapter usar) y tenant_id + sucursal_id (para resolver DB/schema).

Flujo de acreditacion automatica:

mermaid
flowchart TD
    A[Webhook recibido] --> B[Extraer external_id del body]
    B --> C[Buscar portal_payment por external_id]
    C -->|No encontrado| D[Ignorar - 200]
    C -->|Encontrado| E[Resolver gateway desde portal_payments.gateway]
    E --> F[Resolver tenant desde portal_payments.tenant_id + sucursal_id]
    F --> G[Crear adapter via PaymentGatewayFactory]
    G --> H[adapter.validateWebhook - validar firma]
    H -->|Invalida| I[Rechazar - 401]
    H -->|Valida| J[adapter.processWebhook - normalizar respuesta]
    J --> K{Status actual del payment?}
    K -->|Ya approved| L[Ignorar por idempotencia - 200]
    K -->|pending| M{Status del webhook?}
    M -->|approved| N[UPDATE status = approved]
    N --> O[Crear recibo via ReciboRelationsService]
    O --> P[UPDATE recibo_id en portal_payments]
    P --> Q[Responder 200]
    M -->|rejected| R[UPDATE status = rejected]
    R --> S[Guardar raw_response]
    S --> Q

La acreditacion es completamente automatica. Cuando el webhook confirma un pago aprobado:

  1. Se busca portal_payments por external_id → se obtiene gateway, tenant_id, sucursal_id
  2. Se crea el adapter via PaymentGatewayFactory::create(portal_payment.gateway, config)
  3. Se valida la firma con adapter->validateWebhook(headers, body)
  4. Se normaliza la respuesta con adapter->processWebhook(headers, body) → DTO con status, amount, etc.
  5. Se actualiza portal_payments.status = 'approved'
  6. Se llama a ReciboRelationsService::crearRecibo() con las facturas asociadas
  7. El recibo se crea en ordcta como si un operador lo hubiera creado desde el ERP
  8. Se vincula portal_payments.recibo_id con el recibo creado

NO hay cola manual ni revision por operador. El flujo es: webhook -> lookup -> adapter -> approved -> recibo -> listo.

getHistorial()

Proposito: Obtener historial de pagos del usuario autenticado.

Parametros:

  • portalUserId: ID del portal_user (extraido del JWT)

Retorna: Lista de pagos con su estado y recibo asociado:

json
[
  {
    "id": "uuid-payment-123",
    "fecha": "2026-01-20",
    "metodo": "online",
    "monto": 15000.00,
    "estado": "approved",
    "facturas_pagadas": [
      {"tipo": "Factura A", "numero": 123, "monto": 10000.00}
    ],
    "recibo_numero": "REC-00123"
  }
]

cancelarPago()

Proposito: Cancelar un pago pendiente que aun no fue aprobado por el gateway.

Parametros:

  • portalUserId: ID del portal_user (extraido del JWT)
  • paymentId: UUID del portal_payment a cancelar

Flujo:

  1. Buscar portal_payments por paymentId
  2. Validar que el pago pertenece al usuario autenticado
  3. Validar que el status sea pending (solo se pueden cancelar pagos pendientes)
  4. Obtener configuracion del gateway del tenant
  5. Crear adapter via PaymentGatewayFactory::create(portal_payment.gateway, config)
  6. Llamar adapter->cancelPayment(external_id) para notificar al gateway
  7. UPDATE portal_payments.status = 'cancelled'

Retorna:

json
{
  "success": true,
  "payment_id": "uuid-payment-123",
  "status": "cancelled"
}

devolverPago()

Proposito: Solicitar la devolucion (reembolso) de un pago ya aprobado.

Parametros:

  • portalUserId: ID del portal_user (extraido del JWT)
  • paymentId: UUID del portal_payment a devolver

Flujo:

  1. Buscar portal_payments por paymentId
  2. Validar que el pago pertenece al usuario autenticado
  3. Validar que el status sea approved (solo se pueden devolver pagos aprobados)
  4. Obtener configuracion del gateway del tenant
  5. Crear adapter via PaymentGatewayFactory::create(portal_payment.gateway, config)
  6. Llamar adapter->refundPayment(external_id) para procesar el reembolso en el gateway
  7. UPDATE portal_payments.status = 'refunded'
  8. Registrar la devolucion en cuenta corriente via ReciboRelationsService

Retorna:

json
{
  "success": true,
  "payment_id": "uuid-payment-123",
  "status": "refunded"
}

Interfaz del Adapter

Cada gateway debe implementar PaymentGatewayInterface:

php
interface PaymentGatewayInterface
{
    /** Crea un pago en el gateway. Retorna external_id + redirect_url. */
    public function createPayment(PaymentRequest $request): PaymentResponse;

    /** Valida la firma/autenticidad del webhook. */
    public function validateWebhook(array $headers, array $body): bool;

    /** Normaliza el payload del webhook a un DTO estandar. */
    public function processWebhook(array $headers, array $body): WebhookResult;

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

    /** Cancela un pago pendiente en el gateway. */
    public function cancelPayment(string $externalId): bool;

    /** Solicita reembolso de un pago aprobado en el gateway. */
    public function refundPayment(string $externalId): bool;
}

Todos los adapters implementan la misma interfaz. El service invoca estos metodos sin saber que gateway esta detras. Los DTOs (PaymentRequest, PaymentResponse, WebhookResult) normalizan las diferencias entre gateways.

createPayment(PaymentRequest)

PaymentRequest DTO (estandar para todos los adapters):

json
{
  "payment_id": "uuid-payment-123",
  "total": 15000.00,
  "description": "Pago de facturas - Portal Clientes",
  "payer_email": "juan@example.com",
  "webhook_url": "https://api.tenant.com/portal/pagos/webhook",
  "return_url": "https://portal.tenant.com/pagos/resultado"
}

PaymentResponse DTO:

json
{
  "external_id": "1234567890",
  "redirect_url": "https://checkout.gateway.com/..."
}

Cada adapter mapea estos campos a la API de su gateway. Ejemplo: PagoTIC retorna form_url como redirect_url; MercadoPago retornaria init_point.

processWebhook() → WebhookResult

WebhookResult DTO (salida normalizada, igual para todos los adapters):

json
{
  "status": "approved",
  "external_id": "1234567890",
  "amount": 15000.00,
  "payment_date": "2026-01-20T14:30:00Z",
  "raw_response": {}
}

Cada adapter parsea el payload nativo de su gateway y lo normaliza a este DTO.

Estados normalizados: pending, approved, rejected, refunded

Tabla portal_payments

sql
CREATE TABLE portal_payments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    portal_user_id INTEGER NOT NULL REFERENCES portal_users(id),
    external_id VARCHAR(255) NULL,
    gateway VARCHAR(50) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    amount DECIMAL(15,2) NOT NULL,
    facturas JSONB NOT NULL,
    recibo_id INTEGER NULL,
    raw_response JSONB NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_portal_payments_external ON portal_payments(external_id);
CREATE INDEX idx_portal_payments_user ON portal_payments(portal_user_id);

Esta tabla vive en el mismo schema que ordcon y portal_users.

Flujo Completo de Pago

mermaid
sequenceDiagram
    participant CL as Cliente
    participant FE as Frontend
    participant BE as Backend
    participant GW as Gateway
    participant DB as PostgreSQL

    CL->>FE: Selecciona facturas a pagar
    FE->>BE: POST /portal/pagos/iniciar (JWT + facturas)
    BE->>DB: INSERT portal_payments (pending)
    BE->>GW: createPayment()
    GW-->>BE: external_id + redirect_url
    BE->>DB: UPDATE portal_payments SET external_id
    BE-->>FE: redirect_url
    FE->>GW: Redirige al cliente
    CL->>GW: Completa el pago
    GW->>BE: POST /portal/pagos/webhook
    BE->>BE: Validar firma
    BE->>DB: UPDATE portal_payments SET status = approved
    BE->>DB: INSERT recibo en ordcta (via ReciboRelationsService)
    BE->>DB: UPDATE portal_payments SET recibo_id
    BE-->>GW: 200 OK
    CL->>FE: Vuelve al portal
    FE->>BE: GET /portal/pagos/historial
    BE-->>FE: Pago aprobado con recibo

Configuracion por Tenant

Cada tenant configura su gateway en ini.sistema:

CampoDescripcion
payment_gatewayNombre del gateway: mercadopago, pagotic, pagomiscuentas, none
payment_gateway_configJSON con credenciales y configuracion del gateway

Ejemplo de configuracion: Ejemplo PagoTIC:

json
{
  "api_key": "xxxxx",
  "secret_key": "xxxxx",
  "environment": "production",
  "commerce_id": "12345"
}

Ejemplo MercadoPago:

json
{
  "access_token": "APP_USR-xxxxx",
  "webhook_secret": "secret_key",
  "environment": "production"
}

Si payment_gateway es none o no esta configurado, el endpoint de iniciar pago retorna error GATEWAY_NOT_CONFIGURED.

Validaciones de Negocio

  • El tenant debe tener un gateway configurado (no none)
  • Las facturas deben existir y pertenecer al cliente autenticado
  • Cada monto debe ser > 0 y <= saldo de la factura (soporte de pago parcial)
  • El monto total debe coincidir con la suma de montos de facturas
  • El webhook debe tener firma valida (especifica por gateway)
  • No se puede procesar un pago que ya fue aprobado (idempotencia)
  • El recibo se crea como transaccion atomica con el update del payment
  • En pago parcial: el recibo cubre el monto parcial, el saldo restante queda pendiente

Casos de Prueba

  1. Iniciar pago exitoso: Crea payment pending y retorna redirect_url
  2. Gateway no configurado: Retorna error GATEWAY_NOT_CONFIGURED
  3. Monto no coincide: Retorna error MONTO_MISMATCH
  4. Monto parcial valido: Factura con saldo $10.000, monto $5.000 -> acepta, recibo por $5.000, saldo restante $5.000
  5. Monto parcial invalido (excede saldo): Factura con saldo $10.000, monto $15.000 -> rechaza INVALID_FACTURAS
  6. Monto parcial invalido (cero o negativo): monto <= 0 -> rechaza INVALID_FACTURAS
  7. Webhook aprobado: Actualiza status, crea recibo, vincula recibo_id
  8. Webhook rechazado: Actualiza status a rejected, guarda raw_response
  9. Webhook duplicado (idempotencia): Payment ya approved -> ignora
  10. Webhook con firma invalida: Rechaza con 401
  11. Historial: Retorna pagos del usuario con estado y recibo
  12. Cancelar pago pendiente: Llama adapter.cancelPayment(), actualiza status a cancelled
  13. Cancelar pago aprobado: Rechaza — solo se pueden cancelar pagos pendientes
  14. Devolver pago aprobado: Llama adapter.refundPayment(), actualiza status a refunded
  15. Devolver pago pendiente: Rechaza — solo se pueden devolver pagos aprobados
  16. Factory con gateway no soportado: Lanza GatewayNotSupportedException