Table of Contents
- The Challenge
- Architecture & Solution
- Tech Stack
- Key Engineering Decisions
- Database Schema
- Core Modules
- Financial Engine
- Security & Access Control
- Deployment
- Roadmap
The Challenge
Municipal Boards in India manage thousands of commercial properties including shops, stalls, and market spaces. The tax collection process involves complex financial workflows that must maintain strict accounting integrity across fiscal year boundaries while providing accessible interfaces for both administrators and field collectors.
Prior to this system, Bajali Municipal Board relied on manual register-based tracking of tenant payments, demand generation through handwritten bills, and reconciliation through physical cash counting. This approach introduced significant inefficiencies: duplicate allotments due to lack of occupancy status tracking, inconsistent application of arrears and current dues, inability to generate comprehensive DCB (Demand-Collection-Balance) reports, and no audit trail for financial mutations.
The fundamental challenge was building a system that could handle the complete tax administration lifecycle while enforcing strict financial rules around the Arrear/Current model. Every payment must be automatically allocated to oldest arrears first, demand generation must respect fiscal year boundaries, and financial year transitions (period close) must lock all transactions while carrying forward balances correctly.
The requirement: Build a production-grade tax administration platform with automated demand generation, multi-mode payment collection (Cash/Online/Cheque), daily reconciliation, DCB reporting, RBAC with three tiers (Admin/Superuser/Tax Collector), full audit logging, and deployment-ready Docker configuration.
Architecture & Solution
MBTCMS follows a three-layer architecture designed for scalability and audit integrity, implemented as a monorepo using NPM workspaces:
Three-Layer Model
| Layer | Components | Purpose |
|---|---|---|
| Configuration | Masters, Roles, Permissions, Financial Years | Setup once, reference many |
| Operations | Allotment, Demand Generation, Payment Collection | Recurring daily/cyclical |
| Control | Reconciliation, Adjustments, Audit Log, Reports | Oversight and accountability |
Core Engine Services
The system implements five core services handling all critical financial logic:
| Service | Responsibility |
|---|---|
| Demand Service | Automated monthly/yearly demand calculation with intelligent arrear rollover |
| Payment Service | Multi-mode collection with auto-allocation to arrears first |
| Adjustment Service | Remissions with mandatory justification and Arrear/Current scope |
| Period-Close Service | Financial year transitions, transaction locking, balance rollover |
| Reconciliation Service | Daily cash/online/bank verification with carry-forward logic |
Tech Stack
| Layer | Technology | Version |
|---|---|---|
| Runtime | Node.js | 20 LTS |
| Backend Framework | NestJS | 11.x |
| Frontend Framework | Next.js (App Router) | 16.x |
| UI Library | React | 19.x |
| Styling | Tailwind CSS | 4.x |
| Animation | Framer Motion | 12.x |
| Charts | Recharts | 3.x |
| Icons | Lucide React | 0.563+ |
| Database | PostgreSQL | 15 |
| ORM | Prisma | 6.x |
| Auth | JWT + Passport | — |
| API Docs | Swagger/OpenAPI | via @nestjs/swagger |
| Process Manager | PM2 | — |
| Reverse Proxy | Traefik | — |
| Excel Export | ExcelJS | 4.x |
| PDF Generation | PDFKit | — |
| Package Manager | NPM Workspaces | — |
Key Engineering Decisions
1. Strict Arrear/Current Financial Model
Every financial transaction maintains explicit separation between arrear dues (from previous financial years) and current dues (current year). This model ensures accounting integrity across fiscal year boundaries.
// payments.service.ts - Auto-allocation logic
async create(createPaymentDto: CreatePaymentDto) {
const payment = await tx.payment.create({
data: {
amount: createPaymentDto.amount,
arrearAmount: createPaymentDto.arrearAmount || 0,
currentAmount: createPaymentDto.currentAmount || 0,
// ...
},
});
// Update Allotment balances: arrear first, then current
if (createPaymentDto.allotmentId) {
const allotment = await tx.allotment.findUnique({
where: { id: createPaymentDto.allotmentId },
});
let remainingAmount = Number(createPaymentDto.amount);
// Apply to Arrears first
if (remainingAmount > 0 && Number(allotment.arrearDemand) > 0) {
const arrearPayment = Math.min(remainingAmount, Number(allotment.arrearDemand));
await tx.allotment.update({
where: { id: createPaymentDto.allotmentId },
data: { arrearDemand: { decrement: arrearPayment } },
});
remainingAmount -= arrearPayment;
}
// Apply remaining to Current
if (remainingAmount > 0) {
await tx.allotment.update({
where: { id: createPaymentDto.allotmentId },
data: { currentDemand: { decrement: remainingAmount } },
});
}
}
}The system prevents negative balances through Math.max(0, val) clamping across all financial operations, ensuring data integrity even under edge cases.
2. Financial Year Lifecycle Management
The system implements a strict financial year state machine with three phases: OPEN, ADJUSTMENT_WINDOW (7 days after year end), and CLOSED. All financial operations are blocked when no active financial year exists.
// period-close.service.ts
async validateTransactionAllowed(financialYear: string) {
const fy = await this.prisma.financialYear.findFirst({
where: { year: financialYear },
});
if (!fy) {
throw new BadRequestException('No active financial year found. Please configure a financial year first.');
}
if (fy.status === 'CLOSED') {
throw new BadRequestException('Financial year is closed. New transactions are not allowed.');
}
}
async closeFinancialYear(year: string) {
return this.prisma.$transaction(async (tx) => {
// 1. Calculate new arrear: Old Arrear + Old Current
const allotments = await tx.allotment.findMany({
where: { status: 'ACTIVE' },
});
for (const allotment of allotments) {
const newArrear = Number(allotment.arrearDemand) + Number(allotment.currentDemand);
await tx.allotment.update({
where: { id: allotment.id },
data: {
arrearDemand: newArrear,
currentDemand: 0,
},
});
}
// 2. Mark FY as closed
await tx.financialYear.update({
where: { year },
data: { status: 'CLOSED', closedAt: new Date() },
});
});
}3. Comprehensive Audit Logging
Every data mutation is captured through a global interceptor that records user context, IP address, user agent, old and new values:
// common/interceptors/audit.interceptor.ts
@Injectable()
export class AuditInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const user = request.user;
return next.handle().pipe(
tap(async (data) => {
if (this.isMutatingRequest(request.method)) {
await this.prisma.auditLog.create({
data: {
userId: user?.id,
userName: user?.name,
action: `${request.method} ${request.route.path}`,
entity: this.extractEntityName(request.route.path),
entityId: data?.id,
oldValue: request.method !== 'POST' ? await this.getOldValue(request) : null,
newValue: data,
remark: request.body.remark,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
},
});
}
}),
);
}
}4. Collector Auto-Resolution from JWT
Tax collector identity is automatically resolved from the JWT token context, eliminating manual collector selection and ensuring all transactions are attributable:
// payments.controller.ts
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN', 'SUPERUSER', 'TAX_COLLECTOR')
@Post()
async create(@Body() createPaymentDto: CreatePaymentDto, @Request() req) {
// Collector ID is automatically extracted from JWT
createPaymentDto.collectorId = req.user.taxCollector?.id;
return this.paymentsService.create(createPaymentDto);
}5. Occupancy Management
Room and asset status prevents double-allotment through explicit VACANT/OCCUPIED states:
// allotments.service.ts
async create(createAllotmentDto: CreateAllotmentDto) {
// Check room availability
const room = await this.prisma.room.findUnique({
where: { id: createAllotmentDto.roomId },
});
if (room.status !== 'VACANT') {
throw new BadRequestException('Room is not available for allotment. Select a VACANT room.');
}
// Create allotment and update room status
const allotment = await this.prisma.allotment.create({
data: { ...createAllotmentDto },
});
await this.prisma.room.update({
where: { id: createAllotmentDto.roomId },
data: { status: 'OCCUPIED' },
});
return allotment;
}Database Schema
The shared Prisma schema defines 20 core models across three functional domains:
Masters Layer
| Model | Purpose |
|---|---|
| Ward | Geographic administrative divisions |
| Street | Streets within wards |
| Market | Markets within streets |
| Room | Individual shop/stall units in markets |
| Bank | Bank master for payment tracking |
| TaxCollector | Field collector registry |
| Distribution | Usage type distribution |
| WorkAssignment | Collector area assignments |
Operations Layer
| Model | Purpose |
|---|---|
| Tenant | Shop/stall tenant registry with unique TNT-XXXX codes |
| Allotment | Shop assignment with rent and security deposit |
| Demand | Monthly tax bills with Bill IDs |
| Payment | Collection records with Arrear/Current breakdown |
| Adjustment | Remissions and corrections |
| LeaseAllotment | Leased market contracts |
| LeasePayment | Lease rental collections |
| Lessie | Leased market lessee registry |
| LeasedAsset | Markets, stalls, open spaces |
| MiscReceipt | Non-tax income receipts |
| MiscReceiptType | Receipt category master |
Control Layer
| Model | Purpose |
|---|---|
| FinancialYear | FY lifecycle (OPEN/CLOSED) |
| DailyReconciliation | Collector daily cash verification |
| AuditLog | Comprehensive mutation trail |
| SystemSetting | Configuration key-value store |
Core Modules
Room Rent Management
The system manages shop and stall allotments through a complete lifecycle:
| Feature | Implementation |
|---|---|
| Tenant Engine | Lifecycle tracking (ACTIVE/NOTICE/LEFT), unique TNT-XXXX codes |
| Allotment Engine | Shop/stall assignment with rent amount, security deposit |
| Revocation System | Contract termination with automatic VACANT status update |
| Tax Demand Engine | Automated monthly billing with unique Bill IDs |
| Payment Recovery | Split-payment mode (Cash+Online+Cheque), auto-allocated to arrears |
Leased Market Management
Comprehensive handling of leased assets:
| Feature | Implementation |
|---|---|
| Leased Assets | Markets, stalls, open spaces with ward/street mapping |
| Lessie Registry | Personal, Firm, Company types with Aadhar/PAN |
| Lease Allotments | Quarterly billing, security deposit, order numbers |
| Lease Revocation | Security deposit settlement (Refund/Forfeit/Deduct) |
Miscellaneous Receipts
Non-tax income management:
| Feature | Implementation |
|---|---|
| Receipt Type Masters | Configurable categories with Ledger Code |
| Quick Receipting | Fast form for recording income with payer identity |
Daily Reconciliation
End-of-day verification workflow:
| Feature | Implementation |
|---|---|
| Unified Verification | Cash, Online, Bank daily by collector |
| Variance Detection | Physical vs digital discrepancy calculation |
| Carry Forward | Unverified amounts roll to next period |
| Reconciliation Guard | Blocks collection if overdue reports exist |
Financial Engine
Demand Generation
The demand service generates monthly tax bills automatically:
// demands.service.ts - Single demand generation
async generateSingle(dto: GenerateSingleDemandDto) {
const allotment = await this.prisma.allotment.findUnique({
where: { id: dto.allotmentId },
});
// baseTax = Current month's rent from Allotment
// arrears = Allotment's arrearDemand (previous FY balance)
const baseTax = Number(allotment.rentAmount);
const arrears = Number(allotment.arrearDemand);
const billId = await this.codeGenerator.generateBillId();
const demand = await tx.demand.create({
data: {
allotmentId: dto.allotmentId,
financialYear: dto.financialYear,
month: dto.month,
baseTax,
arrears,
totalDemand: baseTax, // Only monthly rent is billed
billId,
},
});
// Update Allotment current demand
await tx.allotment.update({
where: { id: dto.allotmentId },
data: { currentDemand: { increment: baseTax } },
});
return demand;
}Receipt Number Generation
Automatic receipt numbering with daily reset:
// payments.service.ts
private async generateReceiptNo(): Promise<string> {
const today = new Date();
const datePrefix = `MBTCMS-${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const lastPayment = await this.prisma.payment.findFirst({
where: { receiptNo: { startsWith: datePrefix } },
orderBy: { receiptNo: 'desc' },
});
let nextSeq = 1;
if (lastPayment) {
const lastSeq = parseInt(lastPayment.receiptNo.split('-')[4], 10);
nextSeq = lastSeq + 1;
}
return `${datePrefix}-${String(nextSeq).padStart(4, '0')}`;
}Security & Access Control
Three-Tier RBAC
| Role | Permissions |
|---|---|
| ADMIN | Full system access: Masters, Allotments, Demands, Payments, Users, Reports, Financial Year management |
| SUPERUSER | Administrative operations without user management |
| TAX_COLLECTOR | Read-only Masters; can record Payments, Lease Payments, Misc Receipts, submit Daily Reconciliations; cannot create/update/delete Masters, Allotments, Tenants |
Guard Implementation
Role enforcement applied at both backend and frontend:
// common/guards/roles.guard.ts
@Injectable()
export class RolesGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
handleRequest(err, user, info) {
if (err || !user) {
throw err || new UnauthorizedException();
}
return user;
}
}
// Usage in controllers
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Delete(':id')
async delete(id: string) { ... }Security Features
- Active FY Guard: Blocks all financial operations if no Financial Year is open
- Financial Ledger Guards:
Math.max(0, val)clamping prevents negative balances - Double-Click Protection: Lock guards on all transactional submit buttons
- Occupancy Management: VACANT/OCCUPIED status prevents double-allotment
Deployment
Docker Quickstart
# Clone and configure
git clone <repository-url>
cd mbtcms
# Configure environment
cp .env.example .env
# Edit .env with POSTGRES_PASSWORD and JWT_SECRET
# Start with Docker Compose
cd deployment
docker-compose up -dServices started:
mbtcms-web→ Frontend on port 3000mbtcms-api→ Backend on port 3001mbtcms-postgres→ PostgreSQL on port 5435
Production (Dokploy/Traefik)
# Deploy script
bash deploy.shConfiguration for Dokploy with Traefik reverse proxy and automatic HTTPS via Let's Encrypt.
PM2 (Bare Metal)
npm run build --workspace=backend
npm run build --workspace=frontend
pm2 start ecosystem.config.jsDefault Access
| URL | http://localhost:3000 |
| Admin Email | [email protected] |
| Admin Password | admin123 |
Roadmap
- Phase 1 — Core Infrastructure (Complete) - NestJS, Next.js, Prisma, PostgreSQL
- Phase 2 — Room Rent Module (Complete) - Tenant, Allotment, Demand, Payment
- Phase 3 — Leased Market Module (Complete) - Assets, Lessies, Lease Allotments, Payments
- Phase 4 — Misc Receipts (Complete) - Receipt types, quick receipting
- Phase 5 — Daily Reconciliation (Complete) - Cash/Online/Bank verification
- Phase 6 — RBAC Implementation (Complete) - Three-tier role system
- Phase 7 — Audit Logging (Complete) - Global interceptor for all mutations
- Phase 8 — Reports & Analytics (Complete) - DCB, Revenue, Visualization
- Phase 9 — Mobile App (In Progress) - React Native companion for collectors
- Phase 10 — Payment Gateway Integration (Planned) - Online payment collection
Conclusion
MBTCMS provides Bajali Municipal Board with a comprehensive, production-grade tax administration platform that manages the complete lifecycle of shop and stall allotments. The strict Arrear/Current financial model ensures accounting integrity across fiscal year boundaries, while automated demand generation and payment allocation reduce manual workload significantly.
The system handles over 2,000 tenant records and 5,000+ monthly transactions with a comprehensive audit trail logging every data mutation. The three-tier RBAC ensures appropriate access controls for administrators, supervisors, and field collectors, with automatic collector resolution from JWT tokens eliminating manual assignment errors.
Production deployment through Docker with Traefik reverse proxy ensures reliable, secure access via HTTPS. The modular architecture supports future extensions including mobile applications for field collectors and payment gateway integration for online collections.
Architecture Feedback
Spotted a potential optimization or antipattern? Let me know.