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, el webhook actualiza portal_payments; la acreditacion contable en cuenta corriente queda para reconciliacion manual.
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)
|
+-- 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 pago total obligatorio por cada factura seleccionada.
Parametros:
portalUserId: ID del portal_user (extraido del JWT)facturas: Array con las facturas a pagar[{id, monto}]—montodebe coincidir con el total pendiente de cada factura (deuda.debe)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: cada
montodebe ser > 0, no excederfactura.saldoy coincidir condeuda.debedentro de una tolerancia de0.001 - 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 completos por factura - Retornar URL de redireccion
Pago parcial prohibido: Si el request intenta iniciar un pago con monto < deuda.debe, el dominio rechaza la operacion con PartialPaymentNotAllowedException y el controller responde HTTP 400 PARTIAL_PAYMENT_NOT_ALLOWED. La UI no expone ningun input para editar el monto por factura.
procesarWebhook()
Proposito: Procesar notificaciones del gateway y actualizar el estado del pago portal.
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 actualizacion de estado:
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 --> Q[Responder 200]
M -->|rejected| R[UPDATE status = rejected]
R --> S[Guardar raw_response]
S --> QCuando 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' - No se crea recibo en
ordctadentro del webhook - La reconciliacion y creacion del recibo se realiza luego desde la vista operativa de pagos portal
El flujo del webhook es: webhook -> lookup -> adapter -> status final en portal_payments. La acreditacion contable queda fuera de este servicio.
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/backend/portal/pagos/webhook?tenant_id=7&sucursal_id=3&token=webhook-token",
"return_url": "https://portal.tenant.com/pagar/resultado?payment_id=uuid-payment-123"
}return_url se construye desde el header Origin del request del navegador. notification_url/webhook_url se construye desde BACKEND_URL más tenant_id, sucursal_id y webhook_token. Ninguna de las dos URLs puede enviarse vacía al gateway.
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 /backend/portal/pagos/webhook?tenant_id=...&sucursal_id=...&token=...
BE->>BE: Validar firma
BE->>DB: UPDATE portal_payments SET status = approved
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
- Cada monto debe coincidir con el saldo pendiente completo de la factura; el portal no acepta pagos parciales por factura
- El monto total debe coincidir con la suma de montos de facturas
Origindebe estar presente para construir la URL de retorno al portalBACKEND_URLdebe estar configurada para construir la URL publica del webhook- 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
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 rechazado: Factura con saldo $10.000, monto $5.000 -> rechaza PARTIAL_PAYMENT_NOT_ALLOWED
- Monto invalido (excede saldo): Factura con saldo $10.000, monto $15.000 -> rechaza INVALID_FACTURAS
- Monto invalido (cero o negativo): monto <= 0 -> rechaza INVALID_FACTURAS
- Origin ausente: Rechaza MISSING_ORIGIN antes de llamar al gateway
- BACKEND_URL ausente: Rechaza MISCONFIGURED con HTTP 500 antes de llamar al gateway
- Webhook aprobado: Actualiza status en
portal_payments; no crea recibo automaticamente - 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