Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

BankFlow Pro — Advanced Banking Transaction System

Node.js Express.js MongoDB JWT Nodemailer Status

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.


Table of Contents


Project Overview

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)

Tech Stack

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

Project Structure

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

Environment Variables

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_token

Never commit .env to GitHub — it is listed in .gitignore


Installation & Setup

# 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 start

Expected 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

Database Setup

MongoDB Atlas (Free Tier)

  1. Go to https://cloud.mongodb.com and create a free account
  2. Create a new project → name it bankflow-pro
  3. Create a free cluster → choose M0 Sandbox
  4. Set a database username and strong password → save them
  5. Network Access → Add IP Address → 0.0.0.0/0 (allow from anywhere)
  6. Connect → Drivers → Copy the connection string
  7. Paste into .env as MONGO_URI — replace <password> with real password

MongoDB Compass (Visual GUI Tool)

  • Download from https://www.mongodb.com/products/compass
  • Paste your MONGO_URI to connect
  • Use it to inspect all collections: users, accounts, transactions, ledgers, blacklists
  • Useful for verifying data during development and testing

System User Setup

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.


Email Setup

BankFlow Pro uses Nodemailer with Gmail OAuth2 — more secure than a plain Gmail password because it uses rotating access tokens.

Step 1 — Google Cloud Console

  1. Go to https://console.cloud.google.com
  2. Create a new project → name it bankflow-pro
  3. Go to APIs & Services → Enable APIs → search Gmail API → Enable

Step 2 — Create OAuth2 Credentials

  1. APIs & Services → Credentials → Create Credentials → OAuth Client ID
  2. Application type → Web Application
  3. Authorized Redirect URIs → add: https://developers.google.com/oauthplayground
  4. Copy CLIENT_ID and CLIENT_SECRET

Step 3 — Get Refresh Token

  1. Go to https://developers.google.com/oauthplayground
  2. Click gear icon (Settings) → check "Use your own OAuth credentials"
  3. Enter your CLIENT_ID and CLIENT_SECRET
  4. In Step 1 — select https://mail.google.com/ from Gmail API
  5. Click Authorize APIs → login with your Gmail
  6. In Step 2 — click Exchange authorization code for tokens
  7. Copy the REFRESH_TOKEN value

Step 4 — Update .env

EMAIL_USER=your_gmail@gmail.com
CLIENT_ID=paste_client_id_here
CLIENT_SECRET=paste_client_secret_here
REFRESH_TOKEN=paste_refresh_token_here

Email Functions Summary

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

Data Models

User Model — src/models/User.js

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 modified
  • comparePassword(candidatePassword) — uses bcrypt.compare(), returns true/false

Email regex used:

/^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/

Account Model — src/models/Account.js

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 lookups
  • status — 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)

Transaction Model — src/models/Transaction.js

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 PENDINGCOMPLETED 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)

Ledger Model — src/models/Ledger.js

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

Blacklist Model — src/models/Blacklist.js

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.


Middleware

authMiddleware.js — Regular User Protection

Used on all routes that require a logged-in customer.

How it works step by step:

  1. Extracts JWT from req.cookies.bankflow_token — falls back to Authorization: Bearer <token> header
  2. Verifies JWT signature using JWT_SECRET — rejects if expired or tampered
  3. Checks if token exists in Blacklist collection — rejects if user already logged out
  4. Finds user by decoded.id — rejects if user deleted
  5. Attaches user object to req.user — available in all downstream controllers
  6. 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


authSystemMiddleware.js — System User Only

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:

  1. Extracts and verifies JWT same as authMiddleware
  2. Finds user using .select('+systemUser') — required because systemUser has select: false in schema
  3. Checks user.systemUser === true — rejects with 403 if false
  4. Attaches user to req.user and calls next()

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


Controllers

authController.js

register

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:

  1. Validate firstName, lastName, email, password all present
  2. Check if email already exists in users collection
  3. User.create() — password is auto-hashed by pre-save hook
  4. jwt.sign({ id: user._id }) with JWT_SECRET and JWT_EXPIRES_IN
  5. res.cookie('bankflow_token', token, { httpOnly: true, ... })
  6. emailService.sendRegistrationEmail(user.email, user.fullName)
  7. Return 201 with token and user object (no password)

