El segundo producto satélite de Escena Viva es la tienda de merchandising: camisetas del Concierto de Otoño (evt-001), carteles serigrafiados de la Noche de Monólogos (evt-002) y vinilos en directo del Festival de Jazz de Primavera (evt-003). Lucía quiere la camiseta talla M en negro; Marc, el vinilo y un cartel. La tienda es un dominio nuevo, pero los usuarios, los roles y los eventos son los mismos. La idea genuinamente nueva de este proyecto es el dinero de verdad. Hasta ahora, cuando algo fallaba se devolvía un 500 y se reintentaba; aquí un fallo puede significar que un cliente pagó y no recibió nada, o que recibió algo sin pagar. Eso obliga a otro nivel de rigor: estados explícitos, consistencia con un sistema externo que no controlas, idempotencia en todas partes y auditoría de cada céntimo.

Advertencia necesaria. Cobrar dinero real implica obligaciones legales, fiscales, contables y de protección de datos que varían por país y que exigen asesoramiento profesional. Este proyecto es didáctico: enseña la ingeniería, no sustituye a un asesor ni a un jurista.

Contenido

  1. El requisito de negocio
  2. Decisiones de diseño y alternativas descartadas
  3. El modelo de datos: variantes, carrito y pedido
  4. El carrito: invitado, usuario y fusión al iniciar sesión
  5. El importe se calcula siempre en el servidor
  6. Reserva de stock frente a sobreventa
  7. El reto técnico: integrar una pasarela de pago
  8. Webhooks: firma, cuerpo en crudo e idempotencia
  9. La máquina de estados del pedido
  10. Colas, reembolsos y auditoría
  11. Pruebas de lo que puede romperse y qué queda fuera

  1. El requisito de negocio

  • Cada evento tiene productos asociados; un producto tiene variantes (talla, color) con existencias propias.
  • Cualquiera puede navegar el catálogo y llenar un carrito, con o sin cuenta; para pagar hay que estar autenticado.
  • El pago se hace con tarjeta a través de una pasarela; Escena Viva no ve nunca los datos de la tarjeta.
  • El pedido pasa por estados verificables y el cliente puede consultarlos; se pueden emitir reembolsos totales o parciales.
  • Los organizadores ven los pedidos de los productos de su sala; los administradores, todos.

  1. Decisiones de diseño y alternativas descartadas

Decisión Alternativa descartada Por qué
PostgreSQL con Sequelize (M7) MongoDB, como el chat El dinero pide transacciones ACID entre varias tablas, integridad referencial y sumas exactas
Stock en la variante, no en el producto Un contador en el producto Vender "camiseta" no significa nada: se vende la talla M negra. Un stock por producto no impide vender diez XXL inexistentes
Líneas de pedido con precio congelado Leer el precio actual del producto Ver punto 3: es la decisión más importante de la lección
PaymentIntent creado en el servidor Datos de tarjeta que llegan a nuestra API Recibir un número de tarjeta nos mete en el alcance completo de PCI DSS
Confirmación por webhook Confirmar cuando el navegador vuelve a la página de éxito El navegador puede cerrarse justo después de pagar. La verdad del pago la tiene la pasarela
Reserva temporal de stock Descontar al añadir al carrito, o solo al pagar Lo primero bloquea existencias durante horas; lo segundo permite sobreventa mientras se teclea la tarjeta
Máquina de estados explícita Booleanos pagado, enviado, cancelado Con cinco booleanos hay 32 combinaciones y solo 7 válidas. Los estados imposibles deben ser irrepresentables

Dependencia nueva: npm install stripe. Nada más: sequelize, pg, bullmq, ioredis y zod ya están.

  1. El modelo de datos: variantes, carrito y pedido

Tabla Campos clave Nota de diseño
productos id, eventoId, nombre, salaId, activo Sin precio ni stock: ambos viven en la variante
variantes id, productoId, sku, talla, color, precioCentimos, stock, stockReservado SKU único y legible: EV-CAM-003-M-NEG
carritos id, usuarioId (nulo si invitado), tokenInvitado, expiraEn
lineas_carrito carritoId, varianteId, cantidad No guarda precio: el carrito muestra el precio actual
pedidos id, usuarioId, estado, subtotalCentimos, impuestosCentimos, envioCentimos, totalCentimos, moneda
lineas_pedido pedidoId, varianteId, sku, nombreProducto, talla, color, precioUnitarioCentimos, cantidad Copia precio y descripción
pagos pedidoId, proveedor, referenciaExterna, importeCentimos, estado, crudoEvento Guarda el JSON original de la pasarela

