Skip to content

Adapter: MercadoPago

Estado: Planificado

Resumen

Adapter de MercadoPago Checkout Pro que implementa la interfaz estandar PaymentGatewayAdapter. Utiliza el SDK oficial mercadopago/dx-php para interactuar con la API de MercadoPago.

Cuando el webhook confirma estado approved, el sistema crea el recibo en CtaCte automaticamente sin intervencion manual.

Configuracion

1. Obtener Credenciales

  1. Registrarse en MercadoPago
  2. Ir a Tus integraciones -> Credentials
  3. Obtener:
    • Access Token (produccion)
    • Access Token de prueba (sandbox)
    • Webhook Secret (para validar webhooks)

2. Instalar SDK

bash
composer require mercadopago/dx-php

3. Variables de Entorno

env
MERCADOPAGO_ACCESS_TOKEN=APP_USR-xxx
MERCADOPAGO_WEBHOOK_SECRET=secret

4. Configurar Tenant

En ini.sistemas:

json
{
  "payment_gateway": "mercadopago",
  "payment_gateway_config": {
    "access_token": "APP_USR-xxx",
    "webhook_secret": "secret",
    "environment": "production"
  }
}

Mapeo a la Interfaz Estandar

Campos

Campo EstandarMercadoPagoNotas
checkoutUrlinit_pointURL del Checkout Pro
gatewayPaymentIdpreference.idID de la preferencia
externalIdexternal_referenceNuestra referencia (portal_payment.id)
itemsitems[]Array de items de la preferencia
items[].amountitems[].unit_pricePrecio unitario
items[].descriptionitems[].titleTitulo del item
notificationUrlnotification_urlURL del webhook
returnUrlback_urls.successURL post-pago exitoso
backUrlback_urls.failureURL post-pago fallido
payer.namepayer.nameNombre del pagador
payer.emailpayer.emailEmail del pagador
payer.dniCuitpayer.identification.numberDocumento del pagador
currencycurrency_id"ARS"
metadatametadataJSON libre

Estados

Estado EstandarMercadoPagoAccion
PENDINGpendingEsperar siguiente webhook
ISSUEDN/AMercadoPago no usa este estado
APPROVEDapprovedCrear recibo automatico en CtaCte
REJECTEDrejectedMarcar como rechazado
REFUNDEDrefundedRevertir acreditacion
CANCELLEDcancelledMarcar como cancelado

Implementacion del Adapter

createPayment()

Mapeo: PaymentRequest -> MercadoPago Preference

php
public function createPayment(PaymentRequest $request): PaymentResponse
{
    // 1. Crear Preference del SDK
    $preference = new Preference();
    $preference->items = [
        [
            'title'       => $request->items[0]['description'],
            'quantity'     => 1,
            'unit_price'   => $request->amount,
            'currency_id'  => $request->currency,
        ]
    ];

    // 2. URLs de retorno (relativas a la instancia Docker del tenant)
    $preference->back_urls = [
        'success' => $request->returnUrl,
        'failure' => $request->backUrl,
        'pending' => str_replace('/exito', '/pendiente', $request->returnUrl),
    ];

    // 3. Referencia y notificacion
    $preference->external_reference = $request->externalId;
    $preference->notification_url   = $request->notificationUrl;

    // 4. Datos del pagador
    $preference->payer = [
        'name'  => $request->payer->name,
        'email' => $request->payer->email,
        'identification' => [
            'type'   => 'DNI',
            'number' => $request->payer->dniCuit,
        ],
    ];

    // 5. Guardar preferencia
    $result = $preference->save();

    // 6. Mapear a PaymentResponse estandar
    return new PaymentResponse(
        gatewayPaymentId: $result->id,
        checkoutUrl:      $result->init_point,    // <-- init_point -> checkoutUrl
        status:           'pending',
        finalAmount:      $request->amount,
    );
}

processWebhook()

Mapeo: Webhook payload -> WebhookResult

MercadoPago envia un payload minimo con type y data.id. El adapter debe consultar la API para obtener el detalle completo.

php
public function processWebhook(array $headers, array $body): WebhookResult
{
    // 1. Verificar tipo de notificacion
    if ($body['type'] !== 'payment') {
        throw new \InvalidArgumentException('Tipo de webhook no soportado');
    }

    // 2. Obtener detalle del pago desde la API
    $paymentId = $body['data']['id'];
    $payment = MercadoPago\Payment::find_by_id($paymentId);

    // 3. Mapear a WebhookResult estandar
    return new WebhookResult(
        gatewayPaymentId: (string) $payment->id,
        externalId:       $payment->external_reference,  // <-- nuestra referencia
        status:           $this->mapStatus($payment->status),
        amount:           (float) $payment->transaction_amount,
        paymentDate:      $payment->date_approved,
        rawResponse:      (array) $payment,
    );
}

validateWebhook()

MercadoPago usa firma HMAC-SHA256 con un formato de manifest especifico.

php
public function validateWebhook(array $headers, array $body): bool
{
    // 1. Extraer headers
    $signature = $headers['x-signature'] ?? '';
    $requestId = $headers['x-request-id'] ?? '';

    // 2. Parsear signature (formato: "ts=123456,v1=hash...")
    $parts = [];
    foreach (explode(',', $signature) as $part) {
        [$key, $value] = explode('=', $part, 2);
        $parts[$key] = $value;
    }

    // 3. Construir manifest
    $dataId = $body['data']['id'] ?? '';
    $manifest = "id:{$dataId};request-id:{$requestId};ts:{$parts['ts']};";

    // 4. Calcular HMAC-SHA256
    $computed = hash_hmac('sha256', $manifest, $this->webhookSecret);

    // 5. Comparar
    return hash_equals($computed, $parts['v1']);
}

