El segon producte satèl·lit d'Escena Viva és la botiga de marxandatge: samarretes del Concierto de Otoño (evt-001), cartells serigrafiats de la Noche de Monólogos (evt-002) i vinils en directe del Festival de Jazz de Primavera (evt-003). La Lucía vol la samarreta talla M en negre; en Marc, el vinil i un cartell. La botiga és un domini nou, però els usuaris, els rols i els esdeveniments són els mateixos. La idea genuïnament nova d'aquest projecte són els diners de veritat. Fins ara, quan alguna cosa fallava es retornava un 500 i es reintentava; aquí una fallada pot voler dir que un client ha pagat i no ha rebut res, o que ha rebut alguna cosa sense pagar. Això obliga a un altre nivell de rigor: estats explícits, consistència amb un sistema extern que no controles, idempotència a tot arreu i auditoria de cada cèntim.

Advertiment necessari. Cobrar diners reals implica obligacions legals, fiscals, comptables i de protecció de dades que varien segons el país i que exigeixen assessorament professional. Aquest projecte és didàctic: ensenya l'enginyeria, no substitueix ni un assessor ni un jurista.

Contingut

  1. El requisit de negoci
  2. Decisions de disseny i alternatives descartades
  3. El model de dades: variants, cistella i comanda
  4. La cistella: convidat, usuari i fusió en iniciar sessió
  5. L'import es calcula sempre al servidor
  6. Reserva d'stock contra la sobrevenda
  7. El repte tècnic: integrar una passarel·la de pagament
  8. Webhooks: signatura, cos en cru i idempotència
  9. La màquina d'estats de la comanda
  10. Cues, devolucions i auditoria
  11. Proves del que pot trencar-se i què queda fora

  1. El requisit de negoci

  • Cada esdeveniment té productes associats; un producte té variants (talla, color) amb existències pròpies.
  • Qualsevol persona pot navegar pel catàleg i omplir una cistella, amb compte o sense; per pagar cal estar autenticat.
  • El pagament es fa amb targeta a través d'una passarel·la; Escena Viva no veu mai les dades de la targeta.
  • La comanda passa per estats verificables i el client els pot consultar; es poden emetre devolucions totals o parcials.
  • Els organitzadors veuen les comandes dels productes de la seva sala; els administradors, totes.

  1. Decisions de disseny i alternatives descartades

Decisió Alternativa descartada Per què
PostgreSQL amb Sequelize (M7) MongoDB, com el xat Els diners demanen transaccions ACID entre diverses taules, integritat referencial i sumes exactes
Stock a la variant, no al producte Un comptador al producte Vendre «samarreta» no vol dir res: es ven la talla M negra. Un stock per producte no impedeix vendre deu XXL inexistents
Línies de comanda amb preu congelat Llegir el preu actual del producte Vegeu el punt 3: és la decisió més important de la lliçó
PaymentIntent creat al servidor Dades de targeta que arriben a la nostra API Rebre un número de targeta ens fica dins l'abast complet de PCI DSS
Confirmació per webhook Confirmar quan el navegador torna a la pàgina d'èxit El navegador es pot tancar just després de pagar. La veritat del pagament la té la passarel·la
Reserva temporal d'stock Descomptar en afegir a la cistella, o només en pagar El primer bloqueja existències durant hores; el segon permet sobrevenda mentre es teclegen les dades de la targeta
Màquina d'estats explícita Booleans pagat, enviat, cancellat Amb cinc booleans hi ha 32 combinacions i només 7 de vàlides. Els estats impossibles han de ser irrepresentables

Dependència nova: npm install stripe. Res més: sequelize, pg, bullmq, ioredis i zod ja hi són.

  1. El model de dades: variants, cistella i comanda

Taula Camps clau Nota de disseny
productes id, esdevenimentId, nom, salaId, actiu Sense preu ni stock: tots dos viuen a la variant
variants id, producteId, sku, talla, color, preuCentims, stock, stockReservat SKU únic i llegible: EV-SAM-003-M-NEG
cistelles id, usuariId (nul si és convidat), tokenConvidat, expiraEl
linies_cistella cistellaId, variantId, quantitat No desa el preu: la cistella mostra el preu actual
comandes id, usuariId, estat, subtotalCentims, impostosCentims, enviamentCentims, totalCentims, moneda
linies_comanda comandaId, variantId, sku, nomProducte, talla, color, preuUnitariCentims, quantitat Copia preu i descripció
pagaments comandaId, proveidor, referenciaExterna, importCentims, estat, cruEsdeveniment Desa el JSON original de la passarel·la

