Shipping fast on a growing feature set — and paying for it in silent drift
The web client was built out feature-by-feature over an extended period, growing to ~66K lines across 20+ dashboard modules (inventory, credits, quotations, payroll, services, letters, reports). Development prioritized shipping working screens quickly, using whatever styling/library approach solved the immediate problem (Tailwind, MUI, Emotion, three separate PDF/export libraries). This got a full-featured product to market, but by the end of the build, a full audit of the codebase turned up a long list of features that looked finished in the UI but weren't wired to anything real underneath.
Feature velocity: every core business workflow (POS, credit ledger, quotations, payroll, services/job-costing, letters) shipped and is in production use. The RBAC model (roles, per-page fixed/configurable/blocked rules, per-user permission overrides) is genuinely well thought out and defensively coded — every role check lowercases its input, so a casing bug class that bit other parts of the project never appeared here. Where a clean reusable pattern existed (CustomerSelector.js's search-and-pick-a-customer widget, the Report page's preset-dropdown-plus-custom-date-range pattern), it was good enough to later become the reference implementation for the mobile rebuild.
No single source of truth for UI state or styling — three styling systems and no shared server-state layer (every screen hand-rolls its own axios calls and useState/useEffect), which meant the same problem (e.g., "per-unit vs. already-totaled discount/tax" math) got solved three separate times in three different files instead of once. Features that look real but aren't: the Wallet screen has no backend ledger at all — its "balance" lives only in localStorage and never reconciles with anything server-side; "Send Money"/M-Pesa STK push in the UI never calls a real Daraja API; the Help page's "AI Assistant" always returns the same canned response regardless of input. Silent breakage from copy-paste call-signature drift: Settings' "Save" button called a service function with the wrong argument order, so userId received the whole form object and settings silently failed to persist. A notification-toggle endpoint was called with the wrong ID field for the same reason. A five-layer error boundary system with nothing behind it — every onError handler is an empty stub; there's no Sentry, no error reporting of any kind, despite PostHog being installed and working for page views. A cross-tenant-shaped hole in Suppliers (no uniqueness validation on create or update, unlike Customers which at least checks on create) went unnoticed because nothing exercised it.
Ad hoc "whichever library is fastest right now" choices compound — by month six there were three PDF export paths and three input-field components with no canonical one to reach for. A feature that renders without erroring is not evidence it works end-to-end — several of the worst bugs here (Wallet, AI chatbot, Settings save) were caught only by a deliberate full-codebase read-through, not by normal use. Wiring monitoring/error-reporting stubs without actually connecting them to a real service gives false confidence — an empty onError callback is worse than no callback, because it looks handled. These findings directly shaped how the next codebase (mobile) was built — see Postmortem 2.