Paytm Clone
Full-stack digital payments platform with wallet & P2P transfers
Overview
A production-inspired Paytm clone built during Harkirat Singh's Cohort 2.0 program. Implements core fintech features including user onboarding, wallet management, peer-to-peer money transfers, and transaction history — all with atomic DB transactions to prevent double-spending.
Quick Info
The Problem
Building a payments app requires solving hard consistency problems — what happens if a transfer deducts money from sender but fails before crediting the receiver? Traditional CRUD APIs are not safe for financial operations without atomic transactions.
My Solution
Used PostgreSQL transactions with Prisma to wrap all transfer operations atomically. If any step fails, the entire transaction rolls back. Added JWT-based auth, input validation with Zod, and a monorepo structure with Turborepo for shared types across frontend and backend.
Key Features
- →Atomic P2P money transfers using PostgreSQL transactions — zero partial transfer failures
- →JWT authentication with HTTP-only cookies and refresh token support
- →Zod schema validation on all API endpoints — rejects malformed requests before DB hit
- →Wallet balance management with optimistic locking to prevent race conditions
- →Turborepo monorepo — shared Zod schemas and TypeScript types across frontend and backend
- →Full transaction history with pagination and filtering by date and type
- →Responsive UI built with Tailwind CSS matching Paytm's design language
🏗️System Design (HLD)
Key Design Decisions
Chose PostgreSQL over MongoDB — financial data requires ACID compliance. Document DBs lack native multi-document transactions, making atomic transfers unreliable.
Chose Prisma over raw SQL — type-safe queries reduce runtime errors in financial logic. Prisma's transaction API wraps complex operations cleanly without raw SQL boilerplate.
Chose Turborepo monorepo — shared Zod schemas between frontend and backend eliminate validation drift. One schema change propagates to both layers automatically.
🗄️DB Schema & Optimizations
Performance Optimizations
Transaction history is the hottest read path — every dashboard load queries it. Added composite index on (sender_id, created_at DESC) for fast paginated lookups.
Added a version field to the Wallet table. On every update, the WHERE clause checks the current version. If two concurrent transfers read the same balance, only one write succeeds — the other retries.
Wrapped debit + credit + transaction record creation in a single Prisma $transaction block. If the credit fails, the debit is rolled back automatically by PostgreSQL.
⚙️Technical Deep Dive
Concurrent transfer requests could read the same wallet balance simultaneously, allowing a user to spend more than their balance
Application-level locks using Redis SET NX — adds latency and a new dependency just for locking
PostgreSQL SELECT FOR UPDATE inside a Prisma $transaction block. The row is locked for the duration of the transaction, serializing concurrent transfers at the DB level.
Zero double-spend across 500 concurrent transfer simulations in load testing
Shared types between React frontend and Express backend were drifting — API responses didn't match frontend expectations after schema changes
Manual type duplication in both packages — error-prone and easy to forget during updates
Turborepo monorepo with a shared @repo/types package containing Zod schemas. Both frontend and backend import from the same source. One change propagates everywhere.
Zero type mismatch errors after migration. New fields added once, available everywhere instantly.
JWT tokens stored in localStorage were vulnerable to XSS attacks — any injected script could steal the token
Short-lived JWTs in memory — lost on page refresh, poor UX
Access token in memory (React state), refresh token in HTTP-only cookie. On refresh, backend issues a new access token. XSS cannot read HTTP-only cookies.
Token theft via XSS eliminated. Session persists across page refreshes without localStorage exposure.
📈Performance & Scale Numbers
🔒Security Implementation
| Layer | Implementation |
|---|---|
| Auth | JWT access token (15min) in memory + refresh token in HTTP-only cookie (7 days) |
| Input Validation | Zod schemas on every endpoint — amount must be positive number, UPI ID format validated, balance checked before debit |
| SQL Injection | Prisma parameterized queries — no raw SQL in transfer or auth flows |
| CORS | Strict origin whitelist — only frontend domain allowed in production |
| Rate Limiting | express-rate-limit on /transfer and /auth — 20 req/min per IP to prevent brute force and abuse |
| Helmet.js | Security headers — XSS protection, clickjacking prevention, MIME sniffing disabled |
⚡Caching Strategy (Redis)
Balance is never cached. Every read hits PostgreSQL directly. Stale balance data in a payments app is a critical bug — consistency over performance here.
Name, avatar, UPI ID stored in React context after login. Avoids repeated API calls on every page navigation without risking stale financial data.
First page of transactions cached in React Query with 30s stale time. User sees instant results on dashboard load. Refresh fetches latest from DB.
📬Async & Queue Architecture (BullMQ / Kafka)
P2P Transfer Flow:
POST /api/transfer
→ Zod validation (amount, receiver UPI ID)
→ JWT auth middleware
→ Check sender balance
↓
→ PostgreSQL $transaction block:
SELECT wallet WHERE user_id = sender FOR UPDATE
CHECK balance >= amount
UPDATE wallet SET balance = balance - amount WHERE user_id = sender
UPDATE wallet SET balance = balance + amount WHERE user_id = receiver
INSERT INTO transactions (sender, receiver, amount, status)
↓
→ COMMIT (all succeed) or ROLLBACK (any failure)
↓
→ Return updated balance to frontend
No async queue needed — transfer is synchronous by design.
Financial operations must complete or fail atomically, not eventually.🔴Real-time Layer (Socket.IO)
- →No WebSocket in v1 — balance updates on transfer completion via API response
- →React Query invalidates wallet balance cache on successful transfer
- →Transaction history refetches automatically after new transfer
- →Planned v2: Socket.IO push for instant balance update notifications
🚀DevOps & CI/CD
Local Development:
PostgreSQL via Docker Compose
Turborepo dev — runs frontend + backend concurrently
Prisma Studio for DB inspection
Monorepo Structure (Turborepo):
apps/
frontend/ → React + Vite + Tailwind
backend/ → Express + Prisma
packages/
@repo/types → Shared Zod schemas
@repo/db → Prisma client singleton
Deployment:
Backend → Railway (auto-deploy on push)
Frontend → Vercel
DB → Railway PostgreSQL (managed)
Env vars → Railway + Vercel environment secrets
CI:
GitHub Actions → type-check + lint on every PR
Prisma migrate deploy runs on Railway pre-start hook📄API Reference (Swagger)
🧪Unit Test Coverage
🔁What I'd Do Differently
- →Would add idempotency keys on the /transfer endpoint — network retries can cause duplicate transfers if the client doesn't know if the first request succeeded
- →Would implement database-level CHECK constraints on balance (balance >= 0) as a safety net beyond application-level checks
- →Would add a dead-letter queue for failed transaction notifications instead of silent failures
- →Would use pgBouncer connection pooling in production — Railway's PostgreSQL has a connection limit that becomes a bottleneck under load
💡Key Learnings
- →PostgreSQL SELECT FOR UPDATE is the correct primitive for financial row locking — Redis locks add unnecessary complexity for DB-native operations
- →Turborepo shared packages eliminate type drift between frontend and backend — essential in any full-stack TypeScript monorepo
- →HTTP-only cookies for refresh tokens is non-negotiable in any real auth system — localStorage is not safe for sensitive tokens
- →Prisma $transaction wraps multi-step DB operations cleanly but requires careful error handling — uncaught errors inside the block won't trigger rollback in all cases