For Cline: This document is a complete system design spec. Build the project exactly as described. Follow the folder structure, tech stack, API contracts, and database schema precisely. Do not substitute libraries unless explicitly noted.
A full-stack micro-finance lending management system for a small-city financier who lends money for automobiles, small shops, personal loans, etc.
- Admin can manage customers, approve loans, track EMIs, record repayments
- Customers can apply for loans, view EMI schedules, download receipts
- Works on Web (React) + Mobile (React Native)
- Deployed entirely on free-tier services
- Secure, robust, production-ready
| Layer | Technology |
|---|---|
| Framework | React 18 + Vite |
| Styling | TailwindCSS v3 |
| State / Data | React Query (TanStack Query v5) |
| Routing | React Router v6 |
| Forms | React Hook Form + Zod |
| react-pdf / jsPDF | |
| Charts | Recharts |
| HTTP Client | Axios |
| Layer | Technology |
|---|---|
| Framework | React Native (Expo SDK 51+) |
| Routing | expo-router v3 |
| Styling | NativeWind v4 (TailwindCSS for RN) |
| State / Data | TanStack Query v5 |
| Forms | React Hook Form + Zod |
| Notifications | expo-notifications |
| Storage | expo-secure-store (tokens) |
| Documents | expo-document-picker, expo-image-picker |
| Layer | Technology |
|---|---|
| Runtime | Node.js 20 LTS |
| Framework | Express.js v4 |
| Auth | JWT (jsonwebtoken) + bcrypt |
| Validation | Zod |
| ORM | Prisma v5 |
| Jobs | node-cron |
| PDF Gen | pdfkit |
| Nodemailer + Brevo SMTP | |
| File Upload | Multer + Cloudinary SDK |
| Logging | Morgan + Winston |
| Security | Helmet, cors, express-rate-limit |
| Service | Purpose | Free Tier |
|---|---|---|
| Supabase | PostgreSQL database | 500MB DB, 1GB storage |
| Upstash | Redis cache + rate limiting | 10,000 cmds/day |
| Cloudinary | KYC document/image storage | 25GB |
| Render.com | Backend API hosting | 512MB RAM |
| Vercel | Web frontend hosting | Unlimited |
| Expo EAS | Mobile app builds | 30 builds/month |
| Brevo | Transactional email | 300 emails/day |
| GitHub Actions | CI/CD | 2000 min/month |
microfinance-platform/
├── apps/
│ ├── web/ # React + Vite web app
│ │ ├── src/
│ │ │ ├── api/ # Axios API client + endpoints
│ │ │ ├── components/ # Shared UI components
│ │ │ ├── features/
│ │ │ │ ├── auth/
│ │ │ │ ├── dashboard/
│ │ │ │ ├── loans/
│ │ │ │ ├── customers/
│ │ │ │ ├── repayments/
│ │ │ │ └── reports/
│ │ │ ├── hooks/ # Custom React hooks
│ │ │ ├── layouts/ # AdminLayout, CustomerLayout
│ │ │ ├── pages/ # Page-level components
│ │ │ ├── stores/ # Zustand stores (auth, ui)
│ │ │ ├── utils/ # EMI calc, formatters, etc.
│ │ │ └── main.tsx
│ │ ├── index.html
│ │ ├── vite.config.ts
│ │ └── tailwind.config.ts
│ │
│ └── mobile/ # Expo React Native app
│ ├── app/ # expo-router file-based routing
│ │ ├── (auth)/
│ │ │ ├── login.tsx
│ │ │ ├── register.tsx
│ │ │ └── otp-verify.tsx
│ │ ├── (customer)/
│ │ │ ├── _layout.tsx # Tab layout
│ │ │ ├── index.tsx # Home/Dashboard
│ │ │ ├── loans/
│ │ │ │ ├── index.tsx
│ │ │ │ ├── apply.tsx
│ │ │ │ └── [id].tsx
│ │ │ ├── emi/
│ │ │ │ └── [loanId].tsx
│ │ │ ├── profile.tsx
│ │ │ └── notifications.tsx
│ │ └── _layout.tsx # Root layout
│ ├── components/
│ ├── hooks/
│ ├── utils/
│ ├── app.json
│ └── tailwind.config.ts
│
├── packages/
│ └── shared/ # Shared types, Zod schemas, utils
│ ├── src/
│ │ ├── types/ # TypeScript interfaces
│ │ ├── schemas/ # Zod validation schemas
│ │ └── utils/ # EMI calculator, date helpers
│ └── package.json
│
├── server/ # Express.js backend
│ ├── src/
│ │ ├── config/
│ │ │ ├── db.ts # Prisma client singleton
│ │ │ ├── redis.ts # Upstash Redis client
│ │ │ ├── cloudinary.ts
│ │ │ └── env.ts # Zod env validation
│ │ ├── middleware/
│ │ │ ├── auth.middleware.ts # JWT verify
│ │ │ ├── role.middleware.ts # RBAC guard
│ │ │ ├── validate.middleware.ts
│ │ │ ├── rateLimit.middleware.ts
│ │ │ └── error.middleware.ts
│ │ ├── modules/
│ │ │ ├── auth/
│ │ │ │ ├── auth.router.ts
│ │ │ │ ├── auth.controller.ts
│ │ │ │ └── auth.service.ts
│ │ │ ├── customers/
│ │ │ │ ├── customers.router.ts
│ │ │ │ ├── customers.controller.ts
│ │ │ │ └── customers.service.ts
│ │ │ ├── loans/
│ │ │ │ ├── loans.router.ts
│ │ │ │ ├── loans.controller.ts
│ │ │ │ └── loans.service.ts
│ │ │ ├── emi/
│ │ │ │ ├── emi.router.ts
│ │ │ │ ├── emi.controller.ts
│ │ │ │ └── emi.service.ts
│ │ │ ├── repayments/
│ │ │ │ ├── repayments.router.ts
│ │ │ │ ├── repayments.controller.ts
│ │ │ │ └── repayments.service.ts
│ │ │ ├── documents/
│ │ │ └── reports/
│ │ ├── jobs/
│ │ │ ├── emi-reminder.job.ts
│ │ │ ├── overdue-check.job.ts
│ │ │ └── index.ts
│ │ ├── utils/
│ │ │ ├── emi-calculator.ts
│ │ │ ├── pdf-generator.ts
│ │ │ ├── mailer.ts
│ │ │ └── logger.ts
│ │ └── app.ts
│ ├── prisma/
│ │ ├── schema.prisma
│ │ └── migrations/
│ └── package.json
│
├── .github/
│ └── workflows/
│ ├── deploy-server.yml
│ └── deploy-web.yml
├── package.json # Monorepo root (npm workspaces)
└── README.md
File: server/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
CUSTOMER
}
enum KycStatus {
PENDING
VERIFIED
REJECTED
}
enum LoanStatus {
PENDING
APPROVED
ACTIVE
CLOSED
REJECTED
}
enum LoanType {
AUTOMOBILE
SMALL_BUSINESS
PERSONAL
HOME
CONSUMER_ELECTRONICS
AGRICULTURE
EDUCATION
MEDICAL
EQUIPMENT
}
enum EmiStatus {
PENDING
PAID
OVERDUE
}
enum PaymentMode {
CASH
UPI
BANK_TRANSFER
}
model User {
id String @id @default(uuid())
name String
phone String @unique
email String? @unique
passwordHash String
role Role @default(CUSTOMER)
isActive Boolean @default(true)
kycStatus KycStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer?
refreshTokens RefreshToken[]
}
model Customer {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
address String
city String
pincode String
state String
aadharNo String? @unique
panNo String? @unique
dateOfBirth DateTime?
occupation String?
monthlyIncome Float?
guarantorName String?
guarantorPhone String?
guarantorRelation String?
profilePhotoUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
loans Loan[]
documents Document[]
}
model Loan {
id String @id @default(uuid())
customerId String
customer Customer @relation(fields: [customerId], references: [id])
loanType LoanType
principalAmount Float
interestRate Float // annual percentage
tenureMonths Int
processingFee Float @default(0)
purpose String?
collateral String?
status LoanStatus @default(PENDING)
approvedBy String? // admin user id
approvedAt DateTime?
disbursedAt DateTime?
closedAt DateTime?
rejectionReason String?
totalAmount Float // principal + total interest
emiAmount Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
emiSchedule EmiSchedule[]
}
model EmiSchedule {
id String @id @default(uuid())
loanId String
loan Loan @relation(fields: [loanId], references: [id])
emiNumber Int
dueDate DateTime
emiAmount Float
principalComponent Float
interestComponent Float
outstandingBalance Float
latePenalty Float @default(0)
status EmiStatus @default(PENDING)
createdAt DateTime @default(now())
repayment Repayment?
}
model Repayment {
id String @id @default(uuid())
emiId String @unique
emi EmiSchedule @relation(fields: [emiId], references: [id])
paidAmount Float
paidDate DateTime @default(now())
paymentMode PaymentMode @default(CASH)
receiptNo String @unique
collectedBy String // admin user id
notes String?
latePenalty Float @default(0)
createdAt DateTime @default(now())
}
model Document {
id String @id @default(uuid())
customerId String
customer Customer @relation(fields: [customerId], references: [id])
docType String // AADHAR_FRONT, AADHAR_BACK, PAN, PHOTO, INCOME_PROOF, etc.
fileUrl String
publicId String // Cloudinary public_id
verifiedBy String?
verifiedAt DateTime?
isVerified Boolean @default(false)
createdAt DateTime @default(now())
}
model RefreshToken {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
}https://your-app.onrender.com/api/v1
POST /auth/register # Customer self-register
POST /auth/login # Login (phone + password)
POST /auth/otp/send # Send OTP to phone
POST /auth/otp/verify # Verify OTP → issue token
POST /auth/refresh # Refresh access token
POST /auth/logout # Invalidate refresh token
GET /auth/me # Get current user
GET /customers # List all customers (admin)
POST /customers # Create customer profile (admin)
GET /customers/:id # Get customer details
PUT /customers/:id # Update customer info
GET /customers/:id/loans # All loans for a customer
GET /customers/me # Own profile (customer)
PUT /customers/me # Update own profile (customer)
POST /loans/apply # Apply for loan (customer)
GET /loans # List loans (admin: all, customer: own)
GET /loans/:id # Loan details
PUT /loans/:id/approve # Approve loan (admin)
PUT /loans/:id/reject # Reject loan (admin)
PUT /loans/:id/disburse # Mark as disbursed (admin)
GET /loans/pending # Pending approvals (admin)
GET /loans/overdue # Loans with overdue EMIs (admin)
GET /emi/schedule/:loanId # Full EMI schedule for a loan
GET /emi/upcoming # Upcoming EMIs (customer: own, admin: all)
GET /emi/overdue # All overdue EMIs (admin)
GET /emi/summary/:customerId # EMI summary for customer (admin)
POST /repayments # Record repayment (admin)
GET /repayments/:loanId # Repayment history for loan
GET /repayments/receipt/:id # Get receipt data
GET /repayments/receipt/:id/pdf # Download PDF receipt
POST /documents/upload # Upload KYC document
GET /documents/:customerId # List customer documents
PUT /documents/:id/verify # Verify document (admin)
DELETE /documents/:id # Delete document
GET /reports/dashboard # KPI stats for dashboard
GET /reports/monthly?month=&year= # Monthly repayment report
GET /reports/customers # Customer portfolio summary
GET /reports/overdue # Overdue analysis
GET /reports/monthly/pdf # Download monthly report PDF
File: server/.env (never commit — add to Render dashboard)
# App
NODE_ENV=production
PORT=5000
FRONTEND_URL=https://your-app.vercel.app
# Database (Supabase)
DATABASE_URL=postgresql://postgres:[password]@db.[ref].supabase.co:5432/postgres
# JWT
JWT_ACCESS_SECRET=your_access_secret_min_32_chars
JWT_REFRESH_SECRET=your_refresh_secret_min_32_chars
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Redis (Upstash)
UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_token
# Cloudinary
CLOUDINARY_CLOUD_NAME=your_cloud
CLOUDINARY_API_KEY=your_key
CLOUDINARY_API_SECRET=your_secret
# Email (Brevo SMTP)
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_USER=your_brevo_login
SMTP_PASS=your_brevo_smtp_key
EMAIL_FROM=noreply@yourfinance.com
# Admin
ADMIN_PHONE=9999999999
ADMIN_DEFAULT_PASSWORD=ChangeMe@123File: apps/web/.env
VITE_API_URL=https://your-app.onrender.com/api/v1File: apps/mobile/.env
EXPO_PUBLIC_API_URL=https://your-app.onrender.com/api/v1File: packages/shared/src/utils/emi-calculator.ts
Implement reducing balance (flat interest optional via flag):
export interface EmiInput {
principal: number;
annualInterestRate: number; // e.g. 18 for 18%
tenureMonths: number;
method?: 'reducing' | 'flat';
}
export interface EmiScheduleRow {
emiNumber: number;
dueDate: Date;
emiAmount: number;
principalComponent: number;
interestComponent: number;
outstandingBalance: number;
}
export function calculateEmi(input: EmiInput): number {
// Reducing balance formula: EMI = P * r * (1+r)^n / ((1+r)^n - 1)
const r = input.annualInterestRate / 12 / 100;
const n = input.tenureMonths;
const P = input.principal;
if (input.method === 'flat') {
const totalInterest = P * (input.annualInterestRate / 100) * (n / 12);
return Math.round((P + totalInterest) / n * 100) / 100;
}
const emi = P * r * Math.pow(1 + r, n) / (Math.pow(1 + r, n) - 1);
return Math.round(emi * 100) / 100;
}
export function generateEmiSchedule(
input: EmiInput,
startDate: Date
): EmiScheduleRow[] {
const emiAmount = calculateEmi(input);
const r = input.annualInterestRate / 12 / 100;
const schedule: EmiScheduleRow[] = [];
let balance = input.principal;
for (let i = 1; i <= input.tenureMonths; i++) {
const interest = Math.round(balance * r * 100) / 100;
const principal = Math.round((emiAmount - interest) * 100) / 100;
balance = Math.round((balance - principal) * 100) / 100;
const dueDate = new Date(startDate);
dueDate.setMonth(dueDate.getMonth() + i);
schedule.push({
emiNumber: i,
dueDate,
emiAmount,
principalComponent: principal,
interestComponent: interest,
outstandingBalance: Math.max(balance, 0),
});
}
return schedule;
}File: server/src/middleware/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { env } from '../config/env';
export interface AuthRequest extends Request {
user?: { id: string; role: string };
}
export const authenticate = (req: AuthRequest, res: Response, next: NextFunction) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ message: 'No token provided' });
try {
const decoded = jwt.verify(token, env.JWT_ACCESS_SECRET) as { id: string; role: string };
req.user = decoded;
next();
} catch {
return res.status(401).json({ message: 'Invalid or expired token' });
}
};
export const requireRole = (...roles: string[]) =>
(req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ message: 'Insufficient permissions' });
}
next();
};File: server/src/jobs/index.ts
import cron from 'node-cron';
import { sendEmiReminders } from './emi-reminder.job';
import { checkOverdueEmis } from './overdue-check.job';
export function initJobs() {
// Daily 8:00 AM — send EMI due reminders (3 days before due)
cron.schedule('0 8 * * *', sendEmiReminders);
// Daily 9:00 AM — mark overdue EMIs and apply penalty
cron.schedule('0 9 * * *', checkOverdueEmis);
}checkOverdueEmis logic:
- Query all EmiSchedule where
status = PENDINGanddueDate < today - Set
status = OVERDUE - Apply late penalty (e.g., 2% of EMI amount per day capped at 10%)
- Send email/notification to customer
File: server/render.yaml
services:
- type: web
name: microfinance-api
env: node
region: singapore
buildCommand: npm install && npx prisma generate && npx prisma migrate deploy
startCommand: node dist/app.js
envVars:
- key: NODE_ENV
value: productionFile: apps/web/vercel.json
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}File: .github/workflows/deploy-server.yml
name: Deploy Server
on:
push:
branches: [main]
paths: ['server/**']
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Render
run: curl -X POST ${{ secrets.RENDER_DEPLOY_HOOK_URL }}/login → LoginPage
/register → CustomerRegisterPage
# Admin routes (role: ADMIN)
/admin → AdminDashboard
/admin/customers → CustomerListPage
/admin/customers/:id → CustomerDetailPage
/admin/loans → LoanListPage (with filters)
/admin/loans/pending → PendingApprovalsPage
/admin/loans/:id → LoanDetailPage
/admin/repayments → RepaymentLogPage
/admin/repayments/record → RecordRepaymentPage
/admin/reports → ReportsPage
/admin/settings → SettingsPage
# Customer routes (role: CUSTOMER)
/dashboard → CustomerDashboard
/loans → MyLoansPage
/loans/apply → LoanApplicationPage
/loans/:id → LoanDetailPage
/loans/:id/emi → EmiSchedulePage
/profile → ProfilePage
/documents → DocumentsPage
app/
├── (auth)/
│ ├── login.tsx # Phone + password login
│ ├── register.tsx # Multi-step registration
│ └── otp-verify.tsx # OTP input screen
│
└── (customer)/ # Tab-based layout
├── index.tsx # Home: EMI due, loan status cards
├── loans/
│ ├── index.tsx # My loans list
│ ├── apply.tsx # Loan application form
│ └── [id].tsx # Loan detail + EMI schedule
├── payments/
│ └── index.tsx # Payment history
├── profile.tsx # Profile + KYC documents
└── notifications.tsx # In-app notifications
EmiCalculatorWidget— input principal/rate/tenure → show EMI + scheduleLoanStatusBadge— color-coded: PENDING/ACTIVE/CLOSED/REJECTEDDocumentUploader— drag-drop (web) / picker (mobile) → upload to CloudinaryEmiScheduleTable— show all EMIs with status indicatorsOverdueAlert— banner showing total overdue amount
AdminDashboard— KPI cards + charts (total disbursed, collected, overdue)CustomerTable— searchable, filterable customer listLoanApprovalCard— approve/reject with reason modalRepaymentForm— record cash/UPI payment, auto-generate receiptReportExporter— monthly report → download PDF
HomeScreen— next EMI card, outstanding balance, quick actionsLoanApplicationStepper— 4-step form with progress barEmiCalendarView— calendar with due dates highlighted
Cline must implement all of the following:
- All routes except
/auth/*require valid JWT - Customers can only access their own data (filter by
req.user.id) - File uploads: validate MIME type (images/PDF only), max 5MB
- Rate limiting: 100 requests/15 min per IP on all routes
- Stricter rate limit on
/auth/login: 5 attempts/15 min - All passwords hashed with
bcrypt(12 rounds) - Refresh tokens stored in DB, invalidated on logout
- CORS whitelist: only allow known frontend origins
- Helmet.js enabled with default config
- All env variables validated with Zod at startup
- Receipt numbers: auto-generated, non-guessable (UUID-based)
- Admin seed script with default password force-change on first login
Build in this sequence to avoid dependency issues:
packages/shared— types, Zod schemas, EMI calculator utilityserver/prisma/schema.prisma— full schema + run migrationserver/src/config/— db, redis, cloudinary, env configserver/src/middleware/— auth, role, validate, error handlersserver/src/modules/auth/— register, login, OTP, token refreshserver/src/modules/customers/— CRUD + KYCserver/src/modules/loans/— apply, approve, reject, disburseserver/src/modules/emi/— schedule generation, overdue detectionserver/src/modules/repayments/— record payment, PDF receiptserver/src/modules/reports/— dashboard stats, monthly PDFserver/src/jobs/— cron jobs for reminders + overdue checksapps/web/— auth → admin dashboard → loan management → reportsapps/mobile/— auth → home → loans → EMI view → profile
File: server/prisma/seed.ts
Create:
- 1 admin user (phone from env, hashed password)
- 2 sample customers with complete profiles
- 1 active loan per customer with full EMI schedule
- Sample repayment records
Run with: npx ts-node prisma/seed.ts
| Service | Watch Out For |
|---|---|
| Render | API sleeps after 15min inactivity → use UptimeRobot (free) to ping every 14min |
| Supabase | 500MB DB limit → archive closed loans after 2 years |
| Cloudinary | 25 credits/month → compress images before upload |
| Upstash | 10k commands/day → cache aggressively, don't cache per-request |
| Brevo | 300 emails/day → batch reminders, don't send per EMI individually |
This document was generated for the Cline AI coding agent. All architectural decisions are final. Start with step 1 of the Build Order.