</>CodeWithKarani

One tap at the till, five documents: the submit chain behind ERPNext retail

Karani GeoffreyKarani Geoffrey15 min read

I recorded a full walkthrough of UpeoRetail last week. It runs an hour and twenty minutes, which is a strange length for a product video, and the reason it is that long is that I refused to cut the boring parts. A counter sale takes eight seconds on screen. Receiving a supplier delivery takes four minutes. Explaining why the buying price is invisible to the person operating the till takes longer than either.

Most people watching a retail demo are watching the eight seconds. This article is about everything the eight seconds triggers, because that is the part that decides whether the system is still telling the truth in month six.

UpeoRetail is ERPNext and Frappe, pre-configured for East African retail: serial numbers on every unit, M-Pesa on the payment side, KRA eTIMS on the invoice side. Nothing in here is exotic. All of it is the consequence of one decision, which is that the till writes to the ledger instead of writing to a sales table that somebody reconciles later.

a counter sale in ERPNext is not a row in a sales table. It is a submit that fans out into five separate documents, synchronously, inside one database transaction.

  • One Sales Invoice submit writes stock ledger entries, GL entries, a serial and batch bundle, and a payment, and any one of them failing rolls back the sale.
  • Serial tracking is cheap to demo and expensive to run. Every serialised row adds a child document and a set of row-level status updates to the hot path.
  • eTIMS is what forces you off the POS Invoice consolidation path, because the tax authority wants an invoice per sale and consolidation gives you one per day.
  • M-Pesa STK push is the easy half. Matching an asynchronous callback to the right invoice, exactly once, is the half that generates the support calls.
  • Hiding the buying price from a cashier is a permission-level problem on fields and reports, not a checkbox, and it leaks in about six places if you only do the obvious one.

What one counter sale actually writes

Here is the thing a POS-only app never has to think about. When a shop sells a laptop, five facts become true at the same instant: the shop has one fewer laptop, the shop is owed or has been paid some money, revenue has been earned, the cost of that specific laptop has left inventory and become an expense, and a serial number that used to belong to the shop now belongs to a customer.

A till app records the second of those five and leaves the other four to a spreadsheet at month end. ERPNext records all five, in order, or records none of them.

The documents written by a single counter sale in ERPNext A cashier submits a Sales Invoice with update stock enabled. Inside one database transaction this writes stock ledger entries, general ledger entries, a serial and batch bundle with per-unit status changes, and a payment record. Outside the transaction, an eTIMS call to the tax authority and an M-Pesa callback arrive asynchronously and are reconciled back onto the invoice. At the counter One database transaction Outside, and async Cashier taps Submit Sales Invoice docstatus 0 to 1, update_stock = 1 Stock Ledger Entry one per item row, valuation applied Serial and Batch Bundle per unit, status and warehouse move GL Entry debtors, income, tax, COGS, stock Payment cash, card or M-Pesa reference eTIMS tax authority, can fail M-Pesa callback arrives late, may repeat Any failure inside the dashed box rolls the whole sale back. Nothing outside it can.
One submit, five documents, and two integrations that live outside the transaction and therefore need their own recovery story.

The mechanism is ordinary Frappe. A Sales Invoice with update_stock set moves from docstatus 0 to 1, and the submit hook runs the stock ledger update, the serial and batch handling, and the GL posting inside the same transaction as the document write. If the item has no stock in that warehouse, the whole thing raises and the sale does not happen. If the accounts are misconfigured such that the GL posting cannot balance, the sale does not happen either.

Operationally that is a feature and it feels like a bug for the first week. A cashier is used to software that lets the sale through and complains later. Here, the counter is where data quality gets enforced, because it is the only moment when someone who knows the truth is standing in front of the record.

The POS Invoice fork, and why eTIMS decides it for you

ERPNext gives you two shapes for a till, and the choice is not cosmetic.

The first is a plain Sales Invoice with update_stock enabled, which is what the diagram above shows: everything posts on the spot. The second is the POS Invoice path, where invoices accumulate through the day and get folded into a consolidated invoice when the shift closes. Consolidation exists for a good reason. It keeps the general ledger from accumulating hundreds of tiny entries per till per day, which is a real problem for a supermarket doing four hundred baskets before lunch.

For a serialised, high-value shop under eTIMS, consolidation is the wrong fork, and the tax authority is what settles it. KRA wants a fiscal invoice per sale, signed and numbered at the moment of sale, and the customer wants to leave with it in their hand. A consolidated document at close of business does not satisfy that. So the shops UpeoRetail is built for post per sale and accept the ledger volume, which for a business selling twenty to eighty units a day is not a volume anyone will ever notice.

If you are configuring this for a genuinely high-throughput retailer without a per-sale fiscal requirement, take the consolidation path and do not let anybody talk you out of it. The failure mode of choosing wrong is not a crash; it is a tabGL Entry table that gets slow eighteen months in, at which point you are reading query plans instead of selling laptops.

Serial numbers are cheap to demo and expensive to run

Serial tracking is the feature that sells this system to a phone or laptop dealer, and it is also the feature that costs the most on the hot path. It is worth being honest about both.

The value is not in dispute. Every unit is tracked from the purchase order it arrived on, through the warehouse it sat in, to the invoice that sold it and the customer who took it away. When a phone comes back under warranty eleven months later, the question "did we sell this, when, to whom, and what did we pay for it" is a lookup rather than an investigation. When stock is counted, a discrepancy is a specific IMEI rather than a number, which changes the conversation with the person responsible from an argument into a fact.

The cost is that in ERPNext v15 the serial and batch handling moved into the Serial and Batch Bundle document, which means every serialised row in a transaction creates a child document with one entry per unit, and each Serial No record gets its status and warehouse updated as it moves. That is correct and auditable and it is more writes than a quantity decrement. On a counter sale of one or two units it is invisible. On a goods receipt of two hundred handsets it is very visible, and the honest answer is that receiving stock is a back-office job that can take its time.

Two things make this survivable in practice:

  • Scan, do not type. A barcode or IMEI scanner on the receiving desk and at the counter removes the transcription error that otherwise poisons the whole traceability story. A serial number typed wrong is worse than no serial number, because it is a fact that reads as true.
  • Do not serialise everything. Serialise what has a warranty, a resale value or a theft risk. Charging cables and screen protectors should be quantity-tracked. I have watched implementations die because someone insisted on serialising accessories and made every sale slow.

The older mechanics, and when batches are the right tool instead, I covered in the serial and batch guide.

M-Pesa: the push is easy, the match is the work

Every M-Pesa integration demo shows the same thirty seconds. The cashier enters the amount, the customer's phone buzzes with an STK prompt, they enter their PIN, the invoice goes green. That part is a POST to Daraja and it works.

The work is on the other side. The confirmation is an asynchronous callback from Safaricom to your server, and it has three properties that will hurt you if you design as though it does not:

  • It arrives late, sometimes long after the customer has walked out, so the till cannot block on it.
  • It arrives more than once, because Safaricom retries anything it does not get a clean acknowledgement for.
  • It arrives out of band, which means a customer can also just pay the till directly from their phone with no STK push at all, and now you have money with no invoice attached to it.

So the reconciliation logic is the actual product. Every callback is written to a payment record keyed on the M-Pesa transaction ID, the write is idempotent on that key, and matching to an invoice happens as a second step against amount, till, phone number and time window. Unmatched money does not vanish and does not get force-fitted to the nearest invoice; it sits in a queue that somebody clears, which is exactly what a bank reconciliation is and exactly what shops already understand.

I wrote up the duplicate-callback problem and the specific ERPNext-side idempotency pattern in Your M-Pesa callback will fire twice. For shops that cannot get onto Daraja at all, which is a large number of real merchants, the confirmation still arrives as an SMS on a phone at the counter, and we turn that phone into a signed gateway that posts the message to the backend. Same reconciliation queue, different transport.

eTIMS is a distributed transaction you did not ask for

Here is the structural problem in one sentence: your invoice is not final until KRA says it is, and KRA is on the other side of a network you do not control.

Get this wrong and you build one of two bad systems. Either the sale blocks on the eTIMS response, in which case a slow connection stops the shop trading, or the sale ignores eTIMS entirely, in which case you discover at the end of the month that four hundred invoices were never fiscalised.

The shape that works is a state machine on the invoice. The sale submits locally and completes. Fiscalisation is a separate, queued, retrying job that moves the invoice from pending to signed and stamps the control unit number and the QR data back onto it. The receipt the customer takes away is printed after the stamp when the link is up, and the queue drains when it comes back. What you must never do is let a failed fiscalisation silently succeed, which is why the pending count belongs on a dashboard somebody actually looks at rather than in a log file.

The protocol details, which are more device handshake than REST API, are in the eTIMS survival guide. The engineering point for this article is narrower: two of the five things a sale must do live outside your transaction boundary, and every serious retail system is mostly the machinery for making that safe.

The buying price problem

Of everything in that walkthrough, the question I get asked most often by shop owners is the least technical-sounding: can my cashier see what I paid for this?

The answer in a stock-aware ERP is that by default, in more places than you would guess, yes. Cost is not one field you can hide. It is the item's valuation rate, the last purchase rate, the stock ledger report, the stock balance report with its valuation column, the purchase order and purchase receipt documents, the supplier ledger, the gross profit report, and the item dashboard.

Doing this properly in Frappe means using field-level permissions, which is to say putting cost-bearing fields at a higher permlevel and granting that level only to the roles that should have it, then removing report and doctype access for the buying-side documents from the cashier role, then checking the print formats, because a receipt template that happens to render a valuation field will cheerfully leak it to the customer.

