← all projects
SaaS

Dipstick

A digital station logbook for Nigerian filling-station owners running one to ten branches. Managers record the day end-to-end — opening dip, attendant pump readings, tanker deliveries, expenses, closing dip — and the owner reads a single morning roll-up across every branch. It is the cashier's worn exercise book made shared, signed, and impossible to tamper with after the fact.

Dipstick

Audit-grade operations ledger where integrity comes from posting, signing, and an immutable void idiom rather than hardware telemetry. Built as an Nx + pnpm monorepo: an Express + MongoDB API with multi-document transactions and a permission-keyed RBAC catalogue, a React 19 + Vite operator app, and a Next.js marketing site — all sharing pure-TS domain logic. Money is stored end-to-end in integer kobo (bigint) to eliminate float drift in reconciliation.

TypeScript
React
Vite
Tailwind
Node.js
Express
MongoDB
Fintech

Problem Statement

Nigerian filling-station owners run their forecourts on a paper exercise book: the manager writes down the opening dip, each attendant's pump meter and cash, deliveries received, expenses paid, and the closing dip. The owner — usually off-site, often holding several branches — has no way to see yesterday across every branch, no way to know which till came up short, and no way to prove that a posted entry was not quietly rewritten after the fact. The most fraud-prone moment, a tanker offload, is recorded with the least rigour.

The constraint is deliberate: there are no hardware integrations and no POS / payment-rail linking. The system trusts the manager's entries. So the discipline cannot come from telemetry — it has to come from the mechanics of the ledger itself: posting, signing, audit trails, and an irreversible void idiom.

Proposed Solution

A back-office operations tool with three fixed-spirit roles — Owner, Manager, Attendant — built around a single spine: the day-book. Every other view is a projection onto it. Managers open and close shifts on a phone on the forecourt; the system computes litres dispensed and reconciles cash-declared against expected gross (litres × posted price) live, flagging each pump row as balanced, short, or over. Owners open a multi-branch morning roll-up that summarises yesterday's litres, gross, variance and wet stock, and generates a prioritised "things to do this morning" list from the day's flagged items.

Integrity is structural. A posted shift is final; removing one requires typing the literal word VOID, leaves the entry struck-through-but-visible, and writes an immutable audit record. Every state-changing action is logged with actor, timestamp, and before/after value.

Full Solution Details

  • Account, branches & staff: OTP-verified owner sign-up, an onboarding wizard, per-branch tanks (one per product, with capacity and reorder thresholds) and pumps (idle / live / offline), per-branch posting rules, a weekly roster grid, and a staff directory carrying each person's cumulative variance track-record.
  • The day & shifts: opening dips that carry forward yesterday's close, shift open with per-pump opening meters, live closing-meter reconciliation, note-on-variance, a double-ruled shift total, "post all balanced shifts at once," and end-of-day closing dip with computed wet-stock variance.
  • Deliveries: a guided four-stage tanker offload (arrives → dip before → offload → dip after & sign), each step timestamped, with delivery variance against a configurable tolerance and a witness-signed waybill that updates the tank balance.
  • Pricing: price changes with effective date/time, required reason, and a pinning rule so a litre sold at the old price always reconciles to the old price; full price history per product.
  • Expenses, voids, roll-up, reporting, tank readouts & charts, notes/@mentions, and a full audit timeline round out the modules.

Technical Documentation

Monorepo (Nx 22 + pnpm 9 workspace). One process per apps/ directory; shared, never-deployed code in packages/. A strict dependency graph is enforced: core depends on nothing, api depends only on core, ui depends only on core (no ui → api edge), and apps never import other apps.

  • apps/main-backend — Express public HTTP API. Feature modules each expose a single register(app) that mounts one Router. Services return result objects; controllers unwrap them and respond only through a ResponseUtil envelope ({ data, meta } / { error: { code, message, field_errors } }) — never res.json() directly. Zod validates every request body. Pagination is cursor-based, never offset.
  • apps/web — React 19 + Vite 6 operator app, organised by Feature-Sliced Design. All server state flows through TanStack React Query (no bare useEffect + fetch). Routes come from a shared ROUTES table, endpoints from a shared EP table, icons from an @icons proxy, classes through a cn() (clsx + tailwind-merge) helper.
  • apps/website — Next.js marketing site.
  • packages/core — pure TypeScript: route table, domain types, the RBAC permission catalogue, kobo money helpers, time/format helpers.
  • packages/apiky HTTP client (with a token-refresh afterResponse hook) plus per-feature React Query hooks.
  • packages/ui — ~43 exported React + Tailwind design-system primitives, previewed live at /preview.

Money is stored and computed in integer kobo (bigint) end-to-end; every money field is suffixed _kobo and only formatted to ₦ at the edge. The MongoDB layer is fully isolated — a single MongoClient is created in one file and only *.repo.mongo.ts files import the driver; multi-document writes (post-shift-plus-audit, sign-delivery-plus-update-tank) run through a withTransaction unit of work that degrades gracefully to session-free execution on a standalone (non-replica-set) Mongo.

Tech Stack

  • Language: TypeScript 5 (strict everywhere — noUncheckedIndexedAccess, exactOptionalPropertyTypes; any banned)
  • Frontend (operator app): React 19, Vite 6, React Router 6, TanStack React Query 5, Tailwind CSS 3
  • Marketing site: Next.js 15, React 19, Tailwind
  • Backend: Node 20+, Express 4, MongoDB 7 driver, Zod, JWT (jsonwebtoken) + bcryptjs, Helmet, Pino logging, ULID ids
  • Shared client: ky
  • Tooling: Nx 22 monorepo, pnpm 9 workspaces, ESLint flat config, Prettier, Vitest + Supertest

