Closed padlock on a white gate
Guides

Why your store should never sell the same product twice (and how we prevent it)

Double-selling is not bad luck: it is a design mistake that gets paid for with refunds, apologies, and trust. Here is how we solved it at URG Technology, where almost everything is a one-of-a-kind unit.

Damorus Projects8 min read

Picture the worst pair of messages a customer can receive. First: "thank you for your purchase." The next day: "sorry, that unit was already sold." In an online store, that is not bad luck, it is a design mistake, and it has a name: double-selling.

We ran straight into it while building URG Technology, a refurbished-computer store in Bogotá. There, almost everything is a one-of-a-kind unit: each machine has its own condition, its own price, and its own history. Two people wanting the same laptop at the same time stop being a rare scenario and become a daily risk.

CONFIANZASIN REGLASCON RESERVA1 disponible2 pagos · 1 equipoPAGO 1PAGO 2AGOTADO-1 reembolso-1 disculpa-1 confianzaconfianza intacta ✓
Two payments for one unit cost a refund, an apology, and something you cannot restock: trust.

What double-selling really costs

A double sale gets paid for three times. First the refund, with its paperwork. Then the apology, which eats up someone's time. And at the end, the most expensive part: the trust of a person who had already decided to buy from you and now has doubts. If your customer was buying something used, sight unseen, that doubt costs double.

There is a quiet cousin to the problem: phantom stock. These are customers who start paying, abandon the process, and leave the unit reserved forever. Nobody complains, nobody notices, and the store stops selling a product that is actually available.

Where it slips through

The first version of URG trusted whatever each customer's browser saw: the screen checked the stock, subtracted from it, and gave the go-ahead. That seems fine until two people show up at once. Both see "1 available," both pay, and the store lets both through. The root cause was that inventory was being deducted in the wrong place, with no ordering between requests.

There was a second design problem. That first version needed an administrator to have the panel open to process payment confirmations. If they closed their computer or went to sleep, the store stopped updating inventory. A store that depends on someone being awake is not an online store: it is a counter with business hours.

The atomic reservation, explained without jargon

Atomic, here, means "all or nothing": an operation that cannot be split in half. When someone tries to reserve a unit, the server locks that record for an instant, checks how many units are truly still free, and only then reserves it. If two attempts arrive together, one waits for the other: the second one finds the stock at zero and gets a "sold out" before it ever reaches payment.

On top of that sits a safety belt: a rule inside the database that makes it impossible for reservations to exceed what exists. Even if someone makes a coding mistake tomorrow, the database rejects the move. And every reservation records how it was made, when it was created, and when it expires.

STOCK: 1STOCK: 0CLIENTE 1CLIENTE 2¡comprar!¡comprar!apartado ✓agotado0102TURNOSERVIDOR0102uno espera al otro · nadie paga de más
The server hands out turns. Two simultaneous attempts do not collide: one reserves, and the other finds out before paying.

Here is what the idea looks like in database terms. This is a simplified example meant to explain it, not the actual URG code:

sql
BEGIN;

-- 1. Lock the row while deciding: the other attempt waits its turn.
SELECT stock, apartados
  FROM productos
 WHERE id = 'pc-123'
   FOR UPDATE;

-- 2. Reserve only if a unit is truly still free.
UPDATE productos
   SET apartados = apartados + 1
 WHERE id = 'pc-123'
   AND stock - apartados >= 1;
-- If none was left, no row gets updated: that is a "sold out".

COMMIT;

-- 3. The safety belt: never reserve more than what exists.
ALTER TABLE productos
  ADD CONSTRAINT reservas_sin_exceso CHECK (apartados <= stock);
Simplified example: lock the row, reserve only if a unit is truly still free, and leave a rule the database can never break.

Both pieces have been standard in any serious database for years. FOR UPDATE locks the rows you select, so another transaction trying to modify them has to wait until the first one finishes. [1] And a CHECK constraint forces a column's value to always satisfy a condition. [2] There is no magic here, just order.

Three clocks that work while nobody is watching

Reserving correctly is half the job. The other half is releasing what nobody actually used:

  • Only a confirmed payment touches inventory. Opening the gateway reserves nothing. Once the payment is approved, the notice reaches the server, which validates its origin, checks the real status, deducts stock, and fires off the emails.
  • A sweep every two minutes. A scheduled process checks expired reservations: it releases transfer payments that never got their proof uploaded in time, closes gateway attempts left open for more than an hour, and marks those orders as expired.
  • Cross-cancellation. When a unit sells through one channel, the system goes through the pending reservations for that same product on the other channel and cancels them, in both directions.
STOCK FANTASMAapartados que nadie pagólibres: 2libres: 4LIBRELIBREapartadoLIBRE8 minvencidoapartadoLIBRE1 hvencido
Saving a seat with a jacket works for a while. Eventually someone walks by, moves it, and the seat is free again.

Losing the race does not have to mean losing the sale

Here is the part almost nobody designs for. The customer who arrived second does not see a technical error: they see a message in red, "Someone was faster!", with a short explanation and a button that opens the catalog filtered to the category of the unit they lost. They also get an apology email with a 5% coupon.

The first version of that message said "Darn! You lost by a millisecond." We changed it because tone matters: that person did not lose a game, they lost a computer they wanted. The lesson cuts both ways. Integrity gets solved on the server, and even bad news can end in another sale.

The dangerous part is not failing: it is failing silently

An orphaned reservation does not raise a flag. A payment notice that never arrives does not either. That is why we built Centinela ("Sentinel") at URG, a health-monitoring module whose entire job is to be suspicious: it looks for expired reservations that never got released, payments left unconfirmed for over an hour, products with more units reserved than exist, and server functions with recent errors. Every alert arrives with its severity and a button to resolve it.

“A system that cannot tell you when it is broken will stay broken without anyone knowing.”

A lesson from the URG Technology project

Five questions to know if your store is exposed

  1. 1Do you sell one-of-a-kind pieces or very limited units? Refurbished goods, spare parts, art, limited batches: there the risk is a daily one.
  2. 2Do you accept more than one payment method? Every new method is another door leading to the same product.
  3. 3Who deducts the stock? If the answer is "the page itself," you have the exact problem we had.
  4. 4What happens to a cart that starts checking out and then leaves? If nobody releases it, that product becomes invisible to everyone else.
  5. 5Does your store need someone to have the panel open? If the answer is yes, you are not really selling at night.

Selling the same product twice is one of those mistakes you only notice after it already happened. That is why you design against it beforehand. If you run a store with one-of-a-kind units, or you are about to build one, tell us what you sell and we will tell you how we would lock down your inventory.

Sources

References cited in the text with the [n] notation. Each link was verified and is publicly accessible.

  1. 1

    Explicit Locking: bloqueos a nivel de fila con FOR UPDATE (Documentación oficial de PostgreSQL)

    Primary source: FOR UPDATE locks the selected rows, and other transactions trying to modify them wait until the current transaction finishes.

    https://www.postgresql.org/docs/current/explicit-locking.html
  2. 2

    Constraints: restricciones CHECK (Documentación oficial de PostgreSQL)

    Primary source: a CHECK constraint forces a column's value to always satisfy a boolean expression on every write.

    https://www.postgresql.org/docs/current/ddl-constraints.html

Know someone this helps? Share it.

Your project with us can start today.

Tell us what you want to achieve, or that thing you always wanted your project to achieve, and we will tell you how we would do it and what it costs. If you can imagine it, we can make it real.

or email us at Alejandro@damorus.com