Escena Viva's second satellite product is the merchandise store: T-shirts for the Concierto de Otoño (evt-001), screen-printed posters for the Noche de Monólogos (evt-002) and live vinyl records from the Festival de Jazz de Primavera (evt-003). Lucía wants the black T-shirt in size M; Marc wants the vinyl and a poster. The store is a new domain, but the users, the roles and the events are the same ones. The genuinely new idea in this project is real money. Until now, when something failed you returned a 500 and retried; here a failure can mean a customer paid and received nothing, or received something without paying. That demands another level of rigor: explicit statuses, consistency with an external system you do not control, idempotency everywhere and an audit trail for every cent.

A necessary warning. Charging real money brings legal, tax, accounting and data protection obligations that vary by country and that require professional advice. This project is educational: it teaches the engineering, it does not replace an accountant or a lawyer.

Contents

  1. The business requirement
  2. Design decisions and rejected alternatives
  3. The data model: variants, cart and order
  4. The cart: guest, user and merging on login
  5. The amount is always computed on the server
  6. Stock reservation versus overselling
  7. The technical challenge: integrating a payment gateway
  8. Webhooks: signature, raw body and idempotency
  9. The order state machine
  10. Queues, refunds and auditing
  11. Testing what can break, and what stays out of scope

  1. The business requirement

  • Every event has associated products; a product has variants (size, color) with their own stock.
  • Anyone can browse the catalog and fill a cart, with or without an account; paying requires authentication.
  • Payment is made by card through a gateway; Escena Viva never sees the card details.
  • The order moves through verifiable statuses the customer can check; full or partial refunds can be issued.
  • Organizers see the orders for the products of their venue; administrators see them all.

  1. Design decisions and rejected alternatives

Decision Rejected alternative Why
PostgreSQL with Sequelize (M7) MongoDB, like the chat Money asks for ACID transactions across several tables, referential integrity and exact sums
Stock on the variant, not the product One counter on the product Selling "a T-shirt" means nothing: you sell the black size M. Per-product stock does not stop you selling ten nonexistent XXLs
Order lines with a frozen price Reading the product's current price See section 3: it is the most important decision in this lesson
PaymentIntent created on the server Card details reaching our API Receiving a card number drags us into the full scope of PCI DSS
Confirmation by webhook Confirming when the browser returns to the success page The browser may close right after paying. The truth about the payment lives at the gateway
Temporary stock reservation Deducting when adding to the cart, or only on payment The first locks stock for hours; the second allows overselling while the card is being typed
Explicit state machine Booleans paid, shipped, cancelled Five booleans give 32 combinations and only 7 valid ones. Impossible states must be unrepresentable

One new dependency: npm install stripe. Nothing else: sequelize, pg, bullmq, ioredis and zod are already there.

  1. The data model: variants, cart and order

Table Key fields Design note
products id, eventId, name, venueId, active No price and no stock: both live on the variant
variants id, productId, sku, size, color, priceCents, stock, reservedStock Unique, readable SKU: EV-TEE-003-M-BLK
carts id, userId (null for a guest), guestToken, expiresAt
cart_lines cartId, variantId, quantity Stores no price: the cart shows the current price
orders id, userId, status, subtotalCents, taxCents, shippingCents, totalCents, currency
order_lines orderId, variantId, sku, productName, size, color, unitPriceCents, quantity Copies price and description
payments orderId, provider, externalReference, amountCents, status, rawEvent Stores the gateway's original JSON

Why the order freezes the price. This is the decision that separates a toy store from a real one. The evt-001 T-shirt costs 2200 cents today and Lucía buys it. Three weeks later it drops to 1500 to clear stock. If the order line only stored variantId and the amount were read from the catalog: Lucía's history would show 1500 and would not match what she was charged; the issued invoice would say one thing and the database another — an accounting problem, not a cosmetic one; a refund would return 1500 instead of 2200; and any report of past revenue would change every time someone edits a price. The order line is an immutable historical document: it copies price, name and attributes. Duplicating information here is not sloppy denormalization; it is that the order's data and the catalog's data are different things that happen to coincide for one instant.

