Crear Páginas Web con Lógica de NegocioBeta

0%
Bloque Técnico-Operativo

Módulo 04

Pagos en línea para Latinoamérica

2.5hWompiMercado PagongrokNext.js

Wompi, Mercado Pago, PayU y Stripe comparadas; integración completa con checkout, firma de integridad, webhooks verificados y máquina de estados del pago.

Recursos descargables

Guía de Pasarelas de Pago LatAm

Tabla de decisión por país, fichas de Wompi/Mercado Pago/PayU/Stripe, checklist de webhooks y máquina de estados de referencia

Markdown
En este módulo
01
El mapa de pagos en Latinoamérica Qué pasarela elegir según tu país y cómo pagan realmente tus clientes

El 20% de los clientes de Julián reservaba y no llegaba. Desde que la reserva exige un anticipo de $10.000 COP, la inasistencia bajó a casi cero: quien paga, llega. Pero hay un detalle que lo cambia todo: la mitad de sus clientes no tiene tarjeta de crédito — pagan con Nequi o PSE.

En Latinoamérica, aceptar pagos no es "poner Stripe": es aceptar los métodos locales con los que tu cliente paga de verdad.

1.1. Los métodos de pago que importan en la región

Cada país tiene sus métodos dominantes. Una pasarela sirve en tu país si soporta los de tu cliente:

PaísMétodos locales clavePasarelas fuertes
ColombiaPSE, Nequi, Daviplata, tarjetas, efectivo (Efecty)Wompi, Mercado Pago, PayU
MéxicoSPEI, OXXO (efectivo), tarjetasMercado Pago, Stripe, Conekta
ArgentinaMercado Pago (dominante), transferencia, RapipagoMercado Pago, PayU
PerúYape, Plin, PagoEfectivo, tarjetasMercado Pago, Culqi, PayU
ChileWebpay, tarjetas, transferenciaTransbank, Mercado Pago
BrasilPix (dominante), boleto, tarjetasMercado Pago, Stripe, PagSeguro

1.2. Las 4 pasarelas del curso, comparadas

Colombia
Wompi
De: Bancolombia
Fuerte en: PSE, Nequi, Bancolombia a la mano
Ideal si: tu negocio y tus clientes están en Colombia
Modo prueba: sandbox completo sin papeleo
Regional
Mercado Pago
De: Mercado Libre
Fuerte en: AR, MX, BR, CL, CO, PE, UY
Ideal si: vendes en varios países del cono sur o México
Extra: tus clientes ya tienen la app instalada
Regional
PayU
De: Grupo Prosus
Fuerte en: CO, MX, AR, PE, CL + efectivo local
Ideal si: necesitas muchos métodos de efectivo
Nota: integración más antigua pero muy probada
Global
Stripe
De: EE.UU.
Fuerte en: tarjetas internacionales, suscripciones, MX y BR
Ideal si: cobras en dólares a clientes globales
Límite: no opera local en varios países andinos
El patrón es el mismo en todas

Toda integración de pagos moderna sigue 3 pasos: 1) creas una transacción con referencia única y rediriges al checkout de la pasarela, 2) la pasarela te notifica el resultado por webhook, 3) tu backend actualiza el estado del pedido/cita. Aprende el patrón con Wompi y sabrás integrarlas todas.

02
Integración de Wompi paso a paso El anticipo de ReservaYa cobrado con checkout redirect y firma de integridad

2.1. Llaves y modo sandbox

Crea tu cuenta en comercios.wompi.co. En el modo sandbox obtienes llaves de prueba: puedes simular pagos aprobados y rechazados con tarjetas de test, sin dinero real ni papeleo.

# .env.local — agrega las llaves de Wompi (sandbox)
NEXT_PUBLIC_WOMPI_PUBLIC_KEY=pub_test_xxxxxxxxxxxxx
WOMPI_INTEGRITY_SECRET=test_integrity_xxxxxxxxxxxxx
WOMPI_EVENTS_SECRET=test_events_xxxxxxxxxxxxx
Solo la llave pública lleva NEXT_PUBLIC_

Las variables con prefijo NEXT_PUBLIC_ se incrustan en el JavaScript del navegador — cualquiera las ve. Los secretos de integridad y eventos nunca llevan ese prefijo: solo viven en el servidor.

2.2. La tabla de pagos y la referencia única