Por qué el pedido congela el precio. Es la decisión que separa una tienda de juguete de una real. La camiseta del evt-001 cuesta hoy 2200 céntimos y Lucía la compra. Dentro de tres semanas se rebaja a 1500 para liquidar existencias. Si la línea de pedido guardara solo varianteId y el importe se leyera del catálogo: el histórico de Lucía mostraría 1500 y no cuadraría con lo cobrado; la factura emitida diría una cosa y la base de datos otra —un problema contable, no estético—; un reembolso devolvería 1500 en vez de 2200; y cualquier informe de ingresos pasados cambiaría cada vez que alguien edita un precio. La línea de pedido es un documento histórico inmutable: copia precio, nombre y atributos. Que se duplique información no es desnormalización sucia; es que el dato del pedido y el del catálogo son cosas distintas que coinciden un instante.

// src/dominio/pedido.js — todo lo marcado se COPIA, no se referencia
const crearLineaPedido = ({ variante, producto, cantidad }) => ({
  varianteId: variante.id, sku: variante.sku, cantidad,
  nombreProducto: producto.nombre,                       // congelado
  talla: variante.talla, color: variante.color,          // congelados
  precioUnitarioCentimos: variante.precioCentimos,       // congelado: lo esencial
  importeCentimos: variante.precioCentimos * cantidad,
});
module.exports = { crearLineaPedido };

  1. El carrito: invitado, usuario y fusión al iniciar sesión

Obligar a registrarse antes de ver el carrito es la forma más eficaz de perder ventas. Hay dos tipos: el de invitado, identificado por un tokenInvitado aleatorio guardado en una cookie httpOnly de 30 días (la cookie contiene solo el identificador: nunca confíes en datos de negocio que viajan en el cliente), y el de usuario, ligado a usuarioId y persistente entre dispositivos. Los carritos de invitado caducan a los 30 días con un trabajo repetible de BullMQ. El caso interesante es iniciar sesión con un carrito ya empezado:

// src/servicios/carritos.js
async function fusionarCarritos({ repositorioCarritos, tokenInvitado, usuarioId }) {
  const invitado = await repositorioCarritos.buscarPorToken(tokenInvitado);
  if (!invitado) return repositorioCarritos.obtenerOCrearDeUsuario(usuarioId);
  const propio = await repositorioCarritos.obtenerOCrearDeUsuario(usuarioId);
  for (const linea of invitado.lineas) {
    const existente = propio.lineas.find((l) => l.varianteId === linea.varianteId);
    // Regla de negocio: sumar cantidades, nunca reemplazar en silencio.
    // Reemplazar hace que el usuario "pierda" lo que acababa de añadir.
    if (existente) existente.cantidad = Math.min(existente.cantidad + linea.cantidad, LIMITE_LINEA);
    else propio.lineas.push(linea);
  }
  await repositorioCarritos.guardar(propio);
  await repositorioCarritos.eliminar(invitado.id);
  return propio;
}
module.exports = { fusionarCarritos };

Las tres políticas posibles son sumar, quedarse con el del invitado o con el del usuario; sumar es la que menos sorprende, siempre con un tope por línea.

  1. El importe se calcula siempre en el servidor

Regla sin excepciones: el cliente envía qué quiere comprar, nunca cuánto cuesta. Si tu API acepta un campo totalCentimos del cliente, tienes una tienda gratis para cualquiera con las herramientas de desarrollo abiertas.

// src/servicios/calculo-importe.js
const TIPO_IVA_POR_MIL = 210;   // 21 % en por mil, para no usar decimales
const UMBRAL_ENVIO_GRATIS = 5000;
const ENVIO_ESTANDAR = 495;
function calcularImporte({ lineas, envioCentimos }) {
  const subtotalCentimos = lineas.reduce(
    (suma, l) => suma + l.precioUnitarioCentimos * l.cantidad, 0);
  // Redondeo al céntimo una sola vez y sobre el subtotal completo.
  // Redondear línea a línea acumula desviaciones de hasta N/2 céntimos.
  const impuestosCentimos = Math.round((subtotalCentimos * TIPO_IVA_POR_MIL) / 1000);
  return { subtotalCentimos, impuestosCentimos, envioCentimos,
           totalCentimos: subtotalCentimos + impuestosCentimos + envioCentimos };
}
// Función pura del subtotal: probable sin base de datos.
const calcularEnvio = (subtotal) => subtotal === 0 ? 0
  : (subtotal >= UMBRAL_ENVIO_GRATIS ? 0 : ENVIO_ESTANDAR);
