A production-grade REST API backend that simulates a real-world banking transaction system. Built with Node.js, Express.js and MongoDB Atlas using CommonJS (require/module.exports) throughout.
- Project Overview
- Tech Stack
- Project Structure
- Environment Variables
- Installation & Setup
- Database Setup
- Email Setup
- Data Models
- Middleware
- Controllers
- Services
- API Reference
- Banking Concepts Implemented
- Development Progress
- Common Errors & Fixes
BankFlow Pro is a complete advanced backend system that implements real-world banking concepts from scratch:
- Secure JWT authentication stored in httpOnly cookies
- MongoDB Atlas cloud database with Mongoose ODM
- Double-entry ledger bookkeeping — balance is never stored, always derived
- Idempotent transaction processing — prevents duplicate charges on retry
- Real-time balance calculation via MongoDB Aggregation Pipeline
- System user with special privileges for initial account funding
- JWT blacklisting for secure logout
- Transactional email notifications via Nodemailer Gmail OAuth2
- Full account lifecycle management (ACTIVE / INACTIVE / FROZEN)
| Category | Technology | Version |
|---|---|---|
| Runtime | Node.js | v18+ |
| Framework | Express.js | v5.2.1 |
| Database | MongoDB Atlas | Cloud M0 |
| ODM | Mongoose | v9.3.0 |
| Authentication | jsonwebtoken | v9.0.3 |
| Password Hashing | bcryptjs | v3.0.3 |
| Email Service | Nodemailer + Gmail OAuth2 | v8.0.3 |
| Cookie Handling | cookie-parser | v1.4.7 |
| Environment Config | dotenv | v17.3.1 |
| Dev Server | nodemon | latest |
| IDE | VS Code + Cursor AI | latest |
bankflow-pro/
├── src/
│ ├── config/
│ │ └── db.js ✅ MongoDB Atlas connection
│ ├── controllers/
│ │ ├── authController.js ✅ Register, Login, Logout
│ │ ├── accountController.js ✅ Create, Read, Status, Balance
│ │ └── transactionController.js ✅ Transfer, Initial Funds, Ledger
│ ├── middleware/
│ │ ├── authMiddleware.js ✅ JWT verify + blacklist check
│ │ └── authSystemMiddleware.js ✅ System user privilege check
│ ├── models/
│ │ ├── User.js ✅ User schema + bcrypt + systemUser
│ │ ├── Account.js ✅ Bank account schema + indexes
│ │ ├── Transaction.js ✅ Transaction schema + indexes
│ │ ├── Ledger.js ✅ Immutable ledger + indexes
│ │ └── Blacklist.js ✅ JWT blacklist + TTL index
│ ├── routes/
│ │ ├── authRoutes.js ✅ /api/v1/auth
│ │ ├── accountRoutes.js ✅ /api/v1/accounts
│ │ └── transactionRoutes.js ✅ /api/v1/transactions
│ └── services/
│ └── email.service.js ✅ Nodemailer OAuth2 + email functions
├── .env ✅ Secret environment variables
├── .env.example ✅ Template with empty values
├── .gitignore ✅ node_modules, .env, *.log
├── app.js ✅ Express app + middleware + routes
├── package.json ✅ Dependencies + scripts
├── README.md ✅ This file
└── server.js ✅ Entry point — DB connect + listen
Create .env in the root folder. Use .env.example as a template.
# Server
PORT=3000
NODE_ENV=development
# MongoDB Atlas
MONGO_URI=mongodb+srv://<username>:<password>@cluster.mongodb.net/bankflow
# JSON Web Token
JWT_SECRET=your_super_secret_jwt_key_here
JWT_EXPIRES_IN=7d
# Nodemailer Gmail OAuth2
EMAIL_USER=your_gmail@gmail.com
CLIENT_ID=your_google_client_id
CLIENT_SECRET=your_google_client_secret
REFRESH_TOKEN=your_oauth2_refresh_tokenNever commit
.envto GitHub — it is listed in.gitignore
# Step 1 — Install all dependencies
npm install express mongoose dotenv bcryptjs jsonwebtoken cookie-parser nodemailer
# Step 2 — Install dev dependency
npm install --save-dev nodemon
# Step 3 — Create .env from template
cp .env.example .env
# Fill in all real values in .env
# Step 4 — Start development server
npm run dev
# Step 5 — Start production server
npm startExpected console output on every start:
MongoDB Connected Successfully: cluster0.xxxxx.mongodb.net
Email server is ready to send messages
Server is running on port 3000
- Go to https://cloud.mongodb.com and create a free account
- Create a new project → name it
bankflow-pro - Create a free cluster → choose
M0 Sandbox - Set a database username and strong password → save them
- Network Access → Add IP Address →
0.0.0.0/0(allow from anywhere) - Connect → Drivers → Copy the connection string
- Paste into
.envasMONGO_URI— replace<password>with real password
- Download from https://www.mongodb.com/products/compass
- Paste your
MONGO_URIto connect - Use it to inspect all collections: users, accounts, transactions, ledgers, blacklists
- Useful for verifying data during development and testing
After the system is running, register a system user then manually
set systemUser: true in MongoDB Compass shell:
db.users.updateOne(
{ email: "system@bankflow.com" },
{ $set: { systemUser: true } }
)This user gets exclusive access to the initial funds deposit route.
The systemUser field is immutable — it cannot be changed via the API.
BankFlow Pro uses Nodemailer with Gmail OAuth2 — more secure than a plain Gmail password because it uses rotating access tokens.
- Go to https://console.cloud.google.com
- Create a new project → name it
bankflow-pro - Go to APIs & Services → Enable APIs → search
Gmail API→ Enable
- APIs & Services → Credentials → Create Credentials → OAuth Client ID
- Application type → Web Application
- Authorized Redirect URIs → add:
https://developers.google.com/oauthplayground - Copy
CLIENT_IDandCLIENT_SECRET
- Go to https://developers.google.com/oauthplayground
- Click gear icon (Settings) → check "Use your own OAuth credentials"
- Enter your
CLIENT_IDandCLIENT_SECRET - In Step 1 — select
https://mail.google.com/from Gmail API - Click Authorize APIs → login with your Gmail
- In Step 2 — click Exchange authorization code for tokens
- Copy the
REFRESH_TOKENvalue
EMAIL_USER=your_gmail@gmail.com
CLIENT_ID=paste_client_id_here
CLIENT_SECRET=paste_client_secret_here
REFRESH_TOKEN=paste_refresh_token_here| Function | Trigger | Recipients | Content |
|---|---|---|---|
sendRegistrationEmail |
After register | New user | Welcome message with full name |
sendTransactionEmail (DEBIT) |
After transfer completes | Sender | Amount sent, TXN ID, description |
sendTransactionEmail (CREDIT) |
After transfer completes | Receiver | Amount received, TXN ID, description |
sendTransactionEmail (CREDIT) |
After initial deposit | Account owner | Deposit amount, TXN ID |
Stores registered users. Password is hashed using bcrypt with 12 salt
rounds before saving. The systemUser flag grants access to system-only
routes and is hidden from all API responses.
| Field | Type | Required | Notes |
|---|---|---|---|
_id |
ObjectId | auto | Primary key |
firstName |
String | yes | min 2, max 50 chars, trimmed |
lastName |
String | yes | min 2, max 50 chars, trimmed |
email |
String | yes | unique, lowercase, regex validated |
password |
String | yes | bcrypt hashed, select: false |
isActive |
Boolean | no | default: true |
systemUser |
Boolean | no | default: false, select: false, immutable |
createdAt |
Date | auto | from timestamps |
updatedAt |
Date | auto | from timestamps |
Virtual field: fullName = firstName + " " + lastName (not stored in DB)
Schema methods:
pre('save')— auto-hashes password when created or changed, skips if not modifiedcomparePassword(candidatePassword)— usesbcrypt.compare(), returns true/false
Email regex used:
/^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/
Represents a bank account belonging to a user. One user can have multiple accounts of different types. Balance is never stored here — it is always derived from the Ledger collection.
| Field | Type | Required | Notes |
|---|---|---|---|
_id |
ObjectId | auto | Primary key |
userId |
ObjectId | yes | ref: User |
accountNumber |
String | auto | unique, format: ACC-XXXXXXXX |
accountType |
String | yes | SAVINGS, CURRENT, FIXED_DEPOSIT |
status |
String | no | ACTIVE, INACTIVE, FROZEN — default: ACTIVE |
currency |
String | no | default: USD, stored uppercase |
description |
String | no | max 255 chars |
createdAt |
Date | auto | from timestamps |
updatedAt |
Date | auto | from timestamps |
Indexes:
userId— single index for fast user-account lookupsstatus— single index for status filtering{ userId: 1, status: 1 }— compound index for queries like "get all ACTIVE accounts for this user" (used on every transaction)
Records every financial event — transfers between users and system
deposits. idempotencyKey is the core safety mechanism that prevents
duplicate transactions on client retries.
| Field | Type | Required | Notes |
|---|---|---|---|
_id |
ObjectId | auto | Primary key |
transactionId |
String | yes | unique, format: TXN-XXXXXXXX |
idempotencyKey |
String | yes | unique UUID from client — prevents duplicates |
senderAccountId |
ObjectId | no | ref: Account — null for system DEPOSIT |
receiverAccountId |
ObjectId | yes | ref: Account |
amount |
Number | yes | min: 0.01 |
currency |
String | yes | default: USD, uppercase |
type |
String | yes | TRANSFER, DEPOSIT, WITHDRAWAL |
status |
String | yes | PENDING → COMPLETED or FAILED |
description |
String | no | max 255 chars |
metadata |
Map | no | flexible key-value store |
failureReason |
String | no | populated only when status = FAILED |
completedAt |
Date | no | timestamp when money actually moved |
createdAt |
Date | auto | from timestamps |
updatedAt |
Date | auto | from timestamps |
Indexes:
- Single:
senderAccountId,receiverAccountId,status,createdAt - Compound:
(senderAccountId + status)— fast pending transaction lookups - Compound:
(receiverAccountId + status)— fast received transaction lookups - Compound:
(createdAt DESC + status)— fast recent transaction history
Transaction status flow:
PENDING → COMPLETED (normal success)
PENDING → FAILED (ledger error or validation failure)
The financial heart of the system. Every transaction creates immutable
ledger entries. Balance is always derived by summing these entries —
never stored directly on the account. All fields are marked immutable
and pre-save hooks block any update or delete operations.
| Field | Type | Required | Notes |
|---|---|---|---|
_id |
ObjectId | auto | Primary key |
transactionId |
ObjectId | yes | ref: Transaction, immutable |
accountId |
ObjectId | yes | ref: Account, immutable |
entryType |
String | yes | DEBIT or CREDIT only, immutable |
amount |
Number | yes | min: 0.01, immutable |
currency |
String | yes | default: USD, immutable |
balanceAfter |
Number | yes | account balance after this entry, immutable |
runningBalance |
Number | yes | same as balanceAfter — full audit trail |
description |
String | no | max 255 chars, immutable |
createdAt |
Date | auto | from timestamps |
updatedAt |
Date | auto | from timestamps |
Immutability enforcement — these hooks throw an error if triggered:
pre('findOneAndUpdate'), pre('updateOne'), pre('deleteOne'),
pre('remove'), pre('deleteMany'), pre('findOneAndDelete'),
pre('findOneAndReplace')
Indexes:
- Single:
accountId,transactionId,entryType,createdAt - Compound:
(accountId + createdAt DESC)— transaction history newest first - Compound:
(accountId + entryType)— fast DEBIT/CREDIT separation - Compound:
(transactionId + accountId)— find both entries for one transaction
Double-entry rule for TRANSFER:
Every transfer = exactly 2 ledger entries:
Entry 1 → senderAccount → DEBIT (money leaves)
Entry 2 → receiverAccount → CREDIT (money arrives)
System DEPOSIT exception:
System initial deposit = 1 ledger entry only:
Entry 1 → targetAccount → CREDIT (money injected by system)
senderAccountId is null — no account to debit from
Stores invalidated JWT tokens after logout. The authMiddleware
checks this collection on every protected request. A MongoDB TTL
index automatically deletes expired entries — no manual cleanup needed.
| Field | Type | Required | Notes |
|---|---|---|---|
_id |
ObjectId | auto | Primary key |
token |
String | yes | full JWT string, unique |
userId |
ObjectId | yes | ref: User — audit trail |
createdAt |
Date | no | default: Date.now |
expiresAt |
Date | yes | JWT natural expiry — used by TTL index |
TTL Index:
blacklistSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 })MongoDB automatically deletes the document when expiresAt is reached.
After a JWT naturally expires it cannot be used regardless, so keeping
it in the blacklist beyond expiry wastes space. This index prevents that.
Used on all routes that require a logged-in customer.
How it works step by step:
- Extracts JWT from
req.cookies.bankflow_token— falls back toAuthorization: Bearer <token>header - Verifies JWT signature using
JWT_SECRET— rejects if expired or tampered - Checks if token exists in Blacklist collection — rejects if user already logged out
- Finds user by
decoded.id— rejects if user deleted - Attaches
userobject toreq.user— available in all downstream controllers - Calls
next()to proceed to the route handler
Usage:
const { authMiddleware: protect } = require('../middleware/authMiddleware');
router.get('/', protect, getAllAccounts);Rejects with 401 if: no token, invalid token, blacklisted token, user not found
Used exclusively on system-internal routes like initial funds deposit. Regular users with valid JWTs are still blocked by this middleware.
How it works step by step:
- Extracts and verifies JWT same as authMiddleware
- Finds user using
.select('+systemUser')— required becausesystemUserhasselect: falsein schema - Checks
user.systemUser === true— rejects with403if false - Attaches user to
req.userand callsnext()
Usage:
const { authSystemMiddleware } = require('../middleware/authSystemMiddleware');
router.post('/system/initial-funds', authSystemMiddleware, initialFunds);Rejects with 401 if: no token, invalid token
Rejects with 403 if: valid token but not a system user
Creates a new user account. Validates all required fields, checks for duplicate emails, hashes password via the User model pre-save hook, generates a JWT, sets it as an httpOnly cookie, sends a welcome email, and returns the user object without the password.
Key steps:
- Validate
firstName,lastName,email,passwordall present - Check if email already exists in users collection
User.create()— password is auto-hashed by pre-save hookjwt.sign({ id: user._id })withJWT_SECRETandJWT_EXPIRES_INres.cookie('bankflow_token', token, { httpOnly: true, ... })emailService.sendRegistrationEmail(user.email, user.fullName)- Return
201with token and user object (no password)
Authenticates an existing user. Uses .select('+password') to
explicitly fetch the password hash (hidden by default), compares
using bcrypt, generates a new JWT and sets the cookie.
Key steps:
- Validate
emailandpasswordboth present User.findOne({ email }).select('+password')— password normally hiddenuser.comparePassword(password)— bcrypt compare returns true/false- Same message for wrong email OR wrong password — prevents user enumeration
- Generate JWT → set httpOnly cookie → return
200with user object
Invalidates the current JWT by adding it to the Blacklist collection
then clears the cookie. Uses jwt.decode() (not verify) to read the
token payload even if it is close to expiry.
Key steps:
- Extract token from cookie or Authorization header
jwt.decode(token)— readidandexpwithout verification- Check if already blacklisted — if yes, just clear cookie and return
Blacklist.create({ token, userId, expiresAt: new Date(decoded.exp * 1000) })res.clearCookie('bankflow_token')— remove from browser- Return
200success
Creates a new bank account for the authenticated user. Generates a
unique account number using Node.js built-in crypto module.
Key steps:
- Get
userIdfromreq.user._id(set by authMiddleware) - Accept optional
accountType,currency,descriptionfrom body crypto.randomBytes(4).toString('hex').toUpperCase()→ prefix withACC-Account.create({ userId, accountNumber, accountType, ... })- Return
201with created account
Returns all accounts belonging to the authenticated user.
Supports optional query filters for status and accountType.
Key steps:
- Build dynamic filter from
req.query.statusandreq.query.accountType Account.find({ userId, ...filter }).sort({ createdAt: -1 }).lean()- Return
200with array of accounts and total count
Example queries:
GET /api/v1/accounts
GET /api/v1/accounts?status=ACTIVE
GET /api/v1/accounts?accountType=SAVINGS
GET /api/v1/accounts?status=ACTIVE&accountType=CURRENT
Returns a single account by its MongoDB _id. The userId is
always included in the query — users can only access their own accounts.
Key steps:
- Get
accountIdfromreq.params.id Account.findOne({ _id: accountId, userId })— userId prevents accessing other users' accounts- Return
404if not found,200with account if found
Changes account status to ACTIVE, INACTIVE, or FROZEN.
Validates the status value before updating.
Key steps:
- Validate
statusis one of the three allowed values Account.findOneAndUpdate({ _id, userId }, { status }, { new: true, runValidators: true }){ new: true }returns the updated document, not the old one- Return
200with updated account
Calculates real-time account balance using a 3-stage MongoDB Aggregation Pipeline on the Ledger collection. Also returns the last 5 ledger entries as a mini statement.
Aggregation Pipeline stages:
Stage 1 — $match:
Filter ledger to only entries for this accountId
Convert string ID to ObjectId for matching
Stage 2 — $group:
For each entry:
CREDIT → add amount (money received)
DEBIT → subtract amount (money sent)
Sum all into final balance
Also count total entries and find latest timestamp
Stage 3 — $project:
Remove internal _id
Return only balance, totalEntries, lastTransaction
Key validations:
- Account must exist and belong to authenticated user
- Account status must not be
INACTIVE(closed accounts blocked) - FROZEN accounts CAN check balance (just cannot transact)
- New accounts with no ledger entries return balance of
0
generateTransactionId()
'TXN-' + crypto.randomBytes(4).toString('hex').toUpperCase()
// Example: TXN-3F9A1B2CGenerates a human-readable unique ID for every transaction. Used in emails, receipts and logs.
validateIdempotency(idempotencyKey)
const existing = await Transaction.findOne({ idempotencyKey });
return existing; // null if safe to proceedSearches transactions for the given key. If found, returns the original transaction so the caller can return it without creating a duplicate. The client generates a UUID before sending — same UUID on every retry guarantees the same result.
checkAccountStatus(senderAccountId, receiverAccountId)
Fetches both accounts and throws descriptive errors if either is
not found or not ACTIVE. Returns { senderAccount, receiverAccount }
if both pass. Prevents transactions involving INACTIVE or FROZEN accounts.
getSenderBalance(accountId)
Runs the MongoDB Aggregation Pipeline to derive current balance:
CREDIT entries → +amount
DEBIT entries → -amount
Sum = current balanceReturns 0 for accounts with no ledger history yet.
createLedgerEntries(...)
Creates both ledger entries simultaneously using Promise.all:
Entry 1 → DEBIT sender (senderCurrentBalance - amount)
Entry 2 → CREDIT receiver (receiverCurrentBalance + amount)
balanceAfter and runningBalance are calculated and stored
as permanent snapshots for audit trail.
The main transfer function. Moves money from one account to another with full validation, ledger entries, status updates and email notifications.
Complete 11-step flow:
Step 1 → Validate request body (idempotencyKey, senderAccountId,
receiverAccountId, amount all required)
Step 2 → Check amount is positive number
Step 3 → Check sender !== receiver (no self-transfer)
Step 4 → Idempotency check — return existing if key already used
Step 5 → Account status check — both must be ACTIVE
Step 6 → Ownership check — req.user must own senderAccount
Step 7 → Derive sender balance via aggregation pipeline
Step 8 → Check sufficient funds
Step 9 → Create Transaction document with status: PENDING
Step 10 → Create DEBIT + CREDIT ledger entries (Promise.all)
Step 11 → Mark transaction COMPLETED + send emails (non-blocking)
If ledger creation fails at Step 10:
Transaction status → FAILED
failureReason → set to error message
Emails NOT sent
Returns 500
If email fails at Step 11:
Transaction is already COMPLETED — money has moved
Error is logged but does NOT affect response
Returns 201 success
System-only route handler for seeding a new account with starting
funds. Protected by authSystemMiddleware — only systemUser: true
accounts can call this.
Key differences from createTransaction:
- No
senderAccountId— system has no real account - Only ONE ledger entry created (CREDIT only — no DEBIT)
- Auto-generates
idempotencyKeyasSYSTEM-TXN-XXXXXXXX - Checks existing balance — rejects if account already has funds
- Transaction type is
DEPOSITnotTRANSFER
Complete flow:
Step 1 → Validate targetAccountId and amount
Step 2 → Check target account exists and is ACTIVE
Step 3 → Get existing balance — reject if already > 0
Step 4 → Generate transactionId and idempotencyKey
Step 5 → Create Transaction with status: PENDING, senderAccountId: null
Step 6 → Create single CREDIT ledger entry (balanceAfter = 0 + amount)
Step 7 → Mark Transaction COMPLETED
Step 8 → End session
Step 9 → Send CREDIT email to account owner (non-blocking)
Step 10 → Return 201 success
Configures Nodemailer with Gmail OAuth2 and exports email functions.
transporter.verify() runs on server start — logs success or error
so you know immediately if OAuth2 credentials are wrong.
Private internal function. All other email functions call this.
Uses transporter.sendMail() and logs the messageId on success.
Sends a welcome email after successful registration.
Uses user.fullName virtual from User model.
Sends a styled HTML email after a completed transaction.
type is either DEBIT or CREDIT:
DEBIT→ red header, "💸 Money Sent" — sent to the senderCREDIT→ green header, "💰 Money Received" — sent to the receiver
Email includes a formatted table with amount, transaction ID, description and COMPLETED status.
http://localhost:3000/api/v1
All protected routes require the JWT token. It is automatically set as a cookie after login/register. In Postman, use the Cookies tab to manage it, or set the header manually:
Authorization: Bearer <your_jwt_token>
Register a new user account. Sends welcome email on success.
Request Body:
{
"firstName": "Hassan",
"lastName": "Ali",
"email": "hassan@gmail.com",
"password": "Test1234!"
}Success Response 201:
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "64abc123...",
"firstName": "Hassan",
"lastName": "Ali",
"email": "hassan@gmail.com"
}
}Error Responses:
{ "success": false, "message": "Please provide all required fields" }
{ "success": false, "message": "Email already registered" }Login with email and password. Returns JWT in cookie and body.
Request Body:
{
"email": "hassan@gmail.com",
"password": "Test1234!"
}Success Response 200:
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "64abc123...",
"firstName": "Hassan",
"lastName": "Ali",
"email": "hassan@gmail.com"
}
}Error Responses:
{ "success": false, "message": "Please provide email and password" }
{ "success": false, "message": "Invalid email or password" }Invalidates the current JWT by blacklisting it. Clears the cookie.
No request body needed. Token is read from cookie automatically.
Success Response 200:
{ "success": true, "message": "Logged out successfully" }After logout — any protected route returns 401:
{ "success": false, "message": "Token has been invalidated — please login again" }All routes require bankflow_token cookie (authMiddleware).
Create a new bank account for the authenticated user.
Account number is auto-generated in ACC-XXXXXXXX format.
Request Body:
{
"accountType": "SAVINGS",
"currency": "USD",
"description": "My main savings account"
}accountType options: SAVINGS, CURRENT, FIXED_DEPOSIT
currency defaults to USD if not provided.
Success Response 201:
{
"success": true,
"message": "Account created successfully",
"data": {
"account": {
"_id": "64abc...",
"userId": "64xyz...",
"accountNumber": "ACC-3F9A1B2C",
"accountType": "SAVINGS",
"status": "ACTIVE",
"currency": "USD",
"description": "My main savings account",
"createdAt": "2025-01-15T10:00:00Z"
}
}
}Get all accounts belonging to the authenticated user.
Optional query filters: status and accountType.
Examples:
GET /api/v1/accounts
GET /api/v1/accounts?status=ACTIVE
GET /api/v1/accounts?accountType=SAVINGS
GET /api/v1/accounts?status=ACTIVE&accountType=CURRENT
Success Response 200:
{
"success": true,
"count": 2,
"data": {
"accounts": [ { ... }, { ... } ]
}
}Get a single account by its MongoDB _id.
Only returns accounts owned by the authenticated user.
Success Response 200:
{
"success": true,
"data": {
"account": {
"_id": "64abc...",
"accountNumber": "ACC-3F9A1B2C",
"accountType": "SAVINGS",
"status": "ACTIVE",
"currency": "USD"
}
}
}Error Response 404:
{ "success": false, "message": "Account not found" }Update account status. Allowed values: ACTIVE, INACTIVE, FROZEN.
Request Body:
{ "status": "FROZEN" }Success Response 200:
{
"success": true,
"message": "Account status updated successfully",
"data": { "account": { "status": "FROZEN", ... } }
}Error Response 400:
{ "success": false, "message": "Invalid status. Must be ACTIVE, INACTIVE or FROZEN" }Get real-time balance for an account using MongoDB Aggregation Pipeline. Also returns last 5 ledger entries as a mini statement.
Important: This route is registered BEFORE
/:idin the router. If/:idcame first, Express would treat"balance"as an ID parameter and never reach the balance handler.
Success Response 200:
{
"success": true,
"message": "Balance fetched successfully",
"data": {
"account": {
"id": "64abc...",
"accountNumber": "ACC-3F9A1B2C",
"accountType": "SAVINGS",
"status": "ACTIVE",
"currency": "USD"
},
"balance": {
"available": 4500,
"currency": "USD",
"totalEntries": 3,
"lastTransaction": "2025-01-15T12:00:00Z"
},
"recentEntries": [
{
"entryType": "DEBIT",
"amount": 500,
"balanceAfter": 4500,
"description": "Rent payment",
"transactionId": "TXN-3F9A1B2C",
"transactionType": "TRANSFER",
"date": "2025-01-15T12:00:00Z"
}
]
}
}Transfer funds from one account to another.
Requires bankflow_token cookie (authMiddleware).
The idempotencyKey must be a unique UUID generated by the client
before sending — use the same key on any retry.
Request Body:
{
"idempotencyKey": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderAccountId": "64abc...",
"receiverAccountId": "64def...",
"amount": 500,
"currency": "USD",
"description": "Rent payment for January"
}Success Response 201:
{
"success": true,
"message": "Transaction completed successfully",
"data": {
"transaction": {
"transactionId": "TXN-3F9A1B2C",
"amount": 500,
"currency": "USD",
"type": "TRANSFER",
"status": "COMPLETED",
"description": "Rent payment for January",
"senderAccountId": "64abc...",
"receiverAccountId": "64def...",
"completedAt": "2025-01-15T12:00:00Z",
"createdAt": "2025-01-15T12:00:00Z"
}
}
}Idempotent repeat — same key sent again returns 200:
{
"success": true,
"message": "Transaction already processed — idempotent response",
"data": { "transaction": { ... } }
}Error Responses:
{ "success": false, "message": "Please provide idempotencyKey, senderAccountId, receiverAccountId and amount" }
{ "success": false, "message": "Amount must be a positive number greater than 0" }
{ "success": false, "message": "Sender and receiver accounts cannot be the same" }
{ "success": false, "message": "Sender account is FROZEN — transactions not allowed" }
{ "success": false, "message": "Unauthorized — you do not own the sender account" }
{ "success": false, "message": "Insufficient funds — available balance is 200 USD" }Deposit initial funds into a new account. Requires system user JWT (authSystemMiddleware). Can only be called ONCE per account — rejected if balance already > 0.
Request Body:
{
"targetAccountId": "64abc...",
"amount": 5000,
"currency": "USD",
"description": "Initial account funding"
}Success Response 201:
{
"success": true,
"message": "Initial funds deposited successfully",
"data": {
"transaction": {
"transactionId": "TXN-9B2C4D1E",
"amount": 5000,
"currency": "USD",
"type": "DEPOSIT",
"status": "COMPLETED",
"targetAccountId": "64abc...",
"description": "Initial account funding",
"completedAt": "2025-01-15T10:00:00Z"
}
}
}Error Responses:
{ "success": false, "message": "Target account not found" }
{ "success": false, "message": "Target account is FROZEN — cannot deposit funds" }
{ "success": false, "message": "Account already has funds — initial deposit already made. Current balance: 5000" }
{ "success": false, "message": "Access denied — system privileges required" }| Property | Value |
|---|---|
| Name | bankflow_token |
httpOnly |
true — JS cannot read it (XSS protection) |
secure |
true in production (HTTPS only) |
sameSite |
strict (CSRF protection) |
maxAge |
7 days in milliseconds |
Every transfer creates exactly 2 ledger entries. The total of all CREDIT entries minus all DEBIT entries always equals the current balance. This is the same accounting principle used by all real banks.
Hassan sends $500 to Ali:
Ledger Entry 1:
account → Hassan's account
entryType → DEBIT
amount → 500
balanceAfter → 4500
Ledger Entry 2:
account → Ali's account
entryType → CREDIT
amount → 500
balanceAfter → 5500
Without idempotency:
Client sends $500 → network fails midway → client retries
→ 2 transactions created → $1000 charged ❌
With idempotency:
Client sends UUID key with $500 → network fails → client retries
with SAME UUID → server finds existing transaction
→ returns original result → $500 charged only once ✅
Balance is NEVER stored on Account document.
It is calculated in real time:
sum(all CREDIT amounts) - sum(all DEBIT amounts) = current balance
Why?
Storing balance + having a ledger creates two sources of truth
If one gets out of sync → financial data corrupted
Deriving from ledger = single source of truth always accurate
Without blacklisting:
User logs out → JWT still valid for 7 days → anyone with the
token can still make API calls ❌
With blacklisting:
User logs out → token added to Blacklist collection
→ authMiddleware checks blacklist on every request
→ blacklisted token rejected immediately ✅
→ MongoDB TTL index auto-cleans expired tokens
Real banking systems never modify or delete financial records.
BankFlow Pro enforces this at the database layer:
- All Ledger fields are marked immutable: true
- Pre-save hooks block all update and delete operations
- Any attempt to modify a ledger entry throws an error
| Error | Cause | Fix |
|---|---|---|
MongoDB Connection Error |
Wrong MONGO_URI | Check .env credentials |
Email already registered |
Duplicate email | Use a different email address |
Invalid email or password |
Wrong credentials | Check email and password carefully |
Email server error on start |
OAuth2 config wrong | Re-check all 4 Google credentials in .env |
invalid_grant |
Refresh token expired | Repeat OAuth Playground steps to get new token |
Access denied — system privileges required |
Not a system user | Set systemUser: true in MongoDB Compass |
Account already has funds |
Double deposit attempt | Account was already funded — check balance |
Insufficient funds |
Low balance | Deposit initial funds first |
Sender and receiver accounts cannot be the same |
Same account ID used | Use two different account IDs |
Transaction already processed |
Duplicate idempotencyKey | Generate a new UUID for new request |
Token has been invalidated |
Logged out token reused | Login again to get fresh token |
jwt malformed |
Bad or empty token | Login again |
Cannot find module |
Missing npm package | Run npm install |
Route not found |
Wrong URL | Check base URL is /api/v1/... |
Built with VS Code + Cursor AI Course: Advanced Backend — Banking Transaction System Stack: Node.js · Express.js · MongoDB Atlas · Mongoose · JWT · bcryptjs · Nodemailer · cookie-parser · dotenv