Transmission 003 · 2026-01-10

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 idb library
  • Backend: Supabase (shared project, bexze_pos schema)
  • 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

  1. Customer registers with name, phone, and ID number
  2. Staff creates a contract linking customer to a device (phone), with a down payment and installment schedule (daily, weekly, or monthly)
  3. The system calculates financed_amount = sell_price - down_payment
  4. Each payment records the amount, method, and collector
  5. Contract status tracks: activecompleted or activedefaulted or activesuspended

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.

← Back to Transmissions