A generic ERP assumes the transaction happens where the database is. Retail does not work that way. A sale happens at a counter, on a phone in aisle six, on a marketplace listing you do not control, and sometimes on a laptop with no internet at a pop-up in a parking lot. Each of those writes wants to decrement the same stock number, and two of them will disagree.
We built and operate Retailer OS, an all-in-one retail platform with an offline-capable POS, per-location inventory, an online store and marketplace sync to Google, Meta, Amazon and eBay. The parts that took the longest were not the parts that look hard on a roadmap. They were the parts a generic ERP models as a single integer called quantity_on_hand.
If you are building retail software now, here is where the generic product breaks, and what the replacement mechanism looks like.
The sale cannot wait for the network
Every ERP-shaped system treats a failed write as an error the user retries. In retail, a failed write is a customer standing at a counter with a card in their hand. You do not get to retry. You get to take the money or lose the sale.
That single requirement inverts the architecture. The register must be able to complete a full transaction — line items, discounts, tax, tender, receipt — with no server. Which means the register holds an authoritative local log, and the server is a reconciler, not a gatekeeper.
The rule we hold to: a sale is never rejected by the server for a reason the register could not have known at sale time. Stock going negative is not a rejection reason. Neither is a stale price, if the register had the current price when it rang. The server records what happened and raises an exception for a human. It does not un-sell things.
That forces three concrete decisions:
- Client-generated IDs. The register mints a UUID for the sale and every line. Server-side sequences are useless when the client is the source of truth. Idempotency comes free: replaying the same sale is a no-op.
- Append-only sale events, not mutable sale rows.
sale.opened,line.added,discount.applied,tender.captured,sale.voided. A returning register uploads its log in order. Conflicts become orderable facts instead of overwritten fields. - A monotonic device clock plus a server receipt time. Registers drift. Keep both timestamps and never use the device clock for financial cutoffs like end-of-day.
Card payments are the honest exception. If your processor requires online authorization, offline mode means store-and-forward with a real risk of decline after the customer has left. That is a business decision, not an engineering one. Some clients accept the risk under a dollar ceiling; some disable offline card entirely and take cash only. Build the ceiling as a per-location setting and let the operator choose.
Inventory is not a number, it is a set of buckets per location
The single biggest modeling error we see in retail projects lifted from ERP thinking is one quantity per SKU. Retail needs quantity per SKU per location, split by state, with reservations that expire.
A minimum viable set of buckets:
- On hand — physically present and countable at this location.
- Reserved — allocated to an unfulfilled order, a layaway, or a click-and-collect hold.
- In transit — left one location, not yet received at another. Owned by neither for sale purposes.
- Damaged / quarantine — present but not sellable. Still counts for shrink math.
- Available to sell — derived, never stored: on hand minus reserved minus damaged, possibly plus in-transit depending on the channel's promise window.
Store movements, derive balances. The movement table is the truth; the balance is a materialized read model you can rebuild. This is the only design that survives a physical count disagreeing with the system, because you record the count as an adjustment movement with a reason code rather than silently overwriting a number.
create table stock_movement (
id uuid primary key,
tenant_id uuid not null,
location_id uuid not null,
variant_id uuid not null,
bucket text not null, -- on_hand | reserved | in_transit | damaged
delta integer not null,
reason text not null, -- sale | return | count | transfer | shrink
source_type text not null, -- sale | po | transfer | adjustment
source_id uuid not null,
occurred_at timestamptz not null,
recorded_at timestamptz not null default now()
);
-- one row per bucket change; balances are a rollup, never edited in place
Two things this buys you immediately. Shrink becomes measurable per location per reason instead of appearing as an unexplained variance. And a negative available-to-sell is a legitimate, visible state you can alert on, rather than a constraint violation that blocks a register.
Variants deserve their own note. Retail sells size-and-color grids, bundles, kits and weighted items in the same catalog. ERP product masters usually model a variant as a child product with its own everything. That breaks pricing rules, marketplace listing structure and reporting all at once. Model the variant as the sellable unit and the product as a presentation grouping — the barcode hangs off the variant, always.
Channels have their own state you do not own
Marketplace sync is where teams underestimate scope by an order of magnitude. It looks like an export. It is a distributed state machine where the remote system has opinions.
Amazon, eBay, Google and Meta each keep their own copy of your listing, their own validation rules, their own category taxonomy and their own idea of when your last update landed. A listing can be rejected for a missing attribute your local catalog does not have a field for. A price update can be accepted and then reverted by a channel-side repricing rule. Inventory feeds are asynchronous, so the channel will oversell you.
The mechanism that makes this manageable is a per-channel listing record that is separate from the product:
desired_state— what your catalog says the listing should be.observed_state— what the channel last told you it is.sync_status— in sync, pending, rejected, or drifted, with the channel's own error text kept verbatim.channel_item_id— the remote identifier, which is the only join key that survives a re-list.buffer— units held back from this channel to absorb oversell latency.
Reconcile by diffing desired against observed on a schedule, and never trust a 200 response as proof the channel applied your change. Poll the observed state back. This is the same discipline as any multi-tenant integration surface: the remote system is eventually consistent and occasionally wrong, so your UI has to show the operator which of their listings are lying.
The oversell buffer is a tradeoff worth naming. A buffer of two units per channel per SKU costs you sales on low-stock items. Zero buffer costs you cancellations and marketplace account health. We default to a buffer that scales with how fast the SKU sells rather than a flat number, and we let the operator override per channel.
Returns, exchanges and the fact that money moves backwards
Generic ERP models a return as a negative sale. Retail returns are not negative sales, and treating them that way corrupts every report you care about.
A real return has to answer: which original sale line is this against, what condition is the item in, what tender does the refund go to, was the item bought at a promotional price, and did the customer exchange rather than refund. An exchange is a return and a sale in one atomic interaction that must not double-count revenue or units.
Three rules we apply:
- Link the return line to the original sale line ID, not the SKU. Without it you cannot compute the correct refund on a bundle or a discounted line, and you cannot detect return fraud patterns.
- Refund to the original tender by default, with an explicit override that requires a reason. Refunding cash for a card sale is the single most common register-level loss channel.
- Return condition drives the bucket, not the SKU. Sellable condition creates an
on_handmovement; damaged creates adamagedmovement. Same SKU, different destination.
Layaway, store credit and gift cards belong in the same conversation. They are liabilities, not revenue, and they have to survive a register that was offline when the balance changed. Gift card balance is the one place we accept a hard online check — the fraud exposure on a double-spent card is worse than the lost sale.
The register is a hardware problem wearing a software costume
Barcode scanners, receipt printers, cash drawers, scales, card readers, label printers. A browser cannot reach most of that reliably. We ship the POS as a native shell over the web app using Capacitor precisely so the hardware layer has real access while the business logic stays one codebase.
The practical consequence is that your peripheral layer needs a contract, not direct calls sprinkled through UI code. One interface per device class — scan(), print(receipt), openDrawer(), readWeight() — with a driver behind it per vendor. Otherwise every new printer model is a change to checkout.
If the checkout screen knows the brand of the receipt printer, you will rewrite checkout every time a store buys new hardware.
When generic ERP is actually the right call
None of this argues for building custom in every case. If a retailer has one location, sells under a few hundred SKUs, does not sell on marketplaces, and has reliable internet, an off-the-shelf POS with a stock module will beat anything you build, on cost and on time. The domain rules above only start costing money at multiple locations, multiple channels, or unusual fulfillment.
The decision rule we use when scoping retail work: count the places a single unit of inventory can be promised to a customer. One place, buy software. Three or more places — a store, a website, a marketplace — and the reconciliation logic between them is your actual product. That logic is where the generic tool stops, because it was never modeled to hold conflicting promises about the same unit.
Build the movement ledger and the per-channel listing state first, before the pretty checkout screen. Those two structures determine whether the next two years of features are additive or a rewrite. If you want to talk through where your inventory model breaks, tell us what you are building — and there is more on how we scope and price this kind of work in our approach.
Frequently asked
Can a standard ERP handle a retail point of sale?
It can record the sale, but usually not take it. ERP checkout paths assume the server is reachable and reject writes that violate stock constraints. Retail needs the register to complete a transaction offline and the server to reconcile afterward. If you bolt a POS onto ERP, the POS needs its own authoritative local store and an append-only sync log, which is most of the work anyway.
How do you stop marketplace overselling?
Hold back a buffer of units per channel per SKU, sized to how fast that SKU sells rather than a flat number, and treat the channel's reported inventory as observed state you poll back rather than assume. Never trust a successful API response as proof the change applied. Show operators a drift list so they can see which listings disagree with your catalog.
Should inventory be stored as a balance or as movements?
Store movements, derive balances. An append-only movement table with a reason code per row lets you rebuild any balance, explain shrink per location, and record a physical count as an adjustment instead of overwriting a number. Keep the balance as a materialized read model for query speed, and rebuild it from movements when the two disagree.
Why can't a return just be a negative sale line?
Because a return needs the original sale line ID to compute the right refund on discounted lines and bundles, a condition code to route the unit into sellable or damaged stock, and a tender rule so refunds go back to the original payment method. Modeling it as a negative sale loses all three and corrupts margin, shrink and fraud reporting.
When is custom retail software not worth it?
Single location, a few hundred SKUs, no marketplace channels, reliable internet. Off-the-shelf will win on cost and speed. Custom starts paying when a single unit of inventory can be promised in three or more places at once, because the reconciliation logic between those promises is the part no generic product models.