// src/domain/order.js — everything marked is COPIED, not referenced
const createOrderLine = ({ variant, product, quantity }) => ({
  variantId: variant.id, sku: variant.sku, quantity,
  productName: product.name,                        // frozen
  size: variant.size, color: variant.color,         // frozen
  unitPriceCents: variant.priceCents,               // frozen: the essential one
  amountCents: variant.priceCents * quantity,
});
module.exports = { createOrderLine };

  1. The cart: guest, user and merging on login

Forcing people to register before they can see their cart is the most effective way to lose sales. There are two kinds: the guest cart, identified by a random guestToken stored in a 30-day httpOnly cookie (the cookie holds only the identifier: never trust business data that travels on the client), and the user cart, tied to userId and persistent across devices. Guest carts expire after 30 days via a repeatable BullMQ job. The interesting case is logging in with a cart already started:

// src/services/carts.js
async function mergeCarts({ cartRepository, guestToken, userId }) {
  const guestCart = await cartRepository.findByToken(guestToken);
  if (!guestCart) return cartRepository.getOrCreateForUser(userId);
  const userCart = await cartRepository.getOrCreateForUser(userId);
  for (const line of guestCart.lines) {
    const existing = userCart.lines.find((l) => l.variantId === line.variantId);
    // Business rule: add quantities up, never replace silently.
    // Replacing makes the user "lose" what they had just added.
    if (existing) existing.quantity = Math.min(existing.quantity + line.quantity, LINE_LIMIT);
    else userCart.lines.push(line);
  }
  await cartRepository.save(userCart);
  await cartRepository.remove(guestCart.id);
  return userCart;
}
module.exports = { mergeCarts };

The three possible policies are adding up, keeping the guest cart or keeping the user cart; adding up is the least surprising, always with a per-line cap.

  1. The amount is always computed on the server

A rule with no exceptions: the client sends what it wants to buy, never how much it costs. If your API accepts a totalCents field from the client, you are running a free store for anyone with the dev tools open.

// src/services/amount-calculation.js
const VAT_RATE_PER_MILLE = 210;   // 21% expressed per mille, so no decimals
const FREE_SHIPPING_THRESHOLD = 5000;
const STANDARD_SHIPPING = 495;
function calculateAmount({ lines, shippingCents }) {
  const subtotalCents = lines.reduce(
    (sum, l) => sum + l.unitPriceCents * l.quantity, 0);
  // Round to the cent once, and over the complete subtotal.
  // Rounding line by line accumulates drift of up to N/2 cents.
  const taxCents = Math.round((subtotalCents * VAT_RATE_PER_MILLE) / 1000);
  return { subtotalCents, taxCents, shippingCents,
           totalCents: subtotalCents + taxCents + shippingCents };
}
// Pure function of the subtotal: testable with no database.
const calculateShipping = (subtotal) => subtotal === 0 ? 0
  : (subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_SHIPPING);
module.exports = { calculateAmount, calculateShipping, VAT_RATE_PER_MILLE };

Why everything is integers: 0.1 + 0.2 in floating point gives 0.30000000000000004, and across thousands of orders those residues produce discrepancies nobody can explain. In whole cents there is a single rounding point — the tax — explicit and isolated in a pure function that is easy to test. A visible consequence: if the interface splits VAT per line, the sum may differ from the total by one cent; the server's total is authoritative and the breakdown is informative.

  1. Stock reservation versus overselling

You already solved this in M7 with capacity: 3000 seats, 1811 sold, and a transaction with a lock that stops two simultaneous purchases from selling the same seat. The store is the same problem with one new twist: between the customer clicking "Pay" and the gateway confirming, anywhere from 10 seconds to several minutes go by. If we deduct on confirmation, others can exhaust the stock inside that window and we will end up charging for a vinyl that no longer exists.

The solution is the temporary reservation: two columns, stock and reservedStock, and one rule — what is sellable is stock - reservedStock.