Per què la comanda congela el preu. És la decisió que separa una botiga de joguina d'una de real. La samarreta de l'evt-001 costa avui 2200 cèntims i la Lucía la compra. D'aquí a tres setmanes es rebaixa a 1500 per liquidar existències. Si la línia de comanda desés només variantId i l'import es llegís del catàleg: l'històric de la Lucía mostraria 1500 i no quadraria amb el que es va cobrar; la factura emesa diria una cosa i la base de dades una altra —un problema comptable, no estètic—; una devolució tornaria 1500 en comptes de 2200; i qualsevol informe d'ingressos passats canviaria cada vegada que algú edita un preu. La línia de comanda és un document històric immutable: copia preu, nom i atributs. Que es dupliqui informació no és desnormalització bruta; és que la dada de la comanda i la del catàleg són coses diferents que coincideixen un instant.

// src/domini/comanda.js — tot el que va marcat es COPIA, no es referencia
const crearLiniaComanda = ({ variant, producte, quantitat }) => ({
  variantId: variant.id, sku: variant.sku, quantitat,
  nomProducte: producte.nom,                          // congelat
  talla: variant.talla, color: variant.color,         // congelats
  preuUnitariCentims: variant.preuCentims,            // congelat: l'essencial
  importCentims: variant.preuCentims * quantitat,
});
module.exports = { crearLiniaComanda };

  1. La cistella: convidat, usuari i fusió en iniciar sessió

Obligar a registrar-se abans de veure la cistella és la manera més eficaç de perdre vendes. N'hi ha de dos tipus: la de convidat, identificada per un tokenConvidat aleatori desat en una galeta httpOnly de 30 dies (la galeta conté només l'identificador: no confiïs mai en dades de negoci que viatgen al client), i la d'usuari, lligada a usuariId i persistent entre dispositius. Les cistelles de convidat caduquen als 30 dies amb un treball repetible de BullMQ. El cas interessant és iniciar sessió amb una cistella ja començada:

// src/serveis/cistelles.js
async function fusionarCistelles({ repositoriCistelles, tokenConvidat, usuariId }) {
  const convidada = await repositoriCistelles.cercarPerToken(tokenConvidat);
  if (!convidada) return repositoriCistelles.obtenirOCrearDUsuari(usuariId);
  const propia = await repositoriCistelles.obtenirOCrearDUsuari(usuariId);
  for (const linia of convidada.linies) {
    const existent = propia.linies.find((l) => l.variantId === linia.variantId);
    // Regla de negoci: sumar quantitats, mai reemplacar en silenci.
    // Reemplacar fa que l'usuari "perdi" el que acabava d'afegir.
    if (existent) existent.quantitat = Math.min(existent.quantitat + linia.quantitat, LIMIT_LINIA);
    else propia.linies.push(linia);
  }
  await repositoriCistelles.guardar(propia);
  await repositoriCistelles.eliminar(convidada.id);
  return propia;
}
module.exports = { fusionarCistelles };

Les tres polítiques possibles són sumar, quedar-se amb la del convidat o amb la de l'usuari; sumar és la que sorprèn menys, sempre amb un topall per línia.

  1. L'import es calcula sempre al servidor

Regla sense excepcions: el client envia què vol comprar, mai quant costa. Si la teva API accepta un camp totalCentims del client, tens una botiga gratuïta per a qualsevol que obri les eines de desenvolupament.

// src/serveis/calcul-import.js
const TIPUS_IVA_PER_MIL = 210;   // 21 % en per mil, per no fer servir decimals
const LLINDAR_ENVIAMENT_GRATIS = 5000;
const ENVIAMENT_ESTANDARD = 495;
function calcularImport({ linies, enviamentCentims }) {
  const subtotalCentims = linies.reduce(
    (suma, l) => suma + l.preuUnitariCentims * l.quantitat, 0);
  // Arrodoniment al centim una sola vegada i sobre el subtotal complet.
  // Arrodonir linia a linia acumula desviacions de fins a N/2 centims.
  const impostosCentims = Math.round((subtotalCentims * TIPUS_IVA_PER_MIL) / 1000);
  return { subtotalCentims, impostosCentims, enviamentCentims,
           totalCentims: subtotalCentims + impostosCentims + enviamentCentims };
}
// Funcio pura del subtotal: provable sense base de dades.
const calcularEnviament = (subtotal) => subtotal === 0 ? 0
  : (subtotal >= LLINDAR_ENVIAMENT_GRATIS ? 0 : ENVIAMENT_ESTANDARD);
