←All insights
Data · Postgres6 min readJun 2026

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.

Who checks what
Till
Cart totalsZustand store
VAT portionshop rate
API route
Arithmeticone-cent tolerance
Catalogue priceshalf-cent tolerance
Permissionsper-field flags
Repair balanceamount owed
Postgres
Row locksFOR UPDATE
Stock flooroversell flag

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.

How a sale gets paid
Tender
Cash or card
Splitsplits jsonb
EFT or onlineYoco link
Store creditledger balance
Order row
Paidpayment_status
Pendingawaiting money
Settlement
Change givencash legs
Marked paidsales.edit
Credit spendledger function
Cash-up
Drawer figurecash legs only

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 idea

A claim is cheap to release; an order is not. Everything that can fail goes before the insert.

Where a sale is written
Before insert
Device reservationin_stock to reserved
Repair claimsale_synced flip
Line snapshotscost, price, profit
One transaction
Order rowplpgsql RPC
Stock decrementrow locks
Stock movementsaudit rows
After commit
Device sale
Repair settlement
Credit spendledger function
Warrantiesbest effort
On failure
Release claimsbefore insert
Void orderafter 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.

After the sale
Status change
Refundwhole order
Voidwhole order
Reversal
Stock restoredstock function
Devices returnedto stock
Warranties voided
Destination
Cash back
Store creditcustomer required
Books lock
Date checkbooks_locked_until
Admin overrideaudit logged

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 Cents type in TypeScript and bigint columns would make them plain equality checks.
  • ›Give every money column a scale. Bare numeric on old orders beside numeric(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.
Available for new workBuilding something like this?I'm open to full-time roles and freelance work, remote or relocation.
↑