All lessons
E-commerceAdvanced13 min readIn my stack

Inventory and ERP sync

Warehouses, stock movements, reservations, purchase orders and keeping the shop in sync with accounting.

  • Warehouses
  • Stock movements
  • ERP sync
  • Fiscal receipts
Step 1/5
The very last item on the shelf. Two shoppers press “buy” at the same moment. Only one can win.

In 30 seconds

Inventory answers a simple question — how many can we sell, and where are they? — that becomes hard once there are several warehouses, suppliers, returns and an accounting system that also thinks it knows the answer. The reliable approach is to record every stock change as a movement, reserve stock for orders, and sync with the ERP through scheduled, observable jobs with clear rules for who owns which data.

Why I use it

  • I use movement-based inventory and scheduled sync jobs because I have built a retailer's inventory and its sync with an accounting/ERP system and fiscal receipts.
  • When stock is wrong, everyone suffers: customers buy items that do not exist, and warehouses hold stock nobody can see online.
  • Accounting systems are the legal record for prices, customers and sales. The shop has to agree with them, even when their APIs are slow or down.
  • Fiscal receipts are a legal requirement in many countries; failures there are not just bugs, they are compliance problems.

How it works

01Warehouses, stock levels and movements

A warehouse is any place that holds stock: a central store, a shop branch, a supplier's dropship stock. A stock level is the current quantity of one variant in one warehouse. The key idea is that you never just overwrite that number. Every change is a stock movement — a row saying "+20 received from purchase order 118", "−2 sold in order 5531", "−1 damaged", "+3 count correction". The stock level is the sum of movements, cached for speed. Like a bank statement, the movement history explains every number, lets you rebuild levels after a bug, and gives reports such as stock valuation, dead stock and sell-through. Stock counts (stocktakes) compare physical counts with the system and create correction movements for the difference.

