Skip to content

Repository files navigation

Concurrent Safe Money Transfer System

A small Gin-based Go payment service that supports JWT-authenticated balance checks and money transfers between in-memory accounts.

The service uses a layered architecture:

handler -> service -> repository -> in-memory map

Current Features

  • Health check endpoint.
  • Login endpoint that issues a JWT for an in-memory user.
  • JWT middleware for protected account APIs.
  • Ownership checks for balance and transfer operations.
  • Concurrent-safe balance updates using per-account mutex locks.
  • Internal money storage and calculation using int64 paise.
  • External balance display as rupee strings with exactly two decimal places.
  • Unit and integration tests, including race detector support.
  • Dockerfile for containerized runs.

What This Project Does Not Use

This task intentionally does not add:

  • PostgreSQL
  • Redis
  • Kafka
  • Transaction history
  • Idempotency keys
  • Docker Compose
  • Swagger
  • Password-based login

All user/account data is still in memory and resets on restart.

Initial Users

User ID Name Account Number Balance In Paise Display Balance
U1 Alex URN1001 100000 1000.00
U2 Bob URN1002 50000 500.00

Project Structure

payment-service/
|-- cmd/
|   |-- main.go
|   `-- main_test.go
|-- internal/
|   |-- bootstrap/
|   |-- dto/
|   |-- handlers/
|   |-- jwtutil/
|   |-- logs/
|   |-- middleware/
|   |-- models/
|   |-- money/
|   |-- repositories/
|   `-- services/
|-- Dockerfile
|-- go.mod
|-- go.sum
|-- payment.postman_collection.json
|-- PROJECT_DOCUMENTATION.md
|-- UPDATE_NOTES.md
|-- readme.md
`-- request.json

Money Representation

Internally, money is stored and calculated in paise using int64.

Examples:

Paise Rupees Display
100000 1000.00
50050 500.50
505 5.05
0 0.00

The formatter is implemented in:

internal/money/money.go

Authentication

JWT Secret

The app reads the JWT signing secret from:

JWT_SECRET

If JWT_SECRET is missing, it uses this local-development fallback:

local-dev-secret

Do not use the fallback secret in production.

JWT Claims

Tokens use HS256 and include:

{
  "user_id": "U1",
  "account_number": "URN1001",
  "exp": 1234567890
}

Public Endpoints

  • GET /health
  • POST /auth/login

Protected Endpoints

These require an Authorization header:

Authorization: Bearer <jwt_token>
  • GET /balance?account=URN1001
  • POST /transfer

API Documentation

Base URL:

http://localhost:8080

Health Check

GET /health

Response:

{
  "status": "ok"
}

Login

POST /auth/login

Request:

{
  "user_id": "U1"
}

Success response:

{
  "token": "<jwt_token>"
}

Invalid user response:

{
  "error": "unauthorized"
}

Status codes:

Scenario Status
Valid user 200 OK
Missing user_id 400 Bad Request
Unknown user 401 Unauthorized

Check Balance

GET /balance?account=URN1001
Authorization: Bearer <jwt_token>

Success response:

{
  "account_no": "URN1001",
  "balance": "1000.00"
}

Ownership rule:

  • Token account must match the account query parameter.
  • Example: a token for URN1001 cannot read URN1002.

Status codes:

Scenario Status
Valid token and owned account 200 OK
Missing/invalid/expired token 401 Unauthorized
Account does not match token account 403 Forbidden
Missing account parameter 400 Bad Request
Account not found 404 Not Found

Transfer Funds

POST /transfer
Authorization: Bearer <jwt_token>

Request:

{
  "from": "URN1001",
  "to": "URN1002",
  "amount_in_paise": 100
}

Success response:

{
  "message": "transfer successful"
}

Ownership rule:

  • Token account must match the from account.
  • Example: a token for URN1001 cannot transfer from URN1002.

Status codes:

Scenario Status
Valid token and owned from account 200 OK
Missing/invalid/expired token 401 Unauthorized
from does not match token account 403 Forbidden
Invalid JSON or invalid amount 400 Bad Request
Same sender and receiver 400 Bad Request
Insufficient funds 400 Bad Request
Sender or receiver not found 400 Bad Request

Example PowerShell Flow

Login:

$login = Invoke-RestMethod "http://localhost:8080/auth/login" `
  -Method Post `
  -ContentType "application/json" `
  -Body '{"user_id":"U1"}'

$headers = @{ Authorization = "Bearer $($login.token)" }

Check balance:

Invoke-RestMethod "http://localhost:8080/balance?account=URN1001" -Headers $headers

Transfer 1 rupee:

Invoke-RestMethod "http://localhost:8080/transfer" `
  -Method Post `
  -ContentType "application/json" `
  -Headers $headers `
  -Body '{"from":"URN1001","to":"URN1002","amount_in_paise":100}'

Run Locally

cd C:\Users\bkkav\Downloads\payment-service-bhavya\payment-service
$env:GOCACHE="$PWD\.gocache"
$env:GOMODCACHE="$PWD\.gomodcache"
go run cmd/main.go

Run With Docker

Build:

docker build -t payment-service:paise-refactor .

Run:

docker run --name payment-service-app -p 8080:8080 payment-service:paise-refactor

Stop and remove:

docker stop payment-service-app
docker rm payment-service-app

Testing

Run all tests:

go test ./...

Run race detector:

go test -race ./...

Build all packages:

go build ./...

Validation Status

The latest local validation passed:

go test ./...        PASS
go test -race ./...  PASS
go build ./...       PASS

Manual auth checks passed:

GET /health without token                  -> 200
GET /balance without token                 -> 401
POST /transfer without token               -> 401
POST /auth/login with U1                   -> 200, token returned
GET /balance?account=URN1001 with U1 token -> 200
GET /balance?account=URN1002 with U1 token -> 403
POST /transfer from URN1001 with U1 token  -> 200
POST /transfer from URN1002 with U1 token  -> 403

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages