OnePay is a comprehensive financial inclusion solution targeting underbanked communities. The backend is built with Node.js/Express using TypeScript, following clean architecture principles.
-
Authentication System
- Phone OTP verification
- Session management
- Rate limiting
- Phone number validation
- Error handling
-
User Management & KYC
- Profile management
- Document upload and verification
- Status tracking
- Admin verification system
- Field validation
-
Wallet System
- Balance management
- Transaction history
- Fund transfers
- Multiple currency support
- Transaction categories
-
Budget Management
- Budget creation/tracking
- Expense analytics
- Category-wise tracking
- Time period settings
-
Learning Module
- Course content management
- Progress tracking
- Achievement system
-
Gamification System
- Points/tokens system
- Levels and badges
- Rewards tracking
-
AI/ML Features
- Financial advisor
- Fraud detection
- Smart recommendations
backend/
├── src/
│ ├── config/ # Configuration files
│ ├── controllers/ # Request handlers
│ ├── middleware/ # Custom middleware
│ ├── routes/ # API routes
│ ├── services/ # Business logic
│ ├── utils/ # Helper functions
│ └── types/ # TypeScript types
├── tests/ # Unit & integration tests
└── prisma/ # Database schema & migrations
- Controllers:
name.controller.ts(e.g.,user.controller.ts) - Services: camelCase with 'Service' suffix (e.g.,
auth.service.ts) - Models: camelCase singular (e.g.,
user.model.ts) - Routes: camelCase (e.g.,
user.routes.ts)
-
Clean Architecture
- Clear separation of concerns
- Dependency injection for better testability
- Service layer for business logic
- Controller layer for request handling
-
API Design
- RESTful principles
- Proper error handling
- Input validation
- Rate limiting
- Authentication middleware
-
Security
- OTP-based authentication
- Session management
- Data encryption
- Input sanitization
- Rate limiting
-
Database
- Using Prisma ORM
- Clear schema design
- Proper indexing
- Transaction support
-
Testing
- Unit tests
- Integration tests
- Test coverage tracking
- Always follow TypeScript best practices
- Write comprehensive tests for new features
- Use proper error handling and logging
- Document new APIs and changes
- Follow existing naming conventions
- Never commit sensitive data
- Use environment variables for configuration
- Node.js environment
- PostgreSQL database
- Redis for caching
- Environment variables in
.env - Test environment in
.env.test
- Generate Prisma Client
npx prisma generate- Create and Apply Migrations
# Create a new migration
npx prisma migrate dev --name add_wallet_features
# Apply migrations to production
npx prisma migrate deploy- Reset Database (Development Only)
npx prisma migrate reset- View Database in Prisma Studio
npx prisma studio- Create migration for new wallet features:
npx prisma migrate dev --name add_wallet_featuresThis will:
- Add account number to wallets
- Add UPI ID support
- Create bank account model
- Update transaction model
- Create necessary indexes
- If migration fails:
# Reset the database (WARNING: This will delete all data)
npx prisma migrate reset
# Then create a fresh migration
npx prisma migrate dev --name add_wallet_features- After migration, update the Prisma Client:
npx prisma generate- Regular Backups
# Using pg_dump (PostgreSQL)
pg_dump -U username -d database_name > backup.sql- Database Optimization
-- Analyze tables
ANALYZE wallets;
ANALYZE transactions;
ANALYZE bank_accounts;
-- Update statistics
VACUUM ANALYZE;-
POST /api/wallet- Create a new wallet
- Requires PIN and optional limits
- Returns wallet details
-
GET /api/wallet/balance- Quick balance check
- Returns basic wallet info and user name
- Perfect for balance widgets and mini-views
- Response includes:
{ id: string; balance: number; currency: string; dailyLimit: number; monthlyLimit: number; isActive: boolean; isBlocked: boolean; blockedUntil?: Date; firstName: string; lastName: string; }
-
GET /api/wallet/stats- Get wallet statistics
- Returns monthly income/expenses
- Recent transactions
- Full user profile
- QR code for receiving money
-
POST /api/wallet/transfer- Transfer money between wallets
- Requires PIN verification
- Validates balance and limits
-
Storage
- PINs are hashed using bcrypt
- Failed attempts are tracked
- Temporary blocking after multiple failures
-
QR Code Security
- QR codes are regenerated on each request
- Data is base64 encoded
- Only essential information is included
- QR codes are tied to specific wallets
-
Digital Wallet
- Unique account number for each wallet
- UPI ID support (format: user@onepay)
- PIN-based security with attempt limits
- Daily and monthly transaction limits
- Multi-currency support (default: INR)
- Real-time balance tracking
-
Transaction System
- 12-character unique transaction ID
- UPI transaction reference tracking
- Multiple transaction types
- Bank transfer support
- Real-time status updates
- Transaction history with metadata
model Wallet {
id String @id @default(uuid())
userId String @unique
accountNumber String @unique @default(uuid())
upiId String? @unique
balance Float @default(0)
pin String // Hashed using bcrypt
pinAttempts Int @default(0)
isBlocked Boolean @default(false)
blockedUntil DateTime?
dailyLimit Float @default(10000)
monthlyLimit Float @default(100000)
currency String @default("INR")
type String @default("SAVINGS")
isActive Boolean @default(true)
// Relations
user User @relation(fields: [userId], references: [id])
transactions Transaction[]
}
model Transaction {
id String @id @default(uuid())
transactionId String @unique // Generated 12-char unique ID
type TransactionType
amount Float
description String?
status TransactionStatus @default(PENDING)
metadata Json?
// Relations
walletId String
senderWalletId String?
receiverWalletId String?
wallet Wallet @relation(fields: [walletId], references: [id])
senderWallet Wallet? @relation("SenderWallet")
receiverWallet Wallet? @relation("ReceiverWallet")
}enum WalletType {
SAVINGS = 'SAVINGS', // Default personal wallet
CURRENT = 'CURRENT', // Business transactions
BUSINESS = 'BUSINESS' // Enterprise accounts
}
interface WalletStats {
id: string;
balance: number;
currency: string;
type: WalletType;
isActive: boolean;
blockedUntil?: Date;
dailyLimit: number;
monthlyLimit: number;
monthlyIncome: number;
monthlyExpenses: number;
recentTransactions: RecentTransaction[];
user: {
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
kycStatus: KYCStatus;
};
qrCodeData: string;
}interface TransactionDTO {
amount: number;
type: TransactionType;
description?: string;
metadata?: Record<string, any>;
}
interface TransferDTO extends TransactionDTO {
toWalletId: string;
pin: string;
}Added a lightweight endpoint for quick balance checks:
- Path:
GET /api/wallet/balance - Returns basic wallet info and user details
- Optimized for frequent balance checks
- No rate limiting for better UX
Response Format:
{
success: true,
data: {
id: string;
balance: number;
currency: string;
dailyLimit: number;
monthlyLimit: number;
isActive: boolean;
isBlocked: boolean;
blockedUntil?: Date;
firstName: string;
lastName: string;
}
}- Fixed nullable date handling in wallet responses
- Improved type definitions for wallet status fields
- Added proper null checks for optional fields
- Enhanced TypeScript type safety for API responses
- Removed BankAccount model and BankAccountType enum from schema
- Removed bank account related endpoints from wallet routes
- Removed bank account related methods from wallet controller
- Removed bank account related types and interfaces
- Simplified the codebase to focus on core wallet functionality
The wallet system now focuses on essential functionality:
-
Wallet Creation
- Secure PIN-based authentication
- Configurable daily and monthly limits
- Multiple currency support
-
Wallet Operations
- Get wallet details and balance
- View transaction history
- Track monthly income and expenses
- Generate QR codes for easy transfers
-
Money Transfers
- Direct wallet-to-wallet transfers
- PIN verification for security
- Real-time balance updates
- Atomic transactions with rollback support
-
Security Features
- PIN-based authentication
- Transaction limits enforcement
- Account blocking on suspicious activity
- QR code regeneration for each request
Get user profile with wallet info and QR code
Response:
{
success: true,
data: {
id: string;
balance: number;
currency: string;
type: WalletType;
isActive: boolean;
blockedUntil?: Date;
dailyLimit: number;
monthlyLimit: number;
monthlyIncome: number;
monthlyExpenses: number;
recentTransactions: Array<{
id: string;
type: TransactionType;
amount: number;
status: TransactionStatus;
createdAt: Date;
}>;
user: {
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
kycStatus: KYCStatus;
};
qrCodeData: string; // Base64 encoded QR code data
}
}Update user profile (email only)
Request:
{
email: string;
}
Response:
{
success: true,
data: {
email: string;
}
}The QR code system is implemented in the backend for several reasons:
- Security: Control over what data is exposed
- Consistency: Ensure all clients generate the same format
- Simplicity: Reduce client-side dependencies
- Maintainability: Easy to update QR format across all clients
{
walletId: string;
userId: string;
name: string;
type: 'ONEPAY_WALLET';
}// React Native example
import QRCode from 'react-native-qrcode-svg';
const ProfileQRCode = () => {
const { data } = useProfile();
return (
<QRCode
value={data.qrCodeData}
size={200}
level="H" // High error correction
/>
);
};-
Profile Updates
- Email updates require uniqueness validation
- Other profile fields are read-only or managed through specific endpoints
- KYC status is managed through the KYC process
-
QR Code Security
- QR codes are regenerated on each request
- Data is base64 encoded
- Only essential information is included
- QR codes are tied to specific wallets
-
Rate Limiting
- Profile endpoints use general rate limiting
- QR code generation is included in rate limits
- Prevents abuse and DoS attacks
-
Error Handling
- Proper validation of email format
- Clear error messages for invalid data
- Appropriate HTTP status codes
- All wallet routes require authentication via JWT token
- Wallet operations are restricted to the wallet owner
- PIN verification for sensitive operations (transfers, limit updates)
// Amount validation
amount: z.number()
.min(1, 'Amount must be at least 1')
.max(1000000, 'Amount must not exceed 1000000')
// Currency validation
currency: z.enum(['INR', 'USD'])
// Wallet type validation
type: z.enum(['SAVINGS', 'CURRENT', 'BUSINESS'])
// Payment method validation
paymentMethod: z.enum(['UPI', 'CARD', 'NETBANKING'])// Implement rate limiting for sensitive operations
app.use('/api/wallets/:walletId/transfer', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10 // limit each IP to 10 requests per windowMs
}));- All transactions are atomic using Prisma transactions
- Proper error handling and rollback
- Transaction limits (daily/monthly) enforcement
- Transaction logging for audit trails
Create a new wallet
Request:
{
"type": "SAVINGS" | "CURRENT" | "BUSINESS",
"currency": "INR" | "USD",
"pin": "string" // 4-6 digits
}
Response:
{
"success": true,
"data": {
"id": "uuid",
"balance": number,
"currency": string,
"dailyLimit": number,
"monthlyLimit": number,
"isActive": boolean
}
}Transfer money between wallets
Request:
{
"amount": number,
"toWalletId": "uuid",
"description": string,
"pin": "string"
}
Response:
{
"success": true,
"message": "Transfer completed successfully"
}-
Authentication
- JWT token validation
- Token expiration handling
- Refresh token mechanism
-
Authorization
- Wallet ownership verification
- PIN verification for sensitive operations
- Role-based access control
-
Input Validation
- Amount limits
- Currency validation
- Payment method validation
- Description length limits
-
Rate Limiting
- API endpoint rate limits
- Failed attempt tracking
- IP-based blocking
-
Transaction Security
- Atomic transactions
- Balance verification
- Limit enforcement
- Audit logging
-
Error Handling
- Proper error codes
- Informative messages
- Security error masking
- Transaction rollback
-
Data Protection
- PIN hashing
- Sensitive data masking
- No sensitive data in logs
-
PIN Management
- Store hashed PINs only
- Implement PIN attempt limits
- Temporary blocking after failed attempts
-
Transaction Processing
- Verify sufficient balance
- Check transaction limits
- Validate receiver wallet
- Generate unique transaction IDs
- Maintain detailed transaction logs
-
Error Handling
- Use appropriate HTTP status codes
- Provide clear error messages
- Log detailed errors internally
- Return sanitized errors to clients
-
Performance
- Index frequently queried fields
- Optimize database queries
- Implement caching where appropriate
- Use proper database transactions
-
PIN Protection
- Encrypted PIN storage
- Maximum attempt limits
- Temporary blocking on multiple failures
- PIN reset functionality
-
Transaction Security
- OTP verification for large transactions
- Daily and monthly limits
- Fraud detection system
- Real-time monitoring
-
Bank Account Security
- Two-factor verification for linking
- Penny-drop verification
- IFSC code validation
- Primary account protection
-
Creation
- Generated when user requests verification
- 6-digit numeric code
- Stored with expiration time (10 minutes)
- Associated with user's phone number
-
Verification
- User submits OTP code
- System checks validity and expiration
- On successful verification:
- OTP marked as used and deleted
- User marked as verified
- JWT token generated
-
Cleanup
- Automatic deletion after successful verification
- Scheduled cleanup of expired OTPs (10-minute window)
- Background job removes unused and expired OTPs
- Rate limiting on OTP generation
- Maximum retry attempts
- Expiration time enforcement
- Automatic cleanup of used/expired codes
-
Get Pending KYC Applications
GET /api/admin/kyc/pendingReturns list of pending KYC applications with user details
-
Approve KYC Application
POST /api/admin/kyc/:kycId/approveMarks KYC as approved and updates user verification status
-
Reject KYC Application
POST /api/admin/kyc/:kycId/rejectMarks KYC as rejected with mandatory remarks
interface KYCApplication {
id: string;
userId: string;
panNumber: string;
dateOfBirth: Date;
panCardPath: string;
status: KYCStatus;
remarks?: string;
verifiedAt?: Date;
createdAt: Date;
user: {
firstName: string;
lastName: string;
phoneNumber: string;
}
}
enum KYCStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED'
}-
Review Application
- View submitted documents
- Verify PAN card details
- Check user information
- Review previous applications
-
Approve Application
- Update KYC status to APPROVED
- Set verification timestamp
- Optional remarks
- Trigger user notification
-
Reject Application
- Update KYC status to REJECTED
- Mandatory rejection remarks
- Trigger user notification
- Allow resubmission after fixes
POST /api/auth/admin/loginRequest body:
{
"phoneNumber": "9999999999",
"password": "Admin@123"
}Response:
{
"success": true,
"data": {
"token": "jwt_token_here",
"user": {
"id": "user_id",
"phoneNumber": "9999999999",
"email": "admin@onepay.dev",
"firstName": "Admin",
"lastName": "User",
"role": "ADMIN",
"isVerified": true
}
}
}- Minimum 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one number
- At least one special character
- Password is hashed using bcrypt
- Failed login attempts are logged
- Only active admin accounts can login
- JWT token expires in 24 hours
- Role-based access control (RBAC)
-
Learning Module Integration
- Financial literacy courses
- Progress tracking
- Achievement rewards
-
Gamification System
- Points for transactions
- Badges for milestones
- Rewards tracking
-
AI/ML Features
- Smart spending analysis
- Fraud detection
- Personalized recommendations
The system supports three types of wallets:
enum WalletType {
SAVINGS = 'SAVINGS', // Default personal wallet
CURRENT = 'CURRENT', // Business transactions
BUSINESS = 'BUSINESS' // Enterprise accounts
}
interface WalletStats {
id: string;
balance: number;
currency: string;
type: WalletType;
isActive: boolean;
blockedUntil?: Date;
dailyLimit: number;
monthlyLimit: number;
monthlyIncome: number;
monthlyExpenses: number;
recentTransactions: RecentTransaction[];
user: {
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
kycStatus: KYCStatus;
};
qrCodeData: string;
}- Format: 12-character alphanumeric (e.g., "AB12CD34EF56")
- Generated using nanoid for uniqueness
- Case-sensitive and collision-resistant
- Format: 16-digit numeric (e.g., "1234567890123456")
- Generated using nanoid with numeric-only charset
- Unique across the system
-
Validation
- Verify sender's wallet exists and is active
- Check PIN validity
- Validate sufficient balance
- Check daily/monthly limits
-
Transaction Flow
interface TransferDTO { toWalletId: string; // Recipient's wallet ID amount: number; // Transfer amount description?: string; // Optional description pin: string; // Sender's PIN for verification }
-
Security Measures
- PIN verification before each transfer
- Rate limiting on transfer attempts
- Transaction logging
- Automatic blocking after multiple failed attempts
-
Error Handling
- Insufficient balance
- Invalid PIN
- Daily/monthly limit exceeded
- Blocked wallet
- Invalid recipient
-
PIN Management
- Store hashed PINs only (using bcrypt)
- Implement PIN attempt limits
- Temporary blocking after failed attempts
-
Transaction Security
- Use unique transaction IDs
- Implement proper rollback mechanisms
- Log all transaction attempts
- Rate limit transfer requests
-
Data Validation
- Validate all input data
- Check balance before transfers
- Verify wallet status
- Validate transfer limits
-
Performance
- Index frequently queried fields
- Optimize transaction queries
- Cache wallet statistics
- Use proper database transactions
model Wallet {
id String @id @default(uuid())
userId String @unique
accountNumber String @unique @default(uuid())
upiId String? @unique
balance Float @default(0)
pin String // Hashed using bcrypt
pinAttempts Int @default(0)
isBlocked Boolean @default(false)
blockedUntil DateTime?
dailyLimit Float @default(10000)
monthlyLimit Float @default(100000)
currency String @default("INR")
type String @default("SAVINGS")
isActive Boolean @default(true)
// Relations
user User @relation(fields: [userId], references: [id])
transactions Transaction[]
}model Transaction {
id String @id @default(uuid())
transactionId String @unique // Generated 12-char unique ID
type TransactionType
amount Float
description String?
status TransactionStatus @default(PENDING)
metadata Json?
// Relations
walletId String
senderWalletId String?
receiverWalletId String?
wallet Wallet @relation(fields: [walletId], references: [id])
senderWallet Wallet? @relation("SenderWallet")
receiverWallet Wallet? @relation("ReceiverWallet")
}enum WalletType {
SAVINGS = 'SAVINGS', // Default personal wallet
CURRENT = 'CURRENT', // Business transactions
BUSINESS = 'BUSINESS' // Enterprise accounts
}
interface WalletStats {
id: string;
balance: number;
currency: string;
type: WalletType;
isActive: boolean;
blockedUntil?: Date;
dailyLimit: number;
monthlyLimit: number;
monthlyIncome: number;
monthlyExpenses: number;
recentTransactions: RecentTransaction[];
user: {
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
kycStatus: KYCStatus;
};
qrCodeData: string;
}interface TransactionDTO {
amount: number;
type: TransactionType;
description?: string;
metadata?: Record<string, any>;
}
interface TransferDTO extends TransactionDTO {
toWalletId: string;
pin: string;
}POST /api/wallets/transfer
Body: {
toWalletId: string;
amount: number;
description?: string;
pin: string;
}GET /api/wallets/stats
Response: {
success: true,
data: WalletStats;
}-
PIN Management
- PINs are now hashed using bcrypt
- PIN attempts are tracked
- Automatic blocking after multiple failed attempts
-
Transaction Security
- All transfers are executed in database transactions
- Proper balance validation before transfers
- Transaction IDs are unique and randomly generated
-
Type Safety
- Strict TypeScript types for all operations
- Runtime validation using Zod
- Proper error handling with custom ApiError class
New Endpoint:
POST /api/auth/otp/generateLegacy Endpoint (for backward compatibility):
POST /api/auth/generate-otpRequest body:
{
"phoneNumber": "+919876543210"
}Response:
{
"success": true,
"data": {
"phoneNumber": "+919876543210"
},
"message": "Verification code request initiated"
}New Endpoint:
POST /api/auth/otp/verifyLegacy Endpoint (for backward compatibility):
POST /api/auth/verify-otpRequest body:
{
"phoneNumber": "+919876543210",
"code": "123456"
}Response:
{
"success": true,
"data": {
"token": "jwt_token_here",
"user": {
"id": "user_id",
"phoneNumber": "+919876543210",
"email": null,
"firstName": "",
"lastName": "",
"role": "USER",
"isVerified": false
}
}
}Note: Both new and legacy endpoints provide identical functionality. The legacy endpoints are maintained for backward compatibility with existing mobile apps.
/auth/otp/generate- Generate OTP for phone number/auth/otp/verify- Verify OTP and return user token- Legacy routes maintained for backward compatibility:
/auth/generate-otp/auth/verify-otp
- authController
- generateOTP: Handles OTP generation and rate limiting
- verifyOTP: Validates OTP and creates/updates user
- adminLogin: Separate flow for admin authentication
- asyncHandler wrapper for consistent error handling
- Specific error messages for different failure cases
- Proper HTTP status codes for different scenarios
- Users created/updated during OTP verification
- Stores phone number and verification status
- Token generated upon successful verification
- KYC record must be created first with PAN number (via profile update)
- Age can only be updated after KYC record exists
- Date of birth is calculated from age:
- Uses July 1st as the birth date
- Year is calculated as: currentYear - age
- Valid age range: 18-60 years
- 400 Error if trying to update age before KYC record exists
- 500 Error for database operations or invalid date calculations
- All errors include descriptive messages for debugging
- Always check for nullable fields before access:
if (!user?.kyc) {
throw new ApiError(400, 'Required data missing');
}- Add runtime checks even after TypeScript checks:
// TypeScript might know it exists from previous checks
// but add runtime check for safety
if (!data.someField) {
throw new ApiError(500, 'Expected data missing');
}- Use type guards and assertions judiciously:
if (error instanceof ApiError) {
// Handle ApiError specifically
} else {
// Handle unknown errors
}- Check existence before updates:
const existing = await prisma.model.findUnique({
where: { id },
include: { relations: true },
});
if (!existing) {
throw new ApiError(404, 'Record not found');
}- Validate related records:
if (!existing.relation) {
throw new ApiError(400, 'Related record required');
}- Use transactions for multi-step operations:
await prisma.$transaction(async (tx) => {
// Multiple database operations
});When using ES Modules in Vercel deployments:
- Use dynamic imports for ESM-only packages:
// Instead of static import
import { something } from 'esm-package';
// Use dynamic import
const { something } = await import('esm-package');- For frequently used ESM modules, initialize once and cache:
let cachedFunction: Function;
const initializeFunction = async () => {
const { something } = await import('esm-package');
cachedFunction = something;
};
// Initialize on startup
initializeFunction().catch(console.error);
export const useFunction = async () => {
if (!cachedFunction) {
await initializeFunction();
}
return cachedFunction();
};- Update tsconfig.json settings if needed:
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
}
}- nanoid
- chalk (v5+)
- execa (v6+)
- strip-ansi (v7+)
Always check the package documentation for ESM compatibility.
- Rate limiting for OTP generation (60 seconds)
- Phone number format validation
- Secure token generation
- Admin authentication separate from regular users