Cada intento de pago se registra antes de enviar al cliente al checkout. La referencia conecta tu mundo con el de la pasarela:

-- SQL Editor: la tabla que registra cada intento de pago
create table public.pagos (
  id uuid primary key default gen_random_uuid(),
  cita_id uuid not null references public.citas(id),
  referencia text not null unique,          -- ej: RSV-a1b2c3d4
  monto_cop integer not null check (monto_cop > 0),
  estado text not null default 'PENDIENTE'
    check (estado in ('PENDIENTE','APROBADO','RECHAZADO','ANULADO','ERROR')),
  wompi_transaccion_id text,                -- id que asigna Wompi
  creado_en timestamptz not null default now(),
  actualizado_en timestamptz not null default now()
);

alter table public.pagos enable row level security;
create policy "mis pagos" on public.pagos for select
  using (exists (select 1 from public.citas c
                 where c.id = cita_id and c.cliente_id = auth.uid()));

Wompi exige una firma SHA-256 de referencia + monto + moneda + secreto. Así nadie puede alterar el monto en el camino:

// app/pagar/actions.ts
"use server";

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import crypto from "crypto";

const ANTICIPO_COP = 10_000; // regla de negocio: anticipo fijo de $10.000

export async function iniciarPago(citaId: string) {
  const supabase = await createClient();

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  // La cita debe existir, ser del usuario y estar pendiente de pago
  const { data: cita } = await supabase
    .from("citas")
    .select("id, estado")
    .eq("id", citaId)
    .eq("cliente_id", user.id)
    .single();
  if (!cita || cita.estado !== "pendiente_pago") redirect("/mis-citas");

  // Referencia única: conecta el pago de Wompi con nuestra cita
  const referencia = `RSV-${crypto.randomUUID().slice(0, 8)}-${Date.now()}`;
  const montoEnCentavos = ANTICIPO_COP * 100;

  await supabase.from("pagos").insert({
    cita_id: cita.id,
    referencia,
    monto_cop: ANTICIPO_COP,
  });

  // Firma de integridad: SHA256(referencia + monto + moneda + secreto)
  const firma = crypto
    .createHash("sha256")
    .update(
      `${referencia}${montoEnCentavos}COP${process.env.WOMPI_INTEGRITY_SECRET}`
    )
    .digest("hex");

  // Checkout redirect: el cliente paga en la página segura de Wompi
  const checkout = new URL("https://checkout.wompi.co/p/");
  checkout.searchParams.set("public-key", process.env.NEXT_PUBLIC_WOMPI_PUBLIC_KEY!);
  checkout.searchParams.set("currency", "COP");
  checkout.searchParams.set("amount-in-cents", String(montoEnCentavos));
  checkout.searchParams.set("reference", referencia);
  checkout.searchParams.set("signature:integrity", firma);
  checkout.searchParams.set("redirect-url", "http://localhost:3000/pagar/resultado");

  redirect(checkout.toString());
}
⚙ ¿Por qué checkout redirect y no formulario propio?

Al redirigir a la página de la pasarela, los datos de la tarjeta nunca tocan tu servidor — la certificación de seguridad (PCI-DSS) es problema de Wompi, no tuyo. Para un negocio pequeño o mediano, esta es siempre la opción correcta. Los formularios de tarjeta embebidos son para empresas con equipo de seguridad dedicado.

03
Webhooks: la fuente de verdad del pago Por qué nunca confías en el navegador para confirmar un cobro

Cuando el cliente termina de pagar, Wompi lo devuelve a tu redirect-url. Pero ese regreso no confirma nada: el cliente puede cerrar la pestaña antes, perder la conexión, o fabricar la URL de resultado. La confirmación real llega por otro canal: el webhook, una petición que Wompi hace directamente de servidor a servidor.

Confirmar por el navegador

El cliente cierra la pestaña → nunca marcas el pago

El cliente edita la URL → marcas pagado sin cobrar

Se cae el WiFi del cliente → estado inconsistente

Confirmar por webhook

Wompi te avisa servidor a servidor, pase lo que pase en el navegador

El evento viene firmado: imposible de falsificar

Si tu servidor estaba caído, Wompi reintenta

3.1. El endpoint del webhook

// app/api/webhooks/wompi/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import crypto from "crypto";

// Cliente con service_role: el webhook no tiene sesión de usuario
const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // solo en servidor, jamás NEXT_PUBLIC
);

