Appearance
Adapter: Pago TIC (PayPerTIC)
Estado: Planificado (primer adapter a implementar)
Resumen
Adapter de Pago TIC que implementa la interfaz estandar PaymentGatewayAdapter. Primer gateway integrado al portal de clientes. Usa la API REST de PayPerTIC con autenticacion Bearer token (JWT).
Cuando el webhook confirma estado approved, el sistema crea el recibo en CtaCte automaticamente sin intervencion manual.
API de Pago TIC
Base URL: https://api.paypertic.com
Autenticacion: Bearer token (JWT) en header Authorization.
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...Endpoints
| Metodo | Endpoint | Descripcion |
|---|---|---|
POST | /pagos | Crear pago |
POST | /pagos/cancelar/{id} | Cancelar pago (solo pending/issued) |
POST | /pagos/devolucion/{id} | Devolucion de pago (tipo "online") |
POST | /pagos/agrupar | Agrupar multiples pagos |
Crear Pago (POST /pagos)
Request:
json
{
"external_transaction_id": "portal_payment_uuid",
"currency_id": "ARS",
"details": [
{
"amount": 5000.00,
"concept_id": "FAC-001",
"concept_description": "Factura A-0001-00001234",
"external_reference": "factura_uuid_1"
},
{
"amount": 10000.00,
"concept_id": "FAC-002",
"concept_description": "Factura A-0001-00001235",
"external_reference": "factura_uuid_2"
}
],
"payer": {
"name": "Juan Perez",
"email": "juan@ejemplo.com",
"identification": {
"type": "DNI_ARG",
"number": "12345678",
"country": "ARG"
},
"external_reference": "cliente_123"
},
"due_date": "2026-04-15T23:59:59-03:00",
"last_due_date": "2026-04-30T23:59:59-03:00",
"notification_url": "https://api.bautista.com/portal/pagos/webhook",
"return_url": "https://portal-tenant.ejemplo.com/pagar/exito",
"back_url": "https://portal-tenant.ejemplo.com/pagar",
"metadata": {
"tenant_id": "tenant_001",
"sucursal_id": "suc0001"
}
}Campos del request:
| Campo | Tipo | Requerido | Descripcion |
|---|---|---|---|
external_transaction_id | String | Si | Referencia unica del sistema (portal_payment.id) |
currency_id | String | Si | Moneda ("ARS") |
details | Array | Si | Items del pago |
details[].amount | Float | Si | Monto del item |
details[].concept_id | String | Si | ID del concepto |
details[].concept_description | String | Si | Descripcion del concepto |
details[].external_reference | String | No | Referencia del item (factura_id) |
payer | Object | Si | Datos del pagador |
payer.name | String | Si | Nombre completo |
payer.email | String | Si | |
payer.identification.type | String | Si | "DNI_ARG" o "CUIT_ARG" |
payer.identification.number | String | Si | Numero de documento |
payer.identification.country | String | Si | "ARG" |
payer.external_reference | String | No | Referencia del pagador en el sistema |
due_date | String | No | Primer vencimiento (ISO 8601 con timezone) |
last_due_date | String | No | Ultimo vencimiento (ISO 8601 con timezone) |
notification_url | String | Si | URL del webhook |
return_url | String | Si | URL post-pago (recibe POST con datos del pago) |
back_url | String | No | URL del boton "volver" |
type | String | No | "debit", "online", "transfer", "debin", "coupon". Omitir para solo registrar y retornar URL de checkout |
metadata | Object | No | JSON libre para datos adicionales |
Response:
json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"form_url": "https://checkout.paypertic.com/pay/550e8400...",
"final_amount": 15000.00,
"status": "pending"
}| Campo | Tipo | Descripcion |
|---|---|---|
id | UUID | ID de la transaccion en Pago TIC |
form_url | String | URL del checkout para redirigir al cliente |
final_amount | Float | Monto total incluyendo comisiones |
status | String | Estado inicial de la transaccion |
Cancelar Pago (POST /pagos/cancelar/{id})
Solo se pueden cancelar pagos en estado pending o issued.
Request:
json
{
"status_detail": "Cancelado por el usuario"
}Response: Estado actualizado del pago.
Devolucion (POST /pagos/devolucion/{id})
Solo para pagos aprobados de tipo "online".
Request:
json
{
"type": "online",
"status_detail": "Devolucion solicitada por el cliente",
"reason": "Error en facturacion",
"metadata": {
"motivo_interno": "ajuste_factura"
}
}Response:
json
{
"id": "refund_uuid",
"status": "approved",
"type": "online",
"amount": 15000.00,
"fee_details": {
"type": "refund_fee",
"amount": 150.00
}
}Agrupar Pagos (POST /pagos/agrupar)
Permite agrupar multiples pagos en una unica transaccion.
json
{
"payment_ids": ["uuid-1", "uuid-2", "uuid-3"]
}Codigos de Error
| Codigo | Descripcion |
|---|---|
| 4000 | Request invalido |
| 4001 | ID de pago invalido |
| 4003 | Estado invalido para la operacion |
| 4019 | Accion invalida |
| 4035 | Devolucion no permitida |
| 4050 | Parametro no permitido |
| 4051 | Recaudador no relacionado |
| 4060 | Parametro no encontrado |
| 4100 | Acceso denegado |
| 5001 | Error interno del servidor |
Mapeo a la Interfaz Estandar
Campos
| Campo Estandar | Pago TIC | Notas |
|---|---|---|
checkoutUrl | form_url | URL del checkout de Pago TIC |
gatewayPaymentId | id (UUID) | ID de la transaccion |
externalId | external_transaction_id | Nuestra referencia (portal_payment.id) |
items | details[] | Array de items/conceptos |
items[].amount | details[].amount | Monto del item |
items[].description | details[].concept_description | Descripcion del concepto |
items[].reference | details[].external_reference | Referencia del item |
notificationUrl | notification_url | URL del webhook |
returnUrl | return_url | URL post-pago (recibe POST) |
backUrl | back_url | URL boton "volver" |
payer.name | payer.name | Nombre del pagador |
payer.email | payer.email | Email del pagador |
payer.dniCuit | payer.identification.number | Numero de documento |
currency | currency_id | "ARS" |
dueDate | due_date | ISO 8601 con timezone |
metadata | metadata | JSON libre |
Estados
| Estado Estandar | Pago TIC | Accion |
|---|---|---|
PENDING | pending | Esperar pago del cliente |
ISSUED | issued | Pago emitido, esperando acreditacion |
APPROVED | approved | Crear recibo automatico en CtaCte |
REJECTED | rejected | Marcar como rechazado |
REFUNDED | refunded | Revertir acreditacion |
CANCELLED | cancelled | Marcar como cancelado |
Nota: Pago TIC soporta el estado ISSUED (a diferencia de MercadoPago). Esto indica que el pago fue emitido/registrado pero aun no se completo.
Implementacion del Adapter
createPayment()
Mapeo: PaymentRequest -> Pago TIC POST /pagos
php
public function createPayment(PaymentRequest $request): PaymentResponse
{
$payload = [
'external_transaction_id' => $request->externalId,
'currency_id' => $request->currency,
'details' => array_map(fn($item) => [
'amount' => $item['amount'],
'concept_id' => $item['reference'] ?? '',
'concept_description' => $item['description'],
'external_reference' => $item['reference'] ?? '',
], $request->items),
'payer' => [
'name' => $request->payer->name,
'email' => $request->payer->email,
'identification' => [
'type' => $this->resolveIdType($request->payer->dniCuit),
'number' => $request->payer->dniCuit,
'country' => 'ARG',
],
'external_reference' => $request->payer->externalReference,
],
'notification_url' => $request->notificationUrl,
'return_url' => $request->returnUrl,
'back_url' => $request->backUrl,
'metadata' => $request->metadata,
];
// Agregar vencimientos si estan presentes
if ($request->dueDate) {
$payload['due_date'] = $request->dueDate;
}
// Omitir 'type' para solo registrar y retornar URL de checkout
$response = $this->httpClient->post('/pagos', $payload);
return new PaymentResponse(
gatewayPaymentId: $response['id'], // UUID
checkoutUrl: $response['form_url'], // <-- form_url -> checkoutUrl
status: $response['status'],
finalAmount: $response['final_amount'],
);
}
/**
* Resuelve el tipo de identificacion segun la longitud del numero.
* DNI: 7-8 digitos. CUIT: 11 digitos.
*/
private function resolveIdType(string $dniCuit): string
{
return strlen(preg_replace('/\D/', '', $dniCuit)) === 11
? 'CUIT_ARG'
: 'DNI_ARG';
}processWebhook()
Mapeo: Webhook payload -> WebhookResult
Pago TIC envia la notificacion al notification_url con los datos del pago.
php
public function processWebhook(array $headers, array $body): WebhookResult
{
return new WebhookResult(
gatewayPaymentId: $body['id'],
externalId: $body['external_transaction_id'],
status: $this->mapStatus($body['status']),
amount: (float) ($body['final_amount'] ?? $body['amount'] ?? 0),
paymentDate: $body['payment_date'] ?? null,
rawResponse: $body,
);
}
private function mapStatus(string $gatewayStatus): PaymentStatus
{
return match ($gatewayStatus) {
'pending' => PaymentStatus::PENDING,
'issued' => PaymentStatus::ISSUED,
'approved' => PaymentStatus::APPROVED,
'rejected' => PaymentStatus::REJECTED,
'refunded' => PaymentStatus::REFUNDED,
'cancelled' => PaymentStatus::CANCELLED,
default => PaymentStatus::PENDING,
};
}validateWebhook()
Pago TIC usa Bearer token para autenticacion. La validacion verifica que el webhook proviene de un origen autorizado.
php
public function validateWebhook(array $headers, array $body): bool
{
// Validar que el webhook contiene los campos esperados
if (empty($body['id']) || empty($body['external_transaction_id'])) {
return false;
}
// Verificar que el external_transaction_id corresponde a un pago nuestro
// (validacion adicional en el servicio, no solo en el adapter)
return true;
}Nota: A diferencia de MercadoPago (que usa HMAC-SHA256), Pago TIC autentica las requests con Bearer token. La validacion del webhook se complementa con la verificacion de que el external_transaction_id existe en portal_payments.
getPaymentStatus()
php
public function getPaymentStatus(string $externalId): PaymentStatusResult
{
// Consultar estado actual del pago en Pago TIC
$response = $this->httpClient->get("/pagos/{$externalId}");
return new PaymentStatusResult(
status: $this->mapStatus($response['status']),
amount: (float) $response['final_amount'],
paymentDate: $response['payment_date'] ?? null,
rawResponse: $response,
);
}cancelPayment()
Solo se pueden cancelar pagos en estado pending o issued.
php
public function cancelPayment(string $externalId, string $reason): CancelResult
{
try {
$response = $this->httpClient->post("/pagos/cancelar/{$externalId}", [
'status_detail' => $reason,
]);
return new CancelResult(
success: true,
status: 'cancelled',
);
} catch (ApiException $e) {
// Error 4003: estado invalido para cancelar
if ($e->getCode() === 4003) {
return new CancelResult(
success: false,
status: 'error: pago no cancelable en estado actual',
);
}
throw $e;
}
}refundPayment()
Solo para pagos aprobados de tipo "online".
php
public function refundPayment(string $externalId, RefundRequest $request): RefundResult
{
$response = $this->httpClient->post("/pagos/devolucion/{$externalId}", [
'type' => $request->type ?? 'online',
'status_detail' => $request->reason ?? 'Devolucion solicitada',
'reason' => $request->reason,
'metadata' => $request->metadata,
]);
return new RefundResult(
refundId: $response['id'],
status: $response['status'], // "approved" o "rejected"
amount: (float) $response['amount'],
feeDetails: $response['fee_details'] ?? null,
);
}Flujo Completo
mermaid
sequenceDiagram
participant C as Cliente
participant FE as Frontend (Docker tenant)
participant BE as Backend (compartido)
participant PT as Pago TIC API
participant CC as CtaCte
C->>FE: Selecciona facturas, click "Pagar"
FE->>BE: POST /portal/pagos/iniciar
BE->>BE: PaymentGatewayFactory -> PagoTicAdapter
BE->>PT: POST /pagos (Bearer token)
PT-->>BE: {id (UUID), form_url, final_amount, status}
BE->>BE: Guardar portal_payments (status=PENDING)
BE-->>FE: {payment_id, redirect_url: form_url}
FE->>C: Redirige a form_url (checkout Pago TIC)
C->>PT: Completa pago en checkout
PT-->>C: POST a return_url con datos del pago
Note over PT,BE: Asincrono - webhook
PT->>BE: POST /portal/pagos/webhook (notification_url)
BE->>BE: validateWebhook()
BE->>BE: processWebhook() -> WebhookResult
alt status == approved
BE->>BE: Verificar idempotencia (external_transaction_id)
BE->>CC: Crear recibo automatico
BE->>BE: Actualizar portal_payments (status=APPROVED, recibo_id)
else status == rejected
BE->>BE: Actualizar portal_payments (status=REJECTED)
else status == issued
BE->>BE: Actualizar portal_payments (status=ISSUED, esperar)
endFlujo de Cancelacion
mermaid
sequenceDiagram
participant U as Usuario/Admin
participant BE as Backend
participant PT as Pago TIC API
U->>BE: POST /portal/pagos/{id}/cancelar
BE->>BE: Verificar estado actual (debe ser PENDING o ISSUED)
BE->>PT: POST /pagos/cancelar/{gateway_id}
PT-->>BE: Confirmacion de cancelacion
alt Cancelacion exitosa
BE->>BE: Actualizar portal_payments (status=CANCELLED)
else Error 4003: estado invalido
BE-->>U: "El pago no se puede cancelar en su estado actual"
endFlujo de Devolucion
mermaid
sequenceDiagram
participant U as Usuario/Admin
participant BE as Backend
participant PT as Pago TIC API
participant CC as CtaCte
U->>BE: POST /portal/pagos/{id}/devolver
BE->>BE: Verificar estado actual (debe ser APPROVED)
BE->>PT: POST /pagos/devolucion/{gateway_id}
PT-->>BE: {id, status, amount, fee_details}
alt Devolucion aprobada
BE->>BE: Actualizar portal_payments (status=REFUNDED)
BE->>CC: Revertir acreditacion en CtaCte
else Devolucion rechazada (4035)
BE-->>U: "La devolucion no fue aprobada por el gateway"
endConfiguracion
Variables de Entorno
env
PAYPERTIC_BEARER_TOKEN=eyJhbGciOiJIUzI1NiIs...
PAYPERTIC_API_URL=https://api.paypertic.comConfiguracion por Tenant
En ini.sistemas:
json
{
"payment_gateway": "paypertic",
"payment_gateway_config": {
"bearer_token": "eyJhbGciOiJIUzI1NiIs...",
"api_url": "https://api.paypertic.com",
"environment": "production"
}
}HTTP Client
Todas las llamadas a la API usan un HTTP client configurado con:
php
class PagoTicHttpClient
{
private string $baseUrl;
private string $bearerToken;
public function __construct(array $config)
{
$this->baseUrl = $config['api_url'] ?? 'https://api.paypertic.com';
$this->bearerToken = $config['bearer_token'];
}
public function post(string $endpoint, array $data): array
{
$response = Http::withHeaders([
'Authorization' => "Bearer {$this->bearerToken}",
'Content-Type' => 'application/json',
])->post("{$this->baseUrl}{$endpoint}", $data);
if ($response->failed()) {
throw new PagoTicApiException(
$response->json('message', 'Error desconocido'),
$response->json('code', 5001)
);
}
return $response->json();
}
}Webhook
URL del Webhook
https://api.bautista.com/portal/pagos/webhookURL unica para el backend compartido. El tenant se resuelve via external_transaction_id -> portal_payments.external_id -> tenant_id + sucursal_id.
Resolucion de Tenant
- Pago TIC envia notificacion con
external_transaction_id - Backend busca en
portal_paymentsporexternal_id - La fila contiene
tenant_idysucursal_id(almacenados al crear el pago) - Se resuelve la DB y el schema del tenant
Ver ADR-011 en la arquitectura general.
Diferencia con MercadoPago
| Aspecto | Pago TIC | MercadoPago |
|---|---|---|
| Autenticacion | Bearer token (JWT) | HMAC-SHA256 |
| Webhook payload | Datos completos del pago | Solo type + data.id (requiere consulta adicional) |
| return_url | Recibe POST con datos del pago | Redirige con query params |
| Tipo de documento | DNI_ARG, CUIT_ARG | DNI |
| Estado ISSUED | Soportado | No soportado |
| Cancelacion | API explicita (/pagos/cancelar/{id}) | Expiracion automatica |
| Devolucion | API explicita (/pagos/devolucion/{id}) | SDK Refund |
Manejo de Errores
Errores comunes y acciones
| Codigo | Situacion | Accion del adapter |
|---|---|---|
| 4000 | Request malformado | Loguear error, retornar error al frontend |
| 4001 | ID de pago invalido | Verificar mapeo de IDs |
| 4003 | Estado invalido (ej: cancelar un pago aprobado) | Retornar CancelResult(success: false) |
| 4035 | Devolucion no permitida | Retornar RefundResult(status: "rejected") |
| 4100 | Acceso denegado | Verificar Bearer token |
| 5001 | Error interno de Pago TIC | Reintentar con backoff |
Reintentos
Para errores 5001 (error interno), implementar retry con exponential backoff:
php
$maxRetries = 3;
$delay = 1; // segundos
for ($i = 0; $i < $maxRetries; $i++) {
try {
return $this->httpClient->post($endpoint, $data);
} catch (PagoTicApiException $e) {
if ($e->getCode() !== 5001 || $i === $maxRetries - 1) {
throw $e;
}
sleep($delay * pow(2, $i));
}
}URLs de Retorno
Las URLs se construyen usando la URL base de la instancia Docker del tenant:
return_url: {portal_url}/pagar/exito?payment_id=xxx
back_url: {portal_url}/pagarDiferencia importante: En Pago TIC, return_url recibe un POST con los datos del pago (no un simple redirect como en MercadoPago). El frontend debe manejar este POST para mostrar el resultado.
Testing
Sandbox
Pago TIC proporciona un ambiente sandbox para pruebas:
- URL base sandbox: Usar la URL de sandbox proporcionada por Pago TIC
- Bearer token sandbox: Token de prueba diferente al de produccion
Configurar en ini.sistemas:
json
{
"payment_gateway": "paypertic",
"payment_gateway_config": {
"bearer_token": "sandbox_token_xxx",
"api_url": "https://sandbox.paypertic.com",
"environment": "sandbox"
}
}Tests del Adapter
php
class PagoTicAdapterTest extends TestCase
{
public function testCreatePaymentMapsFieldsCorrectly()
{
// Verificar que PaymentRequest se traduce correctamente a payload de Pago TIC
// external_transaction_id, details[], payer.identification.type, etc.
}
public function testProcessWebhookNormalizesResponse()
{
// Verificar que el webhook se traduce a WebhookResult estandar
}
public function testMapStatusHandlesIssuedState()
{
// Pago TIC tiene estado 'issued' que MercadoPago no tiene
}
public function testCancelPaymentOnlyPendingOrIssued()
{
// Verificar que error 4003 se maneja correctamente
}
public function testRefundPaymentReturnsRefundResult()
{
// Verificar mapeo de respuesta de devolucion
}
public function testResolveIdTypeDniVsCuit()
{
// DNI: 7-8 digitos -> DNI_ARG
// CUIT: 11 digitos -> CUIT_ARG
}
public function testCreatePaymentOmitsTypeForCheckoutUrl()
{
// Omitir 'type' para que Pago TIC retorne form_url
}
}Tests de Integracion
php
class PagoTicIntegrationTest extends TestCase
{
public function testFlujoPagoCompletoSandbox()
{
// Crear pago -> obtener form_url -> simular webhook -> verificar recibo
}
public function testCancelarPagoPendiente()
{
// Crear pago -> cancelar -> verificar estado CANCELLED
}
public function testDevolucionPagoAprobado()
{
// Crear pago -> aprobar -> devolver -> verificar estado REFUNDED
}
public function testErrorCodesHandling()
{
// Verificar manejo de 4000, 4001, 4003, 4035, 4100, 5001
}
}Simular Webhook
bash
curl -X POST https://api.bautista.com/portal/pagos/webhook \
-H "Content-Type: application/json" \
-d '{
"id": "550e8400-e29b-41d4-a716-446655440000",
"external_transaction_id": "portal_payment_uuid",
"status": "approved",
"final_amount": 15000.00,
"payment_date": "2026-04-09T14:30:00-03:00"
}'Monitoreo
Logs importantes:
- Inicio de pago con
id(UUID) yfinal_amount - Recepcion de webhook con
external_transaction_idystatus - Creacion automatica de recibo con
recibo_id - Errores de API con codigo y mensaje (4000, 4001, 4003, etc.)
- Errores de autenticacion (4100 - verificar Bearer token)
- Reintentos por error interno (5001)
- Cancelaciones y devoluciones con resultado
Recursos
- API Pago TIC:
https://api.paypertic.com - Documentacion: Proporcionada por PayPerTIC al momento de la integracion
Ver tambien
- Arquitectura de Medios de Pago -- Interfaz estandar y patron Adapter
- MercadoPago -- Adapter alternativo (planificado)