DEV Community

Cover image for Three bugs a point-of-sale can't afford, and how I designed them out in ASP.NET Core
Zohaib Ahmad
Zohaib Ahmad

Posted on Edited on AI-assisted

Three bugs a point-of-sale can't afford, and how I designed them out in ASP.NET Core

A point-of-sale looks like the easiest app in the world: a list of products, a cart, a "Charge" button. Then you put it in a real shop, with two tills, a flaky Wi-Fi router and a cashier who presses buttons twice, and you find out where the hard parts are.

I recently finished Tilly POS, a multi-branch retail POS built with ASP.NET Core 10, Entity Framework Core, SQL Server and Angular. These are the three failures I designed out first, with the actual code.

1. The double charge

The cashier presses Charge. The network drops before the response comes back. The customer is standing there, so the cashier presses it again. If your API treats that as a new request, the customer has now paid twice and your stock is down two units.

The fix is an idempotency key. The browser generates a key once per sale and sends it with every attempt:

// A retry of a sale that already went through returns the original sale.
if (await LoadExistingAsync(idempotencyKey, ct) is { } existing)
{
    return existing.RequestHash == hash
        ? new CheckoutResult.Completed(existing, Replayed: true)
        : new CheckoutResult.Problem(409, "Conflict",
            "This checkout key was already used for a different sale. Start a new sale.");
}
Enter fullscreen mode Exit fullscreen mode

Two details matter:

  • Store a hash of the request with the key. The same key with a different cart is a bug on the client, and it should fail loudly instead of returning someone else's receipt.
  • Make the key unique in the database (a unique index on business + key), not just checked in code. Two retries can arrive within milliseconds of each other; the unique index is what guarantees only one of them wins.

2. Half a sale

A sale is not one row. It changes the money (payments), the stock (one movement per item), the receipt-number sequence and the audit log. If any of those writes fails after the others succeeded, the books are wrong and nothing tells you.

So everything a sale touches is written in one transaction, and the receipt number is reserved inside it:

await using var tx = await db.Database.BeginTransactionAsync(ct);

var number = $"S-{await sequences.ReserveAsync(tenantId, SequenceNames.Sale, 1, ct) + 10000}";
db.Sales.Add(sale);
await stock.PostAsync(tenantId, branchId, StockMovementType.Sale, stockChanges, allowNegative, number, sale.Id, null, ct);
db.AddAuditEvent(AuditActions.SaleCompleted, /* ... */);

await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
Enter fullscreen mode Exit fullscreen mode

The sequence is a row updated with UPDLOCK inside the same transaction, so a failed sale rolls its number back: receipt numbers have no gaps, which accountants care about more than developers expect.

3. Two tills, one last bag of rice

Two cashiers scan the last unit at the same second. The obvious code reads "1 in stock" on both tills, both check that 1 is enough, and both sell it. Stock goes to minus one.

Checking before writing can't fix this, because the other till lives between the check and the write. The check has to be the write. One conditional, row-locked UPDATE does both, and also moves the weighted-average cost in the same statement:

UPDATE [inventory].[StockBalances] WITH (UPDLOCK, ROWLOCK)
SET [OnHand] = [OnHand] + {delta},
    [AverageCost] = CASE
        WHEN {cost} IS NULL THEN [AverageCost]
        WHEN [OnHand] <= 0 OR [AverageCost] IS NULL THEN {cost}
        ELSE ROUND(([OnHand] * [AverageCost] + {delta} * {cost}) / ([OnHand] + {delta}), 4)
    END
OUTPUT inserted.[OnHand], inserted.[AverageCost]
WHERE [BranchId] = {branchId} AND [VariantId] = {variantId}
  AND ({allowNegative} = 1 OR {delta} >= 0 OR [OnHand] + {delta} >= 0)
Enter fullscreen mode Exit fullscreen mode

If no row comes back, the sale was refused, and the cashier sees: "Only 0 of “Basmati rice 5 kg” left in this branch. Change the quantity or remove the item; nothing was charged." Two more rules keep it safe under load:

  • Rows are locked in variant-id order, so two multi-item sales can never deadlock each other.
  • Every change also appends a stock movement with the balance after it. Stock is a ledger, not a number, so when the shelf and the screen disagree there is a trail to walk.

What else had to be right

  • Multi-tenancy: every tenant-owned table has an EF Core global query filter; a request without a signed-in business reads nothing at all.
  • No privilege escalation: someone who can manage users can only create roles with permissions they hold.
  • An audit log nobody can edit, not even the owner.
  • Money as decimal(19,4), times in UTC shown in the branch's time zone.
  • 104 integration tests run against a real SQL Server in CI, because most of these bugs only exist in the database.

Try it

I'm happy to answer questions about any of these decisions in the comments.

Top comments (4)

Collapse
 
umerfarooqofficial profile image
Umer Farooq •

Great write-up, @zohaibahmad . The conditional **UPDATE **with **OUTPUT **is the part I liked most. Many teams still “check, then write” and only find the race condition in production. Locking rows in variant ID order to avoid deadlocks is also a detail most posts skip.

One question on the gapless receipt numbers. Since every sale locks the same sequence row, does it become a bottleneck at peak hours? Is the sequence scoped per branch or per till to spread the contention?

I’m also curious how you handle a branch losing internet completely. The client-generated idempotency key looks like a good base for an offline queue that syncs later.

Collapse
 
zohaibahmad profile image
Zohaib Ahmad •

Thanks! Great questions. The sequence row can become a bottleneck under heavy load, which is why the scope of the sequence matters. Keeping receipt numbers scoped per branch can reduce contention across branches while preserving the required numbering rules.

For a complete internet outage, the idempotency key is a good foundation for an offline queue, but I’d also account for conflict handling, retries, and duplicate prevention during synchronization. The key challenge is ensuring receipt numbers remain unique and compliant with the business requirements when transactions are synced later.

Definitely an area worth designing carefully as the system scales!

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

gapless receipt numbers plus offline mode is where most POS stories get ugly. row-locked inventory is the unglamorous fix.

Collapse
 
zohaibahmad profile image
Zohaib Ahmad •

Absolutely! The tricky part is balancing gapless numbering with offline sales, since you can’t guarantee a globally sequential number while disconnected. Row-level locking keeps inventory updates safe, but receipt numbering needs a clear strategy for offline transactions and reconciliation during sync.