████████╗ ████████╗ ████████╗
██╔═════██╗██╔════██╗██╔════██╗
██║ ██║████████╔╝████████╔╝
██║ ██║██╔═══██╗ ██╔════██╗
╚████████╔╝██║ ╚██╗████████╔╝
╚═══════╝ ╚═╝ ╚═╝╚═══════╝Orb is a full-stack virtual digital wallet — a UPI/Paytm-style peer-to-peer payment simulator built on a fictional currency called ORB. Every user is auto-credited 1,00,000 virtual balance on signup, can send money to other users by wallet tag or QR code, view transaction history with spending charts, and receive email alerts on incoming transfers. No real money and no real payment rails are ever involved — every "transaction" is an internal database update. This is a portfolio and learning project built with production-grade architecture and discipline.
- Features
- Tech Stack
- Prerequisites
- Getting Started
- Running Tests
- Project Structure
- Database Schema
- API Reference
- Authentication Flow
- P2P Transfer Flow
- Security
- Engineering Decisions
- Deployment
- License
Authentication and Security
- JWT access and refresh token flow with silent renewal
- BCrypt password hashing (never stored or logged plain)
- Separate 4-digit transfer PIN stored as a separate BCrypt hash
- Account auto-lock after 5 consecutive failed login attempts
- Per-IP rate limiting via Bucket4j (20 requests per minute)
- CORS locked to exact frontend origin, never wildcard
- Input validation on every request DTO
- Sensitive fields scrubbed from all log output
Payments and Wallet
- 1,00,000 ORB auto-seeded wallet on every signup
- Atomic P2P transfers — debit, credit, and transaction insert in one
@Transactionalblock - Self-transfer guard and balance pre-check before any debit
- Transfer PIN verified with
BCrypt.matches()on every send - Unique wallet tag (e.g.
@rahul42) for user discovery - UUID reference ID generated per transaction
History and Insights
- Paginated transaction history with sent and received filter tabs
- Date range filtering
- Monthly spending bar chart via Recharts
- Transaction reference IDs for audit trail
User Experience
- Mobile-first responsive design that works on any screen size
- QR code generation via ZXing and camera-based scanning via jsQR
- Notification bell with unread badge
- Async email alerts on incoming transfers (never blocks the API response)
- Toast notifications for all user actions
Backend — Java 17+ / Spring Boot 3.5
| Library | Purpose |
|---|---|
| Spring Boot 3.5 | Core framework, auto-config, embedded Tomcat |
| Spring Security | Auth filter chain, route protection |
| Spring Data JPA | Repository pattern |
| Hibernate ORM | Entity-to-table mapping |
| Flyway | Version-controlled SQL migrations |
| PostgreSQL | Primary database (JDBC driver) |
| JJWT | JWT creation, signing, validation |
| BCrypt (Spring Security) | Password and transfer PIN hashing |
| ZXing | QR code generation as base64 PNG |
| JavaMail / SendGrid | Transactional email on incoming transfers |
| Bucket4j | Per-IP rate limiting |
| Lombok | Boilerplate reduction |
| MapStruct | Compile-time entity-to-DTO mapping |
| springdoc-openapi | Auto-generated API docs at /swagger-ui.html |
| SLF4J + Logback | Structured logging |
Frontend — React 18 / Vite
| Library | Purpose |
|---|---|
| React 18 | UI framework |
| Vite | Dev server and build tool |
| TailwindCSS | Utility-first CSS, mobile-first |
| React Router v6 | Client routing and protected route guards |
| Axios | HTTP client with JWT interceptor and auto-refresh |
| Zustand | Global state management |
| Recharts | Monthly spending bar chart |
| jsQR | Camera-based QR scanner |
| React Hot Toast | Success and error toasts |
Testing
| Library | Purpose |
|---|---|
| JUnit 5 + Mockito | Unit and integration tests |
| H2 | In-memory DB for tests only, running in PostgreSQL compatibility mode |
| Tool | Version | Notes |
|---|---|---|
| Java | 17 or higher | java -version to verify |
| Node.js | 18 or higher | node -v to verify |
| PostgreSQL | 15 or higher | Running locally on port 5432 |
| Maven | 3.9+ | Included via mvnw wrapper — no global install needed |
git clone https://github.com/Mitxh13/Orb.git
cd OrbConnect to PostgreSQL and run:
CREATE DATABASE orb_db;The application reads credentials from environment variables with sensible defaults for local development. You can either set environment variables or create an untracked application-local.yml:
Option A — Environment variables:
export DB_USERNAME=postgres
export DB_PASSWORD=postgres
export JWT_SECRET=YourSuperSecretKeyThatIsAtLeast32CharactersOption B — Local config file (git-ignored):
Create src/main/resources/application-local.yml:
spring:
datasource:
username: your_pg_user
password: your_pg_password
jwt:
secret: YourSuperSecretKeyThatIsAtLeast32CharactersEmail configuration is optional. The app works without it — notifications simply save to the database.
./mvnw spring-boot:runOn Windows:
.\mvnw.cmd spring-boot:runThe backend starts at http://localhost:8080. Flyway automatically creates all 4 tables on first boot. Swagger UI is live at http://localhost:8080/swagger-ui.html.
cd frontend
npm installCreate frontend/.env:
VITE_API_BASE_URL=http://localhost:8080
npm run devThe frontend starts at http://localhost:5173.
Open http://localhost:5173, register an account, and confirm your wallet shows a balance of 1,00,000 ORB.
Tests run against an in-memory H2 database in PostgreSQL compatibility mode. No local PostgreSQL instance is needed.
./mvnw testOn Windows:
.\mvnw.cmd testTest coverage includes:
- User registration with atomic wallet creation
- Login, JWT generation, and token refresh
- P2P transfer happy path with correct balance updates
- Insufficient balance rejection
- Self-transfer blocked
- Invalid PIN rejection
- Account lock after 5 failed logins
- Input validation on all DTOs
Orb/
├── src/main/java/com/orb/
│ ├── config/ SecurityConfig, JwtConfig, CorsConfig, AsyncConfig, OpenApiConfig
│ ├── controller/ AuthController, UserController, WalletController, TransactionController
│ ├── dto/ RegisterRequest, LoginResponse, SendRequest, WalletDTO, ApiResponse<T>
│ ├── entity/ User, Wallet, Transaction, Notification
│ ├── exception/ GlobalExceptionHandler, InsufficientBalanceException, InvalidTransferException
│ ├── mapper/ UserMapper, WalletMapper, TransactionMapper (MapStruct, compile-time)
│ ├── repository/ UserRepository, WalletRepository, TransactionRepository
│ ├── security/ JwtAuthFilter, JwtUtil, UserDetailsServiceImpl
│ └── service/ AuthService, WalletService, TransactionService, NotificationService
│
├── src/main/resources/
│ ├── application.yml
│ ├── application-test.yml
│ └── db/migration/
│ ├── V1__create_users_table.sql
│ ├── V2__create_wallets_table.sql
│ ├── V3__create_transactions_table.sql
│ └── V4__create_notifications_table.sql
│
├── src/test/ JUnit 5 + Mockito (H2 in PostgreSQL mode)
├── .github/workflows/ CI — mvn test on every push to main
│
├── frontend/
│ ├── src/
│ │ ├── api/ Axios instance, JWT interceptor, per-feature API functions
│ │ ├── components/ Navbar, BalanceCard, TransactionItem, QRModal
│ │ ├── hooks/ useAuth, useWallet
│ │ ├── pages/ Login, Register, Dashboard, Send, History, QR, Notifications, Profile
│ │ ├── store/ useAuthStore, useWalletStore (Zustand)
│ │ └── utils/ formatCurrency, formatDate
│ └── package.json
│
├── pom.xml
├── mvnw / mvnw.cmd
└── README.md
Four tables. All monetary values stored as DECIMAL(15,2). UUIDs as native PostgreSQL UUID type.
USERS WALLETS
──────────────────────── ─────────────────────────────
id UUID PK id UUID PK
email VARCHAR(255) UQ user_id UUID FK → users
username VARCHAR(255) UQ balance DECIMAL(15,2) DEFAULT 100000.00
password_hash TEXT currency VARCHAR(10) DEFAULT 'ORB'
full_name VARCHAR(200) wallet_tag VARCHAR(64) UQ
failed_attempts INT DEFAULT 0 transfer_pin_hash VARCHAR(255)
is_active BOOLEAN DEFAULT true is_locked BOOLEAN DEFAULT false
created_at TIMESTAMP created_at TIMESTAMP
TRANSACTIONS NOTIFICATIONS
────────────────────────────── ─────────────────────────
id UUID PK id UUID PK
sender_wallet_id UUID FK user_id UUID FK → users
receiver_wallet_id UUID FK type VARCHAR(20)
amount DECIMAL(15,2) title VARCHAR(200)
type VARCHAR(20) message TEXT
status VARCHAR(20) is_read BOOLEAN DEFAULT false
note TEXT created_at TIMESTAMP
reference_id VARCHAR(64) UQ
created_at TIMESTAMP
Schema is managed by Flyway. Migrations V1 through V4 are frozen once applied. Any future schema change ships as a new migration file (V5, V6, etc.).
Full interactive documentation is available at /swagger-ui.html when the backend is running.
Standard response envelope — every endpoint uses this:
{ "success": true, "message": "Transfer successful", "data": { ... } }
{ "success": false, "message": "Insufficient balance", "data": null }Endpoints
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register |
No | Create account, auto-seed 1,00,000 wallet |
| POST | /api/auth/login |
No | Authenticate, return access and refresh tokens |
| POST | /api/auth/refresh |
No | Exchange refresh token for new access token |
| GET | /api/users/me |
JWT | Fetch own profile |
| PUT | /api/users/me |
JWT | Update full name |
| GET | /api/users/search?username= |
JWT | Search users by username |
| GET | /api/wallet/me |
JWT | Balance, wallet tag, currency |
| GET | /api/wallet/qr |
JWT | Wallet QR as base64 PNG |
| POST | /api/transactions/send |
JWT | Body: { receiverWalletTag, amount, pin, note } |
| GET | /api/transactions?page=0&size=10 |
JWT | Paginated history, filterable by type and date |
Status codes: 200 success, 201 created, 400 bad request, 401 unauthorized, 403 forbidden, 404 not found, 409 conflict, 429 rate limited, 500 server error.
1. POST /api/auth/register
2. Server creates User + Wallet inside one @Transactional method
3. Server returns { accessToken, refreshToken, user }
4. Frontend stores tokens, attaches on every request:
Authorization: Bearer <accessToken>
5. JwtAuthFilter (extends OncePerRequestFilter) validates the token on every protected route
6. On 401, frontend calls POST /api/auth/refresh
7. Gets a new accessToken, retries the original request silently
Access tokens expire after 24 hours. Refresh tokens expire after 7 days. The JWT secret is loaded from an environment variable and must be at least 32 characters.
The most critical path in the application. Designed so that money can never be lost, even on a server crash mid-operation.
POST /api/transactions/send
|
|-- 1. Validate JWT, identify sender
|-- 2. Verify transfer PIN (BCrypt.matches vs transfer_pin_hash)
|-- 3. Look up receiver by wallet_tag
|-- 4. Guard: sender != receiver (block self-transfer)
|-- 5. Guard: sender.balance >= amount (fail fast with 400)
|
|-- @Transactional BEGIN
| |-- sender.balance -= amount (debit)
| |-- receiver.balance += amount (credit)
| |-- INSERT transaction row (status = SUCCESS)
|-- @Transactional COMMIT
| (any exception anywhere above causes full rollback)
|
|-- @Async: send email notification to receiver (non-blocking)
|-- Return { success, referenceId, newBalance }
| Area | Implementation |
|---|---|
| Passwords | BCrypt hashed. Never stored, logged, or returned in any response. |
| Transfer PIN | Separate BCrypt hash stored on the wallet row. Verified on every transfer. Never returned in any API response. |
| JWT | HS256 signed. 24-hour access tokens, 7-day refresh tokens. Secret loaded from environment variable, minimum 32 characters. |
| Account lock | failed_attempts counter on User entity. Account locked (is_active = false) after 5 consecutive bad logins. |
| Rate limiting | Bucket4j. 20 requests per minute per IP in production. |
| CORS | Exact frontend origin only. Never *, especially with allowCredentials(true). |
| Input validation | @Valid on every request DTO. Server-side validation on all fields. |
| Logging | password_hash and transfer_pin_hash excluded from all log output. |
| HTTPS | Enforced by Railway and Vercel in production. |
| Decision | Rationale |
|---|---|
BigDecimal, never double |
Floating-point arithmetic causes silent precision drift in currency calculations. BigDecimal with DECIMAL(15,2) guarantees exact math. BigDecimal values are always constructed from String, never from double literals. |
User + Wallet in one @Transactional |
A user must never exist without a wallet, even if the server crashes mid-registration. One transaction guarantees atomicity. |
Debit + Credit in one @Transactional |
A partial transfer (money debited but not credited) must be structurally impossible. Full rollback on any exception. |
| Transfer PIN is not the login password | Separate BCrypt hash stored on the wallet row. Even if a session is hijacked, money cannot move without the PIN. |
@Async email notifications |
JavaMail and SendGrid calls add 1 to 3 seconds of latency. @Async moves them off the request thread so the API responds instantly. |
OncePerRequestFilter for JWT |
Spring's filter chain can invoke a filter more than once in some configurations. OncePerRequestFilter guarantees exactly one execution per request. |
| Flyway, not Hibernate DDL | ddl-auto: validate ensures Hibernate never silently modifies the schema. All migrations are version-controlled SQL files. A migration that has already run is never edited. |
| MapStruct, not manual mapping | Compile-time entity-to-DTO mapping eliminates runtime reflection overhead and catches mapping errors at build time. |
| H2 in PostgreSQL mode for tests | Tests run fast without needing a real database, but SQL compatibility ensures test behavior matches production. |
- Push the repository to GitHub.
- Connect Railway to the GitHub repository.
- Set environment variables in the Railway dashboard:
DB_USERNAME=<railway-provided> DB_PASSWORD=<railway-provided> JWT_SECRET=<your-secret-min-32-chars> MAIL_USERNAME=<sendgrid-api-key> MAIL_PASSWORD=<sendgrid-password> CORS_ORIGIN=https://your-frontend.vercel.app - Railway auto-detects the Maven project and deploys.
- Import the
frontend/directory in Vercel. - Set build command:
npm run build. - Set output directory:
dist. - Add environment variable:
VITE_API_BASE_URL=https://your-backend.railway.app
MIT — free to use, modify, and distribute.