module.exports = { calcularImporte, calcularEnvio, TIPO_IVA_POR_MIL };

Por qué todo en enteros: 0.1 + 0.2 en coma flotante da 0.30000000000000004, y sobre miles de pedidos esos residuos producen descuadres que nadie sabe explicar. En céntimos enteros solo hay un punto de redondeo —el impuesto—, explícito y aislado en una función pura fácil de probar. Consecuencia visible: si la interfaz reparte el IVA por línea, la suma puede diferir en un céntimo del total; el total del servidor manda y el desglose es informativo.

  1. Reserva de stock frente a sobreventa

Esto ya lo resolviste en el M7 con el aforo: 3000 plazas, 1811 vendidas, y una transacción con bloqueo que impide que dos compras simultáneas vendan la misma butaca. La tienda es el mismo problema con un matiz nuevo: entre que el cliente pulsa "Pagar" y que la pasarela confirma pasan de 10 segundos a varios minutos. Si descontamos al confirmar, en esa ventana otros pueden agotar el stock y acabaremos cobrando un vinilo que ya no existe.

La solución es la reserva temporal: dos columnas, stock y stockReservado, y una regla —lo vendible es stock - stockReservado.

// src/repositorios/inventario-sql.js
async function reservarStock({ sequelize, lineas, pedidoId, minutos = 20 }) {
  return sequelize.transaction(async (t) => {
    for (const linea of lineas) {
      // SELECT ... FOR UPDATE serializa a los compradores de la misma variante.
      const [variante] = await sequelize.query(
        'SELECT stock, stock_reservado FROM variantes WHERE id = :id FOR UPDATE',
        { replacements: { id: linea.varianteId }, type: SELECT, transaction: t });
      const disponible = variante.stock - variante.stock_reservado;
      // Error de dominio del M6: el manejador central lo traduce a 409.
      if (disponible < linea.cantidad) throw new ConflictoDeEstado('STOCK_INSUFICIENTE',
        { sku: linea.sku, solicitado: linea.cantidad, disponible });
      await sequelize.query(
        'UPDATE variantes SET stock_reservado = stock_reservado + :n WHERE id = :id',
        { replacements: { n: linea.cantidad, id: linea.varianteId }, transaction: t });
    }
    await repositorioInventario.crearReserva({ pedidoId, minutos }, t);
  });
}
Momento stock stockReservado Vendible
Inicial 50 0 50
Lucía inicia el pago de 2 50 2 48
El pago se confirma 48 0 48
(alternativa) el pago falla o caduca 50 0 50

La liberación de reservas caducadas es un trabajo repetible de BullMQ (M10) cada minuto: sin él, un cliente que abandona el pago congela stock para siempre.

  1. El reto técnico: integrar una pasarela de pago

sequenceDiagram
  participant C as Cliente
  participant A as API Escena Viva
  participant S as Stripe
  C->>A: POST /api/v1/pedidos (lineas + direccion)
  A->>A: calcular importe + reservar stock
  A->>S: crear PaymentIntent (importe, metadata.pedidoId)
  A-->>C: { pedidoId, clientSecret }
  C->>S: confirmar pago con la tarjeta (nunca pasa por A)
  S->>A: webhook payment_intent.succeeded (firmado)
  A->>A: confirmar pedido, consumir reserva, encolar correo

Lo esencial: la tarjeta nunca toca nuestro servidor. El navegador la envía directamente a Stripe con el clientSecret; nuestra API solo declara "hay que cobrar 4840 céntimos por el pedido ped-8812". PCI DSS en una frase: es la norma de seguridad de la industria de tarjetas, y su alcance —y el coste de cumplirla— crece brutalmente en cuanto los datos de la tarjeta pasan por tus sistemas; delegando en la pasarela te quedas en el nivel de autoevaluación más simple. Todo lo demás son detalles de integración.

