Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Copy to .env and adjust for local development:
# cp .env.example .env

# PostgreSQL connection string (matches docker-compose.yml defaults)
DATABASE_URL=postgres://wallet:wallet@localhost:5432/wallet_transfer?sslmode=disable

# HTTP server port
HTTP_PORT=8080
60 changes: 56 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,59 @@
# OS / Editor
.DS_Store
coverage/
dist/
build/
.tmp/
.DS_Store?
._*
.Spotlight-V100
.Trashes
Thumbs.db
*.swp
*.swo
*~
.idea/
.vscode/
*.iml

# Environment & secrets
.env
.env.*
!.env.example

# Go build artifacts
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
/bin/
/dist/
/build/
/tmp/
.tmp/

# Go module / vendor
/vendor/

# Test & coverage
coverage/
coverage.out
coverage.html
*.coverprofile
profile.cov

# Database (local dev)
*.db
*.sqlite
*.sqlite3
postgres-data/
pgdata/
docker-data/

# Migrations / tooling cache
.migrate/
*.log

# Docker
docker-compose.override.yml


34 changes: 34 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
.PHONY: db-up db-down db-logs migrate-up migrate-down migrate-version

DATABASE_URL ?= postgres://wallet:wallet@localhost:5432/wallet_transfer?sslmode=disable
MIGRATE_IMAGE ?= migrate/migrate:v4.18.1

db-up:
docker compose up -d postgres

db-down:
docker compose down

db-logs:
docker compose logs -f postgres

migrate-up:
docker compose run --rm migrate

migrate-down:
docker run --rm \
-v "$(CURDIR)/migrations:/migrations" \
--add-host=host.docker.internal:host-gateway \
$(MIGRATE_IMAGE) \
-path=/migrations \
-database="postgres://wallet:wallet@host.docker.internal:5432/wallet_transfer?sslmode=disable" \
down 1

migrate-version:
docker run --rm \
-v "$(CURDIR)/migrations:/migrations" \
--add-host=host.docker.internal:host-gateway \
$(MIGRATE_IMAGE) \
-path=/migrations \
-database="postgres://wallet:wallet@host.docker.internal:5432/wallet_transfer?sslmode=disable" \
version
170 changes: 170 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Wallet Transfer Service

## Overview

Built a wallet-to-wallet transfer service with a single endpoint:

`POST /transfers`

Features:
- Wallet to wallet money transfer
- Idempotent requests using `idempotencyKey`
- Double-entry ledger (DEBIT & CREDIT)
- Database transaction for atomic transfers
- Safe concurrent transfers using row locks
- Transfer status: `PENDING` → `PROCESSED` / `FAILED`

Project structure:
- Handler - HTTP layer
- Service - Business logic
- Repository - Database operations
- Domain - Models & validation

Seed data:
- wallet-alice : 100000
- wallet-bob : 50000

---

## AI Disclosure

AI tool used: Cursor

Used AI only as a coding assistant for:
- Creating the test plan
- Generating test cases
- Debugging failed tests
- Fixing a few bugs
- Writing documentation

The application design, database schema, and transfer logic were implemented by me.

---

## Database Schema

### wallets
- id
- balance
- created_at
- updated_at

### transfers
- id
- idempotency_key
- from_wallet_id
- to_wallet_id
- amount
- status
- created_at
- updated_at

### ledger_entries
- id
- transfer_id
- wallet_id
- entry_type (DEBIT / CREDIT)
- amount

### idempotency_records
- idempotency_key
- transfer_id
- response_body
- http_status
- created_at

Notes:
- Wallet balance is stored directly for faster reads.
- Ledger is maintained for audit.
- Database constraints prevent invalid transfers.
- Indexes added on frequently queried columns.

---

## Idempotency

- Check `idempotency_records` first.
- If found, return the stored response.
- Otherwise create the transfer.
- Duplicate requests with the same key return the original response.
- Success and failure responses are both cached.

