Appearance
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:
| Gateway | Estado | Documentacion |
|---|---|---|
| Pago TIC (PayPerTIC) | Primer adapter | paypertic.md |
| MercadoPago | Planificado | mercadopago.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 --> FactoryInterfaz 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 Estandar | MercadoPago | Pago TIC |
|---|---|---|
checkoutUrl | init_point | form_url |
gatewayPaymentId | preference.id | id (UUID) |
externalId | external_reference | external_transaction_id |
items | items[] | details[] |
items[].amount | items[].unit_price | details[].amount |
items[].description | items[].title | details[].concept_description |
notificationUrl | notification_url | notification_url |
returnUrl | back_urls.success | return_url |
backUrl | back_urls.failure | back_url |
payer.name | payer.name | payer.name |
payer.email | payer.email | payer.email |
payer.dniCuit | payer.identification.number | payer.identification.number |
currency | currency_id | currency_id |
dueDate | N/A (no aplica en Checkout Pro) | due_date (ISO 8601) |
metadata | metadata | metadata |
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 Estandar | MercadoPago | Pago TIC |
|---|---|---|
PENDING | pending | pending |
ISSUED | N/A | issued |
APPROVED | approved | approved |
REJECTED | rejected | rejected |
REFUNDED | refunded | refunded |
CANCELLED | cancelled | cancelled |
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=payperticEsta 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:
| Gateway | Variables |
|---|---|
| Pago TIC | PAYPERTIC_BEARER_TOKEN, PAYPERTIC_API_URL |
| MercadoPago | MERCADOPAGO_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
endEndpoints
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 devolucionResolucion de Tenant en Webhooks (ADR-011)
El webhook llega al backend compartido sin JWT ni contexto de tenant. La resolucion funciona asi:
- El gateway envia una notificacion con el
external_id(que corresponde aexternal_transaction_iden Pago TIC oexternal_referenceen MercadoPago). - El backend busca en
portal_paymentsporexternal_id. - La fila contiene
tenant_idysucursal_id, almacenados al momento de crear el pago (cuando el usuario tenia JWT con contexto de tenant). - Con
tenant_idse resuelve la DB viaini.sistema; consucursal_idse resuelve el schema. - 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 checkoutprocessWebhook()— Normalizar la notificacion del gatewayvalidateWebhook()— Validar autenticidad (firma, token, etc.)getPaymentStatus()— Consultar estado actualcancelPayment()— Cancelar pago pendienterefundPayment()— 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_idantes de procesar (evitar doble acreditacion) - Credenciales seguras: En
ini.sistemaso 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
- Pago TIC (PayPerTIC) — Primer adapter implementado
- MercadoPago — Adapter planificado
- Integraciones — Vista general