// src/repositories/inventory-sql.js
async function reserveStock({ sequelize, lines, orderId, minutes = 20 }) {
  return sequelize.transaction(async (t) => {
    for (const line of lines) {
      // SELECT ... FOR UPDATE serializes the buyers of the same variant.
      const [variant] = await sequelize.query(
        'SELECT stock, reserved_stock FROM variants WHERE id = :id FOR UPDATE',
        { replacements: { id: line.variantId }, type: SELECT, transaction: t });
      const available = variant.stock - variant.reserved_stock;
      // M6 domain error: the central handler translates it into a 409.
      if (available < line.quantity) throw new StateConflict('INSUFFICIENT_STOCK',
        { sku: line.sku, requested: line.quantity, available });
      await sequelize.query(
        'UPDATE variants SET reserved_stock = reserved_stock + :n WHERE id = :id',
        { replacements: { n: line.quantity, id: line.variantId }, transaction: t });
    }
    await inventoryRepository.createReservation({ orderId, minutes }, t);
  });
}
Moment stock reservedStock Sellable
Initial 50 0 50
Lucía starts paying for 2 50 2 48
The payment is confirmed 48 0 48
(alternative) the payment fails or expires 50 0 50

Releasing expired reservations is a repeatable BullMQ job (M10) that runs every minute: without it, a customer who abandons checkout freezes stock forever.

  1. The technical challenge: integrating a payment gateway

sequenceDiagram
  participant C as Client
  participant A as Escena Viva API
  participant S as Stripe
  C->>A: POST /api/v1/orders (lines + address)
  A->>A: compute amount + reserve stock
  A->>S: create PaymentIntent (amount, metadata.orderId)
  A-->>C: { orderId, clientSecret }
  C->>S: confirm payment with the card (never passes through A)
  S->>A: webhook payment_intent.succeeded (signed)
  A->>A: confirm order, consume reservation, enqueue email

The essential point: the card never touches our server. The browser sends it straight to Stripe with the clientSecret; our API only declares "4840 cents must be charged for order ord-8812". PCI DSS in one sentence: it is the card industry's security standard, and its scope — and the cost of complying — grows brutally the moment card data passes through your systems; by delegating to the gateway you stay at the simplest self-assessment level. Everything else is integration detail.

// src/services/payments.js
async function createPaymentIntent({ order, user }) {
  const intent = await stripe.paymentIntents.create({
    amount: order.totalCents,       // the server sets the amount (section 5)
    currency: order.currency,       // 'eur'
    // The metadata is the bridge between Stripe's world and ours.
    metadata: { orderId: order.id, userId: user.id },
    receipt_email: user.email,      // [email protected]
    automatic_payment_methods: { enabled: true },
  // Outbound idempotency: on a retry, Stripe does not create two intents.
  }, { idempotencyKey: `intent-${order.id}` });
  return { externalReference: intent.id, clientSecret: intent.client_secret };
}
module.exports = { createPaymentIntent, stripe };

  1. Webhooks: signature, raw body and idempotency

A webhook is an HTTP request the provider makes to you, and since anyone can make you an HTTP request, verifying the signature is not optional: without it anyone can send you payment_intent.succeeded and walk away with free merchandise.

The classic mistake. The signature is computed over the exact bytes of the body. If express.json() already parsed it, the original is gone; re-serializing with JSON.stringify produces different bytes (key order, spacing, Unicode escapes) and verification always fails. The symptom is a 400 signature verification failed that looks like a key problem and is not. The fix is to mount the route with express.raw before the global express.json():

