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
- El requisito de negocio
- Decisiones de diseño y alternativas descartadas
- El modelo de datos: variantes, carrito y pedido
- El carrito: invitado, usuario y fusión al iniciar sesión
- El importe se calcula siempre en el servidor
- Reserva de stock frente a sobreventa
- El reto técnico: integrar una pasarela de pago
- Webhooks: firma, cuerpo en crudo e idempotencia
- La máquina de estados del pedido
- Colas, reembolsos y auditoría
- Pruebas de lo que puede romperse y qué queda fuera
- 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.
- 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.
- 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 };
- 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.
- 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.
- 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.
- 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 };
- 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.
- 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.
- 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.
- 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
Numberen euros. Céntimos enteros siempre, con sufijoCentimosen 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.crudoEventote salvará el día que haya que reconciliar con el extracto bancario. Y no reembolses sinidempotencyKey: 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 enpendiente_pago. Si esa edad crece, algo va mal con los webhooks y lo sabrás antes de que llame un cliente.
Ejercicios
-
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.
-
Cálculo de importe a prueba de balas. Escribe pruebas de
calcularImporteycalcularEnviopara: 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. -
Endurecer el webhook. Crea la tabla
eventos_pagocon clave primariaevento_idy hazregistrarSiEsNuevoató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
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
