Implementa flujos de pago completos (Flow y MercadoPago Checkout Pro) en ruta-araucaria. Usa cuando: agregar un nuevo punto de entrada de pago, implementar polling de confirmación, integrar SelectorPasarela, manejar retornos de Flow o MP, disparar conversiones de Google Ads post-pago, crear un nuevo flujo de reserva con cobro inmediato. NO usar para: cambios de UI sin pago, gestión de reservas sin cobro, panel admin.
Backend: https://transportes-araucaria.onrender.com (Node.js/Express)
Dos pasarelas disponibles en todos los puntos de entrada de pago:
| Pasarela | Endpoint de creación | Retorno |
|---|---|---|
| Flow | POST /create-payment | Backend POST → redirect GET /flow-return |
| MercadoPago | POST /api/create-payment-mp | MP redirect GET /mp-return |
Regla de oro: Usa siempre getBackendUrl() desde src/lib/backend.ts. Nunca hardcodees la URL del backend en componentes.
src/lib/backend.ts ← getBackendUrl()
src/components/SelectorPasarela.tsx ← componente UI reutilizable
src/components/BookingForm.tsx ← ejemplo de flujo con reserva previa
src/hooks/useCotizacion.ts ← patrón de hook con fetch + abort
Usa el componente existente SelectorPasarela (ya tiene estilos y lógica):
import SelectorPasarela from "../components/SelectorPasarela";
// Estado local
const [pasarela, setPasarela] = useState<"flow" | "mercadopago">("flow");
// En el JSX, antes del botón de pago:
<SelectorPasarela pasarela={pasarela} onChange={setPasarela} />
Si la configuración dinámica de pasarelas está activa, el componente se enriquece con logos y habilitación remota. Para el caso simple, usa el componente tal cual.
Antes de iniciar el pago, la reserva debe existir en la DB. Usa el endpoint correcto según el flujo:
// Reserva express (nuevo cliente)
POST /enviar-reserva-express
Body: {
nombre, email, telefono,
origen, destino, fecha, hora, pasajeros,
idaVuelta?: boolean, fechaRegreso?, horaRegreso?,
upgradeVan?: boolean,
precio, totalConDescuento, abono, saldoPendiente,
descuentoBase, descuentoRoundTrip, descuentoOnline,
source: "web", // Importante para el webhook
estadoPago: "pendiente",
tipoPago: "total" | "abono",
}
Response: { success: true, reservaId: number, codigoReserva: string }
Si la reserva ya existe (pago de saldo, código de pago), saltar al Paso 3 con el reservaId existente.
interface PaymentPayload {
gateway: "flow" | "mercadopago"; // de SelectorPasarela
amount: number; // monto en CLP (entero, mayor a 0)
description: string; // "Traslado ZCO - Temuco"
email: string; // del usuario (será sanitizado en backend)
nombre?: string; // para MP: nombre del pagador
telefono?: string; // para MP: teléfono formateado
reservaId?: number; // ID de la reserva en DB
codigoReserva?: string; // "AR-20260417-0001"
tipoPago?: "total" | "abono"; // default: "total"
paymentOrigin: string; // clave que identifica el flujo
referenciaPago?: string; // código de cupón si aplica
codigoPagoId?: number; // ID del CodigoPago si flujo "pagar con código"
}
paymentOrigin| Flujo | paymentOrigin |
|---|---|
| Reserva web express | "reserva_express" |
| Pagar con código de pago | "pagar_con_codigo" |
| Pago de saldo desde consulta | "consultar_reserva" |
| Oportunidades de traslado | "oportunidad_traslado" |
| Banner/promoción | "banner_promocional" |
| Propina de evaluación | "propina_evaluacion" |
import { getBackendUrl } from "../lib/backend";
async function iniciarPago(payload: PaymentPayload): Promise<string> {
// Seleccionar endpoint según pasarela
const endpoint =
payload.gateway === "mercadopago"
? "/api/create-payment-mp"
: "/create-payment";
const resp = await fetch(`${getBackendUrl()}${endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || `HTTP ${resp.status}`);
}
const { url } = await resp.json();
return url; // Redirigir al usuario con window.location.href = url
}
const url = await iniciarPago(payload);
window.location.href = url;
/flow-returnFlow redirige por POST al backend, que a su vez hace un redirect GET al frontend con estos parámetros:
| Parámetro | Descripción |
|---|---|
token | Token único de la transacción Flow |
status | success, pending, error |
amount | Monto en CLP (embebido por el backend) |
reserva_id | ID de la reserva |
d | Datos del usuario en Base64 (email, nombre, teléfono) para Enhanced Conversions |
flow_status | Código numérico de Flow (3=rechazado, 4=anulado) cuando status=error |
// Detectar retorno de Flow en el componente
const params = new URLSearchParams(window.location.search);
const flowToken = params.get("token");
const flowStatus = params.get("status"); // "success" | "pending" | "error"
const amount = params.get("amount");
const reservaId = params.get("reserva_id");
if (flowToken && flowStatus === "success") {
// Mostrar confirmación, disparar conversión GA
} else if (flowStatus === "pending") {
// Iniciar polling (ver Paso 5)
} else if (flowStatus === "error") {
// Mostrar mensaje de error
}
Nota crítica: Flow siempre llega a /flow-return. Para reserva express el backend redirige a /?flow_payment=success&... en lugar de /flow-return.
/mp-returnMP redirige por GET directamente con estos parámetros:
| Parámetro | Descripción |
|---|---|
collection_id / payment_id | ID único del pago en MP |
status | approved, pending, failure |
amount | Monto embebido por el backend en la back_url |
reserva_id | ID de la reserva |
codigo | Código AR-... |
d | Datos del usuario en Base64 |
// Detectar retorno de MP
const isMpReturn = window.location.pathname === "/mp-return";
const mpStatus = params.get("status"); // "approved" | "pending" | "failure"
const collectionId = params.get("collection_id") || params.get("payment_id");
Flow a veces llega con status=pending. El backend confirma el pago vía webhook (/api/flow-confirmation) segundos después. El frontend debe hacer polling:
import { getBackendUrl } from "../lib/backend";
function iniciarPolling(
token: string | null,
reservaId: string | null,
onSuccess: (monto: number) => void,
) {
const INTERVALO_MS = 5000;
const MAX_INTENTOS = 24; // 2 minutos total
let intentos = 0;
let cancelado = false;
const pollingInterval = setInterval(async () => {
if (cancelado) return;
intentos++;
if (intentos > MAX_INTENTOS) {
clearInterval(pollingInterval);
return;
}
try {
const qs = new URLSearchParams();
if (token) qs.set("token", token);
if (reservaId) qs.set("reserva_id", reservaId);
const resp = await fetch(
`${getBackendUrl()}/api/payment-status?${qs.toString()}`,
);
const data = await resp.json();
if (data.pagado) {
clearInterval(pollingInterval);
onSuccess(data.monto ?? 0);
}
} catch {
// Error de red: seguir intentando
}
}, INTERVALO_MS);
// Retornar función de limpieza (useEffect return)
return () => {
cancelado = true;
clearInterval(pollingInterval);
};
}
// Uso en useEffect:
useEffect(() => {
if (flowStatus !== "pending") return;
return iniciarPolling(flowToken, reservaId, (monto) => {
setPaymentStatus("success");
dispararConversionGA(monto, reservaId, flowToken);
});
}, []);
Para MercadoPago el polling es más largo: 6 intentos cada 5s + 18 intentos cada 15s (~5 min).
waitForGtaggtag.js se carga asíncronamente. Sin espera, la conversión se pierde silenciosamente.
const waitForGtag = (maxMs = 5000): Promise<boolean> =>
new Promise((resolve) => {
if (typeof window.gtag === "function") { resolve(true); return; }
const startTime = Date.now();
const interval = setInterval(() => {
if (typeof window.gtag === "function") {
clearInterval(interval); resolve(true);
} else if (Date.now() - startTime >= maxMs) {
clearInterval(interval); resolve(false);
}
}, 100);
});
// Dispara inmediatamente antes del window.location.href = url
await waitForGtag(2000);
window.gtag?.("event", "conversion", {
send_to: "AW-17529712870/8GVlCLP-05MbEObh6KZB",
event_callback: () => { window.location.href = url; },
});
// Protección contra duplicados
const conversionKey = `flow_conversion_${transactionId}`;
if (sessionStorage.getItem(conversionKey)) return;
sessionStorage.setItem(conversionKey, "1");
// Decodificar datos del usuario del parámetro "d"
let userData = {};
try {
const dParam = params.get("d") || "";
if (dParam) userData = JSON.parse(atob(decodeURIComponent(dParam)));
} catch { /* ignorar */ }
await waitForGtag(5000);
window.gtag?.("set", "user_data", {
email: userData.email ?? "",
phone_number: userData.telefono ?? "",
address: { first_name: userData.nombre ?? "" },
});
window.gtag?.("event", "conversion", {
send_to: "AW-17529712870/M7-iCN_HtZUbEObh6KZB", // Flow/genérico
// Para MP usar: "AW-17529712870/yZz-CJqiicUbEObh6KZB"
value: Number(amount) || 1.0, // Fallback 1.0, nunca 0
currency: "CLP",
transaction_id: transactionId,
});
IDs de conversión por evento:
| Evento | ID |
|---|---|
| Lead (intención de pago) | AW-17529712870/8GVlCLP-05MbEObh6KZB |
| Purchase Flow/genérico | AW-17529712870/M7-iCN_HtZUbEObh6KZB |
| Purchase MercadoPago | AW-17529712870/yZz-CJqiicUbEObh6KZB |
paymentOrigin con el valor correcto para el flujoamount es un número entero mayor a 0 (validar antes de enviar)getBackendUrl() para construir la URL — nunca hardcodeadaSelectorPasarela está montado antes del botón de pago/flow-return (o /?flow_payment=success para express)/mp-returnstatus=pending, se inicia el polling a /api/payment-statuswaitForGtag y sessionStorage para deduplicar1.0 si todo fallad (Base64) se incluye en el análisis de Enhanced ConversionsNo confiar en el monto del IPN de MP: El webhook del backend siempre re-consulta el pago al API de MP vía SDK antes de actualizar la reserva. En el frontend, usa el amount embebido por el backend en la back_url.
Email sanitizado en backend: El backend llama a sanitizarEmailRobusto() antes de enviar a Flow (previene error Flow 1620). Aun así, validar formato en el frontend antes de enviar.
Idempotencia: El backend ignora webhooks duplicados si reserva.estadoPago === "pagado". No es necesario proteger el frontend, pero sí usar sessionStorage para no disparar GA dos veces.
Monto simbólico centinela: Si el backend no puede determinar el monto real, usa 1000 CLP como fallback para que Google Ads registre la conversión con algún valor en lugar de 0.
CORS: El backend ya está configurado para aceptar localhost:5173. No agregar headers CORS en el frontend.
| Error | Causa | Solución |
|---|---|---|
| Flow error 1620 | Email con formato inválido o carácter especial | Sanitizar email antes de enviar; el backend también lo hace |
url undefined en respuesta | Monto ≤ 0 o faltan campos requeridos | Validar amount > 0, gateway, description, email antes del fetch |
| Conversión GA no se registra | gtag no cargó antes del evento | Usar waitForGtag(5000) obligatoriamente en páginas de retorno |
Pago queda en pending para siempre | Cold start de Render.com: el webhook llegó antes de que el servidor despertara | El endpoint /api/payment-status ya compensa esto consultando a Flow API directamente |
| Doble pago detectado en tramos | Webhook procesado dos veces | El backend detecta pagoAcumulado >= total * 1.5 y detiene el procesamiento |
collection_id null en retorno MP | MP usó payment_id en lugar de collection_id | Leer params.get("collection_id") || params.get("payment_id") |
POST /create-payment ← Flow (todos los flujos)
POST /api/create-payment-mp ← MercadoPago (todos los flujos)
GET /api/payment-status ← Polling de estado
POST /api/payment-result ← Webhook interno Flow (backend→frontend redirect)
POST /api/flow-confirmation ← Webhook Flow (server-to-server)
POST /api/mp-confirmation ← Webhook MercadoPago IPN (server-to-server)
GET /api/codigos-pago/:codigo ← Validar código de pago
GET /api/reservas/:id/pay-redirect ← Enlace de pago directo para correos
BookingForm.tsx → "reserva_express"
SelectorPasarela.tsx → (solo UI, no hace fetch)
AdminCodigosPago → "pagar_con_codigo"
ConsultarReserva → "consultar_reserva"
OportunidadesTraslado → "oportunidad_traslado"
BannerPromoción → "banner_promocional"
Siempre agrega el paymentOrigin correcto: el backend lo usa para determinar la ruta de redirección final (/flow-return vs /?flow_payment=success).