Omni POS
Multi-tenant, offline-first SaaS point-of-sale for Kenyan retail and wholesale SMEs — POS terminal, UOM-aware inventory, multi-branch management, native M-Pesa payments with reconciliation, and customer credit. A pnpm/Turbo monorepo spanning an Express + PostgreSQL API, React 19 admin/tenant dashboards, and an Electron POS that keeps selling when the internet drops.
Overview
Omni POS is a multi-tenant, offline-first point-of-sale platform built for Kenyan retail and wholesale SMEs — electricals shops, general dukas, and the kind of counter business where a dropped internet connection can't be allowed to stop a sale. It's delivered as SaaS across three surfaces: a marketing site at omnipos.co.ke, the merchant app at app.omnipos.co.ke, and a platform-admin console at admin.omnipos.co.ke.

The Problem
The merchants Omni targets were running a mix of paper, spreadsheets, and legacy POS software with no visibility across branches, manual end-of-day stock reconciliation, and M-Pesa handled as a side channel that never reconciled cleanly against the till. Checkout had to keep working when the connection got flaky, inventory needed to understand that stock arrives in boxes but sells in singles, and the whole thing had to be cheap enough for a small shop to adopt.
The goal: a single platform that runs the till, the stockroom, and the books — multi-branch, M-Pesa-native, and resilient under poor connectivity by design rather than as an afterthought.
Architecture
Omni is a pnpm + Turbo monorepo with shared types at the center, so the API and every client speak the same validated contracts.
omni/
├── api/ Express 5 (ESM) · PostgreSQL + Drizzle · Redis/BullMQ · Socket.io
├── tenant/ Merchant dashboard — React 19 · Vite · Tailwind v4 · shadcn/ui
├── admin/ Platform admin — React 19 · Vite · Tailwind v4 · shadcn/ui
├── app/ Offline POS terminal — React 19 · Electron · SQLite/Drizzle
├── web/ Marketing site — vite-react-ssg
└── packages/contracts/ Shared Zod schemas + TypeScript types
Tenancy is enforced in layers: every query scopes by tenant_id, an operational business layer adds business_id for entities with their own tax identity (KRA PIN), and branch-locked roles derive their scope from there. State that needs to be live — stock levels, sales — pushes over Socket.io. Background work (bulk uploads, subscription jobs, email) runs on BullMQ/Redis and degrades gracefully when Redis isn't available rather than hard-blocking the request path.
Technical Decisions
Offline-first sync
The Electron POS keeps a local SQLite store (with an IndexedDB fallback on web) and queues sales locally when the connection drops. Replay goes through POST /api/sales/sync with client-generated UUIDs, so re-sending the same sale is idempotent — a terminal can reconnect and flush its queue without ever double-charging.
Native M-Pesa via Daraja
Payments use the Daraja API directly — STK push (the USSD prompt on the customer's phone) and C2B — with per-business credentials. Reconciliation runs soft fraud controls: duplicate-receipt detection and amount-mismatch flagging mark a payment for review instead of hard-failing the sale, which matters at a busy counter where blocking is more costly than flagging.
UOM-aware inventory
Products carry units of measure — a box of 12, a dozen, a single — so stock received as cartons can be sold as units and the inventory log stays correct across the conversion. Bulk onboarding is a CSV import.
Role-based access
Permissions run top to bottom: SAAS_ADMIN, SYSTEM_ADMIN, BRANCH_MANAGER, CASHIER. Cashiers authenticate by PIN for fast shift handover; managers switch branches from a single login with sales and stock scoped per branch.
Shared Zod contracts
Request/response shapes live once in packages/contracts and are consumed by both the Express API (validateBody strips unknown keys) and every React client, so a schema change surfaces as a type error across the monorepo instead of a runtime surprise.

Key Features
- POS terminal — barcode-first checkout, PIN-based cashier auth, shift management with cash-drop tracking and reconciliation, thermal receipt printing
- Inventory — SKU/barcode, unit-of-measure conversions, automated stock logs on every change, categories, CSV bulk upload
- Multi-branch — independent stock and shifts per branch under one tenant; manager branch switching
- M-Pesa payments — Daraja STK push + C2B with reconciliation and fraud flags
- Customer credit — customer master records with per-customer credit limits and balance settlement
- Procurement — suppliers, purchase orders, and goods-receipt notes feeding stock
- Reporting — Z-reports for end-of-day reconciliation and real-time sales analytics
- Tax & compliance — eTIMS skeleton with KRA PIN capture and audit logging across modules
- Marketing engine — blog with Cloudinary image hosting, SEO metadata, and a demo-request onboarding flow

Tradeoffs
Built pilot-first: the plan hardens the product with a handful of real merchant partners before a public launch, which means some integrations ship as deliberate skeletons. eTIMS is mocked against the real IRIS/OSCU flow rather than fully wired, and M-Pesa runs against the sandbox per business until production credentials are provisioned — both are integration-shaped placeholders with the surrounding data model already in place.
Deployment is evolving rather than fixed: the MVP runs on Render's free tier (API + Postgres, with health checks at /health and /health/db), with a documented path toward Docker Compose + Caddy (auto-TLS) on a VPS, nightly pg_dump backups, and a GitHub CI/CD pipeline as the pilot scales.
Retrospective
Putting offline-first and idempotent sync in the foundation — client UUIDs, a local store, a single sync endpoint — was the decision that paid off most. It's the property that makes the system trustworthy at a counter, and retrofitting it later would have meant rethinking the sale model from scratch.
The shared Zod contracts package was the other quiet win: in a monorepo with four clients and one API, having the wire format defined once turned a whole class of integration bugs into compile-time errors. If I were starting again I'd push more of the business-layer scoping into that shared layer even earlier, so multi-tenant boundaries are expressed as types rather than enforced query by query.