Gwenfred Tham

Client project · in-store rollout Nov 2026

BJ-POS

An offline point-of-sale and inventory system for a hardware and building-materials shop in Keningau, Sabah.

RepoDesktop app · demo video coming

retail_tillBJ-POS till: category rail, product grid and a cart totalling RM 442.00.
The till: category rail, product grid and cart, laid out for a 15.6-inch touchscreen.

The problem

The shop kept its sales and stock on paper. That makes simple questions slow to answer: what is left on the shelf, what sold today, and which contractor still owes money. The counter is busy, goods are sold by the bag, the bundle, the piece, the kilo and the foot, and a sale cannot wait for a network connection.

What it does

How it works

  1. Vue tillScreens, cart and dialogs. Talks to exactly one repository interface.apps/pos
  2. Domain rulesMoney, quantities, pricing, orders, receipts and stock planning. Pure functions, no I/O.packages/core
  3. RepositorySQLite schema, migrations and SqlPosRepo, written with Drizzle ORM.packages/data
  4. IPC write queueIn Electron, statements go over IPC to the main process through one shared queue.apps/pos/src/ipc-driver.ts
  5. SQLite filebetter-sqlite3 in the main process writes the shop database on the till itself. In browser development the same repository runs on sql.js.apps/pos/electron/main.ts

Close Day: Excel workbook + SQLite snapshot → outbox → Google Drive, retried while the till is offline.

Inventory overview listing items with current stock and Low Stock, In Stock or Untracked status.
Inventory overview. Two items at 5 are flagged Low Stock; items without a count show Untracked.
Owner sales report for one day: total by payment channel and items ranked by revenue.
Owner sales report: totals by payment method and items ranked by revenue.

Screenshots are from the repo's browser development build with its built-in product list. Stock levels on the inventory screen were set with the app's own +5 / +10 buttons.

Hard parts and decisions

No floating-point money
Money is stored as integer cents and measured quantities as integer milli-units, so 1.250 kg is 1250. decimal.js is used only where the UI converts to and from those integers, so rounding happens in one place.
One write queue
All renderer writes share one IPC queue, so two transactions can never interleave their BEGIN and COMMIT.
Stock checked before anything is written
planStockDeduction in packages/core checks every line of an order against stock first and refuses with "not enough stock" if a tracked item would go negative. Items without a stock count are marked Untracked and still sell.
Stock can't be restored twice
Voiding checks the order's current voided state inside the transaction, so a double void cannot return the same stock twice.
One definition for every total
Gross sales, money received and drawer cash are computed by one pure function, computeFinancialMetrics in packages/core. The Close Day screen, the report screen, the printed day report and the daily Drive report all call it, and a test checks that the printed report and the Excel workbook carry the same figures, so a contractor's repayment is never counted as a second sale.
The shop's day, not the clock's
A business day ends when someone presses Close Day, not at midnight; later sales count toward the next day.
Never lose the shop database
Schema changes only add columns. The update script builds first and installs only if the build succeeds, so a failed build leaves the working till alone.

Stack

View the repo

More work