Hubal — offline-first POS and installment tracking
Two Vite+React apps for shop owners: a point-of-sale with IndexedDB offline sync, and a phone installment system with an immutable payment ledger.
What it does
Hubal is two separate applications that solve two problems for shop owners who are not at their shops:
Hubal POS — A point-of-sale system for pharmacies, supermarkets, and general shops. Cashiers ring up sales. Owners see revenue remotely. The app works offline because internet in Mogadishu drops regularly.
Hubal Installments — A phone financing tracker. A customer buys a phone on installments. The system tracks the contract, records every payment into an immutable ledger, and flags overdue accounts.
Stack
Both apps are built with the same tooling:
- Frontend: Vite + React 19 + TypeScript
- State: Zustand stores
- Offline storage: IndexedDB via
idblibrary - Backend: Supabase (shared project,
bexze_posschema) - Routing: Wouter (lightweight, ~1KB)
- UI: Tailwind CSS 4
How offline-first works in Hubal POS
IndexedDB is the primary database, not Supabase. Every read and write hits IndexedDB first. Supabase is the replication target.
The offline system has three layers:
HubalDB.ts — Manages four IndexedDB object stores: products (catalog), sales (history), mutations (pending sync queue), metadata (key-value pairs like lastSyncAt).
HubalQueue.ts — When a cashier records a sale offline, the mutation goes into the mutations store with status PENDING. Each mutation gets a client_sale_id for idempotency.
HubalSync.ts — When connectivity returns, the sync engine drains the queue in FIFO order. It calls a PostgreSQL RPC function create_sale_atomic that inserts the sale header, line items, and decrements stock in a single transaction. The client_sale_id prevents duplicate processing — if the ID already exists, the function returns the existing sale ID.
// From HubalDB.ts — the offline sale write path
export async function putSale(sale: Sale): Promise<void> {
const db = await getDb();
await db.put(STORES.SALES, sale);
}
The atomic sale function
On the server side, create_sale_atomic runs as SECURITY DEFINER in the bexze_pos schema:
-- Insert sale header + line items + decrement stock
-- Single transaction. client_sale_id enforces idempotency.
INSERT INTO bexze_pos.sales (
client_sale_id, shop_id, cashier_id,
total_amount, total_cost, total_profit, payment_method
) VALUES (...) RETURNING id INTO v_sale_id;
If client_sale_id already exists, the function returns the existing row ID and does nothing. No duplicate sales, no double stock decrements.
The immutable payment ledger
Hubal Installments tracks phone financing contracts. The install_payments table is append-only:
-- Prevent payment updates (immutable ledger)
CREATE OR REPLACE RULE prevent_payment_update AS
ON UPDATE TO public.install_payments
DO INSTEAD NOTHING;
CREATE OR REPLACE RULE prevent_payment_delete AS
ON DELETE TO public.install_payments
DO INSTEAD NOTHING;
PostgreSQL rules block UPDATE and DELETE at the database level. If a payment needs correction, the system inserts a reversal record that references the original payment_id. The ledger is always additive, never modified.
Payment types: payment, reversal, down_payment. Payment methods: edahab, evc_plus, cash.
The contract lifecycle
- Customer registers with name, phone, and ID number
- Staff creates a contract linking customer to a device (phone), with a down payment and installment schedule (daily, weekly, or monthly)
- The system calculates
financed_amount = sell_price - down_payment - Each payment records the amount, method, and collector
- Contract status tracks:
active→completedoractive→defaultedoractive→suspended
An overdue page lists contracts where payments have fallen behind schedule.
Current state
Both apps are scaffolded with working authentication, routing, and database schemas. The POS has five pages: Dashboard, Catalog, POS (checkout), Sales History, and Login. The Installments app has seven: Dashboard, Devices, Customers, Contracts, Payments, Overdue, and Login.
The database migrations are written and the offline sync architecture is implemented. What remains is field testing with an actual pharmacy in Mogadishu and hardening the sync conflict resolution.
Why offline matters here
This is not a theoretical design choice. Mogadishu has unreliable internet. A cashier ringing up a $2 box of paracetamol cannot wait for a server round-trip. The sale records to IndexedDB, the UI updates immediately, and the sync engine pushes it to Supabase when the connection comes back. The customer is gone before the server even knows about the transaction.