Formato del header x-signature:

ts=1234567890,v1=abcdef1234567890...

Manifest:

id:{data.id};request-id:{x-request-id};ts:{timestamp};

getPaymentStatus()

php
public function getPaymentStatus(string $externalId): PaymentStatusResult
{
    $payment = MercadoPago\Payment::find_by_id($externalId);

    return new PaymentStatusResult(
        status:      $this->mapStatus($payment->status),
        amount:      (float) $payment->transaction_amount,
        paymentDate: $payment->date_approved,
        rawResponse: (array) $payment,
    );
}

cancelPayment()

MercadoPago no soporta cancelacion directa de preferencias. La preferencia simplemente expira.

php
public function cancelPayment(string $externalId, string $reason): CancelResult
{
    // MercadoPago: las preferencias expiran automaticamente.
    // Se puede actualizar la preferencia para deshabilitarla.
    return new CancelResult(
        success: true,
        status:  'cancelled',
    );
}

refundPayment()

php
public function refundPayment(string $externalId, RefundRequest $request): RefundResult
{
    $refund = new MercadoPago\Refund();
    $refund->payment_id = $externalId;
    $refund->save();

    return new RefundResult(
        refundId: (string) $refund->id,
        status:   $refund->status,    // "approved" o "rejected"
        amount:   (float) $refund->amount,
        feeDetails: null,
    );
}

Flujo Completo

mermaid
sequenceDiagram
    participant C as Cliente
    participant FE as Frontend (Docker tenant)
    participant BE as Backend (compartido)
    participant MP as MercadoPago API
    participant CC as CtaCte

    C->>FE: Selecciona facturas, click "Pagar"
    FE->>BE: POST /portal/pagos/iniciar
    BE->>BE: PaymentGatewayFactory -> MercadoPagoAdapter

    BE->>MP: Crear Preference (SDK)
    MP-->>BE: preference_id + init_point
    BE->>BE: Guardar portal_payments (status=PENDING)
    BE-->>FE: {payment_id, redirect_url: init_point}
    FE->>C: Redirige a MercadoPago Checkout Pro

    C->>MP: Completa pago
    MP-->>C: Redirige a back_urls.success

    Note over MP,BE: Asincrono - webhook

    MP->>BE: POST /portal/pagos/webhook
    BE->>BE: validateWebhook() - HMAC-SHA256
    BE->>MP: GET /v1/payments/{id}
    MP-->>BE: status, amount, date, external_reference
    BE->>BE: processWebhook() -> WebhookResult

    alt status == approved
        BE->>BE: Verificar idempotencia (external_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)
    end

Configurar Webhook en MercadoPago

  1. Panel MercadoPago -> Tus integraciones -> Webhooks
  2. URL: https://api.bautista.com/portal/pagos/webhook
  3. Eventos a escuchar:
    • payment.created
    • payment.updated

La URL del webhook apunta al backend compartido, no a la instancia Docker del tenant. Un solo webhook endpoint procesa pagos de todos los tenants.

Resolucion de Tenant

El webhook no tiene JWT ni contexto de tenant. La resolucion se hace via portal_payments:

  1. MercadoPago envia external_reference (nuestro payment_id)
  2. Se busca en portal_payments por external_id
  3. La fila contiene tenant_id y sucursal_id
  4. Con esos datos se resuelve la DB y el schema del tenant

Ver ADR-011 en la arquitectura general.

URLs de Retorno

Las URLs se construyen usando la URL base de la instancia Docker del tenant:

success:  {portal_url}/pagar/exito?payment_id=xxx
failure:  {portal_url}/pagar/error?payment_id=xxx
pending:  {portal_url}/pagar/pendiente?payment_id=xxx

MercadoPago usa back_urls con tres destinos separados (success, failure, pending), a diferencia de otros gateways que pueden usar una unica return_url.

Particularidades

Timeout de Webhook

MercadoPago espera respuesta en menos de 5 segundos. Si el procesamiento es lento:

  1. Responder 200 OK inmediatamente
  2. Procesar webhook (creacion de recibo) en background

SDK vs API directa

Este adapter usa el SDK oficial mercadopago/dx-php en lugar de llamadas HTTP directas. El SDK maneja autenticacion, serialization y manejo de errores.

Preferencias vs Pagos

MercadoPago distingue entre:

  • Preference (preferencia): la intencion de pago, creada por el adapter
  • Payment (pago): el pago real, creado por MercadoPago cuando el cliente paga

El init_point pertenece a la preferencia. Los webhooks reportan sobre pagos.

Testing

Ambiente Sandbox

Usar Access Token de prueba en lugar del de produccion.

Tarjetas de prueba:

TarjetaNumeroResultado
Visa4509 9535 6623 3704Aprobado
Mastercard5031 7557 3453 0604Rechazado

CVV: 123 | Vencimiento: 11/25

Simular Webhook

bash
curl -X POST https://api.bautista.com/portal/pagos/webhook \
  -H "Content-Type: application/json" \
  -H "x-signature: ts=123456,v1=hash_simulado" \
  -H "x-request-id: test-id" \
  -d '{"type": "payment", "data": {"id": "123456"}}'

Verificar Idempotencia

  1. Enviar webhook con status approved
  2. Verificar creacion de recibo en CtaCte
  3. Enviar mismo webhook nuevamente
  4. Verificar que NO se crea recibo duplicado

Monitoreo

Logs importantes:

  • Inicio de pago con preference_id y monto
  • Recepcion de webhook con payment_id y estado
  • Creacion automatica de recibo con recibo_id
  • Validacion fallida de webhook (posible ataque)
  • Errores de comunicacion con API de MercadoPago
  • Errores de idempotencia (intento de doble acreditacion)

Recursos Oficiales

Ver tambien