M
Back to client projects
clientInnovative WidgetJul 21 – Jul 25, 2026 (5-day build, single developer)

One Developer Investor ERP

Full-stack property investment management platform enabling administrators to manage Rental, Development, and Holding properties with a draft/publish workflow, comprehensive audit logging, and a dedicated investor portal with portfolio analytics.

Solo developer on this repo, end to end: architecture, all 44 route/API files, the ResERP integration layer (lib/reserp-client.ts, lib/session.ts, lib/units.ts, lib/schedules.ts, lib/receipts.ts, lib/pdf.ts), the auth flow, and the UI system (11 shared components, 4-state pattern on every route). I also wrote and maintained the five planning docs the repo is built from (docs/PRD.md, docs/API_Integration.md, docs/Frontend_Routes.md, docs/Design.md, docs/Implementation_Plan.md) and updated them in place as live testing against the real ResERP API contradicted what the vendor's Postman collection and PDF documentation said. This sits alongside two earlier repos I also built for the same client — backend_onecapitalinvestment (Node/Express + Postgres, 25-table schema, manual admin data entry) and onecapitalinvestment (the Next.js admin/investor dashboard on top of it) — which this project replaces for the buyer-facing side once the client's ERP access came through.

// the problem

What was broken

Square One Capital sells property units to buyers who then pay in installments over time. Before this portal, a buyer with a question about their account — how much is due, when, whether last month's cheque cleared, or getting a copy of their allotment certificate for a bank loan application — had no self-service option. They called or visited the office, and staff looked it up manually in the ERP (ResERP) or the company's own records. The earlier version of this client's system (the onecapitalinvestment/backend_onecapitalinvestment repos) tried to solve this by having Square One Capital's own admins manually re-key investor, property, and transaction data into a separate Postgres database — which meant two sources of truth and admin time spent on data entry that the ERP already had.

// constraints

What made it hard

ResERP is the client's existing, external, staff-facing ERP — not something this project could modify. Its client-facing API surface is fixed at 11 endpoints, all read-only except login, with no admin/bulk endpoint, no pagination, and (per docs/API_Integration.md §6) no documented error-response shape or shape consistency between endpoints. The vendor-supplied documentation (Postman collection + PDF) turned out to be wrong or incomplete on two load-bearing points — the PDF_TOKEN auth and the /product/:id response shape — discovered only by making real calls against the live API. Solo developer, five-day build, no dedicated QA, and (per docs/Git_Workflow.md §4) an open question the whole build shipped under: whether a sandbox ResERP instance exists at all, or whether dev/staging testing during the build was hitting real buyer CNIC/password data.

// architecture

How it fits together