It is a tedious afternoon and it is not optional. A retail permission model that only covers the obvious screen is the kind of thing that looks finished right up until a cashier opens a stock report on their break.

What I would check in your own build

If you are configuring ERPNext for a retail shop rather than buying something pre-configured, this is the short list of things that separate an installation that survives from one that gets abandoned in month four:

  • Perpetual inventory on, and the accounts actually mapped. If cost of goods sold is not posting on sale, your gross profit report is fiction and you will not find out for a quarter.
  • A backdated entry policy. Somebody will post a purchase receipt with last week's date, and ERPNext will queue a Repost Item Valuation that rewrites valuation forward from that point. Know that this happens, know it runs in the background, and know that a report read while it is running is a report read mid-rewrite.
  • One warehouse per physical place, and no more. Every extra warehouse is a place stock can hide. Stock transfers between branches should be documents, not adjustments.
  • Refunds and discounts behind approval. The most common shrinkage in a small shop is not theft of goods, it is a discount nobody authorised. Both are workflow states, not honour systems.
  • Someone owns the reorder level. The feature is trivial. The discipline of maintaining it per item per warehouse is the whole value, and it is the first thing to rot.

The trade-off I keep defending

A till app is faster to install and it will never lie to you about how much money came in today, because that is the only question it answers. The reason I keep building retail on ERPNext instead is that "how much money came in today" is not actually the question a shop owner has. Their questions are which items made the margin, what is sitting in the store not moving, which customer owes what, whether the count matches the ledger, and whether the phone that just came back under warranty was ever ours.

Every one of those questions is answerable only if the sale, the stock movement and the accounting entry are the same event. Which means paying for it at the counter, in the form of a submit that can refuse, an integration state machine for the tax authority, a reconciliation queue for money that arrives asynchronously, and an afternoon of permission work so a cashier never sees a cost price.

That is what the boring parts of the walkthrough are. The eight seconds at the till is the easy bit, and it is the last bit you should judge a retail system on.

Frequently asked questions

Why does a counter sale in ERPNext fail instead of just letting the sale through?
Because the sale, the stock movement and the accounting entry are one transaction. If the item has no stock in that warehouse, or the accounts cannot balance, the submit raises and nothing is written. It feels obstructive for about a week. The alternative is software that accepts anything at the counter and hands you an unreconcilable mess at month end, which is exactly the problem an ERP is there to remove. The counter is the only moment when a person who knows the truth is standing in front of the record.
Should I use POS Invoice with consolidation, or a plain Sales Invoice that updates stock?
It depends on volume and on whether you have a per-sale fiscal requirement. Consolidation keeps the general ledger small by folding a day of POS invoices into one document at shift close, which matters for a high-throughput supermarket. Under KRA eTIMS you need a signed fiscal invoice per sale, so consolidation does not satisfy the requirement and you post per sale instead. For a shop doing tens of sales a day rather than hundreds, the ledger volume from posting per sale is not something anyone will ever notice.
Does serial number tracking slow the system down?
On a counter sale of one or two units, no. On a goods receipt of two hundred units, yes, and visibly, because each serialised unit creates entries in a Serial and Batch Bundle and updates its own Serial No record. Receiving is a back-office job that can take its time, so this is usually acceptable. The way to keep it acceptable is to serialise only what has a warranty, a resale value or a theft risk, and to leave accessories on plain quantity tracking.
What happens to a sale if the eTIMS connection is down?
The sale should complete locally and the fiscalisation should queue. Blocking the till on a tax authority response means a bad connection stops the shop trading, which is unacceptable. The invoice carries a fiscalisation state, a retrying background job moves it from pending to signed and stamps the control unit details back onto the document, and the count of pending invoices belongs on a dashboard somebody checks, not buried in a log.
Can I stop cashiers from seeing what I paid for stock?
Yes, but it is more work than one setting. Cost appears in the item valuation rate, the last purchase rate, stock ledger and stock balance reports, purchase orders and receipts, the supplier ledger, and gross profit reports. Doing it properly means putting cost-bearing fields at a higher permlevel, granting that level only to the roles that should have it, removing buying-side report and doctype access from the cashier role, and checking print formats so a receipt template does not leak a valuation field.
Is M-Pesa STK push enough on its own?
No. The push is a straightforward API call. The engineering is in the callback, which arrives asynchronously, can arrive more than once because Safaricom retries, and can be bypassed entirely by a customer paying the till directly. You need idempotent writes keyed on the M-Pesa transaction ID, matching against amount, till, phone and time window as a separate step, and a queue where unmatched money waits for a human rather than being force-fitted to the nearest invoice.
#ERPNext#Frappe#Point of Sale#Inventory#Serial Numbers#M-Pesa#eTIMS#Retail
Keep reading

Related articles