// src/app.js — excerpt of the middleware order
function createApplication(dependencies = {}) {
  const app = express();
  app.use(helmet());
  app.use(requestId);
  // CRITICAL ORDER: the webhook needs a Buffer, not an object.
  app.use('/api/v1/webhooks/payments',
    express.raw({ type: 'application/json', limit: '1mb' }),
    createPaymentsWebhookRoutes(dependencies));
  app.use(express.json({ limit: '100kb' }));  // everything else does use JSON
  // ... normal routers and the central error handler (M6) ...
  return app;
}
// src/routes/payments-webhook.js
routes.post('/', async (req, res) => {
  let event;
  try {
    // req.body is a Buffer thanks to express.raw.
    event = stripe.webhooks.constructEvent(req.body,
      req.get('stripe-signature'), configuration.stripe.webhookSecret);
  } catch (error) {
    return res.status(400).json({ error: { code: 'INVALID_SIGNATURE',
      message: 'Signature could not be verified', status: 400, details: null } });
  }
  // Idempotency: Stripe retries for days if you do not answer 2xx.
  // event.id acts as the Idempotency-Key (M10), persisted in the database.
  const isNew = await paymentEventRepository.recordIfNew(event.id, event.type);
  if (!isNew) return res.status(200).json({ received: true, duplicate: true });
  try {
    const object = event.data.object;
    if (event.type === 'payment_intent.succeeded') {
      await orderService.confirmPayment({ orderId: object.metadata.orderId,
        externalReference: object.id, amountCents: object.amount_received });
    } else if (event.type === 'payment_intent.payment_failed') {
      await orderService.markPaymentFailed({ orderId: object.metadata.orderId });
    }
    return res.status(200).json({ received: true });  // fast 200, queue separately
  } catch (error) {
    // A 500 makes Stripe retry: correct for a transient failure.
    await paymentEventRepository.markFailed(event.id, error.message);
    return res.status(500).json({ error: { code: 'INTERNAL_ERROR',
      message: 'Retry', status: 500, details: null } });
  }
});

Four points people learn the hard way. recordIfNew must be atomic: an INSERT with event.id as the primary key that catches the uniqueness violation, because checking and then inserting in two steps has a race if two copies land on two processes. Answer fast and with 2xx: the provider's timeouts are short and everything slow goes to BullMQ. The webhook can arrive before the response to the client, which is common; that is why the order already exists in the database before the intent is created — when the webhook lands there is something to update — and why GET /orders/:id is the front-end's source of truth. And event ordering is not guaranteed: a payment_failed from an earlier attempt can arrive after succeeded, so the state machine must reject the invalid instead of applying it.

  1. The order state machine

stateDiagram-v2
  [*] --> cart
  cart --> pending_payment: start payment (reserves stock)
  pending_payment --> paid: webhook succeeded
  pending_payment --> cancelled: failure, expiry or cancellation
  paid --> preparing: warehouse accepts
  preparing --> shipped: carrier picks up
  shipped --> delivered: delivery confirmation
  paid --> refunded: full refund
  preparing --> refunded: full refund
  delivered --> refunded: return accepted

Valid transitions are declared as data and checked in the domain, not scattered across controllers:

// src/domain/order-statuses.js
const TRANSITIONS = {
  cart: ['pending_payment'],
  pending_payment: ['paid', 'cancelled'],
  paid: ['preparing', 'refunded', 'cancelled'],
  preparing: ['shipped', 'refunded'],
  shipped: ['delivered', 'refunded'],
  delivered: ['refunded'],
  cancelled: [], refunded: [],   // terminal statuses: no way out
};

function transition(order, newStatus) {
  const allowed = TRANSITIONS[order.status] ?? [];
  if (!allowed.includes(newStatus)) throw new StateConflict('INVALID_TRANSITION',
    { currentStatus: order.status, requestedStatus: newStatus, allowed });
  return { ...order, status: newStatus, updatedAt: new Date().toISOString() };
}
module.exports = { transition, TRANSITIONS };

This table solves the problem from section 8: a late payment_failed on an already paid order throws StateConflict, gets logged and corrupts nothing.

  1. Queues, refunds and auditing

Nothing slow happens inside the webhook request:

// src/services/orders.js — excerpt
async function confirmPayment({ orderId, externalReference, amountCents }) {
  const order = await orderRepository.get(orderId);
  if (!order) throw new ResourceNotFound('ORDER_NOT_FOUND', { orderId });
  // Defense: the amount charged must match the amount computed.
  if (amountCents !== order.totalCents) throw new StateConflict(
    'AMOUNT_MISMATCH', { orderId, amountCents });
  const confirmed = transition(order, 'paid');
  // Consumes the reservation in a transaction: stock -= n, reservedStock -= n.
  await orderRepository.confirmInTransaction({ order: confirmed, externalReference });
  // A fixed jobId = idempotency for free: BullMQ rejects an existing id,
  // so even if the webhook were processed twice the email goes out once.
  await emailQueue.add('order-confirmation', { orderId }, { attempts: 5,
    backoff: { type: 'exponential', delay: 2000 }, jobId: `confirmation-${orderId}` });
  await invoiceQueue.add('generate-invoice', { orderId }, { jobId: `invoice-${orderId}` });
  return confirmed;
}