// src/servicios/pagos.js
async function crearIntencionDePago({ pedido, usuario }) {
  const intencion = await stripe.paymentIntents.create({
    amount: pedido.totalCentimos,   // el importe lo fija el servidor (punto 5)
    currency: pedido.moneda,        // 'eur'
    // La metadata es el puente entre el mundo de Stripe y el nuestro.
    metadata: { pedidoId: pedido.id, usuarioId: usuario.id },
    receipt_email: usuario.correo,  // [email protected]
    automatic_payment_methods: { enabled: true },
  // Idempotencia hacia fuera: si reintentamos, Stripe no crea dos intenciones.
  }, { idempotencyKey: `intencion-${pedido.id}` });
  return { referenciaExterna: intencion.id, clientSecret: intencion.client_secret };
}
module.exports = { crearIntencionDePago, stripe };

  1. Webhooks: firma, cuerpo en crudo e idempotencia

Un webhook es una petición HTTP que el proveedor te hace a ti, y como cualquiera puede hacerte una petición HTTP, verificar la firma no es opcional: sin ella cualquiera te envía payment_intent.succeeded y se lleva mercancía gratis.

El error clásico. La firma se calcula sobre los bytes exactos del cuerpo. Si express.json() ya lo parseó, el original se perdió; volver a serializar con JSON.stringify produce bytes distintos (orden de claves, espaciado, escapes Unicode) y la verificación falla siempre. El síntoma es un 400 signature verification failed que parece un problema de claves y no lo es. La solución es montar la ruta con express.raw antes del express.json() global:

// src/app.js — extracto del orden de middleware
function crearAplicacion(dependencias = {}) {
  const aplicacion = express();
  aplicacion.use(helmet());
  aplicacion.use(idPeticion);
  // ORDEN CRÍTICO: el webhook necesita Buffer, no objeto.
  aplicacion.use('/api/v1/webhooks/pagos',
    express.raw({ type: 'application/json', limit: '1mb' }),
    crearRutasWebhookPagos(dependencias));
  aplicacion.use(express.json({ limit: '100kb' }));  // el resto sí usa JSON
  // ... routers normales y manejador de errores central (M6) ...
  return aplicacion;
}
// src/rutas/webhook-pagos.js
rutas.post('/', async (peticion, respuesta) => {
  let evento;
  try {
    // peticion.body es un Buffer gracias a express.raw.
    evento = stripe.webhooks.constructEvent(peticion.body,
      peticion.get('stripe-signature'), configuracion.stripe.secretoWebhook);
  } catch (error) {
    return respuesta.status(400).json({ error: { codigo: 'FIRMA_INVALIDA',
      mensaje: 'Firma no verificable', estado: 400, detalles: null } });
  }
  // Idempotencia: Stripe reintenta durante días si no respondes 2xx.
  // evento.id hace de Idempotency-Key (M10), persistido en base de datos.
  const esNuevo = await repositorioEventosPago.registrarSiEsNuevo(evento.id, evento.type);
  if (!esNuevo) return respuesta.status(200).json({ recibido: true, repetido: true });
  try {
    const objeto = evento.data.object;
    if (evento.type === 'payment_intent.succeeded') {
      await servicioPedidos.confirmarPago({ pedidoId: objeto.metadata.pedidoId,
        referenciaExterna: objeto.id, importeCentimos: objeto.amount_received });
    } else if (evento.type === 'payment_intent.payment_failed') {
      await servicioPedidos.marcarPagoFallido({ pedidoId: objeto.metadata.pedidoId });
    }
    return respuesta.status(200).json({ recibido: true });  // 200 rápido, cola aparte
  } catch (error) {
    // Un 500 hace que Stripe reintente: correcto ante fallo transitorio.
    await repositorioEventosPago.marcarFallido(evento.id, error.message);
    return respuesta.status(500).json({ error: { codigo: 'ERROR_INTERNO',
      mensaje: 'Reintentar', estado: 500, detalles: null } });
  }
});