sql
CREATE TABLE stock_movements (
  id            BIGSERIAL PRIMARY KEY,
  variant_id    BIGINT NOT NULL,
  warehouse_id  BIGINT NOT NULL,
  quantity      INTEGER NOT NULL,          -- positive in, negative out
  reason        TEXT NOT NULL,             -- sale, receipt, transfer_out, count, waste
  reference     TEXT,                      -- order or purchase order id
  created_by    BIGINT,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
-- Rebuild or verify cached levels from history
SELECT variant_id, warehouse_id, SUM(quantity) AS on_hand
FROM stock_movements
GROUP BY variant_id, warehouse_id;

02Available stock and reservations

"On hand" is what is physically in the warehouse. "Available" is what you can still promise: on hand minus reserved. A reservation is created when an order is placed and paid (or while payment is pending, with an expiry), and becomes a real outgoing movement when the order is picked and shipped. If the order is cancelled or the payment fails, the reservation is released. The storefront should show available stock, summed across the warehouses that can serve the customer. Fulfilment rules choose the source: the nearest branch, the main warehouse, a supplier who ships directly, or backorder (sell now, deliver when restocked). Make these rules explicit, because "which warehouse ships this?" affects delivery time, cost and accounting.

03Transfers, purchase orders and suppliers

A transfer moves stock between warehouses in two steps: an outgoing movement when it leaves, and an incoming one when it arrives. In between, the goods are "in transit" and belong to neither shelf — model that state, or stock seems to vanish. Purchase orders (POs) are how stock enters: you order from a supplier, the PO has expected quantities and costs, and goods receipts record what actually arrived, which may be partial or different. Each receipt creates incoming movements and updates cost price, which feeds margin and valuation reports. With good data you can generate reorder suggestions: average daily sales times supplier lead time plus safety stock, compared with available and incoming stock.

  • Statuses for POs: draft → sent → partially received → received → closed.
  • Record unit cost on receipt; margins depend on it.
  • Track supplier performance: late deliveries and quantity differences.

04Syncing with accounting and ERP

An ERP (enterprise resource planning) or accounting system is the business's back office: products, prices, customers, invoices and stock for tax purposes. First decide ownership per data type. A common split: the ERP owns product codes, prices and official stock; the shop owns descriptions, images, SEO and online orders. Then sync in the direction of ownership. Most ERP APIs are slow or batch-oriented, so sync with scheduled jobs: pull changed prices and stock every few minutes, push new orders and returns as they happen through a queue, run a full comparison nightly. Make each job idempotent by matching on stable external IDs, process in batches, and record a sync log with counts and errors. Let admins switch individual jobs on and off from settings, which is invaluable during ERP maintenance.

php
// Laravel scheduler: each sync is a separate, non-overlapping job
use Illuminate\Support\Facades\Schedule;
 
Schedule::job(new SyncPricesFromErp)->everyFiveMinutes()
    ->withoutOverlapping()->when(fn () => settings('erp.sync_prices'));
 
Schedule::job(new SyncStockFromErp)->everyTenMinutes()
    ->withoutOverlapping()->when(fn () => settings('erp.sync_stock'));
 
Schedule::job(new CompareCatalogueWithErp)->dailyAt('03:00')
    ->withoutOverlapping();
 
Schedule::job(new CheckErpHealth)->everyFiveMinutes(); // alerts admins on failures

05Conflicts, health checks and safety nets

Syncs fail in quiet ways: the ERP is down, a product code is renamed, a price import contains a zero. Protect yourself. Health checks track the last successful run per job and alert when it is too old or the error rate jumps. Sanity rules reject suspicious changes — a price dropping by 90%, all stock becoming zero — and hold them for review. Keep the previous values so an admin can revert a bad price import in one click. For conflicts, the owner wins: if both sides changed a field, take the value from the system that owns it and log the overwrite. A nightly comparison report lists products that exist on one side only or differ, so people fix data at the source instead of patching the shop.

06Fiscal receipts

In many countries, each sale must be registered with the tax authority through a fiscal device or cloud service, which returns a fiscal receipt number (often printed or shown as a QR code). Returns need their own fiscal return receipts linked to the original. Treat this like a payment integration: create a receipt record with a status, send it through a queue with retries, store the fiscal number on the order, and alert on failures. Retry only when it is safe — if the service may have registered the receipt before the timeout, check first to avoid duplicates. Branches or tills that can work offline need an offline mode that queues receipts and submits them when the connection returns, within the time the law allows.

Use it when

  • Stock lives in more than one place, or online and offline sales share stock.
  • The business already runs an accounting or ERP system that must stay the source of truth for prices and stock.
  • Selling an item you do not have is expensive: perishable goods, limited runs, supplier dropship.
  • Local law requires fiscal registration of every sale.

Skip it when

  • Made-to-order or digital products with no stock to track.
  • A single small warehouse where a simple quantity column with atomic updates is enough.
  • When the ERP offers a ready, maintained connector for your platform — use it before writing your own.

Alternatives

  • Shop platform inventory

    Shopify and WooCommerce track stock per location; fine for simple cases, weak for purchase orders and costing.

  • Dedicated inventory / WMS tools

    Warehouse management systems handle picking, bins and transfers; the shop syncs with them instead of owning stock.

  • ERP as the only stock source

    The shop reads stock from the ERP in near real time; simplest ownership, but every outage blocks sales.

Best practices

  1. Record every stock change as a movement with a reason and reference.
  2. Separate on hand, reserved and available stock.
  3. Decide data ownership per field before writing any sync code.
  4. Run syncs as idempotent, non-overlapping scheduled jobs keyed by external IDs.
  5. Monitor last success time per job and alert on stale or failing syncs.
  6. Reject suspicious bulk changes and keep previous values for revert.
  7. Queue fiscal receipts with safe retries and store their numbers on the order.

Common mistakes

  • Overwriting stock quantities directly, losing the history of why they changed.
  • Two-way sync without ownership rules, so systems overwrite each other in a loop.
  • Sync jobs that overlap and process the same batch twice.
  • Silent sync failures noticed only when customers complain about prices.
  • Ignoring in-transit stock during transfers.

Deep dive

07Consistency between movements and cached levels

Movements are the history and stock levels are a cache, so they must never drift. Write both in the same transaction: insert the movement and update the level with a conditional statement that cannot go negative unless backorders are allowed. Lock order matters when one operation touches several rows, such as a transfer or a multi-line order: always lock rows in a consistent order (for example by variant_id, then warehouse_id) to avoid deadlocks. Keep a nightly verification job that sums movements per variant and warehouse and compares them with the cached level; any difference is a bug to investigate, not something to silently fix. For high write volumes, movements can be partitioned by month, and old months summarised into opening balances.

sql
BEGIN;
-- 1. Decrease the cached level only if enough is available
UPDATE stock_levels
SET on_hand = on_hand - 3
WHERE variant_id = 42 AND warehouse_id = 1 AND on_hand - reserved >= 3;
-- 0 rows → ROLLBACK and report out of stock
 
-- 2. Record why it changed
INSERT INTO stock_movements (variant_id, warehouse_id, quantity, reason, reference)
VALUES (42, 1, -3, 'sale', 'order:5531');
COMMIT;

08Inventory valuation and cost methods

Stock is money on shelves, so finance asks what it is worth and what each sale cost. That depends on a cost method. Weighted average cost recalculates the unit cost on every receipt: (old quantity × old cost + received quantity × new cost) / total quantity. It is simple and common in ERPs. FIFO (first in, first out) assumes the oldest units are sold first, so each sale consumes cost layers from the oldest receipt; it needs per-receipt layers but reflects rising prices accurately. Whichever you choose, it must match the ERP or accounting system, otherwise margins in the shop and in the books disagree. Record the cost on the sale movement at the time of sale, just like price snapshots on orders, so later receipts do not rewrite historical margins.

  • Weighted average: one cost per variant and warehouse, updated on receipt.
  • FIFO: cost layers per receipt, consumed oldest first.
  • Returns re-enter stock at the cost they left with, not today's cost.

09Integration patterns: polling, events and the outbox

There are two ways to learn about changes in another system: polling (ask "what changed since time T?" on a schedule) and events (the other system pushes webhooks or messages). Many ERPs only support polling. Poll with a watermark — the last updated timestamp or change ID you processed — stored per job, and overlap the window slightly to tolerate clock skew, relying on idempotent upserts to make re-reads harmless. For data you send, use the transactional outbox pattern: in the same database transaction as the order, insert a row into an outbox table; a worker reads the outbox and sends it to the ERP, retrying until acknowledged. This guarantees that a committed order is eventually exported, and that a rolled-back order never is, without distributed transactions.

sql
CREATE TABLE outbox (
  id           BIGSERIAL PRIMARY KEY,
  topic        TEXT NOT NULL,        -- erp.order_created, erp.return_created
  payload      JSONB NOT NULL,
  attempts     INT NOT NULL DEFAULT 0,
  sent_at      TIMESTAMPTZ,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
-- Worker: claim a batch without blocking other workers
SELECT id, topic, payload FROM outbox
WHERE sent_at IS NULL
ORDER BY id
LIMIT 50
FOR UPDATE SKIP LOCKED;

10Multi-warehouse allocation and split shipments

With several warehouses, every order poses a small optimisation problem: which locations should fulfil which lines? Common rules, in priority order: ship everything from one location if possible (one parcel is cheaper), prefer the location closest to the customer, avoid draining a branch's display stock, and use supplier dropship or backorder only as a last resort. Write the rules as a scoring function and record the decision on the order, so warehouse staff and support can see why a location was chosen. Split shipments need their own records: one order, several shipments, each with lines, tracking and status, and the order becomes delivered only when all shipments are. Reservations should be made per warehouse at checkout, not later, otherwise the allocation can fail after the customer has paid.

Real-world scenarios

01

A price import sets half the catalogue to 0.01

Problem

The ERP team changes a price list format overnight. The 5-minute price sync imports thousands of products at 0.01 and customers start ordering them at dawn.

How to solve it

Stop the bleeding: switch the price sync off from admin settings, revert prices from the stored previous values, and decide with the business how to handle orders placed at wrong prices according to the terms. Then add guards so it cannot recur: reject a batch if more than a configured share of items changes by more than, say, 50%, refuse zero or near-zero prices unless explicitly allowed, and hold rejected batches for human approval with a diff report. Validate the payload schema so a format change fails loudly instead of parsing into nonsense. Alert the on-call person when a batch is held.

02

Online stock says 5, the shelf is empty

Problem

Branch staff regularly cancel online pickup orders because items shown as available are not physically there. Customers lose trust in the stock shown.

How to solve it

Find where reality and the system diverge. Usual causes: in-store sales recorded in the ERP but synced only every hour, damaged or stolen items never recorded, and display stock counted as sellable. Shorten the sync for stock or push in-store sales as events, add a waste movement type that staff can record from a phone, and introduce cycle counts — counting a few products each day instead of a yearly stocktake. Show online availability with a safety buffer per branch (available minus one or two for fast movers). Track cancellation reasons per branch; the report quickly shows which locations need process changes rather than code.

03

The ERP API is rate-limited to 60 requests per minute

Problem

A full catalogue comparison of 40,000 products takes hours and blocks the order export, which shares the same API quota.

How to solve it

Use the quota deliberately. Ask whether the ERP offers bulk or delta endpoints, such as export files or "changed since" queries, which cost far fewer requests than per-product calls. Put all ERP calls through one client with a shared token-bucket rate limiter, and give order and return exports priority over background comparison so revenue-critical data always gets through. Run the full comparison at night, in pages, resumable from the last processed page. Cache unchanged reference data. Measure quota usage per job, and if the budget is still too small, that is a concrete argument for negotiating a higher limit with the ERP vendor.

Cheat sheet

Rebuild levels from movements
SELECT variant_id, warehouse_id, SUM(quantity) FROM stock_movements GROUP BY 1, 2;
Claim outbox rows safely
SELECT ... FROM outbox WHERE sent_at IS NULL ORDER BY id LIMIT 50 FOR UPDATE SKIP LOCKED;
Idempotent upsert by external ID
INSERT INTO products (erp_id, price) VALUES (:id, :price) ON CONFLICT (erp_id) DO UPDATE SET price = EXCLUDED.price;
Non-overlapping job on one server (Laravel)
Schedule::job(new SyncStockFromErp)->everyTenMinutes()->withoutOverlapping()->onOneServer();
Weighted average cost on receipt
new_cost = (qty_old * cost_old + qty_in * cost_in) / (qty_old + qty_in)
Reorder point
reorder_point = avg_daily_sales * lead_time_days + safety_stock
Stale sync check
SELECT job FROM sync_runs GROUP BY job HAVING MAX(finished_at) < now() - interval '30 minutes';
Process large tables in chunks (Laravel)
Product::query()->chunkById(500, fn ($products) => $this->compare($products));

In my real projects

  • On a pet-supplies platform I built inventory with 13 models: warehouses, stock levels and movements, transfers, stock counts, suppliers, purchase orders and receipts and a supplier fulfilment queue, with reports for reorder suggestions, stock valuation, dead stock and supplier performance.
  • The same platform syncs with an accounting/ERP system through 12 service classes — catalogue, prices, customers, loyalty, fiscal receipts, order export and returns, catalogue comparison and price revert, health alerts — as scheduled jobs toggled from admin settings.
  • On a gym-chain CRM I integrated a cloud fiscal-receipt service with per-branch device pairing and ordered, non-overlapping scheduled jobs; the POS SaaS handles sale and return receipts, offline variants and retries.

Questions & answers

Q1Why use stock movements instead of just a quantity column?

A quantity column only tells you the current number; it cannot explain it. With movements, every receipt, sale, return, transfer and correction is a row with a reason, reference and author, and the quantity is their sum. You can audit discrepancies, rebuild levels after a bug, report on valuation and dead stock, and see exactly when an item ran out. In practice I keep a cached stock level for fast reads and update it in the same transaction as the movement.

Q2What is the difference between on-hand and available stock?

On-hand is what is physically in the warehouse. Available is what you can still sell: on-hand minus stock reserved for orders that are placed but not yet shipped. The storefront must show available stock, otherwise two customers can buy the same last item. Reservations are created when the order is placed, converted into outgoing movements on shipping, and released on cancellation or payment failure. Incoming stock from purchase orders can be shown separately for backorders.

Q3How do you sync a shop with an ERP reliably?

Start with ownership: for each field, decide which system is the source of truth. Then sync in that direction with scheduled, idempotent jobs keyed by stable external IDs, processed in batches and protected against overlapping runs. Push events such as new orders through a queue with retries. Log every run, track the last success per job, and alert when it is stale. Add sanity checks for suspicious bulk changes, keep previous values for revert, and run a nightly comparison report to catch drift.

Q4What happens when the ERP is down?

The shop should keep selling. Prices and stock are already cached locally from the last successful sync, so the storefront works. Outgoing data such as orders and returns waits in a queue and retries with backoff until the ERP is back. Health checks alert admins, and the admin can pause a job during planned maintenance. If stock accuracy is critical, you can add a safety buffer — show slightly less available stock — while the sync is stale.

Q5How do you avoid duplicate fiscal receipts?

Each order gets one fiscal receipt record with a status and a unique reference. Sending happens in a queued job. If the call fails clearly before reaching the service, it is safe to retry. If it times out, the receipt may already be registered, so the job first asks the service whether a receipt with that reference exists. Only then does it retry. The fiscal number is stored on the order, and returns create separate return receipts linked to the original.

Senior-level questions

S1Should the shop or the ERP be the source of truth for stock?

It depends on where stock changes happen and how reliable the ERP is. If most movements happen in the ERP — warehouse receipts, in-store sales, transfers — the ERP should own stock and the shop keeps a near-real-time cache with reservations layered on top. If the shop drives most sales and the ERP is slow or batch-based, the shop can own operational stock and push movements to the ERP for accounting. The worst option is shared ownership without rules. Whichever side owns it, the other must tolerate staleness: show a safety buffer, re-check at checkout, and reconcile nightly. I decide with the client by listing every event that changes stock and where it originates.

S2How do you guarantee that every order reaches the ERP exactly once?

Exactly-once delivery across a network is not achievable in general, so I build at-least-once delivery plus idempotent processing, which gives exactly-once effect. The order and an outbox row are committed in the same transaction, so nothing is lost. A worker sends outbox rows and marks them sent only after the ERP acknowledges. If the acknowledgement is lost, the row is resent, and the ERP side deduplicates by our order reference — through a unique external ID field, or by querying for the reference before creating. If the ERP cannot deduplicate, the worker must query first on any retry after a timeout. Monitoring alerts on outbox age, so stuck exports are visible within minutes.

S3How would you scale inventory writes for a high-volume sale?

The bottleneck is contention on a few hot rows: the same popular variant in the same warehouse. Options, in order of complexity: keep transactions very short and lock rows in a consistent order; move non-critical work such as movements for analytics and ERP export out of the hot transaction into the outbox; pre-split stock for hot items into several buckets that are decremented independently and summed for display; or front the hot items with an atomic Redis counter that grants reservations, with periodic reconciliation against the database. Each step trades simplicity for throughput, and the Redis approach adds a second source of truth that needs careful recovery. I measure lock waits first, because most shops never need more than the first two.

Glossary

On hand
Units physically present in a warehouse right now.
Available
Units that can still be sold: on hand minus reserved.
Stock movement
A record of one stock change with quantity, reason, reference and author.
Cycle count
Counting a small rotating set of products regularly instead of the whole warehouse at once.
Weighted average cost
A unit cost recalculated on each receipt as the average of old and new stock costs.
FIFO
First in, first out: the oldest stock and its cost are consumed first.
Transactional outbox
Writing outgoing messages to a table in the same transaction as the data, then sending them asynchronously.
Watermark
The last processed timestamp or change ID, so the next poll fetches only newer changes.
Safety stock
Extra stock kept to cover demand spikes and supplier delays.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.