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
- 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
int64paise. - External balance display as rupee strings with exactly two decimal places.
- Unit and integration tests, including race detector support.
- Dockerfile for containerized runs.
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.
| User ID | Name | Account Number | Balance In Paise | Display Balance |
|---|---|---|---|---|
U1 |
Alex | URN1001 |
100000 |
1000.00 |
U2 |
Bob | URN1002 |
50000 |
500.00 |
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
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
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.
Tokens use HS256 and include:
{
"user_id": "U1",
"account_number": "URN1001",
"exp": 1234567890
}GET /healthPOST /auth/login
These require an Authorization header:
Authorization: Bearer <jwt_token>GET /balance?account=URN1001POST /transfer
Base URL:
http://localhost:8080
GET /healthResponse:
{
"status": "ok"
}POST /auth/loginRequest:
{
"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 |
GET /balance?account=URN1001
Authorization: Bearer <jwt_token>Success response:
{
"account_no": "URN1001",
"balance": "1000.00"
}Ownership rule:
- Token account must match the
accountquery parameter. - Example: a token for
URN1001cannot readURN1002.
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 |
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
fromaccount. - Example: a token for
URN1001cannot transfer fromURN1002.
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 |
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 $headersTransfer 1 rupee:
Invoke-RestMethod "http://localhost:8080/transfer" `
-Method Post `
-ContentType "application/json" `
-Headers $headers `
-Body '{"from":"URN1001","to":"URN1002","amount_in_paise":100}'cd C:\Users\bkkav\Downloads\payment-service-bhavya\payment-service
$env:GOCACHE="$PWD\.gocache"
$env:GOMODCACHE="$PWD\.gomodcache"
go run cmd/main.goBuild:
docker build -t payment-service:paise-refactor .Run:
docker run --name payment-service-app -p 8080:8080 payment-service:paise-refactorStop and remove:
docker stop payment-service-app
docker rm payment-service-appRun all tests:
go test ./...Run race detector:
go test -race ./...Build all packages:
go build ./...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