export async function POST(request: Request) {
  const evento = await request.json();

  // 1. VERIFICAR LA FIRMA: ¿esto realmente lo envió Wompi?
  const tx = evento.data?.transaction;
  const props: string[] = evento.signature?.properties ?? [];
  const concatenado =
    props.map((p) => p.split(".").reduce((o: any, k: string) => o?.[k], evento.data)).join("") +
    evento.timestamp +
    process.env.WOMPI_EVENTS_SECRET;

  const checksum = crypto.createHash("sha256").update(concatenado).digest("hex");
  if (checksum !== evento.signature?.checksum) {
    return NextResponse.json({ error: "firma inválida" }, { status: 401 });
  }

  // 2. IDEMPOTENCIA: si ya procesamos esta transacción, respondemos OK y listo
  const { data: pago } = await supabaseAdmin
    .from("pagos")
    .select("id, cita_id, estado, monto_cop")
    .eq("referencia", tx.reference)
    .single();

  if (!pago) return NextResponse.json({ ok: true });        // referencia ajena
  if (pago.estado !== "PENDIENTE") return NextResponse.json({ ok: true }); // ya procesado

  // 3. VALIDAR EL MONTO: ¿pagaron lo que era?
  if (tx.amount_in_cents !== pago.monto_cop * 100) {
    await supabaseAdmin.from("pagos")
      .update({ estado: "ERROR", wompi_transaccion_id: tx.id })
      .eq("id", pago.id);
    return NextResponse.json({ ok: true });
  }

  // 4. APLICAR LA LÓGICA DE NEGOCIO según el estado de Wompi
  const mapa: Record<string, string> = {
    APPROVED: "APROBADO", DECLINED: "RECHAZADO",
    VOIDED: "ANULADO", ERROR: "ERROR",
  };
  const nuevoEstado = mapa[tx.status] ?? "ERROR";

  await supabaseAdmin.from("pagos")
    .update({
      estado: nuevoEstado,
      wompi_transaccion_id: tx.id,
      actualizado_en: new Date().toISOString(),
    })
    .eq("id", pago.id);

  // Pago aprobado → la cita queda confirmada
  if (nuevoEstado === "APROBADO") {
    await supabaseAdmin.from("citas")
      .update({ estado: "confirmada" })
      .eq("id", pago.cita_id);
  }

  // 5. Responder 200 rápido: Wompi reintenta si no respondes
  return NextResponse.json({ ok: true });
}
Los 3 mandamientos del webhook

1. Verifica la firma — cualquiera puede hacer POST a tu URL. 2. Sé idempotente — la pasarela puede enviarte el mismo evento dos veces; procesarlo dos veces no debe duplicar nada. 3. Valida el monto — que el pago recibido corresponda a lo que debía cobrarse.

Probar webhooks en localhost

Wompi no puede llamar a localhost:3000. Para probar en desarrollo, expón tu servidor con un túnel: npx ngrok http 3000 (o Cloudflare Tunnel) y registra la URL pública en el dashboard de Wompi → Eventos. En el módulo 6 la reemplazarás por tu dominio real.

04
Estados de pago y ciclo de vida de la transacción La máquina de estados que evita citas fantasma y cobros perdidos

Un pago no es "pagado o no pagado": es una máquina de estados. Entenderla evita los dos errores caros: dar servicio sin cobrar, y cobrar sin dar servicio.

PENDIENTE
Creamos la referencia y el cliente fue al checkout. La cita: pendiente_pago
APROBADO
Webhook confirmó el cobro. La cita: confirmada
RECHAZADO
Banco rechazó. La cita: sigue pendiente; el cliente puede reintentar
ANULADO
Reversado/anulado después de aprobar. La cita: vuelve a pendiente o se cancela

4.1. Los casos borde que separan aficionados de profesionales

Caso bordeQué debe hacer tu lógica de negocio
Cliente paga pero cierra la pestaña antes del redirectNada especial: el webhook confirma igual. La pantalla "mis citas" muestra el estado real.
PSE queda "pendiente" 10 minutos (transferencia bancaria)La cita se sostiene en pendiente_pago con el horario bloqueado un tiempo límite (ej: 15 min).
Cliente reserva y nunca pagaUn job (módulo 5) libera las citas pendiente_pago con más de 15 minutos.
Llega webhook de una referencia desconocidaResponde 200 y regístralo en logs. Nunca proceses lo que no originaste.
El mismo webhook llega dos vecesLa verificación de idempotencia (estado ≠ PENDIENTE) lo ignora sin efectos.

4.2. El patrón universal: Mercado Pago en 20 líneas

Para demostrar que el patrón se repite, así se ve el paso 1 con Mercado Pago (México, Argentina y el resto de la región):

// Mismo patrón: crear preferencia → redirigir → esperar webhook
import { MercadoPagoConfig, Preference } from "mercadopago";

const mp = new MercadoPagoConfig({
  accessToken: process.env.MP_ACCESS_TOKEN!, // secreto de servidor
});

export async function crearPreferenciaMP(referencia: string, montoMXN: number) {
  const preferencia = await new Preference(mp).create({
    body: {
      items: [{
        id: referencia,
        title: "Anticipo de reserva",
        quantity: 1,
        unit_price: montoMXN,
      }],
      external_reference: referencia,          // = nuestra referencia única
      notification_url: "https://tudominio.com/api/webhooks/mercadopago",
      back_urls: { success: "https://tudominio.com/pagar/resultado" },
    },
  });
  return preferencia.init_point; // URL del checkout, igual que en Wompi
}

Cambian los nombres (external_reference en vez de reference, notification_url en vez de registrar la URL en el dashboard), pero el esqueleto — referencia única, checkout externo, webhook firmado, máquina de estados — es idéntico. Con PayU y Stripe pasa lo mismo.

✗ Errores comunes con pagos
  • Confirmar el pago en la página de "gracias". Solución: la página de resultado solo muestra; quien decide es el webhook verificado.
  • No verificar la firma del webhook. Solución: sin verificación, cualquiera con tu URL confirma citas gratis con un curl. La firma es obligatoria, no opcional.
  • Procesar el mismo evento dos veces. Solución: idempotencia — revisa el estado actual antes de aplicar cambios. Las pasarelas reintentan y duplican eventos por diseño.
  • Calcular el monto en el frontend. Solución: el monto sale de la base de datos en el servidor y viaja firmado (firma de integridad). El navegador solo ve el resultado.
  • Usar las llaves de producción para desarrollar. Solución: sandbox hasta el módulo 6. Las llaves productivas entran solo como variables de entorno del hosting.
✏ Ejercicio: elige la pasarela del restaurante

"La Sazón de la Abuela" (el restaurante del módulo 2) ahora quiere cobrar pedidos a domicilio en línea. Atiende en Barranquilla, pero la dueña planea abrir sede en Ciudad de México el próximo año.

  1. Con la tabla de la sección 1, decide: ¿qué pasarela usarías hoy y cuál agregarías para México? Justifica con los métodos de pago de cada país.
  2. Diseña la máquina de estados del pedido: carrito → pendiente_pago → pagado → en_cocina → despachado → entregado. ¿Qué evento dispara cada transición? ¿Cuáles dispara el webhook y cuáles el personal?
  3. Escribe la regla de negocio para: "si el pago no se aprueba en 20 minutos, el pedido se cancela y el stock se libera".

La guía de pasarelas de la región está en los recursos descargables del módulo.

4.3. Mini-proyecto del módulo

🔨
ReservaYa · Entrega 4

Tu app ahora cobra: tabla pagos con referencia única, botón "Pagar anticipo" que redirige al checkout sandbox de Wompi con firma de integridad, webhook en /api/webhooks/wompi que verifica firma + idempotencia + monto, y la cita pasa a confirmada cuando el pago se aprueba. Prueba el ciclo completo con la tarjeta de test de Wompi (4242 4242 4242 4242, aprobada) y verifica en la tabla que el estado cambió por el webhook, no por el redirect.

✓ Lo que aprendiste en este módulo
  • En LatAm mandan los métodos locales: PSE, Nequi, OXXO, Pix, Yape. La pasarela correcta es la que los soporta en tu país.
  • El patrón universal de pagos: referencia única → checkout externo → webhook firmado → máquina de estados. Igual en Wompi, Mercado Pago, PayU y Stripe.
  • El webhook es la fuente de verdad: firma verificada, idempotencia y validación de monto. El redirect del navegador solo decora.
  • Los pagos son máquinas de estados: modelar PENDIENTE/APROBADO/RECHAZADO/ANULADO y sus casos borde es lo que hace tu negocio confiable.