The PDF invoice is generated in the consumer — a separate process from M10/M11 — and uploaded to object storage, because the PaaS filesystem is ephemeral (M11).

Refunds. A partial refund is not "subtracting from the total": it is refunding specific lines, using the price frozen on the line and checking that the amount already refunded is not exceeded. The call to stripe.refunds.create always carries an idempotencyKey: with money going out, a retry without a key can return the amount twice. Auditing. Every operation that touches money writes an immutable row into audit: who (actorId), what (action), on what (orderId), how much (amountCents), when (ISO) and with which requestId (M11, to cross-reference it with the pino logs). No deletes and no updates. When somebody asks "why is this 4840-cent order showing a 2200-cent refund", the answer must be in a table, not in a colleague's memory.

  1. Testing what can break, and what stays out of scope

Prioritize what would cause real losses:

// test/store/checkout.test.js
describe('store checkout', () => {
  it('rejects a webhook with an invalid signature', async () => {
    await request(createApplication()).post('/api/v1/webhooks/payments')
      .set('stripe-signature', 't=1,v1=fake').set('Content-Type', 'application/json')
      .send(Buffer.from(JSON.stringify({ type: 'payment_intent.succeeded' })))
      .expect(400)
      .expect((r) => expect(r.body.error.code).to.equal('INVALID_SIGNATURE'));
  });
  it('prevents overselling when two purchases race for the last unit', async () => {
    await seedVariant({ sku: 'EV-VIN-003-UNI', stock: 1 });
    const results = await Promise.allSettled([
      startPayment({ sku: 'EV-VIN-003-UNI', quantity: 1, user: 'usr-lucia' }),
      startPayment({ sku: 'EV-VIN-003-UNI', quantity: 1, user: 'usr-marc' }),
    ]);
    expect(results.filter((r) => r.status === 'fulfilled')).to.have.lengthOf(1);
    expect(results.find((r) => r.status === 'rejected').reason.appCode)
      .to.equal('INSUFFICIENT_STOCK');
  });
  it('ignores a payment_failed that arrives after confirmation', async () => {
    await sendSignedWebhook(successEvent);
    await sendSignedWebhook(lateFailureEvent);
    expect((await orderRepository.get('ord-8812')).status).to.equal('paid');
  });
});

Stripe is faked with Sinon (M9) or driven by its CLI in test mode. Transitions and amount calculation are pure functions: test them thoroughly with rounding edge cases.

Out Why Extension
Coupons and promotions They multiply the cases in amount calculation A Discount object applied before tax, with explicit stacking rules
Per-country tax and multi-currency They need up-to-date tax data and legal judgment An external tax service; per-order currency with frozen rates
Subscriptions An entire billing model of its own The gateway's recurring billing + lifecycle webhooks
Warehouse integration Depends on the logistics operator A queue of dispatch and tracking events

Common Mistakes and Tips

  • Putting a global express.json() before the webhook. The number-one mistake in this lesson: the signature will never verify.
  • Working with Number in euros. Whole cents always, with a Cents suffix in the name. And do not trust the browser's success page: it is a hint, not the truth; the truth arrives in the webhook.
  • Not storing the gateway's raw event. Keeping it in payments.rawEvent will save you the day you have to reconcile against a bank statement. And never refund without an idempotencyKey: with money going out, idempotency matters even more than with money coming in.
  • Tip: expose two metrics with prom-client (M11): orders per status and the age of the oldest order in pending_payment. If that age grows, something is wrong with the webhooks and you will know before a customer calls.

