DEV Community

Cover image for Offline POS: store stock as a ledger, not a counter
Stacklyn Labs
Stacklyn Labs

Posted on

Offline POS: store stock as a ledger, not a counter

Here is a stock bug that throws no error, passes every single-device test, and still loses inventory.

The counter model

The obvious model is a number on the product:

product.stock = 12;
// on each sale:
product.stock -= quantitySold;
Enter fullscreen mode Exit fullscreen mode

On one device it works. Now add a second till, and let both work offline.

  • Both tills start with stock = 12.
  • Till A sells 2, so it stores 10.
  • Till B sells 3, so it stores 9.
  • Both reconnect and sync their new value.

The server keeps 10 or 9, depending on which arrives last. Five units left the shop. The system recorded two or three. Nobody sees an error.

The ledger model

Instead of storing the current number, store every change as a movement with a signed delta:

class StockMove {
  const StockMove(this.id, this.productId, this.delta, this.reason);

  final String id;
  final String productId;
  final int delta;     // -2 sold, +24 delivered
  final String reason; // sale, refund, delivery, count
}
Enter fullscreen mode Exit fullscreen mode

Current stock is the sum of the movements:

int stockFor(String productId, Iterable<StockMove> moves) => moves
    .where((m) => m.productId == productId)
    .fold(0, (sum, m) => sum + m.delta);
Enter fullscreen mode Exit fullscreen mode

Same scenario:

Movement Delta
Opening stock +12
Till A sale −2
Till B sale −3
Stock 7

12 − 2 − 3 = 7, whichever till reports first.

Why it works

Movements are append-only, like sales. No till ever overwrites another till's data; each one only adds rows. Syncing append-only data is just addition, so the order of arrival doesn't matter.

This is the same idea accountants have used for centuries: you don't erase the balance, you record transactions and derive the balance.

Making it fast enough

Summing thousands of rows on every screen is slow. So keep a cached count per product and treat it as derived data:

  • update the cached count after each movement is stored
  • rebuild it from the ledger at start-up (or after sync)

If the cache and the ledger ever disagree, the ledger wins.

Stock takes and corrections

When a stock take finds 7 on the shelf and the ledger says 9, don't overwrite the number. Record a correction:

StockMove(uuid(), productId, -2, 'count');
Enter fullscreen mode Exit fullscreen mode

You keep the reason, the history stays auditable, and the correction syncs like any other movement.

Where else this applies

Anything that is a running total edited from more than one place: wallet balances, loyalty points, seat counts, budgets. If two offline devices can change it, store the changes, not the total.


Further reading: Offline POS architecture in Flutter on our blog also covers single-write checkouts, outbox sync and idempotent uploads.

Have you hit the "last sync wins" bug in production?

Top comments (0)