Technical Architecture

A community's money, in a ledger that can't drift.

MainteNex runs maintenance accounting for apartment communities — expenses, invoices, dues and the corpus fund. The people using it are neighbours, not accountants, and the numbers have to survive being argued about at the next general body meeting. So the engineering question was never "how do we display a balance". It was "how do we make the balance impossible to quietly get wrong".

Corpus ledger lockAPPEND ONLY

Nothing in this list is ever edited or deleted. A mistake is corrected by a new entry, so the history of the fund stays readable years later.

The problem, stated honestly

Nobody audits a spreadsheet until the money is already missing.

Most societies run on a workbook one person owns and a WhatsApp group full of payment screenshots. It works until somebody asks a question the records can't answer. Four constraints shaped everything we built.

savings
01

A balance that two people can change at once.

The corpus fund is credited by collections and debited by expenses, from several devices, at the same time. Compute the balance by reading the old one and adding to it, and two simultaneous writes will both read the same number. The fund quietly loses a transaction and nobody notices for months.

autorenew
02

Billing repeats forever, and forgives nothing.

Every flat gets an invoice on a schedule the community chooses — monthly, quarterly, half-yearly or annually. A job that runs twice bills a resident twice, and one that skips a month means a treasurer chasing dues by hand. Both failures destroy trust in the app immediately.

receipt_long
03

"Fair share" means different things to different communities.

A water tanker is usually split by how many people live in each flat. A repair to one block is split equally among the flats in it. Some work is paid out of the corpus and costs residents nothing at all. The split has to be visible on the invoice, because that is what gets challenged.

group
04

One app, six kinds of person.

An owner, a tenant, a family member, a community admin, a security guard at the gate and someone who has only requested to join all open the same app. They must see different things, and one community's records must never be visible to another.

Everything below is how we answered these four.

The system at a glance

One app, one API, one ledger.

There is no admin website to keep in sync. Admins and residents use the same app, and the API decides what each of them is allowed to see — which means one permission model to reason about instead of two.

View text / ASCII version of this diagram
            [ MainteNex app ]
             Flutter, Android & iOS
             on-device invoice PDFs
                     |
                     |  HTTPS + JWT
                     v
   [ MainteNex Core — NestJS 11 on Node 22 ]
    OTP auth · role checks · money rules
              |
   [ PostgreSQL via Prisma ]   [ Scheduled jobs ]
    ledger · invoices           generate · overdue · expire
              |
    push notifications  ·  transactional email

"A number a resident can't trace back is a number they won't believe."

01 · The corpus fund

An append-only ledger, serialised per community.

The problem. A corpus balance stored as a single editable number is one bad write away from being wrong forever, with no way to tell when it happened.

How we solved it. The balance is not stored. It is derived, one entry at a time, and every entry is permanent.

  • check_circleEvery movement of the fund is an immutable ledger row carrying its type, its amount and the running balance that resulted from it.
  • check_circleEach append runs inside a database transaction that first takes a PostgreSQL advisory lock keyed to that community. Two admins acting at the same second are serialised, so no two entries can be computed from the same starting balance.
  • check_circleThe lock is scoped to one community, not to the ledger as a whole. Every other community keeps writing at full speed.
  • check_circleCorrections are reversal entries, never edits. A rejected payment writes a matching credit back rather than deleting the original, so the fund's history stays intact.
  • check_circleEntries are typed — initial deposit, maintenance collection, expense deduction, correction, interest credit, manual adjustment — so a year-end statement can explain where each rupee came from.
Why it matters to you

When a resident asks how the corpus reached its current figure, the answer is a list, not an assertion. That is the difference between a treasurer defending a number and a treasurer showing where it came from.

Two admins, one balance
Admin A
records a collection
Admin B
approves an expense
arrow_drop_down
arrow_drop_down
lock
Advisory lock · this community only
one writer at a time, everyone else unaffected
arrow_drop_down
A's entry
balance 2,04,500
B's entry
balance 1,86,500

Neither write is lost, and the running balance reads the same for both of them.

One billing run, per flat
02 · Invoice generation

A billing job you can run twice with nothing to clean up.

The problem. Scheduled jobs get retried, redeployed mid-run and occasionally started by hand. Any of those can double-bill a whole community, and a resident who receives two invoices for the same month stops trusting the app for good.

How we solved it. The run holds no state of its own. It asks the database what already exists and fills only the gaps.

  • check_circleBefore writing anything for a flat, the job looks for an invoice already covering that billing period. If one exists it moves on. Running the job again produces no second invoice.
  • check_circleEach community sets its own recurrence and billing date. Quarterly and half-yearly cycles are counted from that community's start month, including the wrap around the end of the year — a community billing from November bills in November, February, May and August.
  • check_circleExpenses are marked as invoiced once they are billed, so a repair charged in March can never reappear in April.
  • check_circleA separate nightly pass marks overdue invoices and notifies the residents who own them, so chasing dues isn't somebody's weekend job.
  • check_circleA flat with no resident assigned yet still gets billed, and the invoice is reattached to the owner when they join rather than being lost.
Why it matters to you

Billing happens on the date the community agreed on, without anyone remembering to press a button, and without the failure mode that would make the whole thing worse than the spreadsheet it replaced.

03 · Splitting an expense

The split is computed once, stored, and shown.

The problem. If each flat's share is recalculated whenever a screen renders it, the number can change after the invoice was issued — when a family moves out, say. Then two people looking at the same bill see two different figures.

How we solved it. Splitting happens when the expense is recorded, and the result is stored with the expense.

  • check_circlePro-rata by occupancy — a shared cost like a water tanker is divided by the number of residents in each flat, so a family of five carries more of it than a couple.
  • check_circleEqual across selected flats — work that benefits one block or one floor is split only among the flats it applies to.
  • check_circlePaid from the corpus — an expense marked as corpus-funded is debited from the fund and contributes exactly zero to every resident's bill. It appears in the ledger, not on the invoice.
  • check_circleShares are rounded to the paisa at the moment of the split and frozen there, so the flat-by-flat amounts always add back up to the expense.
  • check_circleEvery expense carries its receipt as an attachment, and the invoice shows the split that produced the line, so the question "why am I paying this" has an answer inside the app.
Why it matters to you

Arguments about maintenance are almost never about the total. They are about the share. Storing the split at the moment it was agreed is what lets you settle those arguments with a screen instead of a meeting.

Three ways a cost lands
From transfer to settled invoice
04 · Payments and part payments

Every state change is one transaction, or none.

The problem. Verifying a payment touches several things at once: the payment record, the invoice's status and paid amount, and the corpus ledger. If half of that lands and half doesn't, the community's books are wrong in a way that is very hard to find later.

How we solved it. Verification, part payment and bulk settlement each run as a single database transaction. Either the whole change happens or none of it does.

  • check_circlePart payments are first-class, not a workaround. Paying half a bill moves the invoice to partially paid and tracks the cumulative amount, so the next payment settles the remainder correctly.
  • check_circleA rejected payment writes a reversing ledger entry and returns the invoice to what it was. The original record and the rejection reason both stay visible.
  • check_circleThe community's collection figures come from verified payments only, so a pending receipt someone uploaded doesn't inflate this month's number.
  • check_circleAdmins can record payments on a resident's behalf — cash and cheques still exist — and settle several invoices in one action, with the same transactional guarantees.
  • check_circleResidents and admins are notified at each step: a receipt awaiting review, a payment verified, an invoice gone overdue.
Why it matters to you

Money moves exactly as it does today, between the resident and the association. What changes is that both sides can see the same record of it, with a receipt attached and a name against the approval.

05 · Who is allowed to see what

The same app, opening onto different products.

Authentication is closed by default: every endpoint requires a valid token unless it is explicitly marked public, so a new endpoint is private the moment it is written rather than the moment someone remembers to protect it.

group

Roles belong to a community, not to a person

The same account can be an admin of one community and a resident of another. Permissions are resolved per community on every request.

apartment

Owners, tenants and family members

A flat has a primary member and others attached to it, with move-in and move-out dates. What a tenant sees is not what the owner sees.

lock

Gate staff sign in with a PIN

Security guards get a temporary PIN they must change on first use, with lockout after repeated failures. Stored hashed, never recoverable.

qr_code_2

Joining is a request, not an act

A code or a QR scan puts someone in the queue. An admin approves them into a specific flat, with an expiring community code so an old screenshot doesn't grant access.

fingerprint

One-time codes, then biometrics

Sign-in is an emailed one-time code with a short expiry and a lockout on repeated wrong attempts. After that, the device's own fingerprint or face unlock reopens the app.

shield_with_heart

Administrative actions are logged and encrypted

Sensitive admin operations are captured with the acting user, the entity and the outcome, with the payload encrypted at rest for later review.

The stack, and why

Chosen for the hard parts, not for the résumé.

Mobile app
Flutter (Dart), Android and iOS

