Skip to main content

Command Palette

Search for a command to run...

Building a Paytm-Style Payments Backend with Bun, TypeScript, Express & MongoDB Transaction

Updated
32 min read
Building a Paytm-Style Payments Backend with Bun, TypeScript, Express & MongoDB Transaction
S

I'm Shubham (@shubhamsinghbundela), I'm a Software Engineer, a Full-stack developer, a tech enthusiast, and a technical writer here on @Hashnode. I have a strong zeal to share my acquired knowledge and I am also willing to learn from others.

Digital wallets have become a part of everyday life.

We transfer money to friends.

We split bills.

We pay merchants.

We check balances before making payments.

But behind every wallet application lies a backend system responsible for:

  • Authentication

  • Account Management

  • Balance Tracking

  • Secure Money Transfers

  • Transaction Safety

The real challenge isn't moving money — it's ensuring that every transfer is secure, consistent, and reliable.


What Are We Building?

A mini Paytm-style wallet backend where users can:

  • Create an account

  • Login securely

  • View wallet balance

  • Search other users

  • Transfer money

  • Maintain transaction consistency using MongoDB Transactions


Goal

To understand how modern payment systems work behind the scenes by building a simplified wallet application.

Instead of:

"Did the money get deducted?"

"Did the receiver get credited?"

"What happens if the server crashes during transfer?"

We move to:

"Transfers are atomic."

"Balances remain consistent."

"No money is lost during transactions."


Core Features

Authentication

Every user starts here.

Users can:

  • Sign up

  • Sign in

  • Receive JWT tokens

This ensures every request can be linked to an authenticated user.


Wallet Account

Every user automatically gets a wallet account.

When a user signs up:

  1. User document is created.

  2. Account document is created.

  3. Initial balance is assigned.

Example:

User Collection

{
  "username": "shubham",
  "password": "hashed-password",
  "firstName": "Shubham",
  "lastName": "Singh"
}

Account Collection

{
  "userId": "userObjectId",
  "balance": 10000
}

View Balance

Authenticated users can view their current wallet balance.

Endpoint:

GET /api/v1/account/balance

Example Response:

{
  "balance": 5000
}

Search Users

Before sending money, users can search for other users.

Endpoint:

GET /api/v1/user/bulk?filter=shu

Example Response:

[
  {
    "_id": "123",
    "firstName": "Shubham",
    "lastName": "Singh"
  }
]

Money Transfer

Users can transfer money to other users.

Endpoint:

POST /api/v1/account/transfer

Request:

{
  "to": "receiverUserId",
  "amount": 100
}

Transfer Flow:

  • Identify sender

  • Find sender account

  • Find receiver account

  • Validate balance

  • Deduct amount

  • Credit receiver

  • Commit transaction

This guarantees transaction safety.


Technology Stack

For this project I decided to use:

  • Bun

  • TypeScript

  • Express

  • Zod

  • MongoDB

  • Mongoose

  • JWT Authentication

Why Bun?

  • Fast package manager

  • Fast runtime

  • TypeScript support out of the box

  • Simple developer experience


Project Structure

backend/
│
├── index.ts
│
└── src/
    ├── app.ts
    ├── config/
    │     └── 
    ├── routes/
    │   └── user.ts
    │
    ├── controller/
     │   └── user.ts
    │
    ├── service/
    │   └── user.ts
    │
    └── types/
    │   └── user.ts
    │       
    ├── middleware/
    │   └── 
    │
    └── types/
    │   └── user.ts
    │
    │
    └── model/
        └── user.ts

I intentionally separated:

  • Routes

  • Controllers

  • Services

  • Types

to keep the application maintainable as it grows.


Step 1: Initialize Bun Project

Create a new Bun project:

bun init

Choose:

Blank project with TypeScript

Bun automatically creates:

package.json
tsconfig.json
index.ts

Step 2: Install Dependencies

Install Express:

bun add express

Install MongoDB dependencies:

bun add mongoose

Install TypeScript types:

bun add -d @types/express

Step 3: Create Express Application

Create:

src/app.ts
import express from "express";
import authRoute from "./routes/user";

const app = express();

app.use(express.json());

app.get("/v1/health", (req, res) => {
  res.status(200).json({
    message: "Health is Good",
  });
});

export default app;

Step 4: Server Entry Point

Create:

index.ts
import app from "./src/app";
import connectDB from "./src/config";

const PORT = process.env.PORT || 3000;

const start = async () => {

  app.listen(PORT, () => {
    console.log(
      `Server is running at \({PORT} in \){process.env.NODE_ENV} mode`
    );
  });
};

start().catch((err) => {
  console.error("Failed to start server", err);
  process.exit(1);
});

Responsibilities:

  • Connect database

  • Start server

  • Handle startup failures


Step 5: Setup MongoDB Connection

Create:

src/config/db.ts
import mongoose from "mongoose";
import dns from "dns";

dns.setServers(["1.1.1.1", "8.8.8.8"]);

const url: string | undefined = process.env.DATABASE_URL;

const connectDB = async () => {
  if (url) {
    const conn = await mongoose.connect(url);

    console.log(
      `MongoDB connected: ${conn.connection.host}`
    );
  } else {
    throw new Error(
      "DATABASE_URL is not defined"
    );
  }
};

export default connectDB;

Initially, I tried connecting to MongoDB (cloud) but faced a DNS issue

Problem:

MongoDB connection was failing due to DNS resolution.

Fix:

After watching a YouTube video: , I added this:

const dns = require("dns");
dns.setServers(["1.1.1.1", "8.8.8.8"]);

This fixed the issue and I was able to connect to cloud MongoDB.

Update index.ts :

import app from "./src/app";
import connectDB from "./src/config/db";

const PORT = process.env.PORT || 3000;

const start = async () => {
  await connectDB();
  app.listen(PORT, () => {
    console.log(`Server is running at \({PORT} in \){process.env.NODE_ENV} mode`);
  });
};

start().catch((err) => {
  console.error("Failed to start server", err);
  process.exit(1);
});

Understanding TypeScript's Strictness

While setting up the project, I noticed TypeScript forcing me to handle cases that plain JavaScript would ignore.

Example:

const url: string | undefined =
  process.env.DATABASE_URL;

Why?

Because environment variables may not exist.

TypeScript understands that:

process.env.DATABASE_URL

can return:

string | undefined

This means we must explicitly verify it before using it.

if (url) {
  await mongoose.connect(url);
}

This prevents runtime crashes caused by missing environment variables.

One thing I started realizing while learning TypeScript is that it's not only about handling:

  • string

  • number

  • boolean

  • object

It also forces us to think about:

  • undefined

  • null

  • optional values

before the code reaches production.


Step 6: Standard API Response Structure

Create:

src/utils/apiResponse.ts
import { type Response } from "express";

class ApiResponse {
  static ok(res: Response, message: string, data: any = null) {
    return res.status(200).json({
      success: true,
      message,
      data,
    });
  }
}

export default ApiResponse;

Why This is Important

Instead of writing:

res.status(200).json({ message: "Success" });

To avoid repeating response logic across multiple APIs, I implemented a reusable utility using the DRY (Don't Repeat Yourself) principle:

ApiResponse.ok(res, "Success", data);

Why Use type Response?

You might notice this import:

import { type Response } from "express";

By specifying Response, TypeScript knows that res is an Express response object.

For example, when typing:

res.

TypeScript can suggest methods like:

res.status()
res.json()
res.send()
res.cookie()

and immediately report errors if we try to use methods that don't exist.

The type keyword is also important:

import { type Response } from "express";

It tells TypeScript that this import is only needed for type checking and should not be included in the generated JavaScript code.


Step 9: Standard API Error Structure

While building APIs, handling errors properly is as important as handling success responses.

  1. Create Custom Error Class
src/utils/apiError.js
class ApiError extends Error {
  statusCode: number;
  // Inherit from built-in JavaScript Error
  constructor(statusCode: number, message: string) {
    super(message);

    this.statusCode = statusCode;

    // Capture clean stack trace
    Error.captureStackTrace(this, this.constructor);
  }

  static badRequest(message = "Bad Request") {
    return new ApiError(400, message);
  }

  static forbidden(message = "Forbidden") {
    return new ApiError(403, message);
  }

  static notFound(message = "Not Found") {
    return new ApiError(404, message);
  }

  static unauthorized(message = "401 Not Authorized") {
    return new ApiError(401, message);
  }
}

export default ApiError;

Why Extend Error?

avaScript provides built-in errors like:

  • Error

  • TypeError

  • ReferenceError

But they don’t include HTTP status codes, which are required in APIs.

2. Global Error Middleware

Create:

src/middleware/errorMiddleware.ts
import { type Request, type Response, type NextFunction } from "express";
import ApiError from "../utils/apiError";

interface Error {
  statusCode?: number;
  status?: number;
  message?: string;
  name?: string;
}

const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  // Handle known (custom) errors
  if (err instanceof ApiError) {
    return res.status(err.statusCode).json({
      success: false,
      message: err.message,
    });
  }

  if (err.name === "JsonWebTokenError") {
    return res.status(401).json({
      success: false,
      message: err.message,
    });
  }

  if (err.name === "TokenExpiredError") {
    return res.status(401).json({
      success: false,
      message: err.message,
    });
  }

  // Handle unknown errors
  return res.status(500).json({
    success: false,
    message: "Internal Server Error",
  });
};

export default errorHandler;

Learning About unknown While Building Error Middleware

While creating the global error middleware, I initially came across examples where the error parameter was typed as:

const errorHandler = (
  err: unknown,
  req: Request,
  res: Response,
  next: NextFunction,
) => {

At first, this confused me.

I wanted to access properties like:

err.message
err.name
err.statusCode

but TypeScript immediately reported errors.

After some research, I learned that unknown means:

"TypeScript doesn't know what value is stored here."

The value could be:

"Something went wrong"

or

404

or

new Error("Database Error")

Since TypeScript cannot guarantee what the value actually is, it prevents us from accessing properties directly.

To solve this, I introduced an interface describing the structure of the errors I expected to handle:

interface Error {
  statusCode?: number;
  status?: number;
  message?: string;
  name?: string;
}

Then I updated the middleware:

const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction,
) => {

Now TypeScript understood that these properties might exist:

err.message
err.name
err.statusCode

This helped me understand an important TypeScript concept:

JavaScript often assumes values have certain properties and only fails at runtime if that assumption is wrong.

TypeScript asks us to either:

  • Verify what the value is (typeof, instanceof, etc.)

  • Or explicitly describe the expected shape using types or interfaces.

Although it felt strict at first, it helps catch many mistakes while writing code instead of discovering them later in production.

3. Register Middleware in App

Update src/app.ts:

import express from "express";
import authRoute from "./routes/user";
import accountRoute from "./routes/account";
import errorHandler from "./middleware/errorMiddleware";
import cookieParser from "cookie-parser";

const app = express();

app.use(express.json());

app.use(cookieParser());

app.get("/v1/health", (req, res) => {
  res.status(200).json({
    message: "Health is Good",
  });
});


app.use(errorHandler);

export default app;

This must be the last middleware in the app.

Final Error Flow

Request → Route → Controller → Service → throws ApiError → Passes to errorHandler middleware → JSON response 

Step 10: Request Validation Using Zod

Before writing any business logic, I wanted to validate incoming request data.

Imagine a user sends:

{
  "email": "abc",
  "password": "123"
}

Without validation, invalid data reaches our service layer and database.

The goal is:

Request
   ↓
Validation
   ↓
Controller
   ↓
Service
   ↓
Database

Invalid requests should be rejected as early as possible.


Why Zod?

After exploring the Zod documentation, I learned that it provides:

  • Runtime validation

  • Type safety

  • Friendly error messages

  • TypeScript integration

Install Zod:

bun add zod

Create User Validation Schema

Create:

src/schema/user.ts
import { z } from "zod";

export const userSignupSchema = z.object({
  email: z.email(),

  password: z
    .string()
    .min(8, "Password must be at least 8 characters long")
    .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
    .regex(/[a-z]/, "Password must contain at least one lowercase letter")
    .regex(/[0-9]/, "Password must contain at least one number")
    .regex(
      /[^A-Za-z0-9]/,
      "Password must contain at least one special character"
    ),

  firstName: z.string(),

  lastName: z.string(),
});

Now every signup request must satisfy these rules before reaching our business logic.


Create User Validation Schema

Create:

src/schema/user.ts
import { z } from "zod";

export const userSignupSchema = z.object({
  email: z.email(),

  password: z
    .string()
    .min(8, "Password must be at least 8 characters long")
    .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
    .regex(/[a-z]/, "Password must contain at least one lowercase letter")
    .regex(/[0-9]/, "Password must contain at least one number")
    .regex(
      /[^A-Za-z0-9]/,
      "Password must contain at least one special character"
    ),

  firstName: z.string(),

  lastName: z.string(),
});

Now every signup request must satisfy these rules before reaching our business logic.


Step 11: Creating a Reusable Validation Middleware

While searching for best practices, I came across a blog explaining how to integrate Zod with Express middleware.

Instead of validating inside every controller, I created a generic validation middleware.

Create:

src/middleware/validationMiddleware.ts
import {
  type Request,
  type Response,
  type NextFunction,
} from "express";

import { z, ZodError } from "zod";

export function validateData(
  schema: z.ZodObject<any, any>
) {
  return (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    try {
      schema.parse(req.body);

      next();
    } catch (error) {
      if (error instanceof ZodError) {
        const errorMessages =
          error.issues.map((issue) => ({
            message: issue.message,
          }));

        res.status(400).json({
          error: "Invalid data",
          details: errorMessages,
        });
      } else {
        res.status(500).json({
          error: "Internal Server Error",
        });
      }
    }
  };
}

Why Middleware?

Middleware allows validation to happen before the controller executes.

Flow:

Request
   ↓
Validation Middleware
   ↓
Controller
   ↓
Service

This keeps controllers focused on handling requests and responses rather than validation.


Using Validation Middleware

router.post(
  "/signup",
  validateData(userSignupSchema),
  controller.signup
);

Now:

Invalid Request
      ↓
Validation Middleware
      ↓
400 Response

Valid Request
      ↓
Controller
      ↓
Service

Step 11: Defining Signup Routes

Now that request validation was in place, the next step was connecting incoming requests to the appropriate controller.

Create:

src/routes/user.ts
import { Router } from "express";

import * as controller from "../controller/user.ts";
import { validateData } from "../middleware/validationMiddleware.ts";
import { userSignupSchema } from "../schema/user.ts";

const router = Router();

router.post(
  "/signup",
  validateData(userSignupSchema),
  controller.signup,
);

export default router;

Understanding the Request Flow

When a user sends a signup request:

POST api/v1/user/signup

the flow becomes:

Request
   ↓
Route
   ↓
Validation Middleware
   ↓
Controller
   ↓
Service
   ↓
Database

The route's responsibility is very simple:

  • Define the endpoint

  • Register middleware

  • Forward the request to the controller

Business logic should never live inside routes.


Creating the Controller Layer

Create:

src/controller/user.ts
import {
  type Request,
  type Response,
  type NextFunction,
} from "express";

import * as userService from "../service/user.ts";

import type { SignupInput } from "../types/user";
import ApiResponse from "../utils/apiResponse.ts";

const signup = async (
  req: Request<{}, {}, SignupInput>,
  res: Response,
  next: NextFunction,
) => {
  try {
    const user = await userService.signup(req.body);

    ApiResponse.ok(
      res,
      "User successfully Created",
      user,
    );
  } catch (error) {
    next(error);
  }
};

export { signup };

At this point, I started exploring TypeScript more deeply and came across several concepts that were initially confusing.


Why Import Request, Response, and NextFunction?

One of the first questions I had was:

Why am I importing these types when Express works perfectly without them in JavaScript?

import {
  type Request,
  type Response,
  type NextFunction,
} from "express";

The answer is type safety.

These types help TypeScript understand:

  • What req contains

  • What res can do

  • What next() represents

For example:

req.body
req.params
req.query

res.status()
res.json()

are now fully typed.

Another thing I learned was the use of the type keyword:

import { type Request } from "express";

This tells TypeScript that the import is only required during type checking and should not be included in the generated JavaScript.


Understanding SignupInput

I also created a dedicated type describing the signup payload.

Create:

src/types/user.ts
export type SignupInput = {
  email: string;
  password: string;
  firstName: string;
  lastName: string;
};

Initially I wondered:

Zod is already validating the request. Why create another type?

The reason is that Zod and TypeScript solve different problems.

Zod validates data at runtime.

TypeScript validates code while we're developing.

This type describes exactly what data the signup service expects to receive.

Instead of treating:

req.body

as an unknown object, TypeScript now understands:

req.body.email
req.body.password
req.body.firstName
req.body.lastName

and provides autocomplete and error checking.


Understanding Express Request Generics

Another line that caught my attention was:

Request<{}, {}, SignupInput>

At first it looked strange.

After exploring the Express type definitions, I learned that Request accepts generic parameters:

Request<
  Params,
  ResponseBody,
  RequestBody,
  Query
>

In our case:

Request<{}, {}, SignupInput>

means:

Params       → {}
ResponseBody → {}
RequestBody  → SignupInput
Query        → Default

The most important part is:

SignupInput

because it tells TypeScript that:

req.body

must match the structure defined in our signup type.

This means TypeScript can catch mistakes while writing code instead of waiting until runtime.


Creating the User Model

Now that requests were validated and typed correctly, it was time to persist users in MongoDB.

Create:

src/model/user.ts
import mongoose from "mongoose";

const userSchema = new mongoose.Schema(
  {
    firstName: {
      type: String,
      required: true,
      trim: true,
    },

    lastName: {
      type: String,
      required: true,
      trim: true,
    },

    email: {
      type: String,
      required: true,
      unique: true,
      lowercase: true,
      trim: true,
    },

    password: {
      type: String,
      required: true,
    },
  },
  { timestamps: true },
);

const userModel = mongoose.model(
  "users",
  userSchema,
);

export default userModel;

Implementing Signup Business Logic

Now that the route, controller, validation, and database schema were ready, it was time to implement the actual signup functionality.

Create:

src/service/user.ts
import accountModel from "../model/account";
import userModel from "../model/user";

import type { SignupInput } from "../types/user";

import ApiError from "../utils/apiError";

const signup = async ({
  email,
  password,
  firstName,
  lastName,
}: SignupInput) => {
  const userExist = await userModel.findOne({
    email,
  });

  if (userExist) {
    throw ApiError.forbidden(
      "User Already Exists",
    );
  }

  const hashedPassword = await bcrypt.hash(
    password,
    10,
  );

  const newUser = await userModel.create({
    firstName,
    lastName,
    email,
    password: hashedPassword,
  });

  await accountModel.create({
    userId: newUser._id,
    balance: 100,
  });

  return {
    userId: newUser._id,
    firstName: newUser.firstName,
    lastName: newUser.lastName,
    email: newUser.email,
  };
};

Responsibility of the Service Layer

The service layer contains the application's business logic.

In our signup flow, the service is responsible for:

  • Checking whether the user already exists

  • Hashing the password

  • Creating the user

  • Creating a wallet account

  • Returning a safe response

This keeps the controller clean and focused only on handling requests and responses.


Final Result: Testing /v1/user/signup API Using Postman


Step 12: Implementing SignIn

Now that users can create accounts, the next step is allowing them to securely authenticate and access protected APIs.

For this project, I implemented:

  • Access Tokens

  • Refresh Tokens

The idea is simple:

User Signs In
      ↓
Access Token (short-lived)
      ↓
Access Protected APIs

Refresh Token (long-lived)
      ↓
Generate New Access Tokens

This approach is commonly used in modern applications because it improves security while maintaining a good user experience.


Creating JWT Utilities

Create:

src/utils/jwt.ts
import jwt, { type SignOptions, type JwtPayload } from "jsonwebtoken";

import { Types } from "mongoose";

type Payload = {
  userId: Types.ObjectId;
};

const accessSecret = process.env.JWT_ACCESS_SECRET!;
const refreshSecret = process.env.JWT_REFRESH_SECRET!;

const generateAccessToken = (payload: Payload): string => {
  try {
    return jwt.sign(payload, accessSecret, {
      expiresIn: process.env.JWT_ACCESS_EXPIRES_IN || "15m",
    } as SignOptions);
  } catch (error) {
    throw error;
  }
};

const generateRefreshToken = (payload: Payload): string => {
  try {
    return jwt.sign(payload, refreshSecret, {
      expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
    } as SignOptions);
  } catch (error) {
    throw error;
  }
};

const verifyAccessToken = (token: string) => {
  try {
    return jwt.verify(token, accessSecret) as JwtPayload;
  } catch (error) {
    throw error;
  }
};

const verifyRefreshToken = (token: string) => {
  try {
    return jwt.verify(token, refreshSecret) as JwtPayload;
  } catch (error) {
    throw error;
  }
};

export {
  generateAccessToken,
  generateRefreshToken,
  verifyAccessToken,
  verifyRefreshToken,
};

Another TypeScript Learning Moment

While implementing JWT, I encountered several TypeScript errors.

Initially I expected this to work:

jwt.sign(payload, secret, {
  expiresIn: "15m",
});

However TypeScript complained about the options object.

After reading the documentation and exploring GitHub issues, I learned that the third parameter expects a specific type.

So I imported:

import { type SignOptions }
  from "jsonwebtoken";

and updated the code:

jwt.sign(payload, secret, {
  expiresIn: "15m",
} as SignOptions);

This tells TypeScript:

This object follows the structure expected by jsonwebtoken.


Understanding the Non-Null Assertion Operator

Another thing that stood out was:

const accessSecret = process.env.JWT_ACCESS_SECRET!;

Notice the exclamation mark:

!

This is called the Non-Null Assertion Operator.

Without it, TypeScript sees:

process.env.JWT_ACCESS_SECRET

as:

string | undefined

because environment variables may not exist.

By adding:

!

I'm telling TypeScript:

I know this value exists.

If the environment variable is actually missing, the application will fail at runtime.

For production projects, a safer approach is usually validating environment variables during application startup.


Understanding JwtPayload

While implementing token verification, I hit another TypeScript issue.

Initially I wrote:

jwt.verify(token, accessSecret);

TypeScript complained because the return type of jwt.verify() is not always obvious.

After reading the documentation, I learned that verify() can return multiple types depending on the payload.

To help TypeScript understand the returned structure, I imported:

import { type JwtPayload } from "jsonwebtoken";

and used:

jwt.verify( token, accessSecret ) as JwtPayload;

Now TypeScript understands that the decoded token contains JWT payload information.

This was another example of TypeScript forcing me to be explicit about the types I'm working with.


Implementing Signin Route

With JWT utilities ready, users can now authenticate.

Define the route:

router.post(
  "/signin",
  validateData(userSigninSchema),
  controller.signin,
);

Controller Layer

const signin = async (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  try {
    const {
      accessToken,
      refreshToken,
      user,
    } = await userService.signin(
      req.body,
    );

    res.cookie(
      "refreshToken",
      refreshToken,
      {
        httpOnly: true,
        secure:
          process.env.NODE_ENV ===
          "production",
        maxAge:
          7 * 24 * 60 * 60 * 1000,
      },
    );

    ApiResponse.ok(
      res,
      "Signin Successful",
      {
        accessToken,
        user,
      },
    );
  } catch (error) {
    next(error);
  }
};

The controller is responsible for:

  • Calling the service

  • Setting the refresh token cookie

  • Returning the access token

The business logic remains inside the service layer.


Signin Service

const signin = async ({
  email,
  password,
}: SigninInput) => {
  const userExist =
    await userModel.findOne({
      email,
    });

  if (!userExist) {
    throw ApiError.forbidden(
      "User Not Found",
    );
  }

  const correctPassword =
    await bcrypt.compare(
      password,
      userExist.password,
    );

  if (!correctPassword) {
    throw ApiError.forbidden(
      "Password is invalid",
    );
  }

  const accessToken =
    generateAccessToken({
      userId: userExist._id,
    });

  const refreshToken =
    generateRefreshToken({
      userId: userExist._id,
    });

  return {
    accessToken,
    refreshToken,
    user: {
      userId: userExist._id,
      firstName:
        userExist.firstName,
      lastName:
        userExist.lastName,
      email: userExist.email,
    },
  };
};

Signin Flow

The complete signin flow now looks like:

POST /v1/user/signin
  ↓
Route
  ↓
Validation Middleware
  ↓
Controller
  ↓
Find User
  ↓
Compare Password
  ↓
Generate Access Token
  ↓
Generate Refresh Token
  ↓
Store Refresh Token Cookie
  ↓
Return Access Token

At this stage, users can successfully authenticate and receive JWT tokens that will be used to access protected wallet APIs.


Final Result: Testing /v1/user/signin API Using Postman


Step 13: Implementing Refresh Route Flow

After implementing signin, users can receive:

  • Access Token

  • Refresh Token

However, access tokens are intentionally short-lived.

In this project:

Access Token → 15 minutes
Refresh Token → 7 days

This improves security because even if an access token is compromised, it becomes useless after a short period of time.

But this introduces a new challenge:

What happens when the access token expires?

We don't want users to sign in again every 15 minutes.

This is where refresh tokens become useful.


Refresh Token Flow

The idea is simple:

User Signs In
      ↓
Access Token (15 min)
Refresh Token (7 days)
      ↓
Access Token Expires
      ↓
Client Calls /refresh
      ↓
New Access Token Generated

The user remains logged in without entering credentials again.


Defining the Route

Create the refresh endpoint:

src/routes/user.ts
router.post("/refresh", controller.refresh);

Whenever the frontend detects that the access token has expired, it can call:

POST /v1/user/refresh

to obtain a new access token.


Controller Layer

src/controller/user.ts
const refresh = async (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  try {
    const { accessToken } =
      await userService.refresh(
        req.cookies.refreshToken,
      );

    ApiResponse.ok(
      res,
      "Token refreshed successfully",
      { accessToken },
    );
  } catch (error) {
    next(error);
  }
};

The controller remains very small.

Its responsibility is simply:

  • Read refresh token from cookies

  • Call the service layer

  • Return a new access token

The business logic remains inside the service.


Refresh Service

src/service/user.ts
const refresh = async (
  token: string,
) => {
  if (!token) {
    throw ApiError.unauthorized(
      "Refresh token missing",
    );
  }

  const decoded =
    verifyRefreshToken(token);

  const userExists =
    await userModel.findOne({
      _id: decoded.userId,
    });

  if (!userExists) {
    throw ApiError.notFound(
      "User Not found",
    );
  }

  const accessToken =
    generateAccessToken({
      userId: userExists._id,
    });

  return { accessToken };
};

Complete Refresh Flow

The final refresh flow looks like:

POST /v1/user/refresh
          ↓
Read Refresh Token Cookie
          ↓
Verify Token
          ↓
Find User
          ↓
Generate New Access Token
          ↓
Return Access Token

Final Result: Testing /v1/user/refresh API Using Postman


Step 14: Protecting Routes with Authentication Middleware

Now that users can:

✅ Sign Up

✅ Sign In

✅ Refresh to get Access Tokens

The next step is protecting private APIs.

For example:

GET /v1/user/getme

This endpoint should only be accessible to authenticated users.

To achieve this, I created an authentication middleware.


Defining the Route

router.get(
  "/getme",
  authMiddleware,
  controller.getMe,
);

Notice the middleware:

authMiddleware

It runs before the controller.

The request flow becomes:

Request
    ↓
Auth Middleware
    ↓
Controller
    ↓
Response

If authentication fails, the controller never executes.


Creating Authentication Middleware

import {
  type Request,
  type Response,
  type NextFunction,
} from "express";

import userModel from "../model/user";

import ApiError from "../utils/apiError";

import {
  verifyAccessToken,
} from "../utils/jwt";

const authMiddleware = async (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  try {
    const token =
      req.headers.token as string;

    if (!token) {
      throw ApiError.unauthorized(
        "Access token missing",
      );
    }

    const decode =
      verifyAccessToken(token);

    const userExists =
      await userModel.findOne({
        _id: decode.userId,
      });

    if (!userExists) {
      throw ApiError.notFound(
        "User Not found",
      );
    }

    req.userId = decode.userId;

    next();
  } catch (error) {
    next(error);
  }
};

export default authMiddleware;

Middleware Responsibilities

The middleware is responsible for:

  • Reading the access token

  • Verifying the JWT

  • Checking whether the user exists

  • Attaching the user id to the request

  • Passing control to the next middleware

If any step fails, the request is rejected.


Another TypeScript Learning Moment

While implementing this middleware, I ran into an interesting TypeScript error.

When I wrote:

req.userId = decode.userId;

TypeScript complained:

Property 'userId' does not exist on type 'Request'

At first this was confusing because JavaScript allows us to attach new properties to objects at runtime.

However, TypeScript only knows about the properties defined in Express's Request interface.

By default, Express's Request type does not contain:

req.userId

Why TypeScript Complained

TypeScript sees Request as:

interface Request {
  body: any;
  params: any;
  headers: any;
}

There is no:

userId

property defined.

So TypeScript correctly reports an error.

From TypeScript's perspective:

"You are trying to use a property that I don't know exists."

Extending Express Request Type

After researching the issue and reading documentation, I learned that Express types can be extended globally.

Create:

src/types/express/index.d.ts
import express from "express";

declare global {
  namespace Express {
    interface Request {
      userId?: string;
    }
  }
}

This tells TypeScript:

Every Express Request may contain a userId property.

Understanding declare global

This was my first time encountering:

declare global

It allows us to extend existing TypeScript definitions.

Normally Express provides:

interface Request {}

We are effectively adding:

interface Request {
  userId?: string;
}

to that existing definition.

After doing this, TypeScript understands:

req.userId

throughout the entire application.

Updating tsconfig.json

I also needed to ensure TypeScript could discover custom type definitions.

{
  "compilerOptions": {
    "typeRoots": [
      "./src/types",
      "./node_modules/@types"
    ]
  }
}

This tells TypeScript where to search for custom type declarations.


Implementing Get Me API

With authentication middleware in place, implementing the Get Me endpoint becomes straightforward.

The route:

router.get(
  "/getme",
  authMiddleware,
  controller.getMe,
);

Controller Layer

const getMe = async (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  try {
    const userId = req.userId;

    if (!userId) {
      throw ApiError.unauthorized(
        "Unauthorized User",
      );
    }

    const { user } =
      await userService.getMe(
        userId,
      );

    ApiResponse.ok(
      res,
      "User get successfully",
      { user },
    );
  } catch (error) {
    next(error);
  }
};

Notice that the controller never verifies JWTs.

That responsibility belongs entirely to the middleware.

The controller simply consumes:

req.userId

which was already attached by the authentication middleware.


Service Layer

src/service/user.ts
const getMe = async (
  userId: string,
) => {
  const userExist =
    await userModel.findOne({
      _id: userId,
    });

  if (!userExist) {
    throw ApiError.notFound(
      "User Not found",
    );
  }

  return {
    user: {
      userId: userExist._id,
      firstName:
        userExist.firstName,
      lastName:
        userExist.lastName,
      email:
        userExist.email,
    },
  };
};

Final Get Me Flow

GET /v1/user/getme
          ↓
Auth Middleware
          ↓
Verify JWT
          ↓
Find User
          ↓
Attach userId to Request
          ↓
Controller
          ↓
Service
          ↓
Return User Profile

Final Result: Testing /v1/user/getme API Using Postman


Step 15: Implementing Logout

After implementing:

  • Sign Up

  • Sign In

  • Refresh to get access token

  • get Me

the final authentication feature was logout.

The goal of logout is simple:

User Logged In
      ↓
Refresh Token Stored in Cookie
      ↓
User Clicks Logout
      ↓
Refresh Token Removed
      ↓
Session Ends

Defining the Route

router.get("/logout", authMiddleware, controller.logout );

I decided to protect this route using:

authMiddleware

This ensures only authenticated users can log out.


Controller Layer

const logout = async (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  try {
    res.clearCookie("refreshToken");

    ApiResponse.ok(
      res,
      "Logout Success",
    );
  } catch (error) {
    next(error);
  }
};

Unlike signup or signin, logout doesn't require any business logic.

The controller simply:

  1. Removes the refresh token cookie

  2. Returns a success response


Understanding clearCookie

The most important line is:

res.clearCookie("refreshToken");

When users signed in, the refresh token was stored inside an HTTP-only cookie:

res.cookie(
  "refreshToken",
  refreshToken,
  {
    httpOnly: true,
    secure:
      process.env.NODE_ENV ===
      "production",
  },
);

Logout removes that cookie from the browser.

After the cookie is removed:

Browser
    ↓
No Refresh Token
    ↓
Cannot Request New Access Tokens

As a result, once the current access token expires, the user must sign in again.


Why Access Tokens Are Not Removed

A common question is:

If logout removes the refresh token, what about the access token?

The access token is typically stored on the client side (for example, memory or local storage).

The backend cannot directly remove it.

Instead:

Logout
    ↓
Refresh Token Deleted
    ↓
Access Token Eventually Expires
    ↓
User Must Login Again

Since access tokens are short-lived (15 minutes in this project), the session naturally ends.


Logout Flow

The complete flow looks like:

GET /v1/user/logout
          ↓
Auth Middleware
          ↓
Verify User
          ↓
Clear Refresh Token Cookie
          ↓
Return Success Response

At this stage, the authentication system is fully functional.


Step 16: Implementing Money Transfer Using MongoDB Transactions

Why Money Transfer Is Difficult

At first glance, transferring money looks simple.

Suppose:

Shubham → ₹100 → Rahul

You might think:

Deduct ₹100 from Shubham
Add ₹100 to Rahul
Done

But what happens if the server crashes after deducting money from Shubham and before adding it to Rahul?

Shubham Balance = ₹1000
Rahul Balance = ₹500

Transfer ₹100

Step 1:
Shubham = ₹900 

Server crashes 

Rahul = ₹500

Now ₹100 has disappeared from the system.

This creates an inconsistent database state.

In payment systems, this is unacceptable.


Understanding Transactions

While researching this problem, I spent time reading about MongoDB transactions.

Good Read: https://medium.com/henkel-data-and-analytics/understanding-transactions-in-mongodb-9832badd98ea

https://levelup.gitconnected.com/mongodb-transactions-in-node-js-a-comprehensive-guide-7f8bc4d363a6

The key idea is:

Either every operation succeeds or none of them succeed.

This property is called Atomicity.

Think of it as an all-or-nothing operation.

Transaction Starts
        ↓
Deduct Money
        ↓
Add Money
        ↓
Commit

If any step fails:

Transaction Starts
        ↓
Deduct Money
        ↓
Error Occurs
        ↓
Rollback Everything

The database returns to its original state.

No money is lost.


Defining Transfer Types

Before implementing the transfer API, I created a dedicated TypeScript type for the request body.

Create:

src/types/account.ts
export type transferInput = {
  to: string;
  amount: number;
};

The transfer endpoint expects:

{
  "to": "receiverUserId",
  "amount": 100
}

So the transferInput type accurately describes the shape of the data required for a money transfer.


Defining the Route

router.post(
  "/transfer",
  authMiddleware,
  controller.transfer,
);

This route is protected because only authenticated users should be allowed to transfer money.


Controller Layer

const transfer = async (
  req: Request<{}, {}, transferInput>,
  res: Response,
  next: NextFunction,
) => {
  try {
    const userId = req.userId;

    if (!userId) {
      throw ApiError.unauthorized(
        "Unauthorized User",
      );
    }

    const data =
      await accountService.transfer(
        userId,
        req.body,
      );

    ApiResponse.ok(
      res,
      "transferDone",
      data,
    );
  } catch (error) {
    next(error);
  }
};

Just like previous controllers, the responsibility here is minimal:

  • Get authenticated user id

  • Call service layer

  • Return response

All business logic stays inside the service.


Service Layer

src/service/account.ts
import mongoose from "mongoose";

import accountModel from "../model/account";
import ApiError from "../utils/apiError";
import type { transferInput } from "../types/account";

const transfer = async (userId: string, { to, amount }: transferInput) => {
  const session = await mongoose.startSession();

  try {
    session.startTransaction();
    const senderAccount = await accountModel.findOne({ userId }, null, {
      session,
    });

    if (!senderAccount) {
      throw new ApiError(404, "Sender account not found");
    }

    const receiverAccount = await accountModel.findOne({ userId: to }, null, {
      session,
    });

    if (!receiverAccount) {
      throw new ApiError(404, "Receiver account not found");
    }

    if (senderAccount.balance && senderAccount.balance < amount) {
      throw new ApiError(400, "Insufficient balance");
    }

    await accountModel.updateOne(
      { userId },
      {
        $inc: {
          balance: -amount,
        },
      },
      { session },
    );

    await accountModel.updateOne(
      { userId: to },
      {
        $inc: {
          balance: amount,
        },
      },
      { session },
    );

    await session.commitTransaction();

    return {
      success: true,
    };
  } catch (error) {
    await session.abortTransaction();
    throw error;
  } finally {
    await session.endSession();
  }
};

export { transfer };

Starting a MongoDB Session

Inside the service:

const session = await mongoose.startSession();

A session allows MongoDB to track multiple operations as part of a single transaction.

Starting the Transaction

session.startTransaction();

From this point onward, MongoDB treats all database operations as part of the same transaction.

Nothing is permanently saved until we explicitly commit.


Finding Sender Account

const senderAccount =
  await accountModel.findOne(
    { userId },
    null,
    { session },
  );

Notice:

{ session }

This is important.

It tells MongoDB:

Execute this query inside the current transaction.

Without attaching the session, the query would execute outside the transaction.


Finding Receiver Account

const receiverAccount =
  await accountModel.findOne(
    { userId: to },
    null,
    { session },
  );

Again, we execute this query inside the same transaction.


Deducting Money From Sender

await accountModel.updateOne(
  { userId },
  {
    $inc: {
      balance: -amount,
    },
  },
  { session },
);

MongoDB's $inc operator allows us to increment or decrement numeric values.

Example:

Current Balance = ₹1000

$inc: {
  balance: -100
}

New Balance = ₹900

Crediting Receiver

await accountModel.updateOne(
  { userId: to },
  {
    $inc: {
      balance: amount,
    },
  },
  { session },
);

Example:

Current Balance = ₹500

$inc: {
  balance: 100
}

New Balance = ₹600

Committing the Transaction

Once both updates succeed:

await session.commitTransaction();

MongoDB permanently saves all changes.

Sender Updated 
Receiver Updated 
Commit 

The transfer is complete.


Handling Failures

If any step throws an error:

catch (error) {
  await session.abortTransaction();
  throw error;
}

MongoDB rolls back every operation performed inside the transaction.

Example:

Deduct Sender Money
        ↓
Database Error
        ↓
Abort Transaction
        ↓
Sender Balance Restored

This guarantees consistency.


Cleaning Up Resources

Whether the transaction succeeds or fails:

finally {
  await session.endSession();
}

The session must always be closed.

This prevents resource leaks and keeps MongoDB connections healthy.


Complete Transfer Flow

POST /v1/account/transfer
            ↓
Auth Middleware
            ↓
Get Sender ID
            ↓
Start Session
            ↓
Start Transaction
            ↓
Find Sender
            ↓
Find Receiver
            ↓
Validate Balance
            ↓
Deduct Sender Balance
            ↓
Credit Receiver Balance
            ↓
Commit Transaction
            ↓
Success Response

This was the first time I worked with database transactions, and it completely changed how I think about financial operations.

Before learning transactions, I saw money transfer as two database updates.

After learning transactions, I realized that a transfer is actually one logical operation made up of multiple database operations that must succeed together.


Final Result: Testing /v1/account/transfer API Using Postman