System Design

                         pnpm + Nx monorepo

  apps/website (Next.js 15)        apps/web (React 19 + Vite)
   marketing site                   operator app  (Owner / Manager / Attendant)
        |                                   |
        |                          TanStack React Query
        |                                   |
        |                     packages/api  (ky client + hooks, EP)
        |                                   |  HTTPS  (JWT access/refresh)
        +--------------------+              v
                             |   apps/main-backend (Express 4)
                             |   register(app) feature routers
                             |   auth . branches . staff . roles . shifts
                             |   deliveries . pricing . expenses . rollup
                             |   notes . audit . notifications . refs
                             |        |            |             |
                             |   Zod validate  authorize(P.*)  ResponseUtil
                             |        |            |             |
                             |        v            v             v
                             |   services  -> withTransaction (unit of work)
                             |        |
                             |   *.repo.mongo.ts  (only place mongodb is imported)
                             |        v
                             |   MongoDB 7   (money in kobo / bigint; immutable audit log)
                             |
   packages/core  (pure TS: ROUTES, domain types, RBAC catalogue P.*, kobo money)
        ^  imported by every layer (core depends on nothing)
   packages/ui    (React + Tailwind design system, /preview)  -> depends on core only

Smart Architectural Decisions

  • Money as integer kobo (bigint) end-to-end. Reconciliation (expectedGross = round(litres × pricePerLitreKobo), variance = expectedGross − cashDeclared) is pure integer maths with no float drift, and the formula lives in one tested, dependency-free function.
  • A single layered dependency rule, machine-enforceable. core → nothing, api → core, ui → core, apps never cross-import. The domain model (routes, types, RBAC keys) is the one thing every surface shares, so backend authorization and frontend UI-gating key off the same permission strings.
  • RBAC as a frozen permission catalogue, not hardcoded role checks. The MVP's three roles are seeded as editable permission sets (Owner / Manager / Attendant) over a P.* catalogue, so the product can grow custom roles without rewriting authorization — and the same keys gate the UI.
  • MongoDB isolated behind repositories + an opaque transaction handle. Only *.repo.mongo.ts files touch the driver; services receive an opaque Tx (deliberately not typed as a Mongo session) so the storage engine could be swapped to SQL without touching business logic. Transactions degrade to session-free on standalone Mongo so dev/CI work without a replica set.
  • Backend-enriched ID references. List endpoints that carry raw IDs also return a refs map ({ id: { type, label, href_kind } }), so audit logs and tables render human, clickable labels from one server-side resolve instead of N client lookups.
  • Token-refresh as a transparent client hook. The ky client retries a single 401 through a shared, de-duplicated refresh promise, keeping auth invisible to feature code.

Impacts

Delivers an audit-grade operations ledger for a cash-heavy, low-connectivity, fraud-exposed business without any hardware or payment integration — trust is engineered into the data model (immutable audit log, struck-through-not-deleted voids, price-pinning so old-price litres always reconcile to the old price). The strict layered monorepo and shared domain core mean the operator app, marketing site, and API stay consistent by construction, and the kobo-only money discipline removes an entire class of reconciliation bugs.

Demonstrated Skills

  • Domain-driven monorepo architecture (Nx + pnpm) with an enforced dependency graph and a shared pure-TS core
  • Designing for integrity over telemetry: posting/signing semantics, immutable audit trails, irreversible void idiom, price-effectivity pinning
  • Backend engineering: Express feature modules, Zod validation, a strict response envelope, cursor pagination, JWT access/refresh, MongoDB behind repositories with a portable transaction abstraction
  • Permission-catalogue RBAC shared between server authorization and client UI-gating
  • Modern React 19 data architecture (TanStack Query, Feature-Sliced Design, a 40+ component Tailwind design system)
  • Financial correctness discipline (integer kobo / bigint money) and product-grade attention to a real, messy operational domain

Notes

  • Trust-by-design without telemetry: the hardest part of this domain is making a manually entered logbook tamper-evident. Dipstick answers it structurally — immutable audit log, voids that stay visible and struck-through, edits that record before/after, and price-pinning so a litre always reconciles to the price in force when it was sold. That is senior product-and-data-model thinking, not feature checklisting.
  • Architecture that enforces itself: a layered Nx monorepo with a one-way dependency graph (core → nothing, api/ui → core, apps never cross-import) and a single shared domain core means the API, operator app, and marketing site cannot drift apart. The permission catalogue is the standout — one set of P.* keys gates both backend authorization and frontend UI.
  • Financial-correctness discipline: money is integer kobo (bigint) end-to-end with a single tested reconciliation function; floats never touch a naira figure. This is the kind of decision that quietly prevents a whole class of production bugs.
  • Storage-agnostic backend: MongoDB is sealed behind *.repo.mongo.ts repositories and an opaque Tx handle, with multi-document transactions that gracefully degrade on standalone Mongo — a deliberate seam for swapping the engine and a pragmatic dev/CI accommodation.
  • Production-grade conventions: TypeScript strict with any banned, a uniform response envelope, cursor pagination, Zod at every boundary, React Query for all server state (no useEffect + fetch), and a living /preview design system — the markers of a codebase built to be maintained by a team, not a prototype.
  • Built for the real environment: designed for owners running up to ten branches over poor connectivity, with a ledger-first UI (tabular monospace figures, warm-paper palette) that mirrors the paper book it replaces — domain empathy expressed in engineering choices.
Ask me anything