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
23 changes: 4 additions & 19 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ jobs:
name: lint-format-test
runs-on: [actions_runner_dev_new]
env:
LINT_CMD: ${{ vars.LINT_CMD }}
FORMAT_CHECK_CMD: ${{ vars.FORMAT_CHECK_CMD }}
TEST_CMD: ${{ vars.TEST_CMD }}
CGO_ENABLED: ${{ vars.CGO_ENABLED }}
CGO_ENABLED: 1
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -33,32 +30,20 @@ jobs:
with:
go-version: '1.24'

- name: Install golangci-lint
run: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $HOME/.local/bin v1.64.8 echo "$HOME/.local/bin" >> $GITHUB_PATH

- name: Validate commands configured
shell: bash
run: |
set -euo pipefail
test -n "${LINT_CMD:-}" || { echo "Missing repository variable LINT_CMD"; exit 1; }
test -n "${FORMAT_CHECK_CMD:-}" || { echo "Missing repository variable FORMAT_CHECK_CMD"; exit 1; }
test -n "${TEST_CMD:-}" || { echo "Missing repository variable TEST_CMD"; exit 1; }

- name: Run lint
shell: bash
run: |
set -euo pipefail
eval "$LINT_CMD"
go vet ./...

- name: Run format check
shell: bash
run: |
set -euo pipefail
eval "$FORMAT_CHECK_CMD"
test -z "$(gofmt -l .)"

- name: Run tests
shell: bash
run: |
set -euo pipefail
eval "$TEST_CMD"
go test ./...
12 changes: 12 additions & 0 deletions AI_USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# AI Usage Note

Tool used: OpenAI Codex in the Codex desktop app.

How I used it: I used Codex as a pair-programming assistant to inspect the assignment, scaffold the Go project, implement the layered service, write behavioral tests, and prepare documentation. I reviewed the generated design and code decisions while keeping the implementation focused on the assignment requirements.

Prompts used in this session:

1. "connect with my github"
2. "we will do this in go make branch as Wallet Transfer Assignment Repository ... Your PR branch should be named: solution/<your-name> (e.g., solution/jane-doe)."

Transcript note: The full interactive transcript is available in the Codex desktop thread for this work. If a plain-text transcript is required outside Codex, export this thread or provide these prompts with the pull request.
64 changes: 63 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,68 @@
# Wallet Transfer Assignment Repository

This repository is a reusable coding assignment template for evaluating backend engineers on wallet transfers, idempotency, concurrency control, and double-entry ledger design.
This branch contains a Go implementation of the wallet transfer assignment. It provides a small HTTP service with GORM-backed SQLite persistence, idempotent transfer creation, stored wallet balances, safe transfer state transitions, and double-entry ledger entries.

## Running locally

Requirements:

- Go 1.24
- CGO-enabled SQLite build support. On Linux, install `gcc`; on macOS, Xcode command line tools are enough.

Run tests:

```sh
go test ./...
```

Run the service:

```sh
go run ./cmd/server
```

By default the service listens on `:8080` and uses `wallets.db`. Override with:

```sh
HTTP_ADDR=:9090 DB_PATH=/tmp/wallets.db go run ./cmd/server
```

Seed wallets are created if absent:

- `wallet_1` with balance `1000`
- `wallet_2` with balance `1000`
- `wallet_3` with balance `1000`

Create a transfer:

```sh
curl -X POST http://localhost:8080/transfers \
-H 'Content-Type: application/json' \
-d '{"idempotencyKey":"abc123","fromWalletId":"wallet_1","toWalletId":"wallet_2","amount":100}'
```

Read data:

```sh
curl http://localhost:8080/wallets/wallet_1
curl http://localhost:8080/transfers/<transfer_id>
```

## Implementation notes

- Architecture is split into handler, service, repository, domain, and port packages.
- GORM repository models are used with SQLite persistence to keep the assignment self-contained.
- Transfers are executed inside a database transaction.
- Idempotency records store the key, canonical request hash, and original transfer id.
- Duplicate requests with the same key and payload return the original result without side effects.
- Duplicate requests with the same key and different payload return a conflict.
- Wallet debits use a conditional update to prevent overspending.
- Processed transfers write exactly two ledger entries: one `DEBIT` and one `CREDIT`.
- Failed business transfers are durable and do not change balances or write ledger entries.

See [docs/DESIGN.md](./docs/DESIGN.md) for the detailed design, failure modes, and testing strategy.

## Original template content

## Included

Expand Down
63 changes: 63 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package main

import (
"context"
"log"
"net/http"
"os"
"time"

"github.com/vardaanmittal09/wallet-transfer-assignment/internal/handler"
"github.com/vardaanmittal09/wallet-transfer-assignment/internal/repository/sqlite"
"github.com/vardaanmittal09/wallet-transfer-assignment/internal/service"
gormsqlite "gorm.io/driver/sqlite"
"gorm.io/gorm"
)

func main() {
dbPath := getenv("DB_PATH", "wallets.db")
addr := getenv("HTTP_ADDR", ":8080")

db, err := gorm.Open(gormsqlite.Open(sqlite.DSN(dbPath)), &gorm.Config{})
if err != nil {
log.Fatalf("open database: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
log.Fatalf("get database handle: %v", err)
}
defer sqlDB.Close()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

store := sqlite.NewStore(db)
if err := store.Migrate(ctx); err != nil {
log.Fatalf("migrate database: %v", err)
}

if err := store.SeedWallets(ctx, map[string]int64{
"wallet_1": 1000,
"wallet_2": 1000,
"wallet_3": 1000,
}); err != nil {
log.Fatalf("seed wallets: %v", err)
}

svc := service.NewTransferService(store)
mux := http.NewServeMux()
handler.RegisterRoutes(mux, svc)

log.Printf("wallet transfer service listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("serve: %v", err)
}
}

func getenv(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}
96 changes: 96 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Wallet Transfer Service Design

## Problem statement

The service exposes wallet-to-wallet transfers with API-level exactly-once behavior for a caller-supplied `idempotencyKey`. A successful transfer debits one wallet, credits another wallet, and records a balanced double-entry ledger. A failed business transfer is recorded as `FAILED` without partial balance or ledger side effects.

## API contract

### `POST /transfers`

Request:

```json
{
"idempotencyKey": "abc123",
"fromWalletId": "wallet_1",
"toWalletId": "wallet_2",
"amount": 100
}
```

Responses:

- `201 Created` for a newly processed transfer.
- `200 OK` for a replay of the same idempotency key and identical payload.
- `409 Conflict` if an existing idempotency key is reused with a different payload.
- `422 Unprocessable Entity` for a recorded business failure such as insufficient funds.
- `400 Bad Request` for validation errors.

The response includes transfer state, ledger entries for processed transfers, and a `replay` flag.

### Optional read APIs

- `GET /wallets/{id}` returns a wallet and its stored balance.
- `GET /transfers/{id}` returns the transfer and its ledger entries.
- `GET /healthz` returns service health.

## Persistence

SQLite is used for local portability, with GORM repository models and `AutoMigrate` owning table creation. The schema has:

- `wallets`: stored balance with `CHECK (balance >= 0)`.
- `transfers`: immutable transfer request data, state, and failure reason.
- `ledger_entries`: double-entry rows with `DEBIT` and `CREDIT` types and a uniqueness constraint on `(transfer_id, type)`.
- `idempotency_records`: unique idempotency key, canonical request hash, and transfer id.

Amounts are represented as integer minor units. Floating point money is intentionally avoided.

## Idempotency behavior

The idempotency key is inserted in the same database transaction as the transfer. The service stores a SHA-256 hash of the canonical request payload excluding the idempotency key. On duplicate requests:

- Same key and same payload returns the original transfer result.
- Same key and different payload returns `409 Conflict`.
- Duplicate requests never create additional transfers or ledger entries.

## Transaction and concurrency strategy

Each transfer runs in a serializable GORM transaction. The SQLite store uses a single open database connection and a busy timeout, so writes are serialized and duplicate/concurrent requests wait for the active writer instead of observing partial state.

The debit operation uses a guarded update:

```sql
UPDATE wallets
SET balance = balance - ?
WHERE id = ? AND balance >= ?
```

This is the final no-overspend guard. If it affects no rows, the transfer is marked `FAILED` and no ledger entry is written.

## State transitions

Transfers start as `PENDING` and can only move to `PROCESSED` or `FAILED`. Repository updates enforce this with `WHERE state = 'PENDING'`, preventing retries from mutating terminal transfers.

## Failure modes

- Validation errors are rejected before opening the transaction.
- Missing wallets and insufficient funds become durable `FAILED` transfer records.
- Unexpected database errors roll back the whole transaction.
- Processed transfers always have exactly two ledger entries: one debit and one credit.

## Observability

The service logs startup configuration and exposes `/healthz`. In a larger production system I would add request IDs, structured logs, metrics for transfer outcomes, and latency histograms.

## Testing strategy

Tests use a real temporary SQLite database and verify:

- successful transfer execution,
- stored balances,
- double-entry ledger balancing,
- idempotent replay,
- idempotency key conflict,
- insufficient funds failure without side effects,
- concurrent transfers from the same wallet without overspending.
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module github.com/vardaanmittal09/wallet-transfer-assignment

go 1.24

require (
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.30.0
)
Comment on lines +1 to +8
15 changes: 15 additions & 0 deletions internal/domain/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package domain

import "errors"

var (
ErrAmountMustBePositive = errors.New("amount must be positive")
ErrIdempotencyKeyRequired = errors.New("idempotency key is required")
ErrWalletIDRequired = errors.New("wallet ids are required")
ErrSameWalletTransfer = errors.New("source and destination wallets must differ")
ErrIdempotencyConflict = errors.New("idempotency key was already used with a different request")
ErrWalletNotFound = errors.New("wallet not found")
ErrInsufficientFunds = errors.New("insufficient funds")
ErrTransferNotFound = errors.New("transfer not found")
ErrInvalidTransferTransition = errors.New("invalid transfer state transition")
)
60 changes: 60 additions & 0 deletions internal/domain/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package domain

import "time"

type TransferState string

const (
TransferPending TransferState = "PENDING"
TransferProcessed TransferState = "PROCESSED"
TransferFailed TransferState = "FAILED"
)

type LedgerEntryType string

const (
LedgerDebit LedgerEntryType = "DEBIT"
LedgerCredit LedgerEntryType = "CREDIT"
)

type Transfer struct {
ID string
IdempotencyKey string
FromWalletID string
ToWalletID string
Amount int64
State TransferState
FailureReason string
CreatedAt time.Time
UpdatedAt time.Time
}

type Wallet struct {
ID string
Balance int64
CreatedAt time.Time
UpdatedAt time.Time
}

type LedgerEntry struct {
ID int64
WalletID string
TransferID string
Type LedgerEntryType
Amount int64
CreatedAt time.Time
}

type IdempotencyRecord struct {
Key string
RequestHash string
TransferID string
CreatedAt time.Time
}

func (t Transfer) CanTransitionTo(next TransferState) bool {
if t.State != TransferPending {
return false
}
return next == TransferProcessed || next == TransferFailed
}
Loading