Cuatro puntos que se aprenden tarde. registrarSiEsNuevo debe ser atómico: un INSERT con clave primaria evento.id que capture la violación de unicidad, porque comprobar y luego insertar en dos pasos tiene una carrera si llegan dos copias a dos procesos. Responde rápido y en 2xx: los tiempos límite del proveedor son cortos y todo lo lento va a BullMQ. El webhook puede llegar antes que la respuesta al cliente, cosa habitual; por eso el pedido ya existe en base de datos antes de crear la intención —cuando llega el webhook hay algo que actualizar— y por eso GET /pedidos/:id es la fuente de verdad del front-end. Y el orden de eventos no está garantizado: puede llegar un payment_failed de un intento anterior después de succeeded, así que la máquina de estados debe rechazar lo inválido en vez de aplicarlo.

  1. La máquina de estados del pedido

stateDiagram-v2
  [*] --> carrito
  carrito --> pendiente_pago: iniciar pago (reserva stock)
  pendiente_pago --> pagado: webhook succeeded
  pendiente_pago --> cancelado: fallo, caducidad o cancelacion
  pagado --> preparando: almacen acepta
  preparando --> enviado: transportista recoge
  enviado --> entregado: confirmacion de entrega
  pagado --> reembolsado: reembolso total
  preparando --> reembolsado: reembolso total
  entregado --> reembolsado: devolucion aceptada

Las transiciones válidas se declaran como datos y se comprueban en el dominio, no repartidas por los controladores:

// src/dominio/estados-pedido.js
const TRANSICIONES = {
  carrito: ['pendiente_pago'],
  pendiente_pago: ['pagado', 'cancelado'],
  pagado: ['preparando', 'reembolsado', 'cancelado'],
  preparando: ['enviado', 'reembolsado'],
  enviado: ['entregado', 'reembolsado'],
  entregado: ['reembolsado'],
  cancelado: [], reembolsado: [],   // estados terminales: no hay salida
};

function transitar(pedido, estadoNuevo) {
  const permitidos = TRANSICIONES[pedido.estado] ?? [];
  if (!permitidos.includes(estadoNuevo)) throw new ConflictoDeEstado('TRANSICION_INVALIDA',
    { estadoActual: pedido.estado, estadoSolicitado: estadoNuevo, permitidos });
  return { ...pedido, estado: estadoNuevo, actualizadoEn: new Date().toISOString() };
}
module.exports = { transitar, TRANSICIONES };

Esta tabla resuelve el problema del punto 8: un payment_failed tardío sobre un pedido ya pagado lanza ConflictoDeEstado, se registra y no corrompe nada.

  1. Colas, reembolsos y auditoría

Nada lento ocurre dentro de la petición del webhook:

// src/servicios/pedidos.js — extracto
async function confirmarPago({ pedidoId, referenciaExterna, importeCentimos }) {
  const pedido = await repositorioPedidos.obtener(pedidoId);
  if (!pedido) throw new RecursoNoEncontrado('PEDIDO_NO_ENCONTRADO', { pedidoId });
  // Defensa: el importe cobrado debe coincidir con el calculado.
  if (importeCentimos !== pedido.totalCentimos) throw new ConflictoDeEstado(
    'IMPORTE_DESCUADRADO', { pedidoId, importeCentimos });
  const confirmado = transitar(pedido, 'pagado');
  // Consume la reserva en transacción: stock -= n, stockReservado -= n.
  await repositorioPedidos.confirmarEnTransaccion({ pedido: confirmado, referenciaExterna });
  // jobId fijo = idempotencia gratis: BullMQ rechaza un id ya existente,
  // así que aunque el webhook se procesara dos veces el correo sale una.
  await colaCorreos.add('confirmacion-pedido', { pedidoId }, { attempts: 5,
    backoff: { type: 'exponential', delay: 2000 }, jobId: `confirmacion-${pedidoId}` });
  await colaFacturas.add('generar-factura', { pedidoId }, { jobId: `factura-${pedidoId}` });
  return confirmado;
}

La factura en PDF se genera en el consumidor —proceso aparte del M10/M11— y se sube a almacenamiento de objetos, porque el sistema de ficheros del PaaS es efímero (M11).

Reembolsos. Un reembolso parcial no es "restar del total": es reembolsar líneas concretas, usando el precio congelado en la línea y comprobando que no se exceda lo ya reembolsado. La llamada a stripe.refunds.create lleva siempre idempotencyKey: con dinero saliendo, un reintento sin clave puede devolver el importe dos veces. Auditoría. Toda operación que toca dinero escribe una fila inmutable en auditoria: quién (actorId), qué (accion), sobre qué (pedidoId), cuánto (importeCentimos), cuándo (ISO) y con qué idPeticion (M11, para cruzarlo con los registros de pino). Sin borrados ni actualizaciones. Cuando alguien pregunte "por qué este pedido de 4840 céntimos aparece reembolsado por 2200", la respuesta debe estar en una tabla, no en la memoria de un compañero.

  1. Pruebas de lo que puede romperse y qué queda fuera