login

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:

  1. Validate email and password both present
  2. User.findOne({ email }).select('+password') — password normally hidden
  3. user.comparePassword(password) — bcrypt compare returns true/false
  4. Same message for wrong email OR wrong password — prevents user enumeration
  5. Generate JWT → set httpOnly cookie → return 200 with user object

logout

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:

  1. Extract token from cookie or Authorization header
  2. jwt.decode(token) — read id and exp without verification
  3. Check if already blacklisted — if yes, just clear cookie and return
  4. Blacklist.create({ token, userId, expiresAt: new Date(decoded.exp * 1000) })
  5. res.clearCookie('bankflow_token') — remove from browser
  6. Return 200 success

accountController.js

createAccount

Creates a new bank account for the authenticated user. Generates a unique account number using Node.js built-in crypto module.

Key steps:

  1. Get userId from req.user._id (set by authMiddleware)
  2. Accept optional accountType, currency, description from body
  3. crypto.randomBytes(4).toString('hex').toUpperCase() → prefix with ACC-
  4. Account.create({ userId, accountNumber, accountType, ... })
  5. Return 201 with created account

getAllAccounts

Returns all accounts belonging to the authenticated user. Supports optional query filters for status and accountType.

Key steps:

  1. Build dynamic filter from req.query.status and req.query.accountType
  2. Account.find({ userId, ...filter }).sort({ createdAt: -1 }).lean()
  3. Return 200 with 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

getAccountById

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:

  1. Get accountId from req.params.id
  2. Account.findOne({ _id: accountId, userId }) — userId prevents accessing other users' accounts
  3. Return 404 if not found, 200 with account if found

updateAccountStatus

Changes account status to ACTIVE, INACTIVE, or FROZEN. Validates the status value before updating.

Key steps:

  1. Validate status is one of the three allowed values
  2. Account.findOneAndUpdate({ _id, userId }, { status }, { new: true, runValidators: true })
  3. { new: true } returns the updated document, not the old one
  4. Return 200 with updated account

getBalance

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

transactionController.js

Helper Functions (internal — not exported)

generateTransactionId()

'TXN-' + crypto.randomBytes(4).toString('hex').toUpperCase()
// Example: TXN-3F9A1B2C

Generates 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 proceed

Searches 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 balance

Returns 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.


createTransaction (exported)

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

initialFunds (exported)

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 idempotencyKey as SYSTEM-TXN-XXXXXXXX
  • Checks existing balance — rejects if account already has funds
  • Transaction type is DEPOSIT not TRANSFER

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

Services

email.service.jssrc/services/email.service.js

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.

sendEmail(to, subject, text, html) — base function

Private internal function. All other email functions call this. Uses transporter.sendMail() and logs the messageId on success.

sendRegistrationEmail(userEmail, name)

Sends a welcome email after successful registration. Uses user.fullName virtual from User model.

sendTransactionEmail(userEmail, name, type, amount, currency, transactionId, description)

Sends a styled HTML email after a completed transaction. type is either DEBIT or CREDIT:

  • DEBIT → red header, "💸 Money Sent" — sent to the sender
  • CREDIT → green header, "💰 Money Received" — sent to the receiver

Email includes a formatted table with amount, transaction ID, description and COMPLETED status.


API Reference

Base URL

http://localhost:3000/api/v1

Authentication Header / Cookie

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>

Auth Endpoints — /api/v1/auth

POST /api/v1/auth/register

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" }

POST /api/v1/auth/login

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" }

POST /api/v1/auth/logout

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" }

Account Endpoints — /api/v1/accounts

All routes require bankflow_token cookie (authMiddleware).

POST /api/v1/accounts

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 /api/v1/accounts

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 /api/v1/accounts/:id

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" }

PATCH /api/v1/accounts/:id/status

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 /api/v1/accounts/:id/balance

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 /:id in the router. If /:id came 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"
      }
    ]
  }
}

Transaction Endpoints — /api/v1/transactions

POST /api/v1/transactions

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" }

POST /api/v1/transactions/system/initial-funds

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" }

Cookie Reference

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

Banking Concepts Implemented

Double-Entry Bookkeeping

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

Idempotency

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 via Aggregation Pipeline

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

JWT Blacklisting for Logout

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

Immutable Ledger

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


Common Errors & Fixes

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/...

Author

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

About

No description or website provided.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages