Designing out double charges and overselling in an ASP.NET Core POS
A write-up on Tilly POS, a multi-branch point-of-sale built with ASP.NET Core 10, EF Core and SQL Server, explains how three costly failures were designed out: double charges, half-written sales and overselling. The fixes are practical: idempotency keys backed by a unique index, one transaction per sale, and a single conditional UPDATE for stock. Useful for any .NET service that moves money or inventory.
Key points
- Generate one idempotency key per sale, send it with every retry, and store a hash of the request alongside it.
- Enforce key uniqueness with a unique index on business plus key, not only with a code check.
- Write payments, stock movements, the receipt sequence and the audit log in one transaction.
- Reserve receipt numbers inside the transaction so failed sales leave no gaps in the sequence.
- Deduct stock with one conditional UPDATE using UPDLOCK and ROWLOCK, and lock rows in a fixed order to avoid deadlocks.
- Keep stock as an append-only movement ledger that records the balance after each change.
A point-of-sale app running in a real shop has to survive network drops, repeated button presses and two tills selling the same item at once. In this write-up on Tilly POS, a multi-branch system built with ASP.NET Core 10, Entity Framework Core and SQL Server, the author shows how three such failures were designed out from the start.
The first defense is an idempotency key generated once per sale and sent with every retry, so a repeated charge returns the original result. The key is stored with a hash of the request and protected by a unique database index, so a different cart under the same key is rejected. Payments, stock movements, receipt numbering and the audit log are written in one transaction. Stock is deducted with a single conditional, row-locked UPDATE, so the check and the write cannot be separated and overselling is refused.
Real-world use
- A retail or restaurant checkout API where flaky networks cause repeated payment submissions.
- A multi-till inventory system where the last unit of an item could be sold from two terminals at once.
- An order or invoicing service that needs gap-free sequential document numbers for accounting.
Why it matters
Any .NET service that charges money or decrements stock can hit the same races, and the patterns here, a unique index as the guard and a conditional UPDATE as the check, apply well beyond POS systems. Most of these bugs only appear against a real database, so tests should run on SQL Server.