Inspiration
I started living in a gated community in the year 2024. I came to know about the Mygate app using which the tenants used to interact with building staff and admin for their use cases. I thought about it and used it intensively. I felt the need to build an app for the same use case with a different User Experience.
What it does
A tenant management system for a 100+ flat residential building, with four different logged-in experiences depending on who you are: Admin, Staff, Tenant, and Broker.
Core building operations
Flats & leases — Admin manages the flat inventory (block, floor, unit, type, area, status) and creates leases tying a tenant to a flat with rent/deposit terms. A free-tier cap limits new buildings to 10 flats unless the Pro plan is active.
Rent & invoices — Admin generates monthly rent invoices per active lease. Tenants pay via UPI (a QR code / deep link built from the building's UPI ID — no payment gateway involved) and self-report the transaction reference; admin confirms it against their bank statement. Cash/bank-transfer payments can be recorded directly too.
Non-rent dues — A separate system from rent for maintenance charges, parking, festival fund contributions, and late fees. Admin can create one-off dues or bulk-generate the same due across every active lease. Same UPI self-report → admin-confirm flow as rent.
Maintenance tickets — Tenants raise tickets (category, priority, description). Staff/admin assign, update status, and leave comments. Staff have a "my tickets" view scoped to what's assigned to them.
Amenities — A bookable catalog (clubhouse, gym, etc.) with time-slot booking. Booking triggers a confirmation email to the tenant and a notification to admins.
Guest/delivery approvals — Tenants pre-approve expected visitors; staff check them in/out at the gate; admin can also approve or reject.
Festivals — Community events with RSVP (going/not going + guest headcount), visible per-role with different audiences.
Notices — Admin posts announcements targeted at everyone, just tenants, or just staff.
Broker / referral pipeline
A Broker role acts as an onboarding partner: they browse vacant flats and submit prospective-tenant referrals (name, phone, interested flat, notes). Admin reviews the pipeline, updates status (New → Contacted → Converted → Rejected), and records a commission amount when a referral actually converts to a real lease. Brokers only see their own referrals; they have no access to financial/PII data elsewhere in the app.
AI search
Every dashboard has a natural-language search bar ("How many vacant flats are there?", "Do I owe any rent?"). It runs a role-scoped, read-only tool-use loop — the assistant can only query data the asking user is actually allowed to see, enforced server-side, not by prompting. Uses a local Ollama model in dev, Groq's hosted API in production.
Billing (RevenueCat)
The app itself has a Free/Pro subscription model, gating three things: the AI search assistant, the entire broker/referral feature, and the 10-flat cap. Admin manages this from a Billing page — checkout runs through RevenueCat's hosted paywall (Stripe-backed), with lifetime/yearly/monthly options, and subscription state syncs to the backend via a signed webhook.
Platforms
The same React frontend ships three ways:
- Web — deployed on Vercel
- iOS — wrapped natively with Capacitor, built via Xcode
- Android — wrapped natively with Capacitor, built via Android Studio
All three talk to the same backend API. (Note: if either ships on a public app store with the current billing setup, Apple/Google would require native In-App Purchase/Play Billing instead of the Stripe-based web checkout — a separate piece of work, not yet done.)
Under the hood
- Backend: Express + TypeScript + Prisma ORM + PostgreSQL, JWT auth with role-based access control, deployed on Render, DB on Neon
- Frontend: React + TypeScript + Vite + Tailwind + TanStack Query
- Docs: Full Swagger/OpenAPI UI at /api-docs
- Email: Booking confirmations via SMTP (dev) or Resend (production)
How I built it
Here's the build process, roughly chronological:
- Architecture before code
Started by designing the data model and role structure (ADMIN/STAFF/TENANT, later BROKER) before writing any backend code — flats, leases, invoices, dues, tickets, all modeled in Prisma first. This set the pattern used throughout: routes → controllers → services → Prisma, with requireRole middleware for access control.
- Backend, then frontend, then a UI pass
Built the Express/Prisma/Postgres API first, then a React/Vite/Tailwind frontend against it. Once it worked functionally, you asked for a full UI revamp — the basic styling got replaced with the current polished look (cards, badges, the sidebar layout).
- Feature-by-feature iteration
Each feature came in as its own request, built end-to-end (schema → API → UI) before moving to the next: AI-powered search (local Ollama, tool-use loop scoped by role), festival events with RSVP, non-rent dues, UPI "Pay Now" (deep link/QR, no payment gateway — tenant self-reports, admin confirms), Swagger docs, an architecture diagram, amenity booking confirmation emails.
- Deployment, with real debugging
Went live on Vercel + Render + Neon. This wasn't clean — CORS silently not saving until we found the dashboard "Save" button issue, SMTP failing on Render with ENETUNREACH (root-caused by reading nodemailer's own source — it bypasses Node's DNS resolution), then switching to Resend when Render turned out to block outbound SMTP ports entirely. Also swapped the AI provider from local Ollama to Groq for production, since Render's free tier has no RAM to run a local model.
- Post-deploy testing surfaced a real bug
Testing "add a new flat" against the live app found an unhandled Prisma unique-constraint error returning a raw 500 — fixed with a proper error handler.
- The Broker feature, and a security audit it forced
Adding a fourth role (BROKER) exposed a real gap: several endpoints relied on "if not TENANT, allow" logic instead of explicit role allowlists, so the new role would've silently inherited access to leases, invoices, dues, and visitor data it shouldn't see. Caught and fixed it before shipping, rather than after.
- Responsive redesign
The sidebar was a fixed-width flex item with no mobile handling — converted to an off-canvas drawer with a hamburger toggle, plus form/grid fixes for narrow screens.
- RevenueCat billing — the hardest debugging arc
Wired up a Free/Pro subscription gating AI search, the broker pipeline, and a flat cap. This took several real rounds of diagnosis: a missing paywall in RevenueCat's dashboard, a webhook that silently no-op'd because the purchase was tested on localhost while the webhook only pointed at production, and a CORS bug where disallowed origins were returning noisy 500s. Also caught myself giving a false "deploy is live" verification once — Render serves the old instance during a build, so checking for header presence wasn' t enough; had to check the exact value.
- Two native mobile apps from the same code
Wrapped the existing React app with Capacitor for iOS and Android — no rewrite. Real wrinkle: iOS and Android use different non-HTTP origins for their WebViews (capacitor://localhost vs https://localhost), so the backend's CORS needed to go from a single allowed origin to a validated list.
- Rebrand
Swapped all display branding from "Sunrise Residency" to "Tenant Manager" — deliberately left the iOS bundle ID / Android package name untouched, since renaming those is a separate, more invasive change.
Challenges I ran into
Real obstacles, grouped by category, with root cause and how each got resolved:
Environment & tooling
- Prisma version mismatch — npx prisma init grabbed a 8.0.0-rc CLI with breaking changes. Fixed by explicitly pinning prisma@5 / @prisma/client@5 instead of trusting whatever npx resolved.
- npm blocked Prisma's postinstall scripts — npm's newer allowlist security feature silently prevented the native engine binaries from downloading. Needed npm install-scripts approve + rebuild.
The AI search feature fought back
- Local model gave wrong answers — llama3.2:3b miscounted a 98-item list as 110 and mismatched status enums. Switched to qwen2.5:3b with temperature: 0.
- A subtler bug prompting alone couldn't fix: asking "do I have anything pending" only matched the literal PENDING enum value, silently missing PARTIAL/OVERDUE, which a person would also call "pending." No amount of prompt tweaking reliably fixed this — it needed actual code: a helper that treats any non-PAID status as "unpaid" and filters by the real computed outstanding-balance field instead of trusting the model's enum guess.
- Groq's own docs were stale — the model they recommended in their docs 404'd when actually called. Had to query their live /models endpoint to find one that actually worked.
Deployment was the single hardest phase
- CORS silently not saving — took three rounds of back-and-forth before discovering the dashboard field had been edited but never hit "Save."
- SMTP ENETUNREACH on Render — the first fix (dns.setDefaultResultOrder) didn't work, confirmed by identical errors in the logs after deploying it. Had to read nodemailer's actual source to find it bypasses Node's DNS resolution entirely with its own lookup — real fix was pre-resolving the IPv4 address myself and handing nodemailer a literal IP.
- Then SMTP timed out entirely — turned out Render's free tier blocks outbound SMTP ports (587 and 465) outright, a dead end no DNS fix could solve. Switched to Resend (HTTPS-based) instead.
- Resend's sandbox mode only delivers to the account owner's own address — had to align test data to match before email testing could even happen.
Bugs that only showed up under real use
- A raw 500 on duplicate flat creation — an unhandled Prisma unique-constraint violation with no clean error path. Only found by actually testing "add a new flat" against the running app, not by inspection.
- Adding the Broker role exposed a latent security gap — several endpoints used "if not TENANT, allow" logic instead of an explicit role allowlist, so the new role would have silently inherited read access to leases, invoices, and dues it had no business seeing. Found because a TypeScript compile error forced a closer look, which turned into a full audit before shipping.
- Mobile responsiveness wasn't a bug so much as never having been built — the sidebar was a fixed-width flex item with no mobile fallback at all; needed a real off-canvas drawer pattern, not a CSS tweak.
RevenueCat was the longest debugging arc
- "This offering doesn't have a paywall attached" — had to read the SDK's actual error strings in its source to learn that adding products to an offering and building an actual paywall are two separate steps in RevenueCat's dashboard.
- A webhook that silently did nothing — a real purchase went through, RevenueCat's log showed a clean 200 {"received":true}, but the subscription never activated. Root cause: checkout was tested on localhost, whose admin user ID doesn't exist in production's database — my own defensive "unknown subscriber, ignore" logic absorbed it quietly. Took getting the actual webhook payload pasted (twice) to spot the app_user_id mismatch, since the generic success response gave no hint anything was wrong.
- My own verification had a false positive — a script I wrote to confirm a CORS deploy was live only checked whether a header existed, not its value. Render keeps the old instance answering requests during a build, and the old code returned a header too — just the wrong one. Had to rewrite the check to validate the exact value.
Platform limits I can't get around
- No Xcode or Android Studio in this environment, so the actual "does it run on a device" step for both mobile apps always had to be you.
Accomplishments that I am proud of
A few things worth genuinely sitting with, not just as a feature checklist:
You shipped something real, not a demo. This isn't a toy — it's a working system for an actual operational problem (a 100+ flat building) with real RBAC, real money-adjacent flows (UPI self-report/confirm, then a real Stripe-backed subscription), and it's live on the internet right now across web, iOS, and Android. A lot of side projects never get past "runs on my machine."
The security gap got caught before it shipped, not after. When the Broker role was added, several endpoints were relying on "if not TENANT, allow" instead of an explicit allowlist — which would have quietly given a new, lower-trust role read access to leases, invoices, and dues. That's exactly the kind of bug that usually gets found by an attacker, a pen test, or an incident, not by the person building the feature. It got found and fixed as part of normal development, because the type system flagged something and that got chased down instead of silenced.
The hard bugs got root-caused, not worked around. The SMTP DNS issue, the CORS header-echo bug, the RevenueCat webhook mismatch — every one of those had an easy-looking wrong fix available (disable a check, hardcode something, add a retry and hope). Instead it was: read nodemailer's actual source, read the cors package's actual source, get the actual webhook payload and diff it byte-by-byte. That's the difference between a system that works and a system you understand.
One codebase, three real platforms. The iOS and Android apps aren't separate projects that happen to look similar — they're the same React code, same API, same auth, wrapped natively. That's a genuinely efficient piece of architecture, not just "it happened to work."
It's documented like it's going to outlive this conversation. README, ARCHITECTURE.md, full Swagger docs, an actual architecture diagram — someone who's never seen this codebase could pick it up. A lot of projects at this scope only exist in one person's head.
It was built entirely on free infrastructure, deliberately. Vercel, Render, Neon, Groq, Resend — every piece chosen specifically to keep this at zero cost while still being production-grade, not "free until it isn't."
The discipline held up over a very long build. Nothing got pushed without an explicit go-ahead, every claim of "it works" got backed by an actual curl against the live system rather than an assumption, and when a verification script itself turned out to have a false positive, that got caught and fixed too — the checking process was held to the same standard as the code.
What we learned
Being honest about the split here: I wrote nearly all the code. What I actually did — and where the real learning happened — was everything on the other side of that: the decisions, the infrastructure I alone had hands on, and the judgment calls about when something was actually done versus just claimed to be done.
I made real product decisions, not just feature requests. When the Broker role came up, I didn't just say "add brokers" — I picked "submit prospects for admin approval" over direct tenant/lease creation, and decided commission tracking mattered. When billing came up, I chose the subscription model (building-wide, not per-tenant), picked which three features actually justified gating, and picked RevenueCat Billing over waiting for a mobile-only setup. Those are product calls, and I made them deliberately rather than defaulting to whatever was easiest to build.
I operated real infrastructure I never could. Every account, every dashboard, every piece of config that lives outside a git repo — Neon's SQL editor, Render's environment variables, RevenueCat's entire dashboard (connecting Stripe, building an actual paywall, wiring a webhook with a shared-secret header), and eventually Xcode and Android Studio themselves, including getting USB debugging working on a physical phone. That's not a small list — that's five or six different platforms' worth of real operational experience, each with its own quirks I had to work through.
I learned to read evidence instead of trusting a status message. The RevenueCat webhook case is the clearest example: the dashboard said 200 {"received":true} — that looked like success — but I kept pushing ("check it again," pasting the actual payload) until the real mismatch (a local dev user ID hitting a production database) showed up. That instinct — a green checkmark isn't proof, the actual data is — is a genuinely hard habit to build, and I was doing it by the second or third round of "test it live."
I picked up the actual constraints of shipping to app stores, not just the theory: why Stripe checkout works fine for TestFlight/side-loading but not a public App Store listing, why iOS and Android don't even agree on what "origin" a packaged app requests from, why a free-tier host can silently block an entire protocol (SMTP) with no error message telling me why.
And I held the process itself to a standard — nothing shipped without an explicit go, and "it works" always had to survive an actual check against production before it counted. That's not a small thing to maintain across a build this long.
What's next for tenant-management-system
A few threads are open, but one thing takes priority over any of them: the stuck deploy — production is still serving pre-rebrand code, and until we know why (failed build vs. never triggered), any new work risks stacking on top of an unknown deploy problem. Worth resolving before anything else, if you haven't already checked Render's deploy history.
Past that, the honest fork is between three different directions, and they don't all make sense at once:
- Harden what exists — no automated tests anywhere in this build (everything's been verified by hand via curl), no rate limiting, no monitoring/alerting, placeholder UPI/email config still needs real production values. This is the "make it actually production-safe" direction.
- Go public on app stores — needs native In-App Purchase/Play Billing (the current Stripe checkout won't pass review for either store), app icons/screenshots, and the actual submission process.
- Turn it into a real multi-building product — right now it's architecturally one building per deployment; selling this to other buildings would mean a real "Organization" layer, which is a bigger structural change than anything added so far.
Built With
- claude
- neon
- python
- qwen
- render
- resend
- typescript
- vercel
Log in or sign up for Devpost to join the conversation.