---

## Concurrency

- Entire transfer runs inside one database transaction.
- Both wallets are locked using `SELECT ... FOR UPDATE`.
- Wallets are always locked in the same order to avoid deadlocks.
- Balance check happens after acquiring locks.
- Prevents double spending during concurrent requests.

Example:
- Balance = 150
- Two concurrent transfers of 100
- One succeeds.
- One fails.
- Final balance = 50.

---

## Running the Project

Requirements:
- Docker
- Go 1.25+

```bash
make db-up
make migrate-up

cp .env.example .env

go run ./cmd/server
```

Server:

```text
http://localhost:8080
```

Sample request:

```bash
curl -X POST http://localhost:8080/transfers \
-H "Content-Type: application/json" \
-d '{
"idempotencyKey":"req-001",
"fromWalletId":"wallet-alice",
"toWalletId":"wallet-bob",
"amount":100
}'
```

Run tests:

```bash
go test ./...
```

Tests cover:
- Validation
- Successful transfers
- Ledger entries
- Idempotency
- Error cases
- Concurrent transfers

Detailed test scenarios are available in `TEST_CASES.md`.

---

## Assumptions & Trade-offs

- Wallet balance is stored instead of calculating it from the ledger for faster reads.
- Amounts are stored in whole rupees (`BIGINT`).
- Negative balances are prevented through application logic and row locking.
- No cleanup process for `PENDING` transfers since failed transactions are rolled back.
- No authentication (not required for the assignment).
- Balance and transaction history APIs were optional, so they were not implemented.
46 changes: 46 additions & 0 deletions TEST_CASES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Wallet Transfer — Test Plan

Seeded test wallets: `wallet-alice` (100000), `wallet-bob` (50000).
Convention: sender = money leaves this wallet, receiver = money lands here.

## 1. Request validation (no DB)
- Reject empty idempotency key, empty from/to wallet ID, or sender == receiver.
- Reject zero or negative amount.

## 2. Happy path
- Transfer 100 from alice to bob → 201, status `PROCESSED`.
- Sender balance -100, receiver balance +100.
- Exactly one transfer row created; response includes the transfer ID.

## 3. Ledger
- Every transfer writes exactly 2 ledger entries: a DEBIT on the sender and a CREDIT on the receiver, both for the same amount.
- Retrying with the same idempotency key doesn't add more ledger rows.

## 4. Idempotency
- Same key sent twice → same response and transfer ID, balances only move once.
- First call persists a row in `idempotency_records`.
- A failed transfer (e.g. insufficient funds) is idempotent too — retrying doesn't retry the side effects.
- Two concurrent requests with the same key → only one transfer actually happens; both callers get the same result.

## 5. States & failure modes
- Success → `PROCESSED`. Insufficient funds → `FAILED`, no balance change, no ledger rows.
- Status codes: insufficient funds → 409, unknown wallet → 404, malformed/empty JSON → 400.

## 6. Concurrency
- Sender has 150; two parallel 100-transfers to different receivers (different keys) → only one succeeds, final balance is 50 (never negative).
- Sender has 1000; ten parallel 100-transfers → all succeed, final balance 0.
- alice→bob and bob→alice fired simultaneously → both succeed (assuming sufficient funds), final balances correct.

## 7. HTTP handler
- Valid POST → 201 with JSON body. Validation failure → 400. Unknown wallet → 404. Insufficient funds → 409.

## 8. DB constraints (optional/nice-to-have)
- Unique idempotency key, no same-wallet transfers, no zero-amount transfers, one DEBIT per transfer enforced at the DB level.

---

**Order:** validation → happy path → ledger → idempotency → states/failures → concurrency → handler.

**Setup notes:** use the seeded wallets or create ones with known balances per test. Postgres via docker-compose, DB reset between tests, fresh idempotency key per test unless you're specifically testing duplicates.

**Out of scope for now:** balance API, history API, metrics — skip until those exist.
Loading