Lopay — School-Fee Installments
Lopay—School-FeeInstallments
A school-fee installment platform where the entire product is a correctness problem: parents pay over time, schools must be paid exactly once, and no rounding error is acceptable.
The Problem
Paying school fees as a single lump sum is out of reach for many families. Lopay lets parents spread tuition across weekly or monthly installments, while schools still receive confirmed, traceable payments and the platform earns a flat 2.5% fee. Three roles share the system: parents sign up publicly, school owners are onboarded by an admin to confirm payments, and a super-admin manages schools and watches for defaulters. When you're moving other people's tuition money, "it mostly works" isn't a finish line.
Architecture Thinking
The money model is the architecture. I treated every figure as something that has to be reconstructable and defensible months later, which pushed three decisions to the foundation: fees are frozen at enrollment so later changes can't rewrite history, amounts are integers (minor units) so floating-point drift never creeps into a balance, and every payment carries an idempotency key so a retried request can't double-charge. A React 19 + Capacitor frontend talks to a modular NestJS + PostgreSQL backend; Firebase verifies identity once, then the API issues its own JWT for stateless, role-guarded requests.
Data Model
Fees are snapshotted onto the enrollment at creation, and the payment row is pre-split into platform and school amounts so settlement is never ambiguous:
model ChildEnrollment {
totalSchoolFee Int // snapshot of class fee at enrollment
platformFee Int // 2.5% of totalSchoolFee, fixed
schoolMinimumFee Int // 25% of totalSchoolFee
remainingBalance Int // after first payment & installments
paymentStatus PaymentStatus
}
model Payment {
idempotencyKey String? @unique // prevents duplicate submissions on retry
amountPaid Int // what the parent paid
platformAmount Int // fixed 2.5% platform fee
schoolAmount Int // amount going to the school
status PaymentTransactionStatus @default(PENDING)
}
Payment State Machine
Every payment moves through a backend-controlled lifecycle — PENDING → ACTIVE → COMPLETED, or DEFAULTED — and the client can never set that state directly. Enrollment and its first payment run inside a single Prisma $transaction, so a child is never half-enrolled, and a nightly scheduled job sweeps overdue plans and flags them defaulted.
Real-Time & Boundaries
A JWT-authenticated Socket.io gateway pushes payment and enrollment changes to per-user, per-school, and admin rooms instead of polling. Receipts upload to Supabase through backend-signed URLs, so storage keys never touch the client.
Constraints
It handles real tuition money, so correctness and auditability outrank everything — hence integer math, frozen fee snapshots, idempotent writes, and an immutable confirmation audit trail. Schools are onboarded manually for trust while parents self-serve for reach, which forces strict role boundaries. And it's multi-tenant by school, so tenant isolation is enforced centrally rather than hoped for at each query.
What It Demonstrates
- Idempotent payments — a unique
idempotencyKeyper payment makes a retried submission a no-op instead of a double-charge. - Money as integers — amounts stored in minor units, never floats, so a balance can't drift by rounding.
- Immutable financial history — fees snapshotted at enrollment and every confirmation written to an audit row, so a dispute always has an answer.
- State-machine integrity — a backend-only payment lifecycle the client can't mutate, with transactional enrollment so records are never half-written.
- Multi-tenant isolation — per-school scoping enforced centrally, so one missing clause can't leak another school's data.
Outcome
Lopay runs as a modular monolith of ten feature modules, with unit and e2e tests over the payment math, a nightly defaulter sweep, structured logging, error tracking, security headers, and rate limiting. The interesting engineering isn't the screens — it's a financial state machine that stays correct under retries, partial failures, and disputes.