SaaS Architecture Article · 6 min read

How to keep every number in a point-of-sale system in agreement: immutable closed orders, a fixed pricing pipeline, discount allocation, rounding policy, stock as a ledger, and idempotent offline sync.

Published
Reading time6 min
Views25
Written byAhmad Alqunbar
POS & Inventory: A POS where every number agrees
Fig. 01 — POS & Inventory: A POS where every number agreesSaaS Architecture

A point-of-sale system has one job that sounds simple: every number has to agree. The receipt total, the tax report, the cash drawer, the payment provider's settlement, and the stock count on the shelf all describe the same sales. When they drift apart, the store loses trust in the system long before anyone finds the bug.

Most of that drift comes from a handful of design decisions made early, usually under deadline. Here are the ones that matter most.

The order is a state machine, and closed orders are immutable

An order moves through clear stages:

cart -> placed -> paid (fully or partially) -> fulfilled -> closed

The rule that saves you later: once an order is closed, it is never edited. A return is a new document that references the original lines. A price correction is a credit note. A cancelled payment is a reversal entry.

Editing a closed order rewrites history. Last month's tax report changes after it was filed, the cash drawer no longer matches the Z-report, and nobody can explain why.

Price an order with a fixed pipeline

A line's final amount comes out of several steps, and the order of those steps changes the result. Pick one order, write it down, and run every calculation through the same pipeline:

  1. Unit price from the price list valid at the time of sale.
  2. Line discounts, such as an item on promotion.
  3. Order discounts, such as a 10% coupon or a fixed amount off, allocated down to the lines.
  4. Tax per line, on the discounted amount.
  5. Rounding, with one documented policy.

Then store the result on each line: unit price, discount share, taxable amount, tax rate, and tax amount. Never recompute a historical order from today's prices or today's tax rules. The line is a snapshot of what was true when the customer paid.

Allocate order discounts to lines

A coupon for 5.000 off a three-item order looks like an order-level number. Keep it that way and returns become guesswork: if the customer returns one item, how much do you refund?

Allocate order-level discounts to lines in proportion to their value at the time of sale. Rounding will leave a remainder of a cent or a fils; give it to the lines with the largest fractional parts (the largest remainder method), so the allocated parts always add up exactly to the discount. Now every line carries its own net amount, and a return refunds exactly what the customer paid for that line.

Decide tax-inclusive or tax-exclusive, and round once

Retail prices are often shown tax-inclusive, while invoices to businesses are usually tax-exclusive. Both are fine. Mixing them silently is not.

Choose per price list whether prices include tax, derive the other figure on the line, and decide whether you round per line or per invoice. Per-line rounding makes each line self-contained and keeps returns simple, so it is the usual choice for POS. Whatever you choose, apply it everywhere, including in reports, or the totals will disagree by a few fils on large days.

Stock is a ledger, not a number

The most common inventory mistake is a single quantity column that every sale decrements and every delivery increments. It works until it doesn't, and then there is no way to find out why the shelf shows 3 and the system shows 7.

Model stock as a ledger of movements:

stock_movements
  id, product_id, location_id,
  type        -- sale | return | receipt | adjustment | transfer_out | transfer_in
  quantity    -- signed: -2 for a sale, +2 for a return
  reference   -- the document that caused it
  created_at

Current stock is the sum of movements. For speed you keep a running balance per product and location, but you update it in the same transaction that inserts the movement, so the two can't disagree. When a count is wrong, the ledger tells you exactly which documents produced it.

Concurrency: two cashiers, one last item

Two terminals sell the last unit at the same moment. Without care, both succeed and stock goes to -1.

Either lock the balance row for that product and location inside the sale transaction, or use a single conditional update that only succeeds when enough stock remains:

UPDATE stock_balances
   SET quantity = quantity - 1
 WHERE product_id = :product AND location_id = :location
   AND quantity >= 1;

If no row was updated, the sale fails cleanly. Some stores prefer to allow negative stock and fix it later rather than block a sale at the counter. That is a valid business choice, but make it a setting, not an accident.

Returns, exchanges, and damaged goods

A return document references the original sale lines and refunds their stored net amounts, including the allocated discount and the tax that was actually charged. It then produces stock movements, but not always back into sellable stock: a damaged item goes to a write-off location, not the shelf.

An exchange is simply a return and a new sale linked together. Modelling it as one special case usually creates more bugs than it saves.

Multiple branches and transfers

With several locations, every balance is per product per location. A transfer between branches is two movements, out of one location and into another, with an in-transit state in between, because trucks take time and sometimes arrive short. Treat the receiving count as the truth and record the difference as an adjustment with a reason.

Offline mode needs idempotent sync

Counters lose internet. A POS that stops selling when the connection drops is not a POS. Offline terminals create orders with client-generated IDs (UUIDs), queue them locally, and sync when the connection returns. The server accepts each order exactly once, because the ID makes the sync idempotent; a retried upload doesn't create a duplicate sale.

Offline sales can oversell stock. Accept that, let the balance go negative, and surface it in a report instead of rejecting a sale the customer has already walked out with.

Every number traces back to a document

The end-of-day report, cash counts, tax totals, and stock levels should all be computable from documents and movements alone. If a figure can only be explained by "the system adjusted it", that figure will eventually be wrong.

The checklist

  • Closed orders are immutable; returns and corrections are new documents.
  • One pricing pipeline, with the full breakdown stored on every line.
  • Order discounts allocated to lines with the largest remainder method.
  • One rounding policy, applied in sales, returns, and reports alike.
  • Stock as a ledger of movements, with balances updated in the same transaction.
  • Conditional updates or row locks against overselling.
  • Per-location stock, with transfers as paired movements.
  • Client-generated IDs for idempotent offline sync.

Get these right early and the numbers keep agreeing as the business grows. Get them wrong and every new branch multiplies the drift.