Retail POS that talks to a system of record has one architectural decision that dwarfs every other: what happens when the network blinks between the lane and the back-end. We made that decision early when we built SourceForge Retail for Business Central, and it shaped almost everything else.
The decision: the back-end is the source of truth, and re-posting a known sale never duplicates. Everything follows from that.
The two failure modes that destroy retail accounting
Most BC-connected POS designs fall into one of two bad shapes.
The first is fire-and-forget: the lane sends a sale, hopes BC accepts it, and forgets. If the post fails — network blip, missing posting group, locked period, anything — the sale is lost. Finance reconciles at end of day and finds a hundred rupees of cash that the till counted but no corresponding posted invoice. Multiply this by a few hundred lanes and you have a permanent variance.
The second is retry-without-discipline: the lane keeps trying until it gets a 200 OK. If the first attempt actually succeeded but the response was dropped, the second attempt creates a duplicate invoice. Finance now reconciles to find ₹100 of cash but ₹200 of invoices. Customers get charged twice. CFO complains. The lane operator can't tell which attempt was the duplicate, so the only fix is a manual credit memo and an apology.
The right answer is well-known in distributed systems: idempotent operations keyed by a client-generated identifier. The retail world rarely implements it cleanly because the POS vendor and the ERP vendor are usually different companies that don't agree on the contract. When we built the Retail extension for BC, the POS client and the BC system of record were both ours, so the contract was non-negotiable: every sale carries a UUID, and BC's Posted Sale Map remembers it forever.
How the Posted Sale Map works
The Posted Sale Map is a single table in BC with two columns that matter: transactionId (UUID created at the lane, the moment a sale starts) and posted_doc_no (the BC document number once the sale is posted). Plus a few audit fields — timestamp, posting company, posting user — for forensics.
The pipeline:
- The lane creates a sale. It mints a UUID at the start of the transaction and writes it into the local cart.
- When the customer pays, the lane stages the sale to BC via the API. The first thing BC does — before any business logic — is look up the transactionId in the Posted Sale Map.
- If it's a hit: BC returns the existing
posted_doc_no. No new document is created. No journal entry is written. No audit-log row is added. The lane gets back the same response it would have got the first time. - If it's a miss: BC posts the sale normally, then writes the transactionId + posted_doc_no into the Posted Sale Map atomically with the posting. The lane gets the document number.
The map is permanent. We never delete a row. Even years later, replaying a transactionId from an ancient receipt returns the original document. That permanence is the property that makes retry safe.
Why the lane mints the ID, not the server
This is a small detail that matters. If the server mints the ID, the lane needs a round-trip just to get an ID before it can stage the sale — which means a network failure at that step also loses the sale. The whole point of the design is to make the lane sufficient unto itself: it can complete a sale offline, persist it, and try later. The transactionId has to be available to the lane before any network exchange, which means the lane mints it. UUIDs (we use v4) collide on the order of one in ten thousand million billion, so the lane minting them is fine.
Failure modes that this design handles cleanly
Network drops mid-post. The lane retries the same transactionId. The second attempt's first action is a lookup, which finds the row if the first attempt actually succeeded. The lane gets back the same posted_doc_no it would have got the first time. Duplicate prevented. (If the first attempt failed before writing to the map, there's no row, and the second attempt posts as if it were the first — also correct.)
Operator hits "send" twice from confusion. Same lookup, same outcome. Finance sees one invoice.
Multi-day connectivity outage. Every staged sale survives in the lane's local SQLite store. When the link returns, the Job Runner codeunit drains the backlog in transactionId order. Each sale either hits the map (no-op) or posts cleanly (new row). The shift's cash variance balances exactly because there are no phantom or duplicate sales.
Failed post — permission denied, missing posting group, locked period. The staged sale stays in the local store. The error is captured against it. No row in the Posted Sale Map. The lane shows the operator a clear error. After the BC admin fixes the underlying configuration, the next post attempt succeeds and the map writes its row. No data has been lost.
Time-skew across lanes. This is the failure mode that catches naive designs. Lane A and Lane B both attempt the same transactionId at roughly the same moment (in practice, this means a master / replica timing issue with two devices syncing the same offline queue). Because the Posted Sale Map is a single table inside BC with primary-key uniqueness on transactionId, only one wins. The loser gets a constraint error that the API translates into a "no-op, here's the winner's document number." Both lanes end up agreeing on the same posted document.
What we explicitly chose not to do
A few patterns we considered and rejected.
Server-side idempotency tokens. Some systems let the client request an idempotency token from the server, then use it for the actual operation. This adds a round-trip and a failure mode (what if you get the token but lose the actual operation request?). Lane-minted UUIDs are simpler and equally safe.
Hash-of-payload deduplication. If two sales have identical contents, dedupe them. This sounds appealing but is wrong — a customer might genuinely buy the same item twice in a minute and the system should record both sales. The transactionId model says "the lane decides what's the same; the server trusts it." That's the correct boundary.
Replay protection via expiry. "Only honour a transactionId for 24 hours." This breaks the legitimate use case of reposting a sale that's been stuck in the queue for three days because of a long outage. The Posted Sale Map row is permanent.
What this means for our customers
Two things, and they're related.
First, finance reconciles to zero variance. Every till count maps to exactly the right set of posted invoices. There is no chasing phantom or duplicate sales. We've measured this against legacy systems being replaced — typical variance per till per month drops from a few hundred rupees to zero.
Second, operators stop being afraid of the system. The single biggest source of operator stress in legacy POS deployments is "did that go through?". With the idempotent design, the answer is always "yes, post it again if you're not sure — the system will figure it out." That confidence shows up in throughput, in customer service, and in retention of lane staff.
This is the kind of architectural decision that doesn't usually make it into the marketing material because it's hard to explain in three bullets. We're writing this post precisely because it's the thing customers eventually thank us for, and we want to be able to point at it during sales calls.