Prioriza lo que causaría pérdidas reales:

// test/tienda/checkout.test.js
describe('checkout de la tienda', () => {
  it('rechaza un webhook con firma invalida', async () => {
    await peticion(crearAplicacion()).post('/api/v1/webhooks/pagos')
      .set('stripe-signature', 't=1,v1=falsa').set('Content-Type', 'application/json')
      .send(Buffer.from(JSON.stringify({ type: 'payment_intent.succeeded' })))
      .expect(400)
      .expect((r) => expect(r.body.error.codigo).to.equal('FIRMA_INVALIDA'));
  });
  it('impide la sobreventa con dos compras simultaneas de la ultima unidad', async () => {
    await sembrarVariante({ sku: 'EV-VIN-003-UNI', stock: 1 });
    const resultados = await Promise.allSettled([
      iniciarPago({ sku: 'EV-VIN-003-UNI', cantidad: 1, usuario: 'usr-lucia' }),
      iniciarPago({ sku: 'EV-VIN-003-UNI', cantidad: 1, usuario: 'usr-marc' }),
    ]);
    expect(resultados.filter((r) => r.status === 'fulfilled')).to.have.lengthOf(1);
    expect(resultados.find((r) => r.status === 'rejected').reason.codigo)
      .to.equal('STOCK_INSUFICIENTE');
  });
  it('ignora un payment_failed posterior a la confirmacion', async () => {
    await enviarWebhookFirmado(eventoExito);
    await enviarWebhookFirmado(eventoFalloTardio);
    expect((await repositorioPedidos.obtener('ped-8812')).estado).to.equal('pagado');
  });
});

Stripe se simula con Sinon (M9) o con su CLI en modo de pruebas. Transiciones y cálculo de importes son funciones puras: pruébalas a fondo con casos límite de redondeo.

Fuera Por qué Ampliación
Cupones y promociones Multiplican los casos del cálculo de importe Objeto Descuento aplicado antes del impuesto, con acumulabilidad explícita
Impuestos por país y multi-moneda Exigen datos fiscales actualizados y criterio legal Servicio de impuestos externo; moneda por pedido con tipos congelados
Suscripciones Otro modelo de facturación entero Facturación recurrente de la pasarela + webhooks de ciclo
Integración con almacén Depende del operador logístico Cola de eventos de expedición y seguimiento

Errores Comunes y Consejos

  • Poner express.json() global antes del webhook. El error número uno de esta lección: la firma nunca verificará.
  • Trabajar con Number en euros. Céntimos enteros siempre, con sufijo Centimos en el nombre. Y no confíes en la página de éxito del navegador: es una pista, no la verdad; la verdad la trae el webhook.
  • No registrar el evento crudo de la pasarela. Guardarlo en pagos.crudoEvento te salvará el día que haya que reconciliar con el extracto bancario. Y no reembolses sin idempotencyKey: con dinero saliendo, la idempotencia importa más aún que con dinero entrando.
  • Consejo: expón dos métricas con prom-client (M11): pedidos por estado y edad del pedido más antiguo en pendiente_pago. Si esa edad crece, algo va mal con los webhooks y lo sabrás antes de que llame un cliente.

Ejercicios

  1. Caducidad de reservas. Escribe el trabajo repetible de BullMQ que cada minuto libera las reservas caducadas y cancela sus pedidos, con una prueba de que el stock vendible vuelve a su valor original.

  2. Cálculo de importe a prueba de balas. Escribe pruebas de calcularImporte y calcularEnvio para: carrito vacío, subtotal justo en 4999 y en 5000, y una línea de 3 unidades a 1999 céntimos comprobando el IVA exacto.

  3. Endurecer el webhook. Crea la tabla eventos_pago con clave primaria evento_id y haz registrarSiEsNuevo atómico. Prueba que dos entregas concurrentes del mismo evento producen una sola confirmación.

Soluciones

1. Caducidad de reservas

// src/colas/mantenimiento.js
await colaMantenimiento.add('liberar-reservas', {},
  { repeat: { every: 60_000 }, jobId: 'liberar-reservas-repetible' });
new Worker('mantenimiento', async () => {
  for (const reserva of await repositorioInventario.reservasCaducadas()) {
    await sequelize.transaction(async (t) => {
      for (const linea of reserva.lineas) {
        await sequelize.query(
          'UPDATE variantes SET stock_reservado = stock_reservado - :n WHERE id = :id',
          { replacements: { n: linea.cantidad, id: linea.varianteId }, transaction: t });
      }
      // La condición de estado es imprescindible: sin ella, una carrera
      // con el webhook cancelaría un pedido que ya se ha cobrado.
      await sequelize.query(
        `UPDATE pedidos SET estado = 'cancelado' WHERE id = :id AND estado = 'pendiente_pago'`,
        { replacements: { id: reserva.pedidoId }, transaction: t });
      await repositorioInventario.eliminarReserva(reserva.id, t);
    });
  }
}, { connection });

2. Cálculo de importe

it('cobra envio en 4999 y no en 5000', () => {
  expect(calcularEnvio(4999)).to.equal(495);
  expect(calcularEnvio(5000)).to.equal(0);
});
it('calcula el IVA sobre el subtotal completo', () => {
  const lineas = [{ precioUnitarioCentimos: 1999, cantidad: 3 }];
  const r = calcularImporte({ lineas, envioCentimos: calcularEnvio(5997) });
  expect(r.subtotalCentimos).to.equal(5997);
  expect(r.impuestosCentimos).to.equal(1259);  // round(5997 * 210 / 1000)
  expect(r.totalCentimos).to.equal(7256);
});
it('devuelve todo a cero con el carrito vacio', () => {
  expect(calcularImporte({ lineas: [], envioCentimos: 0 }).totalCentimos).to.equal(0);
});

Si el IVA se calculara línea a línea, round(1999 * 0.21) * 3 = 1260: un céntimo de diferencia por pedido. Multiplícalo por diez mil pedidos y tienes una conversación incómoda con contabilidad.

3. Webhook atómico

La tabla es mínima: evento_id TEXT PRIMARY KEY, tipo, recibido_en TIMESTAMPTZ DEFAULT NOW(), estado y error. La clave primaria es lo que hace el trabajo.

async function registrarSiEsNuevo(eventoId, tipo) {
  // ON CONFLICT DO NOTHING RETURNING hace comprobación e inserción en una
  // sola sentencia atómica: no hay ventana de carrera entre dos procesos.
  const [filas] = await sequelize.query(
    `INSERT INTO eventos_pago (evento_id, tipo) VALUES (:eventoId, :tipo)
     ON CONFLICT (evento_id) DO NOTHING RETURNING evento_id`,
    { replacements: { eventoId, tipo } });
  return filas.length > 0;   // false si ya existía: es un reenvío
}

it('procesa una sola vez con entregas simultaneas', async () => {
  const espia = sinon.spy(servicioPedidos, 'confirmarPago');
  await Promise.all([enviarWebhookFirmado(eventoExito), enviarWebhookFirmado(eventoExito)]);
  expect(espia.callCount).to.equal(1);
});

Conclusión

Has construido la tienda de merchandising de Escena Viva y, con ella, has cruzado la línea que separa las aplicaciones que pueden fallar sin consecuencias de las que no. Las tres ideas que te llevas son duraderas y no dependen de Stripe ni de Node: el pedido congela su propia historia y no la lee del catálogo; el importe se calcula siempre en el servidor y en enteros; y la consistencia con un sistema externo se logra con firma, idempotencia y una máquina de estados que rechaza lo imposible en vez de aplicarlo.

También has visto cómo se acumulan las capas del curso: la transacción con bloqueo del M7 evitó la sobreventa, la idempotencia del M10 domó los reintentos del proveedor, las colas sacaron el trabajo lento de la petición y el almacenamiento de objetos del M11 recogió las facturas. Ninguna pieza era nueva; nueva era la exigencia. En la siguiente lección bajamos la tensión transaccional y subimos otra muy distinta: el magazine de Escena Viva, donde el reto ya no es la corrección de un céntimo sino el contenido —Markdown que puede traer HTML malicioso, imágenes subidas que mienten sobre lo que son y miles de lecturas por cada escritura.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados