How money moves through a phone-shop POS
Where SkyPos stores rand, who is allowed to add it up, and how one Postgres call keeps a sale and its stock honest.

One cart at a phone-shop till can hold a serialised handset, a screen repair with a deposit on it and a service priced on the day, paid part cash, part card and part store credit. Each can book the same rand twice, or a rand that never arrived. This is how SkyPos handles it, including what I'd change.
Rand lives in numeric, not cents
The common advice is integer cents. SkyPos doesn't follow it: every amount is a Postgres numeric. The core orders table predates the migration history, so its shape lives in a baseline file. It keeps the cart as an items jsonb column and subtotal, VAT, discount, fee and total as bare numeric. tendered_amount and change_due are numeric(10,2). The payment method is an enum of cash, card, EFT, split, online and store credit, a splits jsonb column holds split legs, payment_status defaults to paid, and every row carries a shop_id.
Newer tables are stricter. Quotes, store credit, and the input-VAT columns on expenses and purchase orders use numeric(12,2). The older order columns have no scale, so the database holds whatever the API sends.
numeric is exact, so storage isn't the risk. The trip through JavaScript is, because every amount becomes a float. The code compares with a tolerance instead of strict equality: one cent throughout the order route, half a cent in the line price check. Anything shown to a customer or written as a derived figure is rounded to two decimals first. It works, but it's a convention every new piece of money code has to remember.
The till adds up, the server checks
The cart lives in a Zustand store, and the till computes subtotal, discount, fee and total so they update as the cashier types. So the POST to /api/orders carries numbers chosen by the browser, and an edited request could sell a R1 500 phone for R1.
The route treats those figures as a claim. Before touching stock or devices, it checks the arithmetic: subtotal must equal price times quantity across the lines, and total must equal subtotal minus discount plus fee, each to within a cent.
Then it checks every product line against the catalogue. A product that's gone, or a price that doesn't match an allowed one to within half a cent, is refused with a message telling the cashier to remove or re-add it. Devices and parts follow the same pattern. A back-office price edit can bounce a cart built a minute earlier, which beats quietly selling at stale prices.
Lines flagged priceOverride skip the catalogue check, because the route already required the pos.change_price permission for them. Discounts, extra fees and selling below recorded stock each have their own permission flag, checked server-side before anything is written. Repair lines are checked differently: the route works out what is still owed on the job after its price, discount and deposits, and refuses the sale unless the line collects exactly that.
VAT is the exception. Prices are VAT-inclusive, and the till extracts the VAT portion using the shop's rate, default 15%: the rate over 100 plus the rate, so 15/115 of the total, rounded to two decimals, or zero if the shop has VAT off. The server only confirms VAT isn't negative or larger than the total. It doesn't recompute it, yet orders.vat is what the VAT201 view sums as output VAT. It's the one number on the order the server takes on trust.
Splits, EFT and store credit
A single payment_method can't describe "R2 000 cash, the rest on card", so a split sale is stored as split plus a splits array of legs, each with a method and an amount. The server's rules:
- ›No split can be negative.
- ›The splits must cover the total to within a cent.
- ›They may exceed the total only if one leg is cash, because only cash gives change.
With EFT the customer leaves before the money lands. An EFT sale without splits is saved as pending, and the till prints a slip with the shop's banking details instead of a receipt. Later someone with sales.edit marks it paid through /api/orders/payment, optionally with a proof-of-payment URL, which sets paid_at and paid_by and writes an audit entry. Yoco online payments are also saved as pending, and the till creates the payment link first, so a shop without Yoco fails before an order exists.
Store credit is a ledger, not a balance. customer_credits holds signed delta rows: positive for a refund to credit or a top-up, negative for a spend. There's no running balance column; the append-only sum is the truth, which keeps every grant and spend auditable. A sale's credit amount is the sum of its store-credit legs if it was split, the whole total if it was a store-credit sale, and nothing otherwise.
The route checks the balance before creating the order, then spends through a spend_store_credit database function that checks the balance and inserts the negative row in one call. If that fails, the route voids the order it just made, so no paid sale is ever backed by credit that didn't exist.
One transaction for the order and its stock
The write is a single Postgres function, pos_create_order_with_stock. It inserts the order, then decrements stock for real products only; services, parts and repair lines have no rows in products. It runs SECURITY DEFINER, and only the service role can execute it.
Each product row is read with SELECT ... FOR UPDATE, scoped to the shop, so two tills selling the last unit queue on the row instead of both succeeding. For a sale or repair usage that would take stock below zero, the function raises an "Insufficient stock" error naming the product, the stock and the quantity requested.
The oversell flag exists because shelves and databases disagree: when a delivery hasn't been captured, a manager can push the sale through. Stock clamps at zero, and the movement note records what the system had and what was sold.
Beforehand, the repository snapshots unit cost, unit price and gross profit onto every line, and profit reports read those, so a cost change next month doesn't rewrite last month's margin.
Key ideaA claim is cheap to release; an order is not. Everything that can fail goes before the insert.
Serialised devices move from in_stock to reserved with a reservation token first. Repairs are claimed by flipping sale_synced from false to true; a completed repair also posts its own sale, and whichever path wins the flip owns the revenue. If the RPC fails, every claim is released. If something fails after the order exists, such as a handset sold on another till in the gap, the route voids its own order, which puts the stock back.
Refunds, voids and the books lock
Refunds work on whole orders. Setting the status to refunded or voided restores stock through the same stock function, returns serialised devices to stock and voids their auto-created warranties. A refund can go to store credit instead of cash if the sale had a customer. I didn't find partial or per-line refunds anywhere in the code.
Expected drawer cash is opening float plus cash sales and repair cash, minus cash refunds and expenses. Split orders count only cash legs, repair-sync orders are left out so cash isn't counted twice, and pending EFT never reaches the drawer.
The year-end lock is a date, shop_settings.books_locked_until. Any void, refund or back-dated sale on or before it gets a 423 unless an admin sends an explicit override, logged as books.lock_override.
Quotes are not sales
Quotes have their own quotes and quote_items tables with numeric(12,2) columns and a deposit_required field. A quote is a price promise, so converting one keeps the quoted prices; if the catalogue has moved, the endpoint returns a 409 with a priceDrift list for the cashier to confirm. It claims the quote by flipping its status to converted only if it isn't already, then calls the till's own createOrder repository function. That skips the catalogue check, since the quote's price applies, but keeps the same transactional RPC and stock movements.
What I'd do differently
- ›Integer cents from the first migration. Exact storage is fine; the cost is dozens of one-cent tolerances and two-decimal roundings every endpoint must copy correctly. A
Centstype in TypeScript andbigintcolumns would make them plain equality checks. - ›Give every money column a scale. Bare
numericon oldordersbesidenumeric(12,2)elsewhere should be fixed with a migration, not kept as history. - ›Compute VAT on the server. It's the only total the server doesn't recompute, and it's the one that goes to SARS. The rate is already in
shop_settings, so it's a few lines. - ›Move the post-insert steps into the transaction. Device sales, repair settlement and the credit spend run after the RPC commits, with compensating voids. It works, but a void is a visible record of something going wrong. Folding them into the function would make the sale all-or-nothing.
- ›Partial refunds. Shops return one item from a three-item sale. Today that means refunding the whole order and ringing it up again, which muddies the sales history.