Skip to content

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

MetodoEndpointDescripcion
POST/pagosCrear pago
POST/pagos/cancelar/{id}Cancelar pago (solo pending/issued)
POST/pagos/devolucion/{id}Devolucion de pago (tipo "online")
POST/pagos/agruparAgrupar 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:

CampoTipoRequeridoDescripcion
external_transaction_idStringSiReferencia unica del sistema (portal_payment.id)
currency_idStringSiMoneda ("ARS")
detailsArraySiItems del pago
details[].amountFloatSiMonto del item
details[].concept_idStringSiID del concepto
details[].concept_descriptionStringSiDescripcion del concepto
details[].external_referenceStringNoReferencia del item (factura_id)
payerObjectSiDatos del pagador
payer.nameStringSiNombre completo
payer.emailStringSiEmail
payer.identification.typeStringSi"DNI_ARG" o "CUIT_ARG"
payer.identification.numberStringSiNumero de documento
payer.identification.countryStringSi"ARG"
payer.external_referenceStringNoReferencia del pagador en el sistema
due_dateStringNoPrimer vencimiento (ISO 8601 con timezone)
last_due_dateStringNoUltimo vencimiento (ISO 8601 con timezone)
notification_urlStringSiURL del webhook
return_urlStringSiURL post-pago (recibe POST con datos del pago)
back_urlStringNoURL del boton "volver"
typeStringNo"debit", "online", "transfer", "debin", "coupon". Omitir para solo registrar y retornar URL de checkout
metadataObjectNoJSON 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"
}
CampoTipoDescripcion
idUUIDID de la transaccion en Pago TIC
form_urlStringURL del checkout para redirigir al cliente
final_amountFloatMonto total incluyendo comisiones
statusStringEstado 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

CodigoDescripcion
4000Request invalido
4001ID de pago invalido
4003Estado invalido para la operacion
4019Accion invalida
4035Devolucion no permitida
4050Parametro no permitido
4051Recaudador no relacionado
4060Parametro no encontrado
4100Acceso denegado
5001Error interno del servidor

Mapeo a la Interfaz Estandar

Campos

Campo EstandarPago TICNotas
checkoutUrlform_urlURL del checkout de Pago TIC
gatewayPaymentIdid (UUID)ID de la transaccion
externalIdexternal_transaction_idNuestra referencia (portal_payment.id)
itemsdetails[]Array de items/conceptos
items[].amountdetails[].amountMonto del item
items[].descriptiondetails[].concept_descriptionDescripcion del concepto
items[].referencedetails[].external_referenceReferencia del item
notificationUrlnotification_urlURL del webhook
returnUrlreturn_urlURL post-pago (recibe POST)
backUrlback_urlURL boton "volver"
payer.namepayer.nameNombre del pagador
payer.emailpayer.emailEmail del pagador
payer.dniCuitpayer.identification.numberNumero de documento
currencycurrency_id"ARS"
dueDatedue_dateISO 8601 con timezone
metadatametadataJSON libre

Estados

Estado EstandarPago TICAccion
PENDINGpendingEsperar pago del cliente
ISSUEDissuedPago emitido, esperando acreditacion
APPROVEDapprovedCrear recibo automatico en CtaCte
REJECTEDrejectedMarcar como rechazado
REFUNDEDrefundedRevertir acreditacion
CANCELLEDcancelledMarcar 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)
    end

Flujo 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"
    end

Flujo 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"
    end

Configuracion

Variables de Entorno

env
PAYPERTIC_BEARER_TOKEN=eyJhbGciOiJIUzI1NiIs...
PAYPERTIC_API_URL=https://api.paypertic.com

Configuracion 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/webhook

URL unica para el backend compartido. El tenant se resuelve via external_transaction_id -> portal_payments.external_id -> tenant_id + sucursal_id.

Resolucion de Tenant

  1. Pago TIC envia notificacion con external_transaction_id
  2. Backend busca en portal_payments por external_id
  3. La fila contiene tenant_id y sucursal_id (almacenados al crear el pago)
  4. Se resuelve la DB y el schema del tenant

Ver ADR-011 en la arquitectura general.

Diferencia con MercadoPago

AspectoPago TICMercadoPago
AutenticacionBearer token (JWT)HMAC-SHA256
Webhook payloadDatos completos del pagoSolo type + data.id (requiere consulta adicional)
return_urlRecibe POST con datos del pagoRedirige con query params
Tipo de documentoDNI_ARG, CUIT_ARGDNI
Estado ISSUEDSoportadoNo soportado
CancelacionAPI explicita (/pagos/cancelar/{id})Expiracion automatica
DevolucionAPI explicita (/pagos/devolucion/{id})SDK Refund

Manejo de Errores

Errores comunes y acciones

CodigoSituacionAccion del adapter
4000Request malformadoLoguear error, retornar error al frontend
4001ID de pago invalidoVerificar mapeo de IDs
4003Estado invalido (ej: cancelar un pago aprobado)Retornar CancelResult(success: false)
4035Devolucion no permitidaRetornar RefundResult(status: "rejected")
4100Acceso denegadoVerificar Bearer token
5001Error interno de Pago TICReintentar 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}/pagar

Diferencia 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) y final_amount
  • Recepcion de webhook con external_transaction_id y status
  • 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