One codebase for residents and admins on whatever phone they already own.

App architecture
GetX for state, routing and injection

A role-aware shell that rebuilds its navigation as a user switches context.

Documents
Invoice PDFs rendered on the device

A bill can be produced and forwarded with no server round trip.

Shared foundation
Appseed's internal Flutter package

API client, biometric unlock, translations and server status, shared across our products.

Languages
English, Hindi and Telugu

The person keeping a society's books is often not working in English.

API
NestJS 11 on Node.js 22, TypeScript

Modules, DI and guards keep a per-community permission model tractable.

Data
PostgreSQL with Prisma

Transactions and advisory locks, because financial records need real constraints.

Auth
One-time codes, signed JWTs, hashed PINs

No password to reuse, and a separate credential model for gate staff.

Scheduling
Server-side cron inside the API

Billing and overdue marking happen whether or not anyone opens the app.

Delivery
Containerised, migrations on deploy

The schema and the code that expects it ship together.

The money rules live in one place. Not in the app, not duplicated in a web portal — in the API, where every client is subject to them equally.

How we build

Practices, not promises.

Nothing untrusted reaches the business logic.

Every request is validated against a strict schema at the edge, and unknown fields are rejected outright rather than ignored. In a system where a stray field could mean an amount nobody intended, that closes off a whole class of bugs before it starts.

Every request is traceable.

Structured JSON logging with a correlation ID follows a single request through the system, tagged with the acting user. Codes, tokens and authorisation headers are redacted before anything is written. When a resident disputes something, we can reconstruct what happened.

Consistency is enforced at the database.

Anything that touches more than one table — verifying a payment, appending to the ledger, letting a visitor through the gate — runs inside a transaction. Partial writes don't happen.

Hardened at the edges.

Security headers, a strict transport policy and frame denial are applied globally. Cross-origin access is restricted to known origins, and the API refuses to start in production if that list was never configured — a misconfiguration fails the deploy instead of shipping quietly.

Abuse limits are built in.

Rate limiting is applied per user and per endpoint. Sign-in codes expire quickly, wrong attempts are counted, and repeated failures lock the attempt out rather than allowing an unlimited guess.

Errors never leak the internals.

A global exception filter shapes every failure into a clean response. Stack traces go to the logs, never to the phone in a resident's hand.

What the app gives you

A society's whole operation, scoped to whoever is holding the phone.

monitoring

Dashboard

Collections this cycle, pending payments and join requests for an admin. Outstanding dues and recent activity for a resident.

receipt_long

Invoices

Generated on the community's own cycle, itemised down to each expense share, and downloadable as a PDF from the phone.

payments

Expenses with receipts

Every community expense recorded with its category, payment mode, receipt image and the split that was applied to it.

check_circle

Payment verification

Residents submit a reference and receipt, admins approve or reject with a reason, and part payments are handled properly.

savings

Corpus fund

A running balance with the full ledger behind it, plus corpus collection drives billed separately from monthly maintenance.

apartment

Blocks, floors and flats

A guided set-up that lays out the community's structure, tracks occupancy and keeps owners, tenants and family members distinct.

group_add

Members and join requests

Invite by code or QR, approve into a specific flat, promote another admin, and handle tenant requests separately from owners.

account_balance

Bank details and UPI QR

The association publishes its own account and UPI QR to its members. The money moves directly, and MainteNex records it.

qr_code_2

Gate and visitors

Residents pre-approve expected visitors, security logs arrivals against a flat, and unexpected visitors wait on the resident's approval.

notifications_active

Notifications

A new invoice, a payment verified, a bill gone overdue, someone at the gate — pushed to the people they concern.

translate

Three languages

English, Hindi and Telugu, switchable inside the app.

fingerprint

Biometric unlock

Optional fingerprint or face unlock on reopening, so the ledger is not one unlocked phone away from a stranger.

shield_with_heart

MainteNex never touches the money.

It is a record-keeping tool, not a payment processor. A community's bank and UPI details stay theirs, and funds move directly between residents and the association exactly as they do today.

That was a deliberate architectural decision, not a gap. Holding other people's maintenance money is a regulated business with an entirely different risk profile, and it is not the problem this product set out to solve. MainteNex keeps the ledger straight.

Shared money records only work if the arithmetic is beyond question.

MainteNex is a product, but the reason a community can rely on it is engineering: an append-only ledger under a per-community lock, billing that can run twice with nothing to clean up, splits stored at the moment they were agreed, and a permission model that follows the people rather than the org chart.