todos los sistemas operativostodo operativo

apenaloza.cl · bitácora

SCL (UTC−3) · v2026.10

~/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
Escritorio de desarrollo de noche con varios monitores que muestran código y terminales, iluminado por la luz de las pantallas
En este artículo
  1. Cómo solucioné un error de CORS que solo fallaba en producción
  2. Una fuga de memoria que reiniciaba el servidor cada madrugada
  3. Refactorizando mi propio código de hace un año
  4. Integrar pagos chilenos y latinoamericanos sin sufrir
  5. Conclusión

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ó)

  1. 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.
  2. 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.
  3. 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: true y 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-Origin tambié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 snapshots

Comparé 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: 512M

Moraleja: 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 float termina 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én response_code === 0 y que el monto devuelto coincida con el del pedido.
  • Olvidar el caso de anulación. Si el usuario presiona "Anular", no llega token_ws sino TBK_TOKEN y TBK_ORDEN_COMPRA. Si no lo manejas, el pedido queda pendiente para siempre.
  • Hacer commit dos 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 float para 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

  1. El navegador nunca confirma un pago: confirma tu servidor contra la API de la pasarela.
  2. Idempotencia: procesar dos veces la misma notificación no debe duplicar nada.
  3. Guarda la respuesta completa de la pasarela en una tabla de transacciones. Cuando un cliente reclame, vas a necesitarla.
  4. Credenciales en variables de entorno, separadas por ambiente.
  5. 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.

compartir:linkedinx

Comentarios

Aún no hay comentarios. ¿Quieres dejar el primero?

Deja un comentario

Los comentarios se revisan antes de publicarse. No pido correo ni guardo tu IP.

$ ls ~/blog | grep relacionados

Sigue leyendo