A backend API for a banking ledger system built with Node.js, Express, and MongoDB. It handles user authentication, bank accounts, and money transfers using a proper double-entry bookkeeping model β meaning every transaction creates both a debit and a credit ledger entry, so the books always balance.
Every rupee amount is stored as integer paise internally (1 rupee = 100 paise). This avoids the IEEE 754 floating-point problem where 0.1 + 0.2 !== 0.3 β a real issue that compounds over time in financial systems.
Auth uses a dual-token system: short-lived 15-minute access tokens paired with 7-day refresh tokens. If your access token expires, you hit /auth/refresh β no re-login needed. The refresh token is stored as a SHA-256 hash in MongoDB, so even a DB breach doesn't hand over usable tokens.
bank-ledger/
βββ server.js # Entry point β boots the app, process guards
βββ jest.config.js # Jest test configuration
βββ src/
β βββ app.js # Express, middleware stack, /api/v1/ routes
β βββ config/
β β βββ db.js # MongoDB connection
β β βββ validateEnv.js # Fail-fast env variable validation
β βββ controllers/
β β βββ auth.controller.js # Register, login, refresh, logout
β β βββ account.controller.js # Create & fetch accounts, balance
β β βββ transaction.controller.js # Transfer, history, detail, initial-funds
β βββ middleware/
β β βββ auth.middleware.js # JWT verification + role guard
β β βββ validate.middleware.js # Input validation + rupeesβpaise conversion
β β βββ errorHandler.middleware.js # Central error handler
β β βββ requestId.middleware.js # X-Request-ID correlation headers
β βββ models/
β β βββ user.model.js # User schema + bcrypt hooks
β β βββ account.model.js # Bank account + getBalance() aggregation
β β βββ ledger.model.js # Immutable DEBIT/CREDIT entries (paise)
β β βββ transaction.model.js # Transaction records (paise amounts)
β β βββ refreshToken.model.js # Hashed refresh tokens with 7d TTL
β β βββ blackList.model.js # JWT blacklist (TTL-indexed, 15min)
β β βββ auditLog.model.js # Immutable audit trail (90d TTL)
β βββ routes/
β β βββ auth.routes.js
β β βββ accounts.routes.js
β β βββ transaction.routes.js
β βββ services/
β β βββ email.service.js # Nodemailer with Google OAuth2
β βββ utils/
β βββ asyncHandler.js # Wraps async controllers for error propagation
β βββ audit.js # Fire-and-forget audit log helper
β βββ currency.js # rupeesToPaise / paiseToRupees / formatRupees
β βββ logger.js # Winston structured logger
βββ tests/
βββ unit/
βββ asyncHandler.test.js # 5 tests
βββ currency.test.js # 12 tests (including IEEE 754 edge cases)
βββ validateEnv.test.js # 7 tests
- Runtime: Node.js
- Framework: Express v5
- Database: MongoDB via Mongoose (requires replica set for ACID transactions)
- Auth: Dual-token JWT β 15min access token + 7d refresh token (httpOnly cookies)
- Password hashing: bcrypt (10 rounds)
- Email: Nodemailer with Google OAuth2
- Logging: Winston (structured JSON in prod) + Morgan (HTTP access logs)
- Security: Helmet, CORS, express-rate-limit
- Validation: express-validator
- Testing: Jest + supertest
- Node.js 18+
- A MongoDB instance with replica set enabled (needed for ACID transactions). MongoDB Atlas free tier works perfectly.
- Gmail OAuth2 credentials for emails (optional β server works fine without it)
git clone https://github.com/Suke2004/Bank-Transaction-System.git
cd bank-ledger
npm installcp .env.example .envThen fill in your values. Generate secrets with:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"Note: The server refuses to start if
JWT_SECRETorREFRESH_TOKEN_SECRETare shorter than 32 characters. You need two separate secrets β one for each token type.
npm run devnpm test # run once with coverage
npm run test:watch # watch mode during developmentGET http://localhost:3000/health
Base URL:
http://localhost:3000/api/v1Auth: Protected routes require either:
- A cookie named
token(set automatically on login/register), orAuthorization: Bearer <token>headerAmount fields: Send amounts in rupees (e.g.
100.50). The API stores them as paise internally and returns rupees in all responses. Max 2 decimal places accepted.
POST /api/v1/auth/register
Body:
{
"name": "Harry Potter",
"email": "harry@hogwarts.com",
"password": "Expelliarmus1"
}Response 201:
{
"user": { "_id": "...", "email": "harry@hogwarts.com", "name": "Harry Potter" },
"token": "<15min-access-jwt>"
}Also sets two httpOnly cookies: token (15min) and refreshToken (7 days).
Validation: email must be valid, name 2β100 chars, password min 6 chars with at least one number.
POST /api/v1/auth/login
Body:
{ "email": "harry@hogwarts.com", "password": "Expelliarmus1" }Response 200:
{
"user": { "_id": "...", "email": "harry@hogwarts.com", "name": "Harry Potter" },
"token": "<15min-access-jwt>"
}Sets both token and refreshToken cookies.
Same error message for wrong email vs wrong password β intentional, prevents user enumeration.
POST /api/v1/auth/refresh
No body needed. Reads the refreshToken cookie automatically.
Response 200:
{ "token": "<new-15min-access-jwt>" }Also sets a fresh token cookie. Call this when you get a 401 β no re-login needed.
POST /api/v1/auth/logout
π No body needed.
Blacklists the current access token + deletes the refresh token from DB + clears both cookies.
Response 200:
{ "message": "User logged out successfully" }POST /api/v1/accounts
π Protected. Creates a new INR account. A user can have multiple accounts.
Response 201:
{
"account": {
"_id": "...",
"user": "<userId>",
"status": "ACTIVE",
"currency": "INR",
"createdAt": "..."
}
}GET /api/v1/accounts?page=1&limit=20
π Protected.
| Query param | Default | Max |
|---|---|---|
page |
1 | β |
limit |
20 | 50 |
Response 200:
{
"accounts": [...],
"pagination": { "page": 1, "limit": 20, "total": 3, "pages": 1 }
}GET /api/v1/accounts/balance/:accountId
π Protected. Balance is derived live from the ledger (sum of CREDITs minus DEBITs in paise, returned in rupees). Always accurate.
Response 200:
{
"accountId": "...",
"balance": 100.50,
"currency": "INR"
}POST /api/v1/transaction
π Protected.
Body:
{
"fromAccount": "<accountId>",
"toAccount": "<accountId>",
"amount": 100.50,
"idempotencyKey": "uuid-or-unique-string-per-attempt"
}Response 201:
{
"message": "Transaction completed successfully",
"transaction": {
"_id": "...",
"fromAccount": "...",
"toAccount": "...",
"amount": 100.50,
"status": "COMPLETED",
"idempotencyKey": "..."
}
}About idempotencyKey: Generate a UUID per transfer attempt. If the same key is submitted twice (e.g. network retry), the second request returns the original result β no double-charge.
Validation: both IDs must be valid ObjectIds, amount > 0, max 2 decimal places, fromAccount β toAccount.
GET /api/v1/transaction?page=1&limit=20&status=COMPLETED
π Protected. Returns all transactions where any of your accounts was sender or receiver.
| Query param | Default | Options |
|---|---|---|
page |
1 | β |
limit |
20 (max 50) | β |
status |
all | PENDING, COMPLETED, FAILED, REVERSED |
Response 200:
{
"transactions": [
{
"_id": "...",
"fromAccount": { "_id": "...", "currency": "INR", "status": "ACTIVE" },
"toAccount": { "_id": "...", "currency": "INR", "status": "ACTIVE" },
"amount": 100.50,
"status": "COMPLETED",
"createdAt": "..."
}
],
"pagination": { "page": 1, "limit": 20, "total": 5, "pages": 1 }
}GET /api/v1/transaction/:id
π Protected. Returns 404 if the transaction doesn't involve your account.
Response 200:
{
"transaction": { ... }
}POST /api/v1/transaction/system/initial-funds
π Requires systemUser flag. Admin top-up endpoint.
Body:
{
"toAccount": "<accountId>",
"amount": 10000,
"idempotencyKey": "initial-funding-<userId>"
}GET /health
No auth. Returns server uptime. Used by load balancers and Kubernetes liveness probes.
Response 200:
{ "status": "ok", "timestamp": "2026-06-09T17:19:00.000Z", "uptime": 342.5 }All errors follow this shape:
{ "status": "error", "message": "Human readable message" }Validation errors include field-level detail:
{
"status": "error",
"message": "Validation failed",
"errors": [
{ "field": "amount", "message": "amount must be a positive number greater than 0" }
]
}| Code | Meaning |
|---|---|
200 |
Success |
201 |
Created |
400 |
Bad request (business logic) |
401 |
Not authenticated |
403 |
Authenticated but not authorised |
404 |
Not found |
409 |
Conflict (duplicate key) |
422 |
Validation failed |
429 |
Rate limited |
500 |
Server error |
| Scope | Limit |
|---|---|
| All endpoints | 200 requests / 15 min / IP |
/api/v1/auth/* |
20 requests / 15 min / IP |
- Passwords: bcrypt, 10 rounds, never stored in plain text
- Access tokens: 15-minute JWT in
httpOnly; Secure; SameSite=Strictcookie - Refresh tokens: 7-day random token stored hashed (SHA-256) in MongoDB
- Logged-out tokens: blacklisted + auto-expire via TTL index
- Security headers: Helmet (14 headers including CSP, X-Frame-Options)
- Stack traces: never sent to clients in production
Every sensitive action is written to an immutable auditLog collection:
| Action | Trigger |
|---|---|
USER_REGISTER |
New user created |
USER_LOGIN |
Successful login |
USER_LOGOUT |
Logout called |
TOKEN_REFRESH |
Access token refreshed |
ACCOUNT_CREATED |
New bank account opened |
TRANSACTION_INITIATED |
Transfer started |
TRANSACTION_COMPLETED |
Transfer committed |
TRANSACTION_FAILED |
Transfer rolled back |
INITIAL_FUNDS_ADDED |
System user top-up |
Audit entries store: userId, action, metadata, IP address, user agent, and X-Request-ID for end-to-end tracing. Auto-deleted after 90 days (configurable via AUDIT_LOG_RETENTION_DAYS).
npm test # jest --coverage (23 tests)
npm run test:watch # watch modeTest coverage of utilities (100%):
asyncHandler.jsβ error forwarding, pass-through, error identitycurrency.jsβ paise conversion, IEEE 754 edge cases, formattingvalidateEnv.jsβ missing vars, weak secrets, mocked process.exit
ISC