A robust financial transaction management system built with NestJS and MongoDB, providing a scalable and reliable solution for managing digital wallets.
- Framework: NestJS (Node.js)
- Database: MongoDB with Mongoose ODM
- API Documentation: Swagger/OpenAPI
- Validation: class-validator & class-transformer
- Error Handling: Custom error handling with global filters
- Code Quality: ESLint & Prettier
- API Response Interceptors: Custom response formatting
src/
├── common/ # Shared utilities, filters, and interceptors
│ ├── constants/ # Application constants
│ ├── decorators/ # Custom decorators
│ ├── dto/ # Common DTOs
│ ├── errors/ # Error handling
│ ├── filters/ # Global filters
│ ├── interceptors/ # Response interceptors
│ ├── types/ # Common types and interfaces
│ └── utils/ # Utility functions
├── wallet/ # Wallet module
│ ├── domain/ # Domain objects and business logic
│ ├── dto/ # Data transfer objects
│ ├── schemas/ # MongoDB schemas
│ ├── swagger/ # Swagger documentation
│ ├── types/ # Module-specific types
│ ├── wallet.controller.ts
│ ├── wallet.service.ts
│ ├── wallet.repository.ts
│ └── wallet.module.ts
└── app.module.ts # Root application module
- Create and manage digital wallets
- Process credit and debit transactions with atomic operations
- Retrieve transaction history with pagination and sorting
- Export transactions to CSV with streaming support
- Comprehensive error handling and logging
- Retry mechanism for handling concurrent operations
-
Controller Layer (
WalletController
)- Handles HTTP requests
- Input validation using class-validator
- Standardized response format
- Global error handling
-
Service Layer (
WalletService
)- Transaction management with MongoDB sessions
- Retry mechanism for concurrent operations
- Comprehensive logging
-
Repository Layer (
WalletRepository
)- Atomic database operations
- Optimized queries with indexes
- Error handling with custom error types
@Schema({ timestamps: true })
export class Wallet {
@Prop({ type: Types.Decimal128, required: true })
balance: number;
@Prop({ type: String, required: true })
name: string;
@Prop({ type: Date, default: Date.now })
date: Date;
}
@Schema({ timestamps: true })
export class Transaction {
@Prop({ type: Types.ObjectId, required: true, ref: 'Wallet' })
walletId: Types.ObjectId;
@Prop({ type: Types.Decimal128, required: true })
amount: number;
@Prop({ type: Types.Decimal128, required: true })
balance: number;
@Prop({ type: String, required: true })
description: string;
@Prop({ type: Date, default: Date.now })
date: Date;
@Prop({ type: String, enum: TransactionType, required: true })
type: TransactionType;
}
All endpoints use a standardized request/response format with comprehensive validation.
{
"success": true,
"statusCode": number, // HTTP status code (default: 200)
"message": string, // Human-readable message
"data": T | null // Response payload or null for errors
}
{
statusCode: status,
message: error.message,
error: {
code: error.code
}
}
POST /setup
Request:
{
"balance": 100.5612, // Initial balance (max 4 decimal places)
"name": "My Wallet" // Wallet name
}
Response:
{
"success": true,
"statusCode": 201,
"message": "Wallet My Wallet created successfully with initial balance of 100.5612",
"data": {
"id": "507f1f77bcf86cd799439011",
"balance": 100.5612,
"transactionId": "507f1f77bcf86cd799439012",
"name": "My Wallet",
"date": "2024-03-20T10:30:00Z"
}
}
POST /transact/:walletId
Request:
{
"amount": -50.25, // Negative for debit, positive for credit
"description": "Monthly subscription"
}
Response:
{
"success": true,
"statusCode": 200,
"message": "Successfully debited 50.25 from wallet",
"data": {
"balance": 50.3112,
"transactionId": "507f1f77bcf86cd799439013"
}
}
GET /transactions?walletId=507f1f77bcf86cd799439011&skip=0&limit=10&sortBy=date&sortOrder=desc
Response:
{
"success": true,
"statusCode": 200,
"message": "Retrieved 2 transactions",
"data": {
"items": [
{
"id": "507f1f77bcf86cd799439013",
"walletId": "507f1f77bcf86cd799439011",
"amount": -50.25,
"balance": 50.3112,
"description": "Monthly subscription",
"date": "2024-03-20T10:35:00Z",
"type": "DEBIT"
}
],
"metadata": {
"total": 2,
"page": 1,
"limit": 10,
"hasMore": false
}
}
}
GET /wallet/:id
Response:
{
"success": true,
"statusCode": 200,
"message": "Retrieved wallet details for My Wallet",
"data": {
"id": "507f1f77bcf86cd799439011",
"balance": 50.3112,
"name": "My Wallet",
"date": "2024-03-20T10:30:00Z"
}
}
GET /transactions/export?walletId=507f1f77bcf86cd799439011&sortBy=date&sortOrder=desc
Response Headers:
Content-Type: text/csv
Content-Disposition: attachment; filename="transactions.csv"
Response Body (CSV):
Date,Amount,Description,Type,Balance
2024-03-20T10:35:00Z,-50.25,Monthly subscription,DEBIT,50.3112
2024-03-20T10:30:00Z,100.5612,Setup,CREDIT,100.5612
All requests are validated using class-validator:
export class TransactionRequestDTO {
@ApiProperty({ description: 'Transaction amount' })
@IsNumber()
@Transform(({ value }) => {
const num = Number(value);
const decimalStr = num.toString().split('.')[1];
if (decimalStr && decimalStr.length > 4) {
throw GHLError.validation('Amount must have at most 4 decimal places');
}
return num;
})
amount: number;
@ApiProperty({ description: 'Transaction description' })
@IsString()
@IsNotEmpty()
description: string;
}
The system uses MongoDB's Decimal128 type to handle financial calculations with precision up to 4 decimal places.
@Schema({ timestamps: true })
export class Transaction {
@Prop({
type: Types.Decimal128,
required: true,
get: (val: Types.Decimal128) => DecimalUtils.fromDecimal128(val),
set: (val: number) => DecimalUtils.toDecimal128(val)
})
amount: number;
@Prop({
type: Types.Decimal128,
required: true,
get: (val: Types.Decimal128) => DecimalUtils.fromDecimal128(val),
set: (val: number) => DecimalUtils.toDecimal128(val)
})
balance: number;
}
export class DecimalUtils {
static toDecimal128(value: number): Types.Decimal128 {
return Types.Decimal128.fromString(value.toFixed(4));
}
static fromDecimal128(decimal: Types.Decimal128): number {
return Number(decimal.toString());
}
static validatePrecision(value: number, field: string): void {
const decimalStr = value.toString().split('.')[1];
if (decimalStr && decimalStr.length > 4) {
throw GHLError.validation(`${field} must have at most 4 decimal places`);
}
}
}
// Transaction Schema Indexes for efficient querying
TransactionSchema.index({ walletId: 1, date: -1 }); // For date-based sorting
TransactionSchema.index({ walletId: 1, amount: 1 }); // For amount-based sorting
TransactionSchema.index({ walletId: 1 }); // For wallet-based queries
// Wallet Schema Index
WalletSchema.index({ balance: 1 }); // For balance-based operations
Example of atomic debit operation:
const updatedWallet = await this.walletModel.findOneAndUpdate(
{
_id: toObjectId(walletId),
balance: { $gte: debitAmountDecimal }, // Atomic balance check
},
{ $inc: { balance: toDecimal128(-amountToDebit) } }, // Atomic update
{ new: true, session }
);
@WithTransactionRetry(RETRY_DELAYS.MEDIUM)
async processTransaction(walletId: Types.ObjectId, amount: number, description: string) {
// Retry configuration
// maxRetries: 5
// baseDelay: 100ms
// maxDelay: 2000ms
// Retries on specific MongoDB error codes: 112 (WriteConflict), 251 (TransactionAborted)
}
CSV export implementation using streaming:
async exportTransactionsToCSV(walletId: Types.ObjectId, writer: CSVStreamWriter) {
const cursor = await this.getTransactionsCursor(walletId);
writer.writeHeaders(['Date', 'Amount', 'Description', 'Type', 'Balance']);
for await (const doc of cursor) {
writer.writeRow({
Date: doc.date,
Amount: doc.amount,
Description: doc.description,
Type: doc.type,
Balance: doc.balance
});
}
}
@Transform(({ value }) => {
const num = Number(value);
const decimalStr = num.toString().split('.')[1];
if (decimalStr && decimalStr.length > 4) {
throw GHLError.validation('Amount must have at most 4 decimal places');
}
return num;
})
amount: number;
Common error responses:
// Error Types
GHLError.validation('Invalid input') // 400 Bad Request
GHLError.notFound('Resource not found') // 404 Not Found
GHLError.internal('Internal server error') // 500 Internal Server Error
// Error with Metadata
throw GHLError.validation('Insufficient balance', {
required: 100,
available: 50
});
Example error responses:
- Validation Error
{
"success": false,
"statusCode": 400,
"message": "Amount must have at most 4 decimal places",
"error": {
"code": "VALIDATION_ERROR",
"metadata": {
"field": "amount",
"value": "100.12345"
}
}
}
- Not Found Error
{
"success": false,
"statusCode": 404,
"message": "Wallet not found",
"error": {
"code": "NOT_FOUND",
"metadata": {
"id": "507f1f77bcf86cd799439011"
}
}
}
- Compound indexes for efficient querying and sorting
- Atomic operations for concurrent transactions
- MongoDB transactions for data consistency
- Optimistic locking for concurrent updates
- Retry mechanism with exponential backoff
- Transaction isolation with MongoDB sessions
- Cursor-based streaming for large datasets
- Efficient pagination with skip/limit
- Connection pooling for database operations
- Node.js (v14 or higher)
- MongoDB (v4.4 or higher)
- npm or yarn
- Clone the repository
git clone <repository-url>
- Install dependencies
npm install
- Set up environment variables
# .env
MONGODB_URI=mongodb://localhost:27017/wallet
- Run the application
# Development
npm run start:dev
# Production
npm run start:prod