module.exports = { calcularImport, calcularEnviament, TIPUS_IVA_PER_MIL };

Per què tot en enters: 0.1 + 0.2 en coma flotant dóna 0.30000000000000004, i sobre milers de comandes aquests residus produeixen desquadraments que ningú no sap explicar. En cèntims enters només hi ha un punt d'arrodoniment —l'impost—, explícit i aïllat en una funció pura fàcil de provar. Conseqüència visible: si la interfície reparteix l'IVA per línia, la suma pot diferir en un cèntim del total; mana el total del servidor i el desglossament és informatiu.

  1. Reserva d'stock contra la sobrevenda

Això ja ho vas resoldre al M7 amb l'aforament: 3000 places, 1811 venudes, i una transacció amb blocatge que impedeix que dues compres simultànies venguin la mateixa butaca. La botiga és el mateix problema amb un matís nou: entre que el client prem «Pagar» i que la passarel·la confirma passen de 10 segons a diversos minuts. Si descomptem en confirmar, en aquesta finestra altres poden exhaurir l'stock i acabarem cobrant un vinil que ja no existeix.

La solució és la reserva temporal: dues columnes, stock i stockReservat, i una regla —el que és venible és stock - stockReservat.

// src/repositoris/inventari-sql.js
async function reservarStock({ sequelize, linies, comandaId, minuts = 20 }) {
  return sequelize.transaction(async (t) => {
    for (const linia of linies) {
      // SELECT ... FOR UPDATE serialitza els compradors de la mateixa variant.
      const [variant] = await sequelize.query(
        'SELECT stock, stock_reservat FROM variants WHERE id = :id FOR UPDATE',
        { replacements: { id: linia.variantId }, type: SELECT, transaction: t });
      const disponible = variant.stock - variant.stock_reservat;
      // Error de domini del M6: el gestor central el tradueix a 409.
      if (disponible < linia.quantitat) throw new ConflicteDEstat('STOCK_INSUFICIENT',
        { sku: linia.sku, sollicitat: linia.quantitat, disponible });
      await sequelize.query(
        'UPDATE variants SET stock_reservat = stock_reservat + :n WHERE id = :id',
        { replacements: { n: linia.quantitat, id: linia.variantId }, transaction: t });
    }
    await repositoriInventari.crearReserva({ comandaId, minuts }, t);
  });
}
Moment stock stockReservat Venible
Inicial 50 0 50
La Lucía inicia el pagament de 2 50 2 48
El pagament es confirma 48 0 48
(alternativa) el pagament falla o caduca 50 0 50

L'alliberament de reserves caducades és un treball repetible de BullMQ (M10) cada minut: sense ell, un client que abandona el pagament congela stock per sempre.

  1. El repte tècnic: integrar una passarel·la de pagament

sequenceDiagram
  participant C as Client
  participant A as API Escena Viva
  participant S as Stripe
  C->>A: POST /api/v1/comandes (linies + adreca)
  A->>A: calcular import + reservar stock
  A->>S: crear PaymentIntent (import, metadata.comandaId)
  A-->>C: { comandaId, clientSecret }
  C->>S: confirmar pagament amb la targeta (mai passa per A)
  S->>A: webhook payment_intent.succeeded (signat)
  A->>A: confirmar comanda, consumir reserva, encuar correu

L'essencial: la targeta no toca mai el nostre servidor. El navegador l'envia directament a Stripe amb el clientSecret; la nostra API només declara «cal cobrar 4840 cèntims per la comanda ped-8812». PCI DSS en una frase: és la norma de seguretat de la indústria de targetes, i el seu abast —i el cost de complir-la— creix brutalment tan bon punt les dades de la targeta passen pels teus sistemes; delegant en la passarel·la et quedes al nivell d'autoavaluació més simple. Tota la resta són detalls d'integració.

