Appearance
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
PaymentGatewayInterfacecon metodos estandarizados PaymentGatewayFactorylee 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:
- El service lee
ini.sistema.payment_gatewaydel tenant → obtiene el nombre del gateway (ej:paypertic) - El service lee
ini.sistema.payment_gateway_config→ obtiene las credenciales como array - Llama a
PaymentGatewayFactory::create($gateway, $config)→ recibe un adapter listo para usar - 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}]—montopuede 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:
- Resolver
cliente_iddesdeportal_users.ordcon_id - Obtener configuracion del gateway del tenant desde
ini.sistema - Validar que el tenant tenga un medio de pago configurado
- Crear adapter mediante
PaymentGatewayFactory::create($gateway, $config) - Validar facturas (existen, pertenecen al cliente)
- Validar montos parciales: cada
montodebe ser > 0 y <=factura.saldo - Validar que total coincide con suma de montos de facturas
- Generar
payment_idunico (UUID) - Construir
PaymentRequestDTO con datos normalizados (payment_id, total, description, payer_email, webhook_url, return_url) - Llamar
adapter->createPayment($paymentRequest)→ recibeexternal_id+redirect_url - INSERT en
portal_paymentscon estadopending, incluyendogateway(nombre del adapter usado),tenant_id+sucursal_id, yfacturasJSONB con los montos (parciales o completos) por factura - 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 webhookbody: 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 --> QLa acreditacion es completamente automatica. Cuando el webhook confirma un pago aprobado:
- Se busca
portal_paymentsporexternal_id→ se obtienegateway,tenant_id,sucursal_id - Se crea el adapter via
PaymentGatewayFactory::create(portal_payment.gateway, config) - Se valida la firma con
adapter->validateWebhook(headers, body) - Se normaliza la respuesta con
adapter->processWebhook(headers, body)→ DTO con status, amount, etc. - Se actualiza
portal_payments.status = 'approved' - Se llama a
ReciboRelationsService::crearRecibo()con las facturas asociadas - El recibo se crea en
ordctacomo si un operador lo hubiera creado desde el ERP - Se vincula
portal_payments.recibo_idcon 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:
- Buscar
portal_paymentsporpaymentId - Validar que el pago pertenece al usuario autenticado
- Validar que el status sea
pending(solo se pueden cancelar pagos pendientes) - Obtener configuracion del gateway del tenant
- Crear adapter via
PaymentGatewayFactory::create(portal_payment.gateway, config) - Llamar
adapter->cancelPayment(external_id)para notificar al gateway - 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:
- Buscar
portal_paymentsporpaymentId - Validar que el pago pertenece al usuario autenticado
- Validar que el status sea
approved(solo se pueden devolver pagos aprobados) - Obtener configuracion del gateway del tenant
- Crear adapter via
PaymentGatewayFactory::create(portal_payment.gateway, config) - Llamar
adapter->refundPayment(external_id)para procesar el reembolso en el gateway - UPDATE
portal_payments.status = 'refunded' - 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 reciboConfiguracion por Tenant
Cada tenant configura su gateway en ini.sistema:
| Campo | Descripcion |
|---|---|
payment_gateway | Nombre del gateway: mercadopago, pagotic, pagomiscuentas, none |
payment_gateway_config | JSON 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
- Iniciar pago exitoso: Crea payment pending y retorna redirect_url
- Gateway no configurado: Retorna error GATEWAY_NOT_CONFIGURED
- Monto no coincide: Retorna error MONTO_MISMATCH
- Monto parcial valido: Factura con saldo $10.000, monto $5.000 -> acepta, recibo por $5.000, saldo restante $5.000
- Monto parcial invalido (excede saldo): Factura con saldo $10.000, monto $15.000 -> rechaza INVALID_FACTURAS
- Monto parcial invalido (cero o negativo): monto <= 0 -> rechaza INVALID_FACTURAS
- Webhook aprobado: Actualiza status, crea recibo, vincula recibo_id
- Webhook rechazado: Actualiza status a rejected, guarda raw_response
- Webhook duplicado (idempotencia): Payment ya approved -> ignora
- Webhook con firma invalida: Rechaza con 401
- Historial: Retorna pagos del usuario con estado y recibo
- Cancelar pago pendiente: Llama adapter.cancelPayment(), actualiza status a cancelled
- Cancelar pago aprobado: Rechaza — solo se pueden cancelar pagos pendientes
- Devolver pago aprobado: Llama adapter.refundPayment(), actualiza status a refunded
- Devolver pago pendiente: Rechaza — solo se pueden devolver pagos aprobados
- Factory con gateway no soportado: Lanza GatewayNotSupportedException