The core decision was to build nothing but a frontend. ResERP already holds every fact this app needs — units, payment schedules, receipts, PDFs — so there is no database, no data model, no sync job. The app is a Next.js BFF (backend-for-frontend): every ResERP call happens server-side in a Route Handler or Server Component, using a RESERP_BASE_URL env var and a bearer token that never reaches the browser. This was chosen over a client-side SPA calling ResERP directly because that would have put the ResERP token in browser JS and made CNIC/password traffic visible in devtools — unacceptable for a portal whose only credential is a national ID number. Auth uses a two-tier check: proxy.ts (Next.js 16's replacement for middleware.ts) does an optimistic cookie-presence check to redirect unauthenticated requests before they render anything, but every Route Handler independently re-verifies the session via verifySession() before touching ResERP — the proxy check alone is documented upstream as insufficient, and I treated that as a hard rule rather than a suggestion.

The session itself is a signed, httpOnly JWT (via jose) holding the ResERP login token, not the token in localStorage or a readable cookie. Because ResERP has no admin/list-all endpoint, there is no server-side aggregation or caching layer beyond what a single buyer's own session token can see — this rules out ever building an admin view against this API without a second, different ResERP credential the client would have to provide. Data fetching is organized as one lib/*.ts module per ResERP resource (units.ts, schedules.ts, receipts.ts, pdf.ts), each owning its own response typing rather than sharing one generic envelope type, because docs/API_Integration.md §6 documents that ResERP's data shape is inconsistent across endpoints — sometimes a named key, sometimes a bare array, sometimes an object that becomes an array only in list mode. Typing each one separately, instead of one clever generic wrapper, is what caught the bugs below at the type level once they were understood.

Buyer

Logs in with CNIC + password on /login, mobile-first, no ResERP knowledge needed

proxy.ts

Optimistic redirect — cookie present? else send to /login (not the security boundary)

Route Handler / Server Component

verifySession() re-checks the JWT, then calls lib/reserp-client.ts with the decrypted token

lib/reserp-client.ts

Single reserpFetch() wrapper — attaches bearer token, normalizes success:false and the 204 "no data" case into typed errors

ResERP API (external)

onedev.myreserp.com/api — source of truth for units, schedules, receipts, PDFs; read-only except /login

lib/units.ts / schedules.ts / receipts.ts / pdf.ts

Per-resource typing so list-vs-single shape differences don't get silently unified

Server Component (page.tsx)

Renders one of loading/empty/error/unauthorized/data — never assumes happy path

Browser

Receives rendered HTML/PDF stream only — never sees RESERP_BASE_URL, the bearer token, or raw ResERP JSON

// challenges

Where I got stuck

Vendor docs vs. live behavior on PDF_TOKEN

The Postman collection specified a separate static PDF_TOKEN for all five PDF endpoints, and the client handed over a value for it. A live curl test against /pdf/statement/:sell_id with that token returned HTTP 200 {"success":false,"code":"401","message":"Token does not match"} — a 200 status hiding a failure. Retesting the same five endpoints with the regular per-user login token instead returned real PDFs every time. I rewrote lib/pdf.ts to use the login token everywhere and documented the discrepancy in docs/API_Integration.md §0/§5 so it wouldn't get "fixed" back to the wrong behavior later by someone trusting the collection.

A response shape bug that broke every unit page in production

GET /product/:id (single unit) does not return the same shape as GET /product (list) — list mode nests an array under data.product, single mode nests a bare object. lib/units.ts originally assumed both were arrays and read data.product[0], which is undefined on a plain object. Every single buyer, on every unit, hit notFound(). Fixed in commit 421c254 by splitting ProductListData/ProductSingleData into distinct types instead of one shared ProductsData — the same mistake was almost repeated on /schedules, which I caught by explicitly re-verifying that endpoint's list-mode shape live rather than assuming parity a second time.

204 isn't an error, it's "zero rows"

ResERP signals an empty result as HTTP 200 with success:false and code:204 ("No Data Found"), not an empty array/object. The generic reserpFetch() throws on any success:false, which would have turned "buyer owns zero units" into a rendered error page instead of an empty state. isNoDataError() in lib/reserp-client.ts exists specifically to unwrap that one code back into a valid empty result at the call site, while every other error code still fails loudly.

Dark-mode cards were invisible

Card was styled bg-background, and in dark mode --background and the card's border-based elevation made cards visually indistinguishable from the page — the only cue was a 12%-opacity border. Fixed by introducing a separate --surface token (#161616 vs. #0a0a0a background) so elevated surfaces are actually a different color, not just a different border, in dark mode — documented in docs/Design.md §2 specifically so future elevated UI (modals, dropdowns) doesn't repeat it.

A one-character case bug broke the logo on Vercel

The header referenced /square.png but the tracked asset was public/square.PNG. It worked locally on a case-insensitive filesystem and 404'd on Vercel's case-sensitive one. Two commits (9577a10, 479c9ac) — first renaming the asset, then finding a second reference that still pointed at the old casing — a small reminder that "works on my machine" for static assets is a filesystem property, not a code property.

// new ground

What I hadn't used before

Next.js 16's proxy.tsThis project's first exposure to Next 16 replacing middleware.ts with a root-level proxy.ts export. I couldn't rely on training-data assumptions about Next middleware here and had to verify behavior against node_modules/next/dist/docs/ directly before writing the auth guard, which is now the documented practice for anything in this codebase where Next 16 semantics might have changed.
BFF pattern with server-only as an enforced boundaryUsing the server-only package to make it a build error (not just a convention) if lib/reserp-client.ts or lib/session.ts were ever imported into a Client Component — turning "never expose the token" from a code-review rule into something the compiler catches.
Treating a third-party API's documentation as a hypothesis, not a contractEvery "resolved" open question in docs/PRD.md §9 and docs/API_Integration.md reflects a live call made specifically to confirm or refute what the vendor's Postman collection claimed. That habit — verify the documented shape against a real request before writing the type — is what caught the /product/:id bug before a second occurrence on /schedules.

// takeaways

What I took away

  • A 200 OK with success:false in the body is still a failure state that needs explicit handling — HTTP status alone is not a reliable signal against this API.
  • List and single-item response shapes from the same endpoint family should never be assumed to match without checking both directly; the cost of being wrong here is a silent, total outage of a whole page, not a type error.
  • A vendor's static, documented credential (PDF_TOKEN) can simply not work in production while returning a 200 status that looks like success at a glance — trust the live response, not the collection.
  • Elevation in a dark theme needs a distinct surface color, not just a border — border-only elevation reads as "flat" the moment background and surface share a hex value.
  • Filesystem case-sensitivity differences between local dev and the deploy target (macOS/Windows vs. Vercel's Linux-based build) are a real, recurring class of "works locally, breaks in prod" bug for static asset references.

// the journey

How it actually went

This started as a pivot, not a greenfield idea — the client already had onecapitalinvestment and backend_onecapitalinvestment running, a full custom platform where Square One Capital's admins manually entered every investor, property, and transaction. When ERP (ResERP) API access became available, the obvious question was whether to retrofit the existing Postgres-backed app to sync from ResERP, or build something new and much smaller that just reads from ResERP directly. I chose the second option, and this repo is the result — no database at all, because ResERP already is the database for everything this portal needs to show.

The first real scare was two days in, seeing every unit detail page return "not found" for every account in production. My first instinct was to suspect the auth layer, since that was the newest code — I spent time re-checking verifySession() and the cookie flow before actually diffing the single-unit response against the list response and seeing the object-vs-array mismatch. That was a useful, humbling lesson: when something fails for literally every user, look at the data shape before the auth code, because "works for nobody" points at something structural, not something session-specific.

The PDF_TOKEN question is the one I'm most glad I didn't guess on. The client had handed over what looked like a legitimate, documented credential, and it would have been easy to wire it in, watch the PDF downloads return something, and move on without noticing the response was a 200-status JSON error body pretending to be a success. Actually curling it and reading the bytes back — %PDF-1.7 vs. a JSON error — is the difference between shipping a working feature and shipping a broken one that looks fine in a quick manual test.

If I were doing this again, I'd push harder, earlier, on the open sandbox-environment question flagged in docs/Git_Workflow.md §4 — building and testing against what might be the live production ResERP instance with real buyer CNICs is a real data-handling risk that got documented rather than resolved, because resolving it wasn't in my control. I'd also add automated tests around the response-shape assumptions in lib/units.ts and lib/schedules.ts specifically, since both bugs found so far in this project were exactly that category of mistake, and there's currently no test suite catching a regression to the same pattern.

// outcome

What it changed

Shipped through six implementation phases (e900197 initial foundation through the feature/phase-6-polish merge) between 2026-07-21 and 2026-07-25, plus a further round of dark-theme redesign and bug fixes on 2026-07-25, promoted through the dev → staging → main branch flow described in docs/Git_Workflow.md. Delivered: CNIC-based login, a buyer's full unit list and per-unit detail (size, layout, registration number, tenant status, utility-bill counters), payment schedule with status filtering, upcoming-installment highlight, receipt history, and all five ResERP PDF documents (statement, booking form, allotment certificate, receipt, upcoming-installment notice) as authenticated streaming downloads — with the PDF_TOKEN and /product/:id issues fixed before or shortly after they reached production.

md` §8 — none of these are recorded in the repo.

// overview

In more detail

One Capital Investment is a full-stack property investment management web application. Administrators manage four property types (Rental, Development, Holding, Other) through a draft/publish workflow, track investor lifecycles, run support ticket queues, and review a comprehensive audit log that records every create/update/delete with IP and user agent. The investor portal lets users browse available properties, view their portfolio and transaction history, upload documents, and review personal analytics.

Built with Next.js 15 / React 19 on the frontend and a Node.js + Express.js REST API on the backend, backed by PostgreSQL (Neon) via Drizzle ORM. Redis caching with configurable TTL and graceful degradation, Cloudinary for organized media storage, and Nodemailer for OTP-based email flows round out the stack.

// features

What it does

  • Full property management with draft/publish workflow (Rental, Development, Holding)
  • Investor lifecycle management — approve, disable, edit investor accounts
  • Investment & transaction tracking across all property types
  • Support ticket system with priority levels and team assignment
  • Advanced analytics dashboard with charts and trends
  • Comprehensive audit log (tracks every create/update/delete with IP and user agent)
  • Investor Portfolio — Browse properties, investment history, personal analytics
  • OTP-based password reset via email
  • Redis caching with TTL strategies and graceful degradation

// stack

Built with

Next.js 15
React 19
TypeScript
Tailwind CSS v4
Node.js
Express.js
PostgreSQL (Neon)
Drizzle ORM
JWT
Zustand
Recharts
Redis
Nodemailer
Cloudinary
Questions about this build? Get in touch