// src/serveis/pagaments.js
async function crearIntencioDePagament({ comanda, usuari }) {
  const intencio = await stripe.paymentIntents.create({
    amount: comanda.totalCentims,   // l'import el fixa el servidor (punt 5)
    currency: comanda.moneda,       // 'eur'
    // La metadata es el pont entre el mon de Stripe i el nostre.
    metadata: { comandaId: comanda.id, usuariId: usuari.id },
    receipt_email: usuari.correu,   // [email protected]
    automatic_payment_methods: { enabled: true },
  // Idempotencia cap a fora: si reintentem, Stripe no crea dues intencions.
  }, { idempotencyKey: `intencio-${comanda.id}` });
  return { referenciaExterna: intencio.id, clientSecret: intencio.client_secret };
}
module.exports = { crearIntencioDePagament, stripe };

  1. Webhooks: signatura, cos en cru i idempotència

Un webhook és una petició HTTP que el proveïdor et fa a tu, i com que qualsevol pot fer-te una petició HTTP, verificar la signatura no és opcional: sense això qualsevol t'envia payment_intent.succeeded i s'emporta mercaderia de franc.

L'error clàssic. La signatura es calcula sobre els bytes exactes del cos. Si express.json() ja l'ha analitzat, l'original s'ha perdut; tornar a serialitzar amb JSON.stringify produeix bytes diferents (ordre de claus, espaiat, escapaments Unicode) i la verificació falla sempre. El símptoma és un 400 signature verification failed que sembla un problema de claus i no ho és. La solució és muntar la ruta amb express.raw abans de l'express.json() global:

// src/app.js — extracte de l'ordre de middleware
function crearAplicacio(dependencies = {}) {
  const aplicacio = express();
  aplicacio.use(helmet());
  aplicacio.use(idPeticio);
  // ORDRE CRITIC: el webhook necessita Buffer, no objecte.
  aplicacio.use('/api/v1/webhooks/pagaments',
    express.raw({ type: 'application/json', limit: '1mb' }),
    crearRutesWebhookPagaments(dependencies));
  aplicacio.use(express.json({ limit: '100kb' }));  // la resta si que fa servir JSON
  // ... enrutadors normals i gestor d'errors central (M6) ...
  return aplicacio;
}
// src/rutes/webhook-pagaments.js
rutes.post('/', async (peticio, resposta) => {
  let esdeveniment;
  try {
    // peticio.body es un Buffer gracies a express.raw.
    esdeveniment = stripe.webhooks.constructEvent(peticio.body,
      peticio.get('stripe-signature'), configuracio.stripe.secretWebhook);
  } catch (error) {
    return resposta.status(400).json({ error: { codi: 'SIGNATURA_INVALIDA',
      missatge: 'Signatura no verificable', estat: 400, detalls: null } });
  }
  // Idempotencia: Stripe reintenta durant dies si no respons 2xx.
  // esdeveniment.id fa d'Idempotency-Key (M10), persistit a base de dades.
  const esNou = await repositoriEsdevenimentsPagament.registrarSiEsNou(esdeveniment.id, esdeveniment.type);
  if (!esNou) return resposta.status(200).json({ rebut: true, repetit: true });
  try {
    const objecte = esdeveniment.data.object;
    if (esdeveniment.type === 'payment_intent.succeeded') {
      await serveiComandes.confirmarPagament({ comandaId: objecte.metadata.comandaId,
        referenciaExterna: objecte.id, importCentims: objecte.amount_received });
    } else if (esdeveniment.type === 'payment_intent.payment_failed') {
      await serveiComandes.marcarPagamentFallit({ comandaId: objecte.metadata.comandaId });
    }
    return resposta.status(200).json({ rebut: true });  // 200 rapid, cua a part
  } catch (error) {
    // Un 500 fa que Stripe reintenti: correcte davant d'una fallada transitoria.
    await repositoriEsdevenimentsPagament.marcarFallit(esdeveniment.id, error.message);
    return resposta.status(500).json({ error: { codi: 'ERROR_INTERN',
      missatge: 'Reintentar', estat: 500, detalls: null } });
  }
});

Quatre punts que s'aprenen tard. registrarSiEsNou ha de ser atòmic: un INSERT amb clau primària esdeveniment.id que capturi la violació d'unicitat, perquè comprovar i després inserir en dos passos té una carrera si arriben dues còpies a dos processos. Respon ràpid i en 2xx: els temps límit del proveïdor són curts i tot el que sigui lent va a BullMQ. El webhook pot arribar abans que la resposta al client, cosa habitual; per això la comanda ja existeix a la base de dades abans de crear la intenció —quan arriba el webhook hi ha alguna cosa per actualitzar— i per això GET /comandes/:id és la font de veritat del front-end. I l'ordre d'esdeveniments no està garantit: pot arribar un payment_failed d'un intent anterior després del succeeded, així que la màquina d'estats ha de rebutjar el que no és vàlid en comptes d'aplicar-ho.

  1. La màquina d'estats de la comanda

stateDiagram-v2
  [*] --> cistella
  cistella --> pendent_pagament: iniciar pagament (reserva stock)
  pendent_pagament --> pagat: webhook succeeded
  pendent_pagament --> cancellat: fallada, caducitat o cancellacio
  pagat --> preparant: magatzem accepta
  preparant --> enviat: transportista recull
  enviat --> lliurat: confirmacio de lliurament
  pagat --> retornat: devolucio total
  preparant --> retornat: devolucio total
  lliurat --> retornat: devolucio acceptada

Les transicions vàlides es declaren com a dades i es comproven al domini, no repartides pels controladors:

// src/domini/estats-comanda.js
const TRANSICIONS = {
  cistella: ['pendent_pagament'],
  pendent_pagament: ['pagat', 'cancellat'],
  pagat: ['preparant', 'retornat', 'cancellat'],
  preparant: ['enviat', 'retornat'],
  enviat: ['lliurat', 'retornat'],
  lliurat: ['retornat'],
  cancellat: [], retornat: [],   // estats terminals: no hi ha sortida
};

function transitar(comanda, estatNou) {
  const permesos = TRANSICIONS[comanda.estat] ?? [];
  if (!permesos.includes(estatNou)) throw new ConflicteDEstat('TRANSICIO_INVALIDA',
    { estatActual: comanda.estat, estatSollicitat: estatNou, permesos });
  return { ...comanda, estat: estatNou, actualitzatEl: new Date().toISOString() };
}
module.exports = { transitar, TRANSICIONS };

Aquesta taula resol el problema del punt 8: un payment_failed tardà sobre una comanda ja pagat llança ConflicteDEstat, es registra i no corromp res.

  1. Cues, devolucions i auditoria

Res de lent no passa dins de la petició del webhook:

// src/serveis/comandes.js — extracte
async function confirmarPagament({ comandaId, referenciaExterna, importCentims }) {
  const comanda = await repositoriComandes.obtenir(comandaId);
  if (!comanda) throw new RecursNoTrobat('COMANDA_NO_TROBADA', { comandaId });
  // Defensa: l'import cobrat ha de coincidir amb el calculat.
  if (importCentims !== comanda.totalCentims) throw new ConflicteDEstat(
    'IMPORT_DESQUADRAT', { comandaId, importCentims });
  const confirmada = transitar(comanda, 'pagat');
  // Consumeix la reserva en transaccio: stock -= n, stockReservat -= n.
  await repositoriComandes.confirmarEnTransaccio({ comanda: confirmada, referenciaExterna });
  // jobId fix = idempotencia de franc: BullMQ rebutja un id ja existent,
  // aixi que encara que el webhook es processes dues vegades el correu surt una.
  await cuaCorreus.add('confirmacio-comanda', { comandaId }, { attempts: 5,
    backoff: { type: 'exponential', delay: 2000 }, jobId: `confirmacio-${comandaId}` });
  await cuaFactures.add('generar-factura', { comandaId }, { jobId: `factura-${comandaId}` });
  return confirmada;
}

La factura en PDF es genera al consumidor —procés a part del M10/M11— i es puja a emmagatzematge d'objectes, perquè el sistema de fitxers del PaaS és efímer (M11).

Devolucions. Una devolució parcial no és «restar del total»: és tornar línies concretes, fent servir el preu congelat a la línia i comprovant que no se superi el que ja s'ha tornat. La crida a stripe.refunds.create porta sempre idempotencyKey: amb diners sortint, un reintent sense clau pot tornar l'import dues vegades. Auditoria. Tota operació que toca diners escriu una fila immutable a auditoria: qui (actorId), què (accio), sobre què (comandaId), quant (importCentims), quan (ISO) i amb quin idPeticio (M11, per creuar-ho amb els registres de pino). Sense esborrats ni actualitzacions. Quan algú pregunti «per què aquesta comanda de 4840 cèntims apareix retornada per 2200», la resposta ha de ser en una taula, no a la memòria d'un company.

  1. Proves del que pot trencar-se i què queda fora

Prioritza el que causaria pèrdues reals:

// test/botiga/pagament.test.js
describe('pagament de la botiga', () => {
  it('rebutja un webhook amb signatura invalida', async () => {
    await peticio(crearAplicacio()).post('/api/v1/webhooks/pagaments')
      .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.codi).to.equal('SIGNATURA_INVALIDA'));
  });
  it("impedeix la sobrevenda amb dues compres simultanies de l'ultima unitat", async () => {
    await sembrarVariant({ sku: 'EV-VIN-003-UNI', stock: 1 });
    const resultats = await Promise.allSettled([
      iniciarPagament({ sku: 'EV-VIN-003-UNI', quantitat: 1, usuari: 'usr-lucia' }),
      iniciarPagament({ sku: 'EV-VIN-003-UNI', quantitat: 1, usuari: 'usr-marc' }),
    ]);
    expect(resultats.filter((r) => r.status === 'fulfilled')).to.have.lengthOf(1);
    expect(resultats.find((r) => r.status === 'rejected').reason.codi)
      .to.equal('STOCK_INSUFICIENT');
  });
  it('ignora un payment_failed posterior a la confirmacio', async () => {
    await enviarWebhookSignat(esdevenimentExit);
    await enviarWebhookSignat(esdevenimentFallidaTardana);
    expect((await repositoriComandes.obtenir('ped-8812')).estat).to.equal('pagat');
  });
});

Stripe se simula amb Sinon (M9) o amb la seva CLI en mode de proves. Transicions i càlcul d'imports són funcions pures: prova-les a fons amb casos límit d'arrodoniment.

Fora Per què Ampliació
Cupons i promocions Multipliquen els casos del càlcul d'import Objecte Descompte aplicat abans de l'impost, amb acumulabilitat explícita
Impostos per país i multimoneda Exigeixen dades fiscals actualitzades i criteri legal Servei d'impostos extern; moneda per comanda amb tipus congelats
Subscripcions Un altre model de facturació sencer Facturació recurrent de la passarel·la + webhooks de cicle
Integració amb magatzem Depèn de l'operador logístic Cua d'esdeveniments d'expedició i seguiment

Errors Comuns i Consells

  • Posar express.json() global abans del webhook. L'error número u d'aquesta lliçó: la signatura no verificarà mai.
  • Treballar amb Number en euros. Cèntims enters sempre, amb sufix Centims al nom. I no confiïs en la pàgina d'èxit del navegador: és una pista, no la veritat; la veritat la porta el webhook.
  • No registrar l'esdeveniment cru de la passarel·la. Desar-lo a pagaments.cruEsdeveniment et salvarà el dia que calgui conciliar amb l'extracte bancari. I no facis devolucions sense idempotencyKey: amb diners sortint, la idempotència importa encara més que amb diners entrant.
  • Consell: exposa dues mètriques amb prom-client (M11): comandes per estat i edat de la comanda més antiga en pendent_pagament. Si aquesta edat creix, alguna cosa va malament amb els webhooks i ho sabràs abans que truqui un client.

Exercicis

  1. Caducitat de reserves. Escriu el treball repetible de BullMQ que cada minut allibera les reserves caducades i cancel·la les seves comandes, amb una prova que l'stock venible torna al seu valor original.

  2. Càlcul d'import a prova de bales. Escriu proves de calcularImport i calcularEnviament per a: cistella buida, subtotal just a 4999 i a 5000, i una línia de 3 unitats a 1999 cèntims comprovant l'IVA exacte.

  3. Endurir el webhook. Crea la taula esdeveniments_pagament amb clau primària esdeveniment_id i fes que registrarSiEsNou sigui atòmic. Prova que dos lliuraments concurrents del mateix esdeveniment produeixen una sola confirmació.

Solucions

1. Caducitat de reserves

// src/cues/manteniment.js
await cuaManteniment.add('alliberar-reserves', {},
  { repeat: { every: 60_000 }, jobId: 'alliberar-reserves-repetible' });
new Worker('manteniment', async () => {
  for (const reserva of await repositoriInventari.reservesCaducades()) {
    await sequelize.transaction(async (t) => {
      for (const linia of reserva.linies) {
        await sequelize.query(
          'UPDATE variants SET stock_reservat = stock_reservat - :n WHERE id = :id',
          { replacements: { n: linia.quantitat, id: linia.variantId }, transaction: t });
      }
      // La condicio d'estat es imprescindible: sense ella, una carrera
      // amb el webhook cancellaria una comanda que ja s'ha cobrat.
      await sequelize.query(
        `UPDATE comandes SET estat = 'cancellat' WHERE id = :id AND estat = 'pendent_pagament'`,
        { replacements: { id: reserva.comandaId }, transaction: t });
      await repositoriInventari.eliminarReserva(reserva.id, t);
    });
  }
}, { connection });

