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 Yume 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."); } 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); 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) 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
- Live demo, no sign-up: open it as the cashier or as the owner. It resets every night, so break whatever you like.
- Documentation: studioyume.pages.dev/pos/docs
- Source code: if you'd rather start from all of this than build it, the full ASP.NET Core 10 + Angular source is available here, with Docker setup, binaries and docs.
I'm happy to answer questions about any of these decisions in the comments.
![Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q4w48b9bzs43tynyhsf.png)
