~/blog/bitacora-de-guerra-cors-refactorizacion-webpay.md
Bitácora de guerra: depurar CORS, refactorizar código propio e integrar Webpay sin perder la tarde
Problemas reales y cómo los resolví: un error de CORS que solo fallaba en producción, una fuga de memoria en el servidor, código mío de hace un año refactorizado sin vergüenza y una guía práctica para integrar Webpay Plus, Mercado Pago y Flow.
- autor
- Alejandro Peñaloza
- Publicado
- Lectura
- 5 min
- English
- Read in English →

En este artículo
Hay posts que se escriben para enseñar algo y hay posts que se escriben para que el próximo desarrollador desesperado que busque el mismo error en Google encuentre la respuesta en cinco minutos y no en cinco horas. Este es de los segundos.
Cada sección sigue el mismo formato: contexto, qué intenté, por qué falló y cuál fue la solución definitiva.
Cómo solucioné un error de CORS que solo fallaba en producción
El contexto
Frontend en Next.js en app.midominio.cl, API en Node.js en api.midominio.cl. En local todo funcionaba. En producción, el login fallaba con el clásico:
Access to fetch at 'https://api.midominio.cl/auth/login' from origin
'https://app.midominio.cl' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested resource.Lo que intenté (y por qué no funcionó)
- Poner
Access-Control-Allow-Origin: *. El error cambió, pero no desapareció: el login usa cookies (credentials: "include") y el navegador no acepta el comodín*cuando se envían credenciales. El origen tiene que ser explícito. - Agregar el header en el controlador del login. Inútil: el navegador primero envía una petición
OPTIONS(el preflight) y esa nunca llegaba a mi controlador. - Revisar la configuración del middleware de CORS. Estaba bien. Y eso fue la pista: si la API estaba bien configurada, el problema estaba antes de la API.
La causa real
Nginx tenía un bloque que respondía a OPTIONS con un 204 vacío, heredado de una configuración antigua. El preflight nunca llegaba a Node, así que la respuesta salía sin headers de CORS. En local no pasaba porque no había Nginx de por medio.
Lo confirmé con curl, que es la mejor herramienta para depurar CORS porque te muestra exactamente lo que responde el servidor:
curl -i -X OPTIONS https://api.midominio.cl/auth/login \
-H "Origin: https://app.midominio.cl" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type"La solución definitiva
Eliminé el bloque de OPTIONS en Nginx y dejé que la API manejara CORS en un solo lugar:
import Fastify from "fastify";
import cors from "@fastify/cors";
const allowed = (process.env.CORS_ORIGINS ?? "").split(",");
const app = Fastify();
await app.register(cors, {
origin: (origin, cb) => {
if (!origin || allowed.includes(origin)) return cb(null, true);
cb(new Error("Origen no permitido"), false);
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
});Y como el login usa cookies entre subdominios, la cookie necesita los atributos correctos:
reply.setCookie("session", token, {
domain: ".midominio.cl",
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
});Lo que aprendí
- CORS es una restricción del navegador, no del servidor. Por eso Postman nunca falla.
- Con credenciales: origen explícito,
Access-Control-Allow-Credentials: truey nada de*. - Configura CORS en un solo lugar: o en el proxy o en la aplicación, nunca en ambos (dos headers
Access-Control-Allow-Origintambién rompen la petición). - Si local funciona y producción no, sospecha de todo lo que existe solo en producción: proxy, CDN, balanceador.
Una fuga de memoria que reiniciaba el servidor cada madrugada
El síntoma
Un worker en Node.js que procesaba tareas en segundo plano consumía cada vez más memoria hasta que el sistema lo mataba (OOMKilled en Docker). Siempre de madrugada, siempre después de unas 20 horas de funcionamiento.
El diagnóstico
docker stats worker # la memoria subía sin bajar
node --inspect=0.0.0.0:9229 dist/worker.js # para tomar heap snapshotsComparé dos heap snapshots tomados con una hora de diferencia en Chrome DevTools. El objeto que más crecía era un Map usado como caché de respuestas de una API externa: se agregaban entradas y nunca se eliminaban.
La solución
Reemplacé el Map sin límite por una caché con tamaño máximo y expiración:
import { LRUCache } from "lru-cache";
const cache = new LRUCache<string, unknown>({
max: 5_000, // máximo de entradas
ttl: 1000 * 60 * 30, // 30 minutos
});Y como red de seguridad, un límite explícito en el docker-compose.yml para que un problema futuro no afecte al resto del servidor:
deploy:
resources:
limits:
memory: 512MMoraleja: toda caché necesita una política de expulsión. Un Map global es una fuga de memoria esperando su momento.
Refactorizando mi propio código de hace un año
Mostrar código antiguo propio da algo de vergüenza, pero es la mejor forma de medir cuánto has avanzado. Este es un controlador real (simplificado) que calculaba el total de un pedido:
Antes
public function store(Request $request)
{
$total = 0;
foreach ($request->items as $item) {
$producto = DB::table('productos')->where('id', $item['id'])->first();
if ($producto->tipo == 1) {
$total += $producto->precio * $item['cantidad'];
} elseif ($producto->tipo == 2) {
$total += $producto->precio * $item['cantidad'] * 0.9;
} elseif ($producto->tipo == 3) {
$total += ($producto->precio * $item['cantidad']) - 1000;
}
}
$total = $total * 1.19;
DB::table('pedidos')->insert([
'cliente_id' => $request->cliente_id,
'total' => $total,
]);
return response()->json(['ok' => true, 'total' => $total]);
}Los problemas
- Sin validación: cualquier dato llega directo a la base.
- Consulta N+1: una consulta por cada ítem.
- Números mágicos: ¿qué es el tipo 2? ¿por qué 0.9? ¿por qué 1000?
- Reglas de negocio en el controlador: imposible de probar sin hacer una petición HTTP.
- Montos en decimales: el IVA calculado con
floattermina en pesos con decimales. - Sin transacción: si falla algo después del
insert, queda un pedido a medias.
Después
Validación en un Form Request:
class StorePedidoRequest extends FormRequest
{
public function rules(): array
{
return [
'cliente_id' => ['required', 'exists:clientes,id'],
'items' => ['required', 'array', 'min:1'],
'items.*.id' => ['required', 'exists:productos,id'],
'items.*.cantidad' => ['required', 'integer', 'min:1'],
];
}
}Cada tipo de producto como estrategia (patrón Strategy), con nombres en vez de números:
enum TipoProducto: int
{
case Normal = 1;
case ConDescuento = 2;
case ConRebaja = 3;
public function subtotal(int $precio, int $cantidad): int
{
return match ($this) {
self::Normal => $precio * $cantidad,
self::ConDescuento => intdiv($precio * $cantidad * 90, 100),
self::ConRebaja => max(0, $precio * $cantidad - 1000),
};
}
}La lógica en un servicio que se puede probar de forma aislada:
class CalculadoraPedido
{
private const IVA = 19;
public function total(Collection $productos, array $items): int
{
$neto = collect($items)->sum(function ($item) use ($productos) {
$producto = $productos[$item['id']];
return $producto->tipo->subtotal($producto->precio, $item['cantidad']);
});
return (int) round($neto * (100 + self::IVA) / 100);
}
}Y un controlador que solo coordina:
public function store(StorePedidoRequest $request, CalculadoraPedido $calculadora)
{
$items = $request->validated('items');
$productos = Producto::whereIn('id', array_column($items, 'id'))->get()->keyBy('id');
$pedido = DB::transaction(fn () => Pedido::create([
'cliente_id' => $request->validated('cliente_id'),
'total' => $calculadora->total($productos, $items),
]));
return response()->json($pedido, 201);
}Resultado: una sola consulta para los productos, montos enteros en pesos, reglas con nombre, validación antes de tocar la base y una clase que puedo cubrir con tests unitarios en segundos.
Integrar pagos chilenos y latinoamericanos sin sufrir
La documentación de las pasarelas suele cubrir el caso feliz. Estas son las partes que me costó descubrir.
Webpay Plus (Transbank) paso a paso
El flujo tiene tres momentos: crear la transacción, redirigir al usuario a Webpay y confirmar (commit) cuando vuelve.
sequenceDiagram
participant U as Usuario
participant A as Tu API
participant T as Transbank
U->>A: Pagar pedido
A->>T: create(buyOrder, sessionId, monto, returnUrl)
T-->>A: token + url
A-->>U: Redirige a url con token_ws
U->>T: Paga en Webpay
T-->>U: Vuelve a returnUrl
U->>A: token_ws
A->>T: commit(token_ws)
T-->>A: status AUTHORIZED / FAILED
Con el SDK oficial para Node.js (transbank-sdk), en ambiente de integración:
const {
WebpayPlus, Options, Environment,
IntegrationApiKeys, IntegrationCommerceCodes,
} = require("transbank-sdk");
const tx = new WebpayPlus.Transaction(
new Options(
IntegrationCommerceCodes.WEBPAY_PLUS,
IntegrationApiKeys.WEBPAY,
Environment.Integration,
),
);
// 1. Crear la transacción
app.post("/pagos/webpay", async (req, res) => {
const pedido = await crearPedidoPendiente(req.body);
const { token, url } = await tx.create(
`PED-${pedido.id}`, // buyOrder (máx. 26 caracteres)
req.sessionID,
pedido.total, // monto entero en pesos
"https://midominio.cl/pagos/webpay/retorno",
);
res.json({ url, token }); // el frontend envía token_ws por POST a url
});
// 2. Retorno: Transbank puede volver por GET o por POST
app.all("/pagos/webpay/retorno", async (req, res) => {
const p = { ...req.query, ...req.body };
// El usuario anuló el pago o se agotó el tiempo
if (!p.token_ws) {
await marcarPedidoComo(p.TBK_ORDEN_COMPRA, "anulado");
return res.redirect("/pago/anulado");
}
const r = await tx.commit(p.token_ws);
const aprobado = r.status === "AUTHORIZED" && r.response_code === 0;
await marcarPedidoComo(r.buy_order, aprobado ? "pagado" : "rechazado");
res.redirect(aprobado ? "/pago/exito" : "/pago/rechazado");
});Errores que vi (y cometí):
- Validar solo
status. Revisa tambiénresponse_code === 0y que el monto devuelto coincida con el del pedido. - Olvidar el caso de anulación. Si el usuario presiona "Anular", no llega
token_wssinoTBK_TOKENyTBK_ORDEN_COMPRA. Si no lo manejas, el pedido queda pendiente para siempre. - Hacer
commitdos veces. Si el usuario recarga la página de retorno, el segundo commit falla. Guarda el estado del pedido y no vuelvas a confirmar uno ya procesado. - Usar
floatpara montos. Webpay trabaja con pesos enteros. - No probar con las tarjetas de prueba. Transbank publica tarjetas y credenciales de integración para simular pagos aprobados y rechazados; úsalas todas antes de pasar a producción, que además requiere un proceso de validación con ellos.
Mercado Pago y Flow: lo que cambia
- Mercado Pago: lo más común es crear una preferencia de pago con el SDK y redirigir al checkout. Lo importante es que la fuente de verdad son las notificaciones (webhooks), no la redirección del navegador: el usuario puede cerrar la pestaña antes de volver. Al recibir la notificación, consulta el pago por su id contra la API y solo entonces marca el pedido como pagado.
- Flow: cada petición a su API se firma con tu
secretKey(HMAC-SHA256 sobre los parámetros ordenados por nombre). El 90 % de los errores de integración son firmas mal construidas: parámetros sin ordenar, el parámetro de firma incluido en su propio cálculo o espacios de más. Al igual que con Mercado Pago, confirma el estado consultando la API cuando recibes la confirmación.
Reglas que aplican a cualquier pasarela
- El navegador nunca confirma un pago: confirma tu servidor contra la API de la pasarela.
- Idempotencia: procesar dos veces la misma notificación no debe duplicar nada.
- Guarda la respuesta completa de la pasarela en una tabla de transacciones. Cuando un cliente reclame, vas a necesitarla.
- Credenciales en variables de entorno, separadas por ambiente.
- Registra cada paso con el id del pedido en los logs; depurar pagos sin trazabilidad es adivinar.
Conclusión
Los problemas difíciles casi nunca están donde uno los busca primero: el CORS estaba en Nginx, la fuga en una caché "inofensiva" y los errores de pago en los casos que la documentación no muestra. Escribirlos es la mejor forma de no repetirlos, y de ahorrarle la tarde a quien venga después.
Comentarios
Aún no hay comentarios. ¿Quieres dejar el primero?
Deja un comentario
$ ls ~/blog | grep relacionados
Sigue leyendo

Productividad para desarrolladores: VS Code, Git avanzado, gestión ágil y scripts de automatización
Mi flujo de trabajo diario como desarrollador: las extensiones y ajustes de VS Code que sí aportan, Git más allá de commit y push (rebase, cherry-pick y bisect), cómo gestionar proyectos pequeños sin burocracia y los scripts que automatizan lo repetitivo.

De localhost a producción sin llorar: VPS, DNS, hosting y Docker para desarrolladores
Guía práctica para sacar un proyecto a producción: cómo asegurar un VPS desde cero, configurar DNS y Cloudflare, elegir entre hosting compartido, VPS o cloud, y levantar tu base de datos local con Docker Compose en segundos.

PHP2Shell: qué son CVE-2026-109618 y CVE-2026-109619 y cómo proteger tus servidores PHP
Dos fallas críticas en PHP permiten ejecutar código remoto sin autenticación, antes de que corra tu aplicación. Qué versiones afectan, quién está expuesto, qué cubre el WAF de Cloudflare, cómo actualizar y qué mitigar mientras tanto.