2. Càlcul d'import

it('cobra enviament a 4999 i no a 5000', () => {
  expect(calcularEnviament(4999)).to.equal(495);
  expect(calcularEnviament(5000)).to.equal(0);
});
it("calcula l'IVA sobre el subtotal complet", () => {
  const linies = [{ preuUnitariCentims: 1999, quantitat: 3 }];
  const r = calcularImport({ linies, enviamentCentims: calcularEnviament(5997) });
  expect(r.subtotalCentims).to.equal(5997);
  expect(r.impostosCentims).to.equal(1259);  // round(5997 * 210 / 1000)
  expect(r.totalCentims).to.equal(7256);
});
it('retorna tot a zero amb la cistella buida', () => {
  expect(calcularImport({ linies: [], enviamentCentims: 0 }).totalCentims).to.equal(0);
});

Si l'IVA es calculés línia a línia, round(1999 * 0.21) * 3 = 1260: un cèntim de diferència per comanda. Multiplica-ho per deu mil comandes i tens una conversa incòmoda amb comptabilitat.

3. Webhook atòmic

La taula és mínima: esdeveniment_id TEXT PRIMARY KEY, tipus, rebut_el TIMESTAMPTZ DEFAULT NOW(), estat i error. La clau primària és el que fa la feina.

async function registrarSiEsNou(esdevenimentId, tipus) {
  // ON CONFLICT DO NOTHING RETURNING fa comprovacio i insercio en una
  // sola sentencia atomica: no hi ha finestra de carrera entre dos processos.
  const [files] = await sequelize.query(
    `INSERT INTO esdeveniments_pagament (esdeveniment_id, tipus) VALUES (:esdevenimentId, :tipus)
     ON CONFLICT (esdeveniment_id) DO NOTHING RETURNING esdeveniment_id`,
    { replacements: { esdevenimentId, tipus } });
  return files.length > 0;   // false si ja existia: es un reenviament
}

it('processa una sola vegada amb lliuraments simultanis', async () => {
  const espia = sinon.spy(serveiComandes, 'confirmarPagament');
  await Promise.all([enviarWebhookSignat(esdevenimentExit), enviarWebhookSignat(esdevenimentExit)]);
  expect(espia.callCount).to.equal(1);
});

Conclusió

Has construït la botiga de marxandatge d'Escena Viva i, amb ella, has creuat la línia que separa les aplicacions que poden fallar sense conseqüències de les que no. Les tres idees que t'emportes són duradores i no depenen ni de Stripe ni de Node: la comanda congela la seva pròpia història i no la llegeix del catàleg; l'import es calcula sempre al servidor i en enters; i la consistència amb un sistema extern s'aconsegueix amb signatura, idempotència i una màquina d'estats que rebutja l'impossible en comptes d'aplicar-ho.

També has vist com s'acumulen les capes del curs: la transacció amb blocatge del M7 va evitar la sobrevenda, la idempotència del M10 va domar els reintents del proveïdor, les cues van treure la feina lenta de la petició i l'emmagatzematge d'objectes del M11 va recollir les factures. Cap peça no era nova; nova era l'exigència. A la lliçó següent abaixem la tensió transaccional i n'apugem una de ben diferent: el magazín d'Escena Viva, on el repte ja no és la correcció d'un cèntim sinó el contingut —Markdown que pot portar HTML maliciós, imatges pujades que menteixen sobre què són i milers de lectures per cada escriptura.

Curs de Node.js: De Principiant a Avançat

Mòdul 1: Introducció a Node.js

Mòdul 2: Conceptes Bàsics

Mòdul 3: Sistema de Fitxers i E/S

Mòdul 4: HTTP i Servidors Web

Mòdul 5: NPM i Gestió de Paquets

Mòdul 6: Framework Express.js

Mòdul 7: Bases de Dades i ORMs

Mòdul 8: Autenticació i Autorització

Mòdul 9: Proves i Depuració

Mòdul 10: Temes Avançats

Mòdul 11: Desplegament i DevOps

Mòdul 12: Projectes del Món Real

© Copyright 2026. Tots els drets reservats