Exercises

  1. Reservation expiry. Write the repeatable BullMQ job that releases expired reservations every minute and cancels their orders, with a test proving that sellable stock returns to its original value.

  2. Bulletproof amount calculation. Write tests for calculateAmount and calculateShipping covering: an empty cart, a subtotal of exactly 4999 and of 5000, and a line of 3 units at 1999 cents checking the exact VAT.

  3. Hardening the webhook. Create the payment_events table with event_id as the primary key and make recordIfNew atomic. Prove that two concurrent deliveries of the same event produce a single confirmation.

Solutions

1. Reservation expiry

// src/queues/maintenance.js
await maintenanceQueue.add('release-reservations', {},
  { repeat: { every: 60_000 }, jobId: 'release-reservations-repeatable' });
new Worker('maintenance', async () => {
  for (const reservation of await inventoryRepository.expiredReservations()) {
    await sequelize.transaction(async (t) => {
      for (const line of reservation.lines) {
        await sequelize.query(
          'UPDATE variants SET reserved_stock = reserved_stock - :n WHERE id = :id',
          { replacements: { n: line.quantity, id: line.variantId }, transaction: t });
      }
      // The status condition is essential: without it, a race with the
      // webhook would cancel an order that has already been charged.
      await sequelize.query(
        `UPDATE orders SET status = 'cancelled' WHERE id = :id AND status = 'pending_payment'`,
        { replacements: { id: reservation.orderId }, transaction: t });
      await inventoryRepository.removeReservation(reservation.id, t);
    });
  }
}, { connection });

2. Amount calculation

it('charges shipping at 4999 and not at 5000', () => {
  expect(calculateShipping(4999)).to.equal(495);
  expect(calculateShipping(5000)).to.equal(0);
});
it('computes VAT over the complete subtotal', () => {
  const lines = [{ unitPriceCents: 1999, quantity: 3 }];
  const r = calculateAmount({ lines, shippingCents: calculateShipping(5997) });
  expect(r.subtotalCents).to.equal(5997);
  expect(r.taxCents).to.equal(1259);  // round(5997 * 210 / 1000)
  expect(r.totalCents).to.equal(7256);
});
it('returns everything at zero for an empty cart', () => {
  expect(calculateAmount({ lines: [], shippingCents: 0 }).totalCents).to.equal(0);
});

If VAT were computed line by line, round(1999 * 0.21) * 3 = 1260: one cent of difference per order. Multiply that by ten thousand orders and you have an uncomfortable conversation with accounting.

3. Atomic webhook

The table is minimal: event_id TEXT PRIMARY KEY, type, received_at TIMESTAMPTZ DEFAULT NOW(), status and error. The primary key is what does the work.

async function recordIfNew(eventId, type) {
  // ON CONFLICT DO NOTHING RETURNING performs the check and the insert in a
  // single atomic statement: there is no race window between two processes.
  const [rows] = await sequelize.query(
    `INSERT INTO payment_events (event_id, type) VALUES (:eventId, :type)
     ON CONFLICT (event_id) DO NOTHING RETURNING event_id`,
    { replacements: { eventId, type } });
  return rows.length > 0;   // false if it already existed: this is a redelivery
}

it('processes only once with simultaneous deliveries', async () => {
  const spy = sinon.spy(orderService, 'confirmPayment');
  await Promise.all([sendSignedWebhook(successEvent), sendSignedWebhook(successEvent)]);
  expect(spy.callCount).to.equal(1);
});

Conclusion

You have built Escena Viva's merchandise store and, with it, crossed the line that separates applications that can fail without consequences from those that cannot. The three ideas you take away are durable and depend on neither Stripe nor Node: the order freezes its own history instead of reading it from the catalog; the amount is always computed on the server, in integers; and consistency with an external system is achieved with signatures, idempotency and a state machine that rejects the impossible instead of applying it.

You have also seen how the course's layers stack up: M7's transaction with locking prevented overselling, M10's idempotency tamed the provider's retries, queues pushed slow work out of the request and M11's object storage collected the invoices. Not one piece was new; what was new was the level of demand. In the next lesson we lower the transactional tension and raise a very different one: Escena Viva's magazine, where the challenge is no longer being right to the cent but the content itself — Markdown that may smuggle in malicious HTML, uploaded images that lie about what they are, and thousands of reads for every write.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved