Skip to content

Repository files navigation

Internal Wallet Service

A ledger-based wallet service built for high-traffic applications like gaming platforms or loyalty reward systems. This service manages application-specific virtual credits (Gold Coins, Diamonds, Loyalty Points) with full transactional integrity, concurrency safety, and idempotent operations.

Live Deployment

Tech Stack & Reasoning

Technology Why
Java 21 + Spring Boot 4 Mature ecosystem for building transactional backend services. @Transactional provides declarative transaction management, and Spring Data JPA eliminates boilerplate database code.
PostgreSQL 16 Industry standard relational database with excellent ACID transaction support, row-level locking (SELECT ... FOR UPDATE), and robust concurrency handling critical for a financial service.
Docker + Docker Compose One-command setup (docker-compose up) ensures the project run without installing Java or PostgreSQL locally.
Double-Entry Ledger Instead of simply updating a balance column, every transaction creates two ledger entries (one debit, one credit). This provides full auditability and ensures the system is always internally consistent, the sum of all ledger entries across all accounts is always zero.

Architecture

Controller Layer (REST API)
    ↓ validates input, returns HTTP responses
Service Layer (Business Logic)
    ↓ handles transaction flows, locking, idempotency
Repository Layer (Database Access)
    ↓ JPA repositories with pessimistic locking support
PostgreSQL (ACID Transactions)

Database Schema

┌──────────────┐     ┌──────────────┐     ┌──────────────────┐
│   assets     │     │    users     │     │    accounts       │
├──────────────┤     ├──────────────┤     ├──────────────────┤
│ id (PK)      │     │ id (PK)      │     │ id (PK)          │
│ name         │◄────┤ name         │     │ user_id (FK)     │──► users
│ code (UNIQUE)│     │ email(UNIQUE)│     │ asset_id (FK)    │──► assets
│ created_at   │     │ created_at   │     │ type (USER/SYSTEM)│
└──────────────┘     └──────────────┘     │ balance          │
                                          │ created_at       │
                                          └──────────────────┘
                                                   │
                                                   ▼
┌──────────────────────---┐     ┌──────────────────────┐
│   transactions          │     │   ledger_entries     │
├──────────────────────---┤     ├──────────────────────┤
│ id (PK)                 │     │ id (PK)              │
│ type (TOPUP/BONUS/      │◄────│ transaction_id (FK)  │
│      SPEND)             │     │ account_id (FK)      │──► accounts
│ idempotency_key(UNIQUE) │     │ type (CREDIT/DEBIT)  │
│ status                  │     │ amount               │
│ created_at              │     │ created_at           │
└──────────────────────---┘     └──────────────────────┘

Key design decisions:

  • accounts.balance is a cached value for fast reads. The source of truth is always the ledger entries.
  • System accounts (Treasury) have user_id = NULL and type = SYSTEM. They act as the source/destination for all credit movements.
  • Every transaction creates exactly 2 ledger entries — one debit and one credit ensuring the system always sums to zero.

Quick Start

Option 1: Docker Compose

git clone https://github.com/Muskan244/InternalWalletService.git
cd internal-wallet-service
docker-compose up --build

This automatically:

  1. Starts a PostgreSQL 16 database
  2. Runs the seed.sql script (creates tables + inserts initial data)
  3. Starts the Spring Boot application on port 8080

The API will be available at http://localhost:8080

Easily accessible through Swagger UI at http://localhost:8080/swagger-ui.html

Option 2: Manual Setup

Prerequisites: Java 21, PostgreSQL 16

# 1. Create the database
createdb wallet

# 2. Run the seed script
psql -d wallet -f src/main/resources/seed.sql

# 3. Start the application
./gradlew bootRun

Seed Data

The seed script creates the following initial state:

Asset Types:

Code Name
GOLD_COINS Gold Coins
DIAMONDS Diamonds
LOYALTY_POINTS Loyalty Points

Users:

Name Email
Aditi Singh aditisingh@gmail.com
Priya Jha priyajha@gmail.com

Initial Balances:

  • Aditi: 500 Gold Coins (backed by a seed TOPUP transaction + ledger entries)
  • Priya: 300 Gold Coins (backed by a seed TOPUP transaction + ledger entries)
  • Treasury (SYSTEM): -800 Gold Coins (net outflow to users, this is expected for a system account)

API Endpoints

1. Process Transaction

POST /api/v1/transactions

Handles all three transaction flows: top-up, bonus, and spend.

Request Body:

{
  "type": "TOPUP",
  "email": "aditisingh@gmail.com",
  "assetCode": "GOLD_COINS",
  "amount": 100,
  "idempotencyKey": "unique-key-123"
}
Field Type Required Description
type String Yes TOPUP, BONUS, or SPEND
email String Yes User's email address
assetCode String Yes Asset code (e.g., GOLD_COINS)
amount Long Yes Amount (must be positive)
idempotencyKey String Yes Unique key to prevent duplicate processing

Success Response (200):

{
  "transactionId": "550e8400-e29b-41d4-a716-446655440000",
  "type": "TOPUP",
  "amount": 100,
  "balanceAfter": 600,
  "status": "SUCCESS",
  "createdAt": "2026-02-22T12:00:00Z"
}

Error Responses:

  • 400 — Insufficient balance, invalid amount, missing required fields
  • 404 — User not found, asset not found, account not found

2. Get Balance

GET /api/v1/wallet/balance?email={email}&assetCode={assetCode}

Success Response (200):

{
  "email": "aditisingh@gmail.com",
  "assetName": "Gold Coins",
  "balance": 600
}

3. Get Transaction History

GET /api/v1/transactions?email={email}

Returns all transactions for a user in reverse chronological order.

Concurrency Strategy

Problem

In a high-traffic system, two requests can arrive simultaneously to spend from the same wallet. Without protection:

  1. Both read balance as 100
  2. Both check "is 100 >= 80?" - both pass
  3. Both debit 80
  4. Balance goes to -60 - data corruption

Solution: Pessimistic Locking with Consistent Lock Ordering

Pessimistic Locking: Before modifying any account balance, the service acquires a row-level lock using SELECT ... FOR UPDATE (via JPA's @Lock(LockModeType.PESSIMISTIC_WRITE)). The second concurrent request blocks until the first completes, then reads the updated balance.

Request 1: BEGIN → LOCK account (acquires lock) → check balance → debit → COMMIT
Request 2: BEGIN → LOCK account (waits...) ────────────────────────────→ (acquires lock) → check balance → sees updated balance → proceeds

Deadlock Avoidance: Every transaction involves two accounts (e.g., user account + Treasury). If two transactions lock these accounts in different orders, a deadlock can occur. To prevent this, accounts are always locked in ascending UUID order regardless of the transaction direction. This eliminates circular wait conditions.

Idempotency Strategy

Problem

Network failures can cause clients to retry requests. Without protection, the same top-up could be applied multiple times.

Solution: Idempotency Key

Every request includes a client-generated idempotencyKey. Before processing:

  1. Check if a transaction with this key already exists in the database
  2. If yes → return the original result without reprocessing
  3. If no → process normally and store the key

The idempotency_key column has a UNIQUE constraint in the database as a safety net even if the application-level check has a race condition, the database prevents duplicate insertions.

About

a wallet service for a high-traffic application like a gaming platform or a loyalty rewards system

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages