diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..aca870b4 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +WORKER_ID=1 +POSTGRES_DSN=postgres://wallet:wallet@localhost:5432/wallet?sslmode=disable +REDIS_ADDR=localhost:6379 +PORT=8080 +LOCK_TIMEOUT=5s diff --git a/.gitignore b/.gitignore index c1643802..ec9899d4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ build/ .tmp/ .env .env.* +!.env.example diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..ea5fd904 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,14 @@ +version: "2" + +linters: + default: standard + enable: + - unconvert + - unparam + - misspell + - revive + +formatters: + enable: + - gofmt + - goimports diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..df3140e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.24-alpine AS build + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/wallet-service ./cmd/server + +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates wget \ + && addgroup -S app && adduser -S app -G app + +WORKDIR /app + +COPY --from=build --chown=app:app /out/wallet-service ./wallet-service +COPY --chown=app:app migrations ./migrations + +USER app + +EXPOSE 8080 + +ENTRYPOINT ["./wallet-service"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..4497a698 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: run migrate lint fmt-check test test-integration seed + +run: + docker compose up -d postgres redis + go run ./cmd/server + +migrate: + go run -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate -path migrations -database "$${POSTGRES_DSN}" up + +lint: + golangci-lint run ./... + +fmt-check: + @test -z "$$(gofmt -l .)" || (echo "The following files are not gofmt'd:"; gofmt -l .; exit 1) + +test: + go test ./... + +# Integration tests run against postgres+redis brought up by docker +# compose, not testcontainers — testcontainers' docker-client dependency +# chain forces go1.25+, which conflicts with this project's go1.24 +# target. TEST_POSTGRES_DSN/TEST_REDIS_ADDR default to docker-compose.yml's +# ports, override for CI. +test-integration: + docker compose up -d postgres redis + TEST_POSTGRES_DSN="postgres://wallet:wallet@localhost:5432/wallet?sslmode=disable" \ + TEST_REDIS_ADDR="localhost:6379" \ + go test -race -tags=integration ./... + +seed: + docker compose up -d postgres + docker compose exec -T postgres psql -U wallet -d wallet < scripts/seed.sql diff --git a/README.md b/README.md index 58c62d1a..f7bbbfdf 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,167 @@ -# Wallet Transfer Assignment Repository +# Wallet Transfer Service -This repository is a reusable coding assignment template for evaluating backend engineers on wallet transfers, idempotency, concurrency control, and double-entry ledger design. +A Go/Gin service implementing idempotent wallet-to-wallet transfers with +a double-entry ledger. Correctness, transactional safety, and clean +layering take priority over feature breadth. -## Included +- **Idempotent by construction** — a two-step `init` → `transfer` flow + where the minted Snowflake ID doubles as the idempotency key, PK, and + ledger FK target. +- **Concurrency-safe** — wallet locks acquired in a fixed order, verified + under real contention by a concurrency stress test and a double-click + race test, both run with the Go race detector. +- **Double-entry ledger** — exactly two `ledger_entries` rows (one DEBIT, + one CREDIT) per processed transfer, written only when the transfer + actually succeeds. +- **Typed domain errors** mapped to precise HTTP status codes, not a + generic 4xx/5xx catch-all. -- `ASSIGNMENT.md` - candidate-facing prompt -- `.github/pull_request_template.md` - required PR structure -- `.github/workflows/ci.yml` - lint, format, test placeholder workflow -- `.github/workflows/sonarqube.yml` - SonarQube pull request analysis -- `.github/copilot-instructions.md` - repository-level Copilot review guidance -- `evaluation_guide.md` - reviewer rubric -- `branch-protection-checklist.md` - GitHub setup checklist +Full design rationale — schema, the idempotency/locking flow, every +considered and rejected alternative — lives in +[docs/DESIGN.md](./docs/DESIGN.md). This project started from the +take-home brief in [ASSIGNMENT.md](./ASSIGNMENT.md); this README +documents the resulting service, not the assignment process. -## Intended use +## Requirements -1. Mark this repository as a GitHub template repository. -2. Create one private repository per candidate from the template. -3. Add the candidate as a collaborator. -4. Ask them to submit via a pull request into `main`. -5. Enable required checks, SonarQube, and Copilot review in GitHub. +- Go 1.24+ +- Docker with the Compose v2 plugin (`docker compose`, not the + deprecated standalone `docker-compose` binary) -## Notes +## Quick start -- Copilot automatic pull request review is configured in GitHub repository or organization settings, not purely through files in the repo. -- The `copilot-instructions.md` file included here provides repository-specific review guidance once Copilot review is enabled. -- The CI workflow is language-agnostic by default and expects you to set the `LINT_CMD`, `FORMAT_CHECK_CMD`, and `TEST_CMD` repository variables or replace the commands directly. +```bash +cp .env.example .env +make run +``` -## How to Submit Assignment +`make run` starts Postgres + Redis via Docker Compose, applies pending +migrations automatically, and starts the server on `:8080`. -1. **Fork this repository** to your own GitHub account. -2. Complete the assignment described in [`ASSIGNMENT.md`](./ASSIGNMENT.md). -3. **Raise a Pull Request** back to this repository (`main` branch) with your full solution. +```bash +make seed # inserts demo wallets — see "Manual testing" below +curl -X POST localhost:8080/transfers/init +``` -Your PR branch should be named: `solution/` (e.g., `solution/jane-doe`). +## API + +### `POST /transfers/init` +Mints a fresh idempotency key. Nothing is persisted. + +```json +{ "transactionId": "123456789012345678" } +``` + +`transactionId` is always a JSON **string** — it's a 63-bit Snowflake ID, +which routinely exceeds what JavaScript's `Number` can represent exactly. + +### `POST /transfers` + +```json +{ + "idempotencyKey": "", + "fromWalletId": "", + "toWalletId": "", + "amount": 10.00 +} +``` + +| Status | Meaning | +|---|---| +| `200` | Transfer processed | +| `422` | Domain failure — `SAME_WALLET`, `INVALID_AMOUNT`, `WALLET_NOT_FOUND`, or `INSUFFICIENT_BALANCE` | +| `409` | `idempotencyKey` reused with a different `fromWalletId`/`toWalletId`/`amount` | +| `400` | Malformed request body | + +### `GET /transfers/{id}` +Always `200` if the transfer exists, `404` otherwise. The body's +`status` field (`PROCESSED` \| `FAILED`) carries the real outcome — HTTP +status here reflects whether the *lookup* succeeded, not the transfer. + +### `GET /health` +`200` if both Postgres and Redis are reachable, `503` otherwise, with +per-dependency status in the body. + +## Configuration + +Set via environment variables (see `.env.example`): + +| Variable | Purpose | +|---|---| +| `WORKER_ID` | Snowflake generator worker ID (0–1023) | +| `POSTGRES_DSN` | Postgres connection string | +| `REDIS_ADDR` | Redis address | +| `PORT` | HTTP port | +| `LOCK_TIMEOUT` | Postgres `lock_timeout` applied per transfer transaction | + +## Testing + +```bash +make test # unit tests — fast, no Docker required +make test-integration # + real Postgres/Redis via docker compose, run with -race +make lint # golangci-lint +make fmt-check # gofmt +``` + +`make test-integration` includes the two tests that matter most for a +service like this: a concurrency stress test (N concurrent transfers on +the same wallet pair land on the exact right final balance) and a +double-click race test (two near-simultaneous requests with the same +idempotency key produce exactly one transfer and identical responses). + +## Project layout + +``` +cmd/server wiring only +internal/domain pure business types, no infra imports +internal/platform generic infra bootstrap (postgres/redis/migrate/logger) +internal/config env struct binding +internal/repository persistence only, no business rules +internal/service orchestration — the idempotency/locking flow lives here +internal/middleware cross-cutting concerns, attached per route-group +internal/handler thin HTTP layer + router +internal/idgen Snowflake ID generator +migrations/ paired .up.sql/.down.sql, applied automatically at startup +scripts/ manual/demo fixtures — never used by automated tests +``` + +See [docs/DESIGN.md](./docs/DESIGN.md) §8 for the full layering rules. + +## Manual testing / demo wallets + +`make seed` inserts a fixed set of wallets against whatever Postgres +`POSTGRES_DSN` points at (defaults to the one `docker compose up -d +postgres` starts). Re-running it is safe — it never resets a wallet's +balance if the row already exists. Fixtures live in `scripts/seed.sql`, +which is deliberately *not* a golang-migrate migration (schema migrations +stay data-free); this is demo/manual-testing data only. + +| Wallet | ID | Starting balance | +|---|---|---| +| Alice | `018f0000-0000-7000-8000-000000000001` | 1000.00 | +| Bob | `018f0000-0000-7000-8000-000000000002` | 500.00 | +| Carol | `018f0000-0000-7000-8000-000000000003` | 0.00 (for `INSUFFICIENT_BALANCE` demos) | + +Example walkthrough against a running server (`make run`, then in +another shell): + +```bash +# 1. Mint an idempotency key +curl -s -X POST localhost:8080/transfers/init +# → {"transactionId":""} + +# 2. Execute the transfer (Alice -> Bob, 10.00) +curl -s -X POST localhost:8080/transfers \ + -H 'Content-Type: application/json' \ + -d '{ + "idempotencyKey": "", + "fromWalletId": "018f0000-0000-7000-8000-000000000001", + "toWalletId": "018f0000-0000-7000-8000-000000000002", + "amount": 10.00 + }' +# → 200 {"id":"...","status":"PROCESSED",...} + +# 3. Fetch it back +curl -s localhost:8080/transfers/ +# → 200 {"id":"...","status":"PROCESSED",...} +``` diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 00000000..22b8e7aa --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,143 @@ +// Command server wires config, Postgres, Redis, migrations, and the HTTP +// router, then serves the wallet transfer API until a SIGTERM/SIGINT +// triggers a graceful shutdown. +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/config" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/handler" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/idgen" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/platform/logger" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/platform/migrate" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/platform/postgres" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/platform/redisclient" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/repository/cache" + pgrepo "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/repository/postgres" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/service" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" +) + +const ( + migrationsPath = "migrations" + pingTimeout = 5 * time.Second + shutdownTimeout = 10 * time.Second +) + +func main() { + cfg, err := config.Load() + if err != nil { + slog.Error("load config", slog.Any("error", err)) + os.Exit(1) + } + + if err := cfg.Validate(); err != nil { + slog.Error("invalid config", slog.Any("error", err)) + os.Exit(1) + } + + log := logger.New() + + ctx := context.Background() + + pool, err := postgres.New(ctx, cfg.PostgresDSN) + if err != nil { + log.Error("create postgres pool", slog.Any("error", err)) + os.Exit(1) + } + defer pool.Close() + + if err := pingPostgres(ctx, pool); err != nil { + log.Error("ping postgres", slog.Any("error", err)) + os.Exit(1) + } + + if err := migrate.Run(cfg.PostgresDSN, migrationsPath); err != nil { + log.Error("run migrations", slog.Any("error", err)) + os.Exit(1) + } + + redisClient := redisclient.New(cfg.RedisAddr) + defer func() { + if err := redisClient.Close(); err != nil { + log.Error("close redis client", slog.Any("error", err)) + } + }() + + if err := pingRedis(ctx, redisClient); err != nil { + log.Error("ping redis", slog.Any("error", err)) + os.Exit(1) + } + + generator, err := idgen.NewGenerator(cfg.WorkerID) + if err != nil { + log.Error("create id generator", slog.Any("error", err)) + os.Exit(1) + } + + transferService := service.NewTransferService( + pgrepo.NewTransactor(pool), + pgrepo.NewWalletRepo(), + pgrepo.NewTransferRepo(pool), + cache.NewTransferCache(redisClient), + generator, + cfg.LockTimeout, + log, + ) + + healthHandler := handler.NewHealthHandler(pool, redisClient) + transferHandler := handler.NewTransferHandler(transferService, log) + router := handler.NewRouter(healthHandler, transferHandler) + + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: router, + } + + runServer(log, srv) +} + +func pingPostgres(ctx context.Context, pool *pgxpool.Pool) error { + ctx, cancel := context.WithTimeout(ctx, pingTimeout) + defer cancel() + return pool.Ping(ctx) +} + +func pingRedis(ctx context.Context, client *redis.Client) error { + ctx, cancel := context.WithTimeout(ctx, pingTimeout) + defer cancel() + return client.Ping(ctx).Err() +} + +func runServer(log *slog.Logger, srv *http.Server) { + go func() { + log.Info("starting server", slog.String("addr", srv.Addr)) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error("server error", slog.Any("error", err)) + os.Exit(1) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) + <-stop + + log.Info("shutting down") + + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Error("graceful shutdown failed", slog.Any("error", err)) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..75d21ac6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: wallet + POSTGRES_PASSWORD: wallet + POSTGRES_DB: wallet + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wallet -d wallet"] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + app: + build: . + environment: + WORKER_ID: "1" + POSTGRES_DSN: postgres://wallet:wallet@postgres:5432/wallet?sslmode=disable + REDIS_ADDR: redis:6379 + PORT: "8080" + LOCK_TIMEOUT: 5s + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + postgres_data: diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..06f0e4e8 --- /dev/null +++ b/go.mod @@ -0,0 +1,58 @@ +module github.com/Nandukrishna-S/wallet-transfer-assignment + +go 1.24.0 + +require ( + github.com/caarlos0/env/v11 v11.4.1 + github.com/gin-gonic/gin v1.11.0 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.8.0 + github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.21.0 + github.com/shopspring/decimal v1.4.0 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/docker/docker v28.5.1+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..20d8c7f8 --- /dev/null +++ b/go.sum @@ -0,0 +1,170 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..caa1d7b5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,54 @@ +// Package config binds application configuration from environment +// variables and validates it before any dependent component starts. +package config + +import ( + "fmt" + "time" + + "github.com/caarlos0/env/v11" + "github.com/joho/godotenv" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/idgen" +) + +// Config holds the environment-sourced settings the server needs to start. +type Config struct { + WorkerID int64 `env:"WORKER_ID"` + PostgresDSN string `env:"POSTGRES_DSN"` + RedisAddr string `env:"REDIS_ADDR"` + Port string `env:"PORT"` + LockTimeout time.Duration `env:"LOCK_TIMEOUT"` +} + +// Load reads .env (if present) and then the process environment into a Config. +func Load() (*Config, error) { + _ = godotenv.Load() + + cfg := &Config{} + if err := env.Parse(cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + return cfg, nil +} + +// Validate checks that every required field is present and well-formed. +// It must be called before any connection is attempted. +func (c *Config) Validate() error { + if c.PostgresDSN == "" { + return fmt.Errorf("POSTGRES_DSN is required") + } + if c.RedisAddr == "" { + return fmt.Errorf("REDIS_ADDR is required") + } + if c.Port == "" { + return fmt.Errorf("PORT is required") + } + if c.LockTimeout < time.Millisecond { + return fmt.Errorf("LOCK_TIMEOUT must be at least 1ms, got %s", c.LockTimeout) + } + if _, err := idgen.NewGenerator(c.WorkerID); err != nil { + return fmt.Errorf("WORKER_ID invalid: %w", err) + } + return nil +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go new file mode 100644 index 00000000..1a87037e --- /dev/null +++ b/internal/domain/errors.go @@ -0,0 +1,51 @@ +// Package domain holds the pure business types and rules for wallets and transfers. +package domain + +// ErrorCode identifies a specific domain failure, used both in API +// responses and, where persisted, in transfers.failure_reason. +type ErrorCode string + +// The full set of typed domain errors. Each maps to one HTTP status and, +// where persisted, one transfers.failure_reason value. +const ( + ErrCodeInvalidAmount ErrorCode = "INVALID_AMOUNT" + ErrCodeSameWallet ErrorCode = "SAME_WALLET" + ErrCodeWalletNotFound ErrorCode = "WALLET_NOT_FOUND" + ErrCodeInsufficientBalance ErrorCode = "INSUFFICIENT_BALANCE" + ErrCodeIdempotencyKeyReused ErrorCode = "IDEMPOTENCY_KEY_REUSED" + ErrCodeTransferInitFailed ErrorCode = "TRANSFER_INIT_FAILED" + ErrCodeInvalidAmountFormat ErrorCode = "INVALID_AMOUNT_FORMAT" + ErrCodeInvalidWalletID ErrorCode = "INVALID_WALLET_ID" + ErrCodeInvalidIdempotencyKey ErrorCode = "INVALID_IDEMPOTENCY_KEY" +) + +// Error is a typed domain failure, distinguishable from unexpected +// (500-mapped) errors via errors.As. +type Error struct { + Code ErrorCode + Message string +} + +func (e *Error) Error() string { return e.Message } + +// ErrInvalidAmountFormat is a tier-1 structural failure: the amount +// either doesn't parse as a decimal at all, or parses but overflows +// int64 once shifted into minor units. Kept distinct from +// ErrCodeInvalidAmount (a tier-2 business check for amount <= 0) so the +// two can carry different HTTP statuses — this one maps to 400, +// INVALID_AMOUNT maps to 422. Both share identical message text, +// distinguished only by Code and HTTP status. +var ErrInvalidAmountFormat = &Error{ + Code: ErrCodeInvalidAmountFormat, + Message: "Invalid transfer amount", +} + +// ErrTransferInitFailed is returned when minting a new transfer ID +// fails (e.g. a Snowflake clock regression) — an infrastructure problem, +// not a client mistake, so the message stays generic and actionable +// rather than exposing why. The real cause is logged server-side at the +// call site, never returned to the client. +var ErrTransferInitFailed = &Error{ + Code: ErrCodeTransferInitFailed, + Message: "unable to initiate transfer, please try again after some time", +} diff --git a/internal/domain/transfer.go b/internal/domain/transfer.go new file mode 100644 index 00000000..d9875197 --- /dev/null +++ b/internal/domain/transfer.go @@ -0,0 +1,34 @@ +package domain + +import ( + "time" + + "github.com/shopspring/decimal" +) + +// Status is the final, immutable outcome of a transfer. A transfer's +// debit/credit/ledger sequence is one atomic DB transaction, so nothing +// mid-flight is ever written — there is no persisted PENDING state. +type Status string + +// The only two statuses a transfer row is ever written with. +const ( + StatusProcessed Status = "PROCESSED" + StatusFailed Status = "FAILED" +) + +// Transfer is a single wallet-to-wallet transfer attempt. Its ID is +// hand-rolled (Snowflake, time-ordered), not a DB serial, because it +// doubles as the PK, the idempotency key, and the ledger_entries FK +// target — one identifier, no translation layer. +type Transfer struct { + ID int64 + RequestHash string + FromWalletID string + ToWalletID string + Amount decimal.Decimal + Status Status + FailureReason *ErrorCode + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/domain/wallet.go b/internal/domain/wallet.go new file mode 100644 index 00000000..2e42159d --- /dev/null +++ b/internal/domain/wallet.go @@ -0,0 +1,15 @@ +package domain + +import ( + "time" + + "github.com/shopspring/decimal" +) + +// Wallet is a holder of funds identified by a UUID v7. +type Wallet struct { + ID string + Balance decimal.Decimal + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/handler/dto.go b/internal/handler/dto.go new file mode 100644 index 00000000..700f9906 --- /dev/null +++ b/internal/handler/dto.go @@ -0,0 +1,33 @@ +package handler + +import "encoding/json" + +// InitTransferResponse is the response body of POST /transfers/init. +type InitTransferResponse struct { + TransactionID string `json:"transactionId"` +} + +// CreateTransferRequest is the request body of POST /transfers. Amount +// decodes as json.Number, never float64 — float64 can't represent most +// decimal fractions exactly, and that precision loss happens at decode +// time, before any validation runs. +type CreateTransferRequest struct { + IdempotencyKey string `json:"idempotencyKey"` + FromWalletID string `json:"fromWalletId"` + ToWalletID string `json:"toWalletId"` + Amount json.Number `json:"amount"` +} + +// TransferView is the shared response representation for both +// POST /transfers and GET /transfers/{id} — a transfers row is immutable +// after creation, so there's no lifecycle point where the two could +// diverge. +type TransferView struct { + ID string `json:"id"` + FromWalletID string `json:"fromWalletId"` + ToWalletID string `json:"toWalletId"` + Amount string `json:"amount"` + Status string `json:"status"` + FailureReason *string `json:"failureReason,omitempty"` + CreatedAt string `json:"createdAt"` +} diff --git a/internal/handler/health_handler.go b/internal/handler/health_handler.go new file mode 100644 index 00000000..aea5ea3d --- /dev/null +++ b/internal/handler/health_handler.go @@ -0,0 +1,83 @@ +package handler + +import ( + "context" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" +) + +const healthCheckTimeout = 2 * time.Second + +// HealthHandler serves GET /health by checking Postgres and Redis +// reachability directly, independent of any other middleware. +type HealthHandler struct { + db *pgxpool.Pool + redis *redis.Client +} + +// NewHealthHandler constructs a HealthHandler for the given dependencies. +func NewHealthHandler(db *pgxpool.Pool, redis *redis.Client) *HealthHandler { + return &HealthHandler{db: db, redis: redis} +} + +// Health returns 200 when both Postgres and Redis are reachable, 503 +// otherwise, reporting per-dependency status in the body. The two +// checks run concurrently so the worst-case latency is bounded by the +// slower of the two, not their sum. +func (h *HealthHandler) Health(c *gin.Context) { + var postgresOK, redisOK bool + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + postgresOK = h.checkPostgres(c.Request.Context()) + }() + go func() { + defer wg.Done() + redisOK = h.checkRedis(c.Request.Context()) + }() + wg.Wait() + + postgresStatus, redisStatus := "ok", "ok" + if !postgresOK { + postgresStatus = "error" + } + if !redisOK { + redisStatus = "error" + } + + if !postgresOK || !redisOK { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "status": "unavailable", + "postgres": postgresStatus, + "redis": redisStatus, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "postgres": postgresStatus, + "redis": redisStatus, + }) +} + +func (h *HealthHandler) checkPostgres(ctx context.Context) bool { + ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout) + defer cancel() + + var result int + return h.db.QueryRow(ctx, "SELECT 1").Scan(&result) == nil +} + +func (h *HealthHandler) checkRedis(ctx context.Context) bool { + ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout) + defer cancel() + + return h.redis.Ping(ctx).Err() == nil +} diff --git a/internal/handler/router.go b/internal/handler/router.go new file mode 100644 index 00000000..c9998894 --- /dev/null +++ b/internal/handler/router.go @@ -0,0 +1,30 @@ +// Package handler is the thin HTTP layer: decode request, call service, +// encode response. No business logic here — every domain rule lives in +// service/domain so it's testable independent of HTTP. +package handler + +import ( + "github.com/gin-gonic/gin" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/middleware" +) + +// NewRouter registers all route groups and their middleware chains. +// /health is deliberately dependency-free (no middleware group) so it +// keeps working even if correlation-id middleware itself failed to init. +func NewRouter(healthHandler *HealthHandler, transferHandler *TransferHandler) *gin.Engine { + router := gin.New() + router.Use(gin.Recovery()) + + router.GET("/health", healthHandler.Health) + + transfers := router.Group("/transfers") + transfers.Use(middleware.CorrelationID()) + { + transfers.POST("/init", transferHandler.InitTransfer) + transfers.POST("", transferHandler.CreateTransfer) + transfers.GET("/:id", transferHandler.GetTransfer) + } + + return router +} diff --git a/internal/handler/transfer_handler.go b/internal/handler/transfer_handler.go new file mode 100644 index 00000000..b0222de0 --- /dev/null +++ b/internal/handler/transfer_handler.go @@ -0,0 +1,126 @@ +package handler + +import ( + "context" + "errors" + "log/slog" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/platform/logger" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/service" +) + +// transferService is the subset of *service.TransferService this handler +// needs, so tests can supply a mock without touching Postgres/Redis. +type transferService interface { + InitTransfer() (int64, error) + Transfer(ctx context.Context, req service.TransferRequest) (*domain.Transfer, error) + GetTransfer(ctx context.Context, id int64) (*domain.Transfer, error) +} + +// TransferHandler serves the /transfers/init, /transfers, and +// /transfers/:id routes. Decode -> call service -> encode, nothing else. +type TransferHandler struct { + service transferService + logger *slog.Logger +} + +// NewTransferHandler constructs a TransferHandler. +func NewTransferHandler(svc transferService, logger *slog.Logger) *TransferHandler { + return &TransferHandler{service: svc, logger: logger} +} + +// InitTransfer handles POST /transfers/init. A NextID failure is logged +// server-side by the service itself (it has the real cause); this +// handler only ever sees the generic typed error, dispatched through +// respondError like every other error in this file. +func (h *TransferHandler) InitTransfer(c *gin.Context) { + id, err := h.service.InitTransfer() + if err != nil { + h.respondError(c, err) + return + } + c.JSON(http.StatusOK, InitTransferResponse{TransactionID: strconv.FormatInt(id, 10)}) +} + +// CreateTransfer handles POST /transfers. +func (h *TransferHandler) CreateTransfer(c *gin.Context) { + var req CreateTransferRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + id, err := validateIdempotencyKey(req.IdempotencyKey) + if err != nil { + h.respondError(c, err) + return + } + if err := validateWalletID(req.FromWalletID); err != nil { + h.respondError(c, err) + return + } + if err := validateWalletID(req.ToWalletID); err != nil { + h.respondError(c, err) + return + } + amount, minorUnits, err := validateAmount(req.Amount) + if err != nil { + h.respondError(c, err) + return + } + + transfer, err := h.service.Transfer(c.Request.Context(), service.TransferRequest{ + ID: id, + FromWalletID: req.FromWalletID, + ToWalletID: req.ToWalletID, + Amount: amount, + AmountMinorUnits: minorUnits, + }) + if err != nil { + h.respondError(c, err) + return + } + + c.JSON(statusForTransfer(transfer), toTransferView(transfer)) +} + +// GetTransfer handles GET /transfers/:id. +func (h *TransferHandler) GetTransfer(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid transfer id"}) + return + } + + transfer, err := h.service.GetTransfer(c.Request.Context(), id) + if err != nil { + h.respondError(c, err) + return + } + if transfer == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "transfer not found"}) + return + } + + c.JSON(http.StatusOK, toTransferView(transfer)) +} + +// respondError maps a *domain.Error to its typed HTTP status and body. +// Anything else is unexpected: full details logged server-side, a +// generic message returned to the client — never leak internals in a +// financial API. +func (h *TransferHandler) respondError(c *gin.Context, err error) { + var domainErr *domain.Error + if errors.As(err, &domainErr) { + c.JSON(domainErrorHTTPStatus(domainErr.Code), gin.H{"error": domainErr.Code, "message": domainErr.Message}) + return + } + + logger.FromContext(c.Request.Context(), h.logger).Error("unexpected transfer error", slog.Any("error", err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) +} diff --git a/internal/handler/transfer_handler_test.go b/internal/handler/transfer_handler_test.go new file mode 100644 index 00000000..f0814c16 --- /dev/null +++ b/internal/handler/transfer_handler_test.go @@ -0,0 +1,307 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/service" +) + +type mockTransferService struct { + initResult int64 + initErr error + transferResult *domain.Transfer + transferErr error + getResult *domain.Transfer + getErr error +} + +func (m *mockTransferService) InitTransfer() (int64, error) { return m.initResult, m.initErr } + +func (m *mockTransferService) Transfer(_ context.Context, _ service.TransferRequest) (*domain.Transfer, error) { + return m.transferResult, m.transferErr +} + +func (m *mockTransferService) GetTransfer(_ context.Context, _ int64) (*domain.Transfer, error) { + return m.getResult, m.getErr +} + +func testRouter(svc *mockTransferService) *gin.Engine { + gin.SetMode(gin.TestMode) + h := NewTransferHandler(svc, slog.New(slog.NewTextHandler(io.Discard, nil))) + + r := gin.New() + r.POST("/transfers/init", h.InitTransfer) + r.POST("/transfers", h.CreateTransfer) + r.GET("/transfers/:id", h.GetTransfer) + return r +} + +const validWalletA = "018f0000-0000-7000-8000-000000000001" +const validWalletB = "018f0000-0000-7000-8000-000000000002" + +func validCreateBody() string { + return `{"idempotencyKey":"123","fromWalletId":"` + validWalletA + `","toWalletId":"` + validWalletB + `","amount":10.00}` +} + +func doRequest(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + return rec +} + +func TestInitTransferHandler(t *testing.T) { + r := testRouter(&mockTransferService{initResult: 123456789}) + + rec := doRequest(r, http.MethodPost, "/transfers/init", "") + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var resp InitTransferResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.TransactionID != "123456789" { + t.Fatalf("expected transactionId as string \"123456789\", got %q", resp.TransactionID) + } +} + +func TestInitTransferHandler_IDGenError(t *testing.T) { + r := testRouter(&mockTransferService{initErr: domain.ErrTransferInitFailed}) + + rec := doRequest(r, http.MethodPost, "/transfers/init", "") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rec.Code) + } + + var resp map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["error"] != string(domain.ErrCodeTransferInitFailed) { + t.Fatalf("expected error code %s, got %+v", domain.ErrCodeTransferInitFailed, resp) + } + if resp["message"] != domain.ErrTransferInitFailed.Message { + t.Fatalf("expected client-facing message %q, got %+v", domain.ErrTransferInitFailed.Message, resp) + } +} + +func TestInitTransferHandler_UnexpectedIDGenError(t *testing.T) { + // A non-domain error (e.g. an infra failure the service forgot to + // wrap) must still 500 with a generic message, never leak internals. + r := testRouter(&mockTransferService{initErr: io.ErrUnexpectedEOF}) + + rec := doRequest(r, http.MethodPost, "/transfers/init", "") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rec.Code) + } +} + +func TestCreateTransfer_MalformedJSON(t *testing.T) { + r := testRouter(&mockTransferService{}) + rec := doRequest(r, http.MethodPost, "/transfers", "{not json") + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestCreateTransfer_InvalidIdempotencyKey(t *testing.T) { + r := testRouter(&mockTransferService{}) + body := `{"idempotencyKey":"not-a-number","fromWalletId":"` + validWalletA + `","toWalletId":"` + validWalletB + `","amount":10.00}` + rec := doRequest(r, http.MethodPost, "/transfers", body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + + var resp map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["error"] != string(domain.ErrCodeInvalidIdempotencyKey) { + t.Fatalf("expected error code %s, got %+v", domain.ErrCodeInvalidIdempotencyKey, resp) + } +} + +func TestCreateTransfer_InvalidWalletID(t *testing.T) { + r := testRouter(&mockTransferService{}) + body := `{"idempotencyKey":"123","fromWalletId":"not-a-uuid","toWalletId":"` + validWalletB + `","amount":10.00}` + rec := doRequest(r, http.MethodPost, "/transfers", body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + + var resp map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["error"] != string(domain.ErrCodeInvalidWalletID) { + t.Fatalf("expected error code %s, got %+v", domain.ErrCodeInvalidWalletID, resp) + } +} + +func TestCreateTransfer_MalformedAmountJSON(t *testing.T) { + // "abc" isn't valid JSON syntax as a bare token, so it 400s at the + // bind step, before validateAmount (and its typed error) ever runs. + body := `{"idempotencyKey":"123","fromWalletId":"` + validWalletA + `","toWalletId":"` + validWalletB + `","amount":abc}` + r := testRouter(&mockTransferService{}) + rec := doRequest(r, http.MethodPost, "/transfers", body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestCreateTransfer_InvalidAmountShape(t *testing.T) { + // Each of these is syntactically a valid bare JSON number, so they + // reach validateAmount's shape regex and fail there, not at bind time. + cases := []string{"-5", "1.234", "0.001"} + for _, amount := range cases { + body := `{"idempotencyKey":"123","fromWalletId":"` + validWalletA + `","toWalletId":"` + validWalletB + `","amount":` + amount + `}` + r := testRouter(&mockTransferService{}) + rec := doRequest(r, http.MethodPost, "/transfers", body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("amount %q: expected 400, got %d", amount, rec.Code) + } + + var resp map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("amount %q: decode response: %v", amount, err) + } + if resp["error"] != string(domain.ErrCodeInvalidAmountFormat) { + t.Fatalf("amount %q: expected error code %s, got %+v", amount, domain.ErrCodeInvalidAmountFormat, resp) + } + } +} + +func TestCreateTransfer_AmountOverflow(t *testing.T) { + // Far more digits than an int64 can hold once shifted into minor + // units -- distinct from a merely negative or too-many-decimals + // amount (TestCreateTransfer_InvalidAmountShape), which are shape + // violations caught by the regex before this ever parses. This + // amount passes the regex (all digits, 2 decimal places) but + // overflows on conversion -- both cases share the same typed error. + body := `{"idempotencyKey":"123","fromWalletId":"` + validWalletA + `","toWalletId":"` + validWalletB + `","amount":999999999999999999999.00}` + r := testRouter(&mockTransferService{}) + rec := doRequest(r, http.MethodPost, "/transfers", body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + + var resp map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["error"] != string(domain.ErrCodeInvalidAmountFormat) { + t.Fatalf("expected error code %s, got %+v", domain.ErrCodeInvalidAmountFormat, resp) + } +} + +func TestCreateTransfer_DomainErrorMapping(t *testing.T) { + cases := []struct { + code domain.ErrorCode + want int + }{ + {domain.ErrCodeSameWallet, http.StatusUnprocessableEntity}, + {domain.ErrCodeInvalidAmount, http.StatusUnprocessableEntity}, + {domain.ErrCodeWalletNotFound, http.StatusUnprocessableEntity}, + {domain.ErrCodeInsufficientBalance, http.StatusUnprocessableEntity}, + {domain.ErrCodeIdempotencyKeyReused, http.StatusConflict}, + // The service itself never returns these codes (they're caught in + // the handler before the service is ever called) -- included here + // to pin domainErrorHTTPStatus's mapping directly, independent of + // which path reaches it. + {domain.ErrCodeInvalidAmountFormat, http.StatusBadRequest}, + {domain.ErrCodeInvalidWalletID, http.StatusBadRequest}, + {domain.ErrCodeInvalidIdempotencyKey, http.StatusBadRequest}, + } + for _, tc := range cases { + svc := &mockTransferService{transferErr: &domain.Error{Code: tc.code, Message: "boom"}} + r := testRouter(svc) + rec := doRequest(r, http.MethodPost, "/transfers", validCreateBody()) + if rec.Code != tc.want { + t.Fatalf("code %s: expected %d, got %d", tc.code, tc.want, rec.Code) + } + } +} + +func TestCreateTransfer_UnexpectedError(t *testing.T) { + svc := &mockTransferService{transferErr: io.ErrUnexpectedEOF} + r := testRouter(svc) + rec := doRequest(r, http.MethodPost, "/transfers", validCreateBody()) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rec.Code) + } +} + +func TestCreateTransfer_Processed(t *testing.T) { + transfer := &domain.Transfer{ID: 1, Status: domain.StatusProcessed} + svc := &mockTransferService{transferResult: transfer} + r := testRouter(svc) + rec := doRequest(r, http.MethodPost, "/transfers", validCreateBody()) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } +} + +func TestCreateTransfer_FailedInsufficientBalance(t *testing.T) { + reason := domain.ErrCodeInsufficientBalance + transfer := &domain.Transfer{ID: 1, Status: domain.StatusFailed, FailureReason: &reason} + svc := &mockTransferService{transferResult: transfer} + r := testRouter(svc) + rec := doRequest(r, http.MethodPost, "/transfers", validCreateBody()) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d", rec.Code) + } +} + +func TestGetTransfer_InvalidID(t *testing.T) { + r := testRouter(&mockTransferService{}) + rec := doRequest(r, http.MethodGet, "/transfers/not-a-number", "") + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestGetTransfer_Found(t *testing.T) { + transfer := &domain.Transfer{ID: 1, Status: domain.StatusFailed} + reason := domain.ErrCodeInsufficientBalance + transfer.FailureReason = &reason + svc := &mockTransferService{getResult: transfer} + r := testRouter(svc) + + rec := doRequest(r, http.MethodGet, "/transfers/1", "") + if rec.Code != http.StatusOK { + t.Fatalf("GET must always return 200 for a found transfer regardless of its outcome, got %d", rec.Code) + } +} + +func TestGetTransfer_NotFound(t *testing.T) { + svc := &mockTransferService{getResult: nil} + r := testRouter(svc) + + rec := doRequest(r, http.MethodGet, "/transfers/1", "") + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } +} + +func TestGetTransfer_UnexpectedError(t *testing.T) { + svc := &mockTransferService{getErr: io.ErrUnexpectedEOF} + r := testRouter(svc) + + rec := doRequest(r, http.MethodGet, "/transfers/1", "") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rec.Code) + } +} diff --git a/internal/handler/transfer_view.go b/internal/handler/transfer_view.go new file mode 100644 index 00000000..fd9d5edf --- /dev/null +++ b/internal/handler/transfer_view.go @@ -0,0 +1,63 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" +) + +// toTransferView builds the one shared response representation used by +// both POST /transfers and GET /transfers/{id} — a transfers row never +// changes after insert, so there's nothing for the two to diverge on. +func toTransferView(t *domain.Transfer) TransferView { + view := TransferView{ + ID: strconv.FormatInt(t.ID, 10), + FromWalletID: t.FromWalletID, + ToWalletID: t.ToWalletID, + Amount: t.Amount.StringFixed(2), + Status: string(t.Status), + CreatedAt: t.CreatedAt.Format(time.RFC3339), + } + if t.FailureReason != nil { + reason := string(*t.FailureReason) + view.FailureReason = &reason + } + return view +} + +// statusForTransfer derives POST /transfers' HTTP status from a +// transfer's outcome. GET never calls this — a found row is always 200 +// there, since the URI addresses a specific resource and the body's +// status field is what carries the real outcome. +func statusForTransfer(t *domain.Transfer) int { + if t.Status == domain.StatusProcessed { + return http.StatusOK + } + if t.FailureReason != nil { + return domainErrorHTTPStatus(*t.FailureReason) + } + return http.StatusInternalServerError +} + +// domainErrorHTTPStatus maps a domain.ErrorCode to its HTTP status, +// shared by both live-rejection errors and persisted FailureReason +// values so the two paths can never disagree on what a code means. +func domainErrorHTTPStatus(code domain.ErrorCode) int { + switch code { + case domain.ErrCodeIdempotencyKeyReused: + return http.StatusConflict + case domain.ErrCodeInvalidAmountFormat, domain.ErrCodeInvalidWalletID, domain.ErrCodeInvalidIdempotencyKey: + // Tier-1 structural failures (malformed request shape), never a + // persisted FailureReason — these only fire via the live-rejection + // path, before the service is ever called. + return http.StatusBadRequest + case domain.ErrCodeTransferInitFailed: + // Infrastructure failure (e.g. ID-generation clock regression), + // not a client mistake or a persisted FailureReason. + return http.StatusInternalServerError + default: + return http.StatusUnprocessableEntity + } +} diff --git a/internal/handler/validate.go b/internal/handler/validate.go new file mode 100644 index 00000000..40c05c7b --- /dev/null +++ b/internal/handler/validate.go @@ -0,0 +1,78 @@ +package handler + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + + "github.com/google/uuid" + "github.com/shopspring/decimal" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" +) + +// amountPattern matches a positive decimal with at most 2 decimal places. +// Zero matches too — this only checks shape; whether zero is a valid +// amount is a business-meaning question the service answers, not this +// handler-level check. +var amountPattern = regexp.MustCompile(`^\d+(\.\d{1,2})?$`) + +// validateAmount checks amount's shape and converts it into both its +// decimal form and integer minor units (e.g. "100.10" -> 10010), the +// same minor-units value request_hash canonicalizes against. +func validateAmount(amount json.Number) (decimal.Decimal, int64, error) { + raw := amount.String() + if !amountPattern.MatchString(raw) { + return decimal.Decimal{}, 0, domain.ErrInvalidAmountFormat + } + + parsed, err := decimal.NewFromString(raw) + if err != nil { + return decimal.Decimal{}, 0, domain.ErrInvalidAmountFormat + } + + // IntPart silently truncates (wraps) if the value doesn't fit in an + // int64. Converting the truncated value back to a decimal and + // comparing against the pre-truncation value catches that silently — + // shifted is already an exact integer decimal at this point (the + // regex above guarantees at most 2 decimal places), so any mismatch + // here can only mean overflow, never a real fractional truncation. + shifted := parsed.Shift(2) + minorUnits := shifted.IntPart() + if !shifted.Equal(decimal.NewFromInt(minorUnits)) { + return decimal.Decimal{}, 0, domain.ErrInvalidAmountFormat + } + return parsed, minorUnits, nil +} + +// validateWalletID checks that raw is syntactically a UUID, deliberately +// not that it's specifically v7 — the UUID version is an internal detail +// of how wallet IDs happen to be generated, not a client-facing +// contract, so pinning to v7 here would make any future generation +// change a breaking API change for no correctness benefit. +func validateWalletID(raw string) error { + if _, err := uuid.Parse(raw); err != nil { + return &domain.Error{ + Code: domain.ErrCodeInvalidWalletID, + Message: fmt.Sprintf("invalid wallet id: %v", err), + } + } + return nil +} + +// validateIdempotencyKey checks that raw is a valid int64-shaped string. +// Snowflake IDs are 63-bit, but JavaScript's Number can only safely +// represent integers up to 2^53-1 — a bare JSON number would get +// silently corrupted by any browser client, which is why this is a +// string on the wire in both directions, not a number. +func validateIdempotencyKey(raw string) (int64, error) { + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 0, &domain.Error{ + Code: domain.ErrCodeInvalidIdempotencyKey, + Message: fmt.Sprintf("idempotencyKey must be a valid int64 string: %v", err), + } + } + return id, nil +} diff --git a/internal/idgen/snowflake.go b/internal/idgen/snowflake.go new file mode 100644 index 00000000..3849fb53 --- /dev/null +++ b/internal/idgen/snowflake.go @@ -0,0 +1,101 @@ +// Package idgen generates Snowflake-style, time-ordered 64-bit IDs used as +// transfer identifiers. A transfer's ID doubles as its idempotency key and +// as the foreign-key target for ledger_entries — +// nothing outside this package should ever need to decode or depend on the +// internal bit layout below. +package idgen + +import ( + "fmt" + "sync" + "time" +) + +const ( + // epoch is a custom reference point (2026-01-01T00:00:00Z in Unix ms), + // not the Unix epoch — this keeps generated IDs from starting out at a + // 1970-scale magnitude and buys ~69 years of headroom before the + // timestamp component wraps around. + epoch int64 = 1767225600000 + + workerBits uint8 = 10 + sequenceBits uint8 = 12 + + workerShift = sequenceBits + timestampShift = workerBits + sequenceBits + + maxWorker int64 = -1 ^ (-1 << workerBits) + maxSequence int64 = -1 ^ (-1 << sequenceBits) +) + +// Generator mints Snowflake IDs for a single worker (process/replica). +// It is safe for concurrent use. +type Generator struct { + mu sync.Mutex + + workerID int64 + + lastTimestamp int64 + sequence int64 +} + +// NewGenerator constructs a Generator for the given workerID. +// +// workerID must be unique across every concurrently running instance of +// this service — two instances sharing a workerID can produce colliding +// IDs. For this project, workerID comes from config (WORKER_ID env var, +// default 0 for a single instance). +func NewGenerator(workerID int64) (*Generator, error) { + if workerID < 0 || workerID > maxWorker { + return nil, fmt.Errorf("idgen: workerID must be between 0 and %d, got %d", maxWorker, workerID) + } + return &Generator{workerID: workerID}, nil +} + +// nowMillis is a variable (not a plain function call) so tests can +// substitute a controllable clock without changing NextID's logic. +var nowMillis = func() int64 { + return time.Now().UnixMilli() +} + +// NextID returns a new, monotonically increasing (within this Generator) +// 64-bit ID, laid out as: +// +// [ 41 bits: ms since epoch ][ 10 bits: workerID ][ 12 bits: sequence ] +// +// Up to 4096 IDs can be minted per worker per millisecond; if that budget +// is exhausted, NextID blocks until the next millisecond rather than +// reusing a sequence value. +// +// If the system clock has moved backward since the last call, NextID +// returns an error instead of spinning — a regression can be seconds or +// minutes long (NTP step, VM migration), and blocking the caller for +// that long would be worse than failing fast. +func (g *Generator) NextID() (int64, error) { + g.mu.Lock() + defer g.mu.Unlock() + + now := nowMillis() + + if now < g.lastTimestamp { + return 0, fmt.Errorf("idgen: clock moved backward by %dms, refusing to generate id", g.lastTimestamp-now) + } + + if now == g.lastTimestamp { + g.sequence = (g.sequence + 1) & maxSequence + if g.sequence == 0 { + // Sequence space exhausted for this millisecond — spin until + // the clock ticks forward rather than reuse a sequence value, + // which would risk a duplicate ID within the same worker. + for now <= g.lastTimestamp { + now = nowMillis() + } + } + } else { + g.sequence = 0 + } + + g.lastTimestamp = now + + return (now-epoch)< tickAfter { + return frozen + 1 + } + return frozen + } + defer func() { nowMillis = restore }() + + var last int64 + for i := int64(0); i <= tickAfter; i++ { + id, err := g.NextID() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + last = id + } + + if last <= 0 { + t.Fatalf("expected a valid ID after rollover, got %d", last) + } +} + +func TestNextID_ClockRegressionReturnsError(t *testing.T) { + g, err := NewGenerator(6) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + restore := nowMillis + defer func() { nowMillis = restore }() + + frozen := epoch + 10_000 + nowMillis = func() int64 { return frozen } + if _, err := g.NextID(); err != nil { + t.Fatalf("unexpected error priming the generator: %v", err) + } + + nowMillis = func() int64 { return frozen - 1_000 } + if _, err := g.NextID(); err == nil { + t.Fatal("expected an error when the clock moves backward, got nil") + } +} diff --git a/internal/middleware/correlation_id.go b/internal/middleware/correlation_id.go new file mode 100644 index 00000000..fdde0794 --- /dev/null +++ b/internal/middleware/correlation_id.go @@ -0,0 +1,38 @@ +// Package middleware holds cross-cutting HTTP concerns. Attached per +// route-group, not globally — there's currently no concern that applies +// to literally every route (e.g. /health deliberately stays +// dependency-free). +package middleware + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/platform/logger" +) + +const correlationIDHeader = "X-Correlation-ID" + +// CorrelationID attaches a per-request correlation ID to the request +// context, reusing an inbound X-Correlation-ID header if present and +// echoing it back on the response. This is the one sanctioned use of +// context.Value here: request-scoped data only, never app-scoped +// dependencies like a DB pool, which are constructor-injected instead. +// +// The context key itself lives in internal/platform/logger, not here — +// that's the only way a service or handler can read the correlation ID +// back out for logging without depending on Gin. +func CorrelationID() gin.HandlerFunc { + return func(c *gin.Context) { + correlationID := c.GetHeader(correlationIDHeader) + if correlationID == "" { + correlationID = uuid.NewString() + } + + ctx := logger.WithCorrelationID(c.Request.Context(), correlationID) + c.Request = c.Request.WithContext(ctx) + + c.Header(correlationIDHeader, correlationID) + c.Next() + } +} diff --git a/internal/platform/logger/context.go b/internal/platform/logger/context.go new file mode 100644 index 00000000..7aacf895 --- /dev/null +++ b/internal/platform/logger/context.go @@ -0,0 +1,36 @@ +package logger + +import ( + "context" + "log/slog" +) + +type correlationIDKey struct{} + +// WithCorrelationID returns a context carrying id, retrievable via +// CorrelationIDFromContext. The only writer of this key is +// middleware.CorrelationID; everything else only ever reads it, which is +// why the setter and getter live together here rather than in the +// middleware package — a service or handler needing the current +// correlation ID for logging has no reason to depend on Gin. +func WithCorrelationID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, correlationIDKey{}, id) +} + +// CorrelationIDFromContext returns the correlation ID set by +// WithCorrelationID, or "" if none is present. +func CorrelationIDFromContext(ctx context.Context) string { + if id, ok := ctx.Value(correlationIDKey{}).(string); ok { + return id + } + return "" +} + +// FromContext returns base with the request's correlation ID attached +// as a field, or base unchanged if none is present. +func FromContext(ctx context.Context, base *slog.Logger) *slog.Logger { + if id := CorrelationIDFromContext(ctx); id != "" { + return base.With(slog.String("correlation_id", id)) + } + return base +} diff --git a/internal/platform/logger/logger.go b/internal/platform/logger/logger.go new file mode 100644 index 00000000..756f7fc1 --- /dev/null +++ b/internal/platform/logger/logger.go @@ -0,0 +1,14 @@ +// Package logger builds the application's structured logger. It has no +// domain knowledge — doesn't know what a "wallet" is. +package logger + +import ( + "log/slog" + "os" +) + +// New returns a JSON-structured slog.Logger writing to stdout. +func New() *slog.Logger { + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}) + return slog.New(handler) +} diff --git a/internal/platform/migrate/migrate.go b/internal/platform/migrate/migrate.go new file mode 100644 index 00000000..32e74cdf --- /dev/null +++ b/internal/platform/migrate/migrate.go @@ -0,0 +1,32 @@ +// Package migrate runs pending golang-migrate migrations at startup, +// before the server binds. It has no domain knowledge — doesn't know +// what a "wallet" is. +package migrate + +import ( + "errors" + "fmt" + + "github.com/golang-migrate/migrate/v4" + // Registers the "postgres" database driver used by migrate.New below. + _ "github.com/golang-migrate/migrate/v4/database/postgres" + // Registers the "file" source driver used by migrate.New below. + _ "github.com/golang-migrate/migrate/v4/source/file" +) + +// Run applies all pending migrations under migrationsPath to dsn. +// A no-change result is not an error. +func Run(dsn, migrationsPath string) error { + m, err := migrate.New("file://"+migrationsPath, dsn) + if err != nil { + return fmt.Errorf("init migrate: %w", err) + } + defer func() { + _, _ = m.Close() + }() + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("run migrations: %w", err) + } + return nil +} diff --git a/internal/platform/postgres/postgres.go b/internal/platform/postgres/postgres.go new file mode 100644 index 00000000..5ad45127 --- /dev/null +++ b/internal/platform/postgres/postgres.go @@ -0,0 +1,21 @@ +// Package postgres bootstraps the pgxpool connection pool. It has no +// domain knowledge — doesn't know what a "wallet" is. Callers are +// responsible for pinging the returned pool before relying on it. +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// New constructs a connection pool for dsn. It does not verify +// connectivity — call Ping on the result to fail fast. +func New(ctx context.Context, dsn string) (*pgxpool.Pool, error) { + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("create postgres pool: %w", err) + } + return pool, nil +} diff --git a/internal/platform/redisclient/redisclient.go b/internal/platform/redisclient/redisclient.go new file mode 100644 index 00000000..888b9960 --- /dev/null +++ b/internal/platform/redisclient/redisclient.go @@ -0,0 +1,12 @@ +// Package redisclient bootstraps the Redis client. It has no domain +// knowledge — doesn't know what a "wallet" is. Callers are responsible +// for pinging the returned client before relying on it. +package redisclient + +import "github.com/redis/go-redis/v9" + +// New constructs a client for addr. It does not verify connectivity — +// call Ping on the result to fail fast. +func New(addr string) *redis.Client { + return redis.NewClient(&redis.Options{Addr: addr}) +} diff --git a/internal/repository/cache/redis_cache.go b/internal/repository/cache/redis_cache.go new file mode 100644 index 00000000..f3ddae76 --- /dev/null +++ b/internal/repository/cache/redis_cache.go @@ -0,0 +1,73 @@ +// Package cache implements a transfer:{id} read-through cache. It is a +// latency optimization only — the Postgres unique constraint on +// transfers.id is the actual correctness guarantee; this cache could +// disappear entirely and idempotency would still hold. +package cache + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/rand/v2" + "strconv" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" +) + +const ( + keyPrefix = "transfer:" + baseTTL = 24 * time.Hour + jitterMax = 30 * time.Minute +) + +// TransferCache caches domain.Transfer rows by ID. A transfers row is +// immutable after creation, so there is no update path to maintain — +// only Get and a single Set, written once per ID. +type TransferCache struct { + client *redis.Client +} + +// NewTransferCache constructs a TransferCache. +func NewTransferCache(client *redis.Client) *TransferCache { + return &TransferCache{client: client} +} + +// Get returns the cached transfer for id, or (nil, nil) on a cache miss. +func (c *TransferCache) Get(ctx context.Context, id int64) (*domain.Transfer, error) { + raw, err := c.client.Get(ctx, key(id)).Bytes() + if errors.Is(err, redis.Nil) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get cached transfer: %w", err) + } + + var transfer domain.Transfer + if err := json.Unmarshal(raw, &transfer); err != nil { + return nil, fmt.Errorf("decode cached transfer: %w", err) + } + return &transfer, nil +} + +// Set caches transfer under its ID with a base TTL plus jitter, to avoid +// many keys written around the same time all expiring together. +func (c *TransferCache) Set(ctx context.Context, id int64, transfer *domain.Transfer) error { + raw, err := json.Marshal(transfer) + if err != nil { + return fmt.Errorf("encode transfer for cache: %w", err) + } + + ttl := baseTTL + time.Duration(rand.Int64N(int64(jitterMax))) + if err := c.client.Set(ctx, key(id), raw, ttl).Err(); err != nil { + return fmt.Errorf("set cached transfer: %w", err) + } + return nil +} + +func key(id int64) string { + return keyPrefix + strconv.FormatInt(id, 10) +} diff --git a/internal/repository/postgres/fixtures_integration_test.go b/internal/repository/postgres/fixtures_integration_test.go new file mode 100644 index 00000000..05ddbba1 --- /dev/null +++ b/internal/repository/postgres/fixtures_integration_test.go @@ -0,0 +1,19 @@ +//go:build integration + +package postgres + +import ( + "testing" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/testutil" +) + +// newTestWallet inserts a fresh wallet with a unique ID and the given +// balance directly via SQL. Every integration test creates its own +// wallets — never reuses another test's — so concurrent test runs can +// never corrupt each other's expected balances. +func newTestWallet(t *testing.T, balance string) domain.Wallet { + t.Helper() + return testutil.NewTestWallet(t, testPool, balance) +} diff --git a/internal/repository/postgres/main_integration_test.go b/internal/repository/postgres/main_integration_test.go new file mode 100644 index 00000000..f65f743f --- /dev/null +++ b/internal/repository/postgres/main_integration_test.go @@ -0,0 +1,42 @@ +//go:build integration + +package postgres + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/platform/migrate" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/testutil" +) + +var testPool *pgxpool.Pool + +func TestMain(m *testing.M) { + ctx := context.Background() + dsn := testutil.PostgresDSN() + + if err := migrate.Run(dsn, "../../../migrations"); err != nil { + fmt.Fprintln(os.Stderr, "run migrations:", err) + os.Exit(1) + } + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + fmt.Fprintln(os.Stderr, "create pool:", err) + os.Exit(1) + } + defer pool.Close() + + if err := pool.Ping(ctx); err != nil { + fmt.Fprintln(os.Stderr, "ping postgres — is `docker-compose up -d postgres` running?", err) + os.Exit(1) + } + + testPool = pool + os.Exit(m.Run()) +} diff --git a/internal/repository/postgres/transactor.go b/internal/repository/postgres/transactor.go new file mode 100644 index 00000000..b0cdd9e7 --- /dev/null +++ b/internal/repository/postgres/transactor.go @@ -0,0 +1,30 @@ +// Package postgres implements the persistence layer against real +// Postgres via pgx/v5. No business rules live here — only how to +// lock/read/write a row. Whether a balance is sufficient, or a wallet +// pair is valid, is a decision for domain/service, not this package. +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/repository" +) + +// Transactor begins transactions on a *pgxpool.Pool, adapting it to +// repository.Transactor so internal/service can own the transaction +// boundary without depending on pgxpool directly. +type Transactor struct { + pool *pgxpool.Pool +} + +// NewTransactor constructs a Transactor for pool. +func NewTransactor(pool *pgxpool.Pool) *Transactor { + return &Transactor{pool: pool} +} + +// Begin starts a new transaction. +func (t *Transactor) Begin(ctx context.Context) (repository.Tx, error) { + return t.pool.Begin(ctx) +} diff --git a/internal/repository/postgres/transfer_repo.go b/internal/repository/postgres/transfer_repo.go new file mode 100644 index 00000000..17105585 --- /dev/null +++ b/internal/repository/postgres/transfer_repo.go @@ -0,0 +1,91 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/shopspring/decimal" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/repository" +) + +const transferColumns = `id, request_hash, from_wallet_id, to_wallet_id, amount, status, failure_reason, created_at, updated_at` + +// TransferRepo persists transfers and their ledger entries. +type TransferRepo struct { + pool *pgxpool.Pool +} + +// NewTransferRepo constructs a TransferRepo. +func NewTransferRepo(pool *pgxpool.Pool) *TransferRepo { + return &TransferRepo{pool: pool} +} + +// InsertTransfer claims id as a new transfer via INSERT ... ON CONFLICT +// DO NOTHING, writing its final, immutable status in the same statement — +// by the time this is called, the caller already holds the wallet locks +// and has decided the outcome. It returns (nil, nil) if id was already +// claimed by another transfer (the conflict-resolve path owns what +// happens next), and a non-nil error only for genuine failures. +func (r *TransferRepo) InsertTransfer(ctx context.Context, tx repository.Tx, id int64, requestHash, fromWalletID, toWalletID string, amount decimal.Decimal, status domain.Status, failureReason *domain.ErrorCode) (*domain.Transfer, error) { + row := tx.QueryRow(ctx, ` + INSERT INTO transfers (id, request_hash, from_wallet_id, to_wallet_id, amount, status, failure_reason, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now()) + ON CONFLICT (id) DO NOTHING + RETURNING `+transferColumns, + id, requestHash, fromWalletID, toWalletID, amount, string(status), failureReason, + ) + + transfer, err := scanTransfer(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("insert transfer claim: %w", err) + } + return transfer, nil +} + +// FindByID reads a transfer with no lock. It returns (nil, nil) if no +// such transfer exists. +func (r *TransferRepo) FindByID(ctx context.Context, id int64) (*domain.Transfer, error) { + row := r.pool.QueryRow(ctx, `SELECT `+transferColumns+` FROM transfers WHERE id = $1`, id) + + transfer, err := scanTransfer(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("find transfer: %w", err) + } + return transfer, nil +} + +// InsertLedgerEntries writes the two DEBIT/CREDIT postings for a +// PROCESSED transfer. Entry IDs are DB-generated identity values, not +// app-minted — unlike transfers.id, an entry ID is never a client-facing +// idempotency key or FK target for anything else, so there's nothing to +// gain from hand-rolling it. +func (r *TransferRepo) InsertLedgerEntries(ctx context.Context, tx repository.Tx, transferID int64, fromWalletID, toWalletID string, amount decimal.Decimal) error { + _, err := tx.Exec(ctx, ` + INSERT INTO ledger_entries (transfer_id, wallet_id, entry_type, amount, created_at) + VALUES ($1, $2, 'DEBIT', $3, now()), ($1, $4, 'CREDIT', $3, now()) + `, transferID, fromWalletID, amount, toWalletID) + if err != nil { + return fmt.Errorf("insert ledger entries: %w", err) + } + return nil +} + +func scanTransfer(row pgx.Row) (*domain.Transfer, error) { + var t domain.Transfer + err := row.Scan(&t.ID, &t.RequestHash, &t.FromWalletID, &t.ToWalletID, &t.Amount, &t.Status, &t.FailureReason, &t.CreatedAt, &t.UpdatedAt) + if err != nil { + return nil, err + } + return &t, nil +} diff --git a/internal/repository/postgres/transfer_repo_integration_test.go b/internal/repository/postgres/transfer_repo_integration_test.go new file mode 100644 index 00000000..0b3585b7 --- /dev/null +++ b/internal/repository/postgres/transfer_repo_integration_test.go @@ -0,0 +1,207 @@ +//go:build integration + +package postgres + +import ( + "context" + "testing" + + "github.com/shopspring/decimal" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/idgen" +) + +var testIDGen = mustIDGenerator() + +func mustIDGenerator() *idgen.Generator { + gen, err := idgen.NewGenerator(0) + if err != nil { + panic(err) + } + return gen +} + +func nextTestTransferID(t *testing.T) int64 { + t.Helper() + id, err := testIDGen.NextID() + if err != nil { + t.Fatalf("mint test transfer id: %v", err) + } + return id +} + +func TestTransferRepo_InsertTransfer_Success(t *testing.T) { + ctx := context.Background() + a := newTestWallet(t, "10.00") + b := newTestWallet(t, "0.00") + repo := NewTransferRepo(testPool) + id := nextTestTransferID(t) + + tx, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + transfer, err := repo.InsertTransfer(ctx, tx, id, "hash-1", a.ID, b.ID, decimal.RequireFromString("5.00"), domain.StatusProcessed, nil) + if err != nil { + t.Fatalf("InsertTransfer: %v", err) + } + if transfer == nil { + t.Fatalf("expected a claimed transfer, got nil") + } + if transfer.ID != id || transfer.RequestHash != "hash-1" || transfer.Status != domain.StatusProcessed { + t.Fatalf("unexpected transfer: %+v", transfer) + } +} + +func TestTransferRepo_InsertTransfer_FailedPersistsReason(t *testing.T) { + ctx := context.Background() + a := newTestWallet(t, "10.00") + b := newTestWallet(t, "0.00") + repo := NewTransferRepo(testPool) + id := nextTestTransferID(t) + + tx, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + reason := domain.ErrCodeInsufficientBalance + transfer, err := repo.InsertTransfer(ctx, tx, id, "hash-1", a.ID, b.ID, decimal.RequireFromString("5.00"), domain.StatusFailed, &reason) + if err != nil { + t.Fatalf("InsertTransfer: %v", err) + } + if transfer.Status != domain.StatusFailed || transfer.FailureReason == nil || *transfer.FailureReason != domain.ErrCodeInsufficientBalance { + t.Fatalf("unexpected transfer: %+v", transfer) + } +} + +func TestTransferRepo_InsertTransfer_ConflictReturnsNil(t *testing.T) { + ctx := context.Background() + a := newTestWallet(t, "10.00") + b := newTestWallet(t, "0.00") + repo := NewTransferRepo(testPool) + id := nextTestTransferID(t) + + tx1, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin tx1: %v", err) + } + if _, err := repo.InsertTransfer(ctx, tx1, id, "hash-1", a.ID, b.ID, decimal.RequireFromString("5.00"), domain.StatusProcessed, nil); err != nil { + t.Fatalf("tx1 InsertTransfer: %v", err) + } + if err := tx1.Commit(ctx); err != nil { + t.Fatalf("commit tx1: %v", err) + } + + tx2, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin tx2: %v", err) + } + defer func() { _ = tx2.Rollback(ctx) }() + + transfer, err := repo.InsertTransfer(ctx, tx2, id, "hash-2", a.ID, b.ID, decimal.RequireFromString("7.00"), domain.StatusProcessed, nil) + if err != nil { + t.Fatalf("tx2 InsertTransfer: %v", err) + } + if transfer != nil { + t.Fatalf("expected nil (conflict), got %+v", transfer) + } +} + +func TestTransferRepo_InsertTransfer_RejectsUnknownWallet(t *testing.T) { + ctx := context.Background() + a := newTestWallet(t, "10.00") + repo := NewTransferRepo(testPool) + id := nextTestTransferID(t) + + tx, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + _, err = repo.InsertTransfer(ctx, tx, id, "hash-1", a.ID, "00000000-0000-0000-0000-000000000000", decimal.RequireFromString("5.00"), domain.StatusProcessed, nil) + if err == nil { + t.Fatalf("expected the FK constraint to reject an unknown wallet id") + } +} + +func TestTransferRepo_FindByID(t *testing.T) { + ctx := context.Background() + a := newTestWallet(t, "10.00") + b := newTestWallet(t, "0.00") + repo := NewTransferRepo(testPool) + id := nextTestTransferID(t) + + tx, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + if _, err := repo.InsertTransfer(ctx, tx, id, "hash-1", a.ID, b.ID, decimal.RequireFromString("5.00"), domain.StatusProcessed, nil); err != nil { + t.Fatalf("InsertTransfer: %v", err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("commit: %v", err) + } + + found, err := repo.FindByID(ctx, id) + if err != nil { + t.Fatalf("FindByID: %v", err) + } + if found == nil || found.Status != domain.StatusProcessed { + t.Fatalf("expected a PROCESSED transfer, got %+v", found) + } + + notFound, err := repo.FindByID(ctx, id+999999) + if err != nil { + t.Fatalf("FindByID (missing): %v", err) + } + if notFound != nil { + t.Fatalf("expected nil for a missing transfer, got %+v", notFound) + } +} + +func TestTransferRepo_InsertLedgerEntries_ExactlyTwoRows(t *testing.T) { + ctx := context.Background() + a := newTestWallet(t, "10.00") + b := newTestWallet(t, "0.00") + repo := NewTransferRepo(testPool) + id := nextTestTransferID(t) + amount := decimal.RequireFromString("5.00") + + tx, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if _, err := repo.InsertTransfer(ctx, tx, id, "hash-1", a.ID, b.ID, amount, domain.StatusProcessed, nil); err != nil { + t.Fatalf("InsertTransfer: %v", err) + } + if err := repo.InsertLedgerEntries(ctx, tx, id, a.ID, b.ID, amount); err != nil { + t.Fatalf("InsertLedgerEntries: %v", err) + } + + var count int + if err := tx.QueryRow(ctx, `SELECT count(*) FROM ledger_entries WHERE transfer_id = $1`, id).Scan(&count); err != nil { + t.Fatalf("count ledger entries: %v", err) + } + if count != 2 { + t.Fatalf("expected exactly 2 ledger entries, got %d", count) + } + + var debitCount, creditCount int + if err := tx.QueryRow(ctx, `SELECT count(*) FROM ledger_entries WHERE transfer_id = $1 AND entry_type = 'DEBIT' AND wallet_id = $2 AND amount = $3`, id, a.ID, amount).Scan(&debitCount); err != nil { + t.Fatalf("count debit entries: %v", err) + } + if err := tx.QueryRow(ctx, `SELECT count(*) FROM ledger_entries WHERE transfer_id = $1 AND entry_type = 'CREDIT' AND wallet_id = $2 AND amount = $3`, id, b.ID, amount).Scan(&creditCount); err != nil { + t.Fatalf("count credit entries: %v", err) + } + if debitCount != 1 || creditCount != 1 { + t.Fatalf("expected 1 DEBIT on %s and 1 CREDIT on %s, got debit=%d credit=%d", a.ID, b.ID, debitCount, creditCount) + } +} diff --git a/internal/repository/postgres/wallet_repo.go b/internal/repository/postgres/wallet_repo.go new file mode 100644 index 00000000..ce5e3c35 --- /dev/null +++ b/internal/repository/postgres/wallet_repo.go @@ -0,0 +1,86 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/jackc/pgx/v5" + "github.com/shopspring/decimal" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/repository" +) + +// WalletRepo persists wallets. +type WalletRepo struct{} + +// NewWalletRepo constructs a WalletRepo. +func NewWalletRepo() *WalletRepo { + return &WalletRepo{} +} + +// GetForUpdate locks and returns the wallets identified by walletIDs, one +// row at a time, in ascending ID order — sorted internally, unconditionally, +// so that concurrent transfers touching the same wallet pair always +// request locks in the same order and can never deadlock, regardless of +// what order the caller happened to pass walletIDs in. +// +// This deliberately issues one `SELECT ... WHERE id = $1 FOR UPDATE` per +// wallet rather than a single `WHERE id = ANY($1) ORDER BY id FOR +// UPDATE`: Postgres's row locking happens during the scan, and when a +// Sort node is needed to satisfy ORDER BY, that scan can visit rows in +// an order that doesn't match the final sorted output — so the ORDER BY +// there does not reliably control lock acquisition order. Sequential +// single-row locks make the acquisition order explicit and impossible +// for the planner to reorder. +func (r *WalletRepo) GetForUpdate(ctx context.Context, tx repository.Tx, walletIDs ...string) ([]domain.Wallet, error) { + sort.Strings(walletIDs) + + wallets := make([]domain.Wallet, 0, len(walletIDs)) + for _, id := range walletIDs { + row := tx.QueryRow(ctx, ` + SELECT id, balance, created_at, updated_at + FROM wallets + WHERE id = $1 + FOR UPDATE + `, id) + + var w domain.Wallet + err := row.Scan(&w.ID, &w.Balance, &w.CreatedAt, &w.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + continue + } + if err != nil { + return nil, fmt.Errorf("lock wallet %s: %w", id, err) + } + wallets = append(wallets, w) + } + return wallets, nil +} + +// UpdateBalances sets both wallets' balances in a single statement — the +// debit and credit side of one transfer — rather than two sequential +// round trips. It makes no decision about whether either balance is +// valid — that's domain/service's call, not this repository's. +func (r *WalletRepo) UpdateBalances(ctx context.Context, tx repository.Tx, fromWalletID string, fromBalance decimal.Decimal, toWalletID string, toBalance decimal.Decimal) error { + // $2/$4 are explicitly cast to numeric: shopspring/decimal.Decimal + // binds through pgx's text format with no type OID of its own, so + // without the cast Postgres infers the CASE expression's type as + // text (from the untyped parameters) rather than numeric (from the + // balance column) and rejects the assignment outright. + _, err := tx.Exec(ctx, ` + UPDATE wallets + SET balance = CASE id + WHEN $1 THEN $2::numeric + WHEN $3 THEN $4::numeric + END, + updated_at = now() + WHERE id IN ($1, $3) + `, fromWalletID, fromBalance, toWalletID, toBalance) + if err != nil { + return fmt.Errorf("update wallet balances: %w", err) + } + return nil +} diff --git a/internal/repository/postgres/wallet_repo_integration_test.go b/internal/repository/postgres/wallet_repo_integration_test.go new file mode 100644 index 00000000..ae4b5b54 --- /dev/null +++ b/internal/repository/postgres/wallet_repo_integration_test.go @@ -0,0 +1,178 @@ +//go:build integration + +package postgres + +import ( + "context" + "testing" + "time" + + "github.com/shopspring/decimal" +) + +func TestWalletRepo_GetForUpdate_ReturnsLockedWallets(t *testing.T) { + ctx := context.Background() + a := newTestWallet(t, "100.00") + b := newTestWallet(t, "50.00") + + tx, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + repo := NewWalletRepo() + ids := []string{a.ID, b.ID} + if ids[0] > ids[1] { + ids[0], ids[1] = ids[1], ids[0] + } + + wallets, err := repo.GetForUpdate(ctx, tx, ids...) + if err != nil { + t.Fatalf("GetForUpdate: %v", err) + } + if len(wallets) != 2 { + t.Fatalf("expected 2 wallets, got %d", len(wallets)) + } +} + +// TestWalletRepo_GetForUpdate_SortsRegardlessOfInputOrder proves +// GetForUpdate itself enforces ascending lock order — passing walletIDs +// in descending order must still lock (and return) them ascending, so +// callers can never defeat the deadlock-free ordering guarantee by +// passing IDs unsorted. +func TestWalletRepo_GetForUpdate_SortsRegardlessOfInputOrder(t *testing.T) { + ctx := context.Background() + a := newTestWallet(t, "100.00") + b := newTestWallet(t, "50.00") + + ids := []string{a.ID, b.ID} + if ids[0] < ids[1] { + ids[0], ids[1] = ids[1], ids[0] + } + // ids is now strictly descending. + + tx, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + repo := NewWalletRepo() + wallets, err := repo.GetForUpdate(ctx, tx, ids...) + if err != nil { + t.Fatalf("GetForUpdate: %v", err) + } + if len(wallets) != 2 { + t.Fatalf("expected 2 wallets, got %d", len(wallets)) + } + if wallets[0].ID > wallets[1].ID { + t.Fatalf("expected wallets locked/returned in ascending ID order despite descending input, got %s then %s", wallets[0].ID, wallets[1].ID) + } +} + +func TestWalletRepo_GetForUpdate_MissingWalletReturnsFewer(t *testing.T) { + ctx := context.Background() + a := newTestWallet(t, "10.00") + + tx, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + repo := NewWalletRepo() + wallets, err := repo.GetForUpdate(ctx, tx, a.ID, "00000000-0000-0000-0000-000000000000") + if err != nil { + t.Fatalf("GetForUpdate: %v", err) + } + if len(wallets) != 1 { + t.Fatalf("expected exactly 1 wallet (the other doesn't exist), got %d", len(wallets)) + } +} + +func TestWalletRepo_UpdateBalances_PersistsBoth(t *testing.T) { + ctx := context.Background() + a := newTestWallet(t, "10.00") + b := newTestWallet(t, "5.00") + + tx, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + + repo := NewWalletRepo() + aBalance := decimal.RequireFromString("9.00") + bBalance := decimal.RequireFromString("6.00") + if err := repo.UpdateBalances(ctx, tx, a.ID, aBalance, b.ID, bBalance); err != nil { + t.Fatalf("UpdateBalances: %v", err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("commit: %v", err) + } + + var gotA, gotB decimal.Decimal + if err := testPool.QueryRow(ctx, `SELECT balance FROM wallets WHERE id = $1`, a.ID).Scan(&gotA); err != nil { + t.Fatalf("read back balance a: %v", err) + } + if err := testPool.QueryRow(ctx, `SELECT balance FROM wallets WHERE id = $1`, b.ID).Scan(&gotB); err != nil { + t.Fatalf("read back balance b: %v", err) + } + if !gotA.Equal(aBalance) { + t.Fatalf("expected balance a %s, got %s", aBalance, gotA) + } + if !gotB.Equal(bBalance) { + t.Fatalf("expected balance b %s, got %s", bBalance, gotB) + } +} + +// TestWalletRepo_GetForUpdate_Serializes proves FOR UPDATE actually +// blocks a second transaction from locking the same wallet until the +// first commits — the mechanism the whole concurrency guarantee rests on. +func TestWalletRepo_GetForUpdate_Serializes(t *testing.T) { + ctx := context.Background() + a := newTestWallet(t, "10.00") + repo := NewWalletRepo() + + tx1, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin tx1: %v", err) + } + if _, err := repo.GetForUpdate(ctx, tx1, a.ID); err != nil { + t.Fatalf("tx1 GetForUpdate: %v", err) + } + + unblocked := make(chan struct{}) + go func() { + tx2, err := testPool.Begin(ctx) + if err != nil { + t.Errorf("begin tx2: %v", err) + return + } + defer func() { _ = tx2.Rollback(ctx) }() + + if _, err := repo.GetForUpdate(ctx, tx2, a.ID); err != nil { + t.Errorf("tx2 GetForUpdate: %v", err) + return + } + close(unblocked) + }() + + select { + case <-unblocked: + t.Fatalf("tx2 acquired the lock while tx1 still held it") + case <-time.After(300 * time.Millisecond): + // expected: tx2 is still blocked + } + + if err := tx1.Commit(ctx); err != nil { + t.Fatalf("commit tx1: %v", err) + } + + select { + case <-unblocked: + // expected: tx2 proceeds once tx1 releases the lock + case <-time.After(2 * time.Second): + t.Fatalf("tx2 never acquired the lock after tx1 committed") + } +} diff --git a/internal/repository/tx.go b/internal/repository/tx.go new file mode 100644 index 00000000..19cecc3a --- /dev/null +++ b/internal/repository/tx.go @@ -0,0 +1,34 @@ +// Package repository holds contracts shared between the postgres and cache +// implementations and their service-layer consumers. Locking and querying +// are mechanical concerns that live here and in its subpackages — the +// DECISION of what to do with a locked row (e.g. what counts as +// insufficient balance) belongs to domain/service, not here. +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// Querier is the subset of pgx.Tx (and *pgxpool.Pool) needed to run SQL. +type Querier interface { + Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +// Tx is a database transaction, as seen by the repository and service +// layers. Concrete pgx.Tx values satisfy this directly. +type Tx interface { + Querier + Commit(ctx context.Context) error + Rollback(ctx context.Context) error +} + +// Transactor begins a new transaction. Implemented by postgres.Transactor, +// wrapping *pgxpool.Pool — the transaction boundary itself is owned by +// internal/service, which decides when to commit or roll back. +type Transactor interface { + Begin(ctx context.Context) (Tx, error) +} diff --git a/internal/service/fixtures_integration_test.go b/internal/service/fixtures_integration_test.go new file mode 100644 index 00000000..00ab0aa7 --- /dev/null +++ b/internal/service/fixtures_integration_test.go @@ -0,0 +1,38 @@ +//go:build integration + +package service + +import ( + "testing" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/idgen" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/testutil" +) + +var testTransferIDGen = mustTestIDGenerator() + +func mustTestIDGenerator() *idgen.Generator { + gen, err := idgen.NewGenerator(1) + if err != nil { + panic(err) + } + return gen +} + +func nextTestTransferID(t *testing.T) int64 { + t.Helper() + id, err := testTransferIDGen.NextID() + if err != nil { + t.Fatalf("mint test transfer id: %v", err) + } + return id +} + +// newTestWallet inserts a fresh wallet with a unique ID and the given +// balance directly via SQL. Every integration test creates its own +// wallets — never reuses another test's — so concurrent test runs can +// never corrupt each other's expected balances. +func newTestWallet(t *testing.T, balance string) string { + t.Helper() + return testutil.NewTestWallet(t, testPool, balance).ID +} diff --git a/internal/service/main_integration_test.go b/internal/service/main_integration_test.go new file mode 100644 index 00000000..9aee6938 --- /dev/null +++ b/internal/service/main_integration_test.go @@ -0,0 +1,79 @@ +//go:build integration + +package service + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/idgen" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/platform/migrate" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/repository/cache" + pgrepo "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/repository/postgres" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/testutil" +) + +var testPool *pgxpool.Pool +var testRedis *redis.Client + +func testRedisAddr() string { + if addr := os.Getenv("TEST_REDIS_ADDR"); addr != "" { + return addr + } + return "localhost:6379" +} + +func TestMain(m *testing.M) { + ctx := context.Background() + dsn := testutil.PostgresDSN() + + if err := migrate.Run(dsn, "../../migrations"); err != nil { + fmt.Fprintln(os.Stderr, "run migrations:", err) + os.Exit(1) + } + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + fmt.Fprintln(os.Stderr, "create pool:", err) + os.Exit(1) + } + defer pool.Close() + if err := pool.Ping(ctx); err != nil { + fmt.Fprintln(os.Stderr, "ping postgres — is `docker-compose up -d postgres` running?", err) + os.Exit(1) + } + testPool = pool + + redisClient := redis.NewClient(&redis.Options{Addr: testRedisAddr()}) + defer func() { _ = redisClient.Close() }() + if err := redisClient.Ping(ctx).Err(); err != nil { + fmt.Fprintln(os.Stderr, "ping redis — is `docker-compose up -d redis` running?", err) + os.Exit(1) + } + testRedis = redisClient + + os.Exit(m.Run()) +} + +func newIntegrationTransferService(t *testing.T) *TransferService { + t.Helper() + gen, err := idgen.NewGenerator(0) + if err != nil { + t.Fatalf("new id generator: %v", err) + } + return NewTransferService( + pgrepo.NewTransactor(testPool), + pgrepo.NewWalletRepo(), + pgrepo.NewTransferRepo(testPool), + cache.NewTransferCache(testRedis), + gen, + 5*time.Second, + testLogger(), + ) +} diff --git a/internal/service/ports.go b/internal/service/ports.go new file mode 100644 index 00000000..d737f362 --- /dev/null +++ b/internal/service/ports.go @@ -0,0 +1,53 @@ +package service + +import ( + "context" + + "github.com/shopspring/decimal" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/repository" +) + +// WalletRepository is the persistence contract TransferService needs for +// wallets. Implemented by internal/repository/postgres.WalletRepo. +type WalletRepository interface { + GetForUpdate(ctx context.Context, tx repository.Tx, walletIDs ...string) ([]domain.Wallet, error) + UpdateBalances(ctx context.Context, tx repository.Tx, fromWalletID string, fromBalance decimal.Decimal, toWalletID string, toBalance decimal.Decimal) error +} + +// TransferRepository is the persistence contract TransferService needs +// for transfers and their ledger entries. Implemented by +// internal/repository/postgres.TransferRepo. +type TransferRepository interface { + InsertTransfer(ctx context.Context, tx repository.Tx, id int64, requestHash, fromWalletID, toWalletID string, amount decimal.Decimal, status domain.Status, failureReason *domain.ErrorCode) (*domain.Transfer, error) + FindByID(ctx context.Context, id int64) (*domain.Transfer, error) + InsertLedgerEntries(ctx context.Context, tx repository.Tx, transferID int64, fromWalletID, toWalletID string, amount decimal.Decimal) error +} + +// Cache is the read-through cache contract for transfers: a single +// transfer:{id} key, written once, never updated — immutability makes +// self-healing on TTL expiry safe with no update path to maintain. +// Implemented by internal/repository/cache.TransferCache. +type Cache interface { + Get(ctx context.Context, id int64) (*domain.Transfer, error) + Set(ctx context.Context, id int64, transfer *domain.Transfer) error +} + +// IDGenerator mints new transfer IDs. Implemented by *idgen.Generator. +type IDGenerator interface { + NextID() (int64, error) +} + +// TransferRequest is the already-shape-validated input to +// TransferService.Transfer — the handler has already parsed and +// type-checked it (malformed JSON, bad UUIDs, bad amount format all +// rejected with 400 before this is built), so the service only ever +// deals with business-meaning validation from here on. +type TransferRequest struct { + ID int64 + FromWalletID string + ToWalletID string + Amount decimal.Decimal + AmountMinorUnits int64 +} diff --git a/internal/service/transfer_service.go b/internal/service/transfer_service.go new file mode 100644 index 00000000..ffdaece7 --- /dev/null +++ b/internal/service/transfer_service.go @@ -0,0 +1,350 @@ +// Package service holds orchestration: hash computation, cache-check, +// transaction boundary, calling repositories and domain rules. This is +// where the whole idempotency/concurrency flow lives. +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "log/slog" + "time" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/platform/logger" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/repository" +) + +// cacheWriteTimeout bounds the background cache write writeCache starts +// after the request's own context is gone (see writeCache). +const cacheWriteTimeout = 3 * time.Second + +// TransferService orchestrates the full idempotency/concurrency/ledger +// flow for wallet-to-wallet transfers. +type TransferService struct { + db repository.Transactor + walletRepo WalletRepository + transferRepo TransferRepository + cache Cache + idGen IDGenerator + lockTimeout time.Duration + logger *slog.Logger +} + +// NewTransferService constructs a TransferService. +func NewTransferService( + db repository.Transactor, + walletRepo WalletRepository, + transferRepo TransferRepository, + cache Cache, + idGen IDGenerator, + lockTimeout time.Duration, + logger *slog.Logger, +) *TransferService { + return &TransferService{ + db: db, + walletRepo: walletRepo, + transferRepo: transferRepo, + cache: cache, + idGen: idGen, + lockTimeout: lockTimeout, + logger: logger, + } +} + +// InitTransfer mints a fresh transfer ID. Nothing is persisted. +// +// A NextID failure (e.g. a Snowflake clock regression) is an +// infrastructure problem, not a client mistake — the real cause is +// logged here, server-side, and the caller gets back a generic typed +// error rather than the underlying detail. +func (s *TransferService) InitTransfer() (int64, error) { + id, err := s.idGen.NextID() + if err != nil { + s.logger.Error("mint transfer id failed", slog.Any("error", err)) + return 0, domain.ErrTransferInitFailed + } + return id, nil +} + +// Transfer runs, in order: same-wallet/invalid-amount checks, the cache +// check, a pre-transaction check for an already-committed id, and — only +// if truly new — locking wallets and claiming the transfer. +func (s *TransferService) Transfer(ctx context.Context, req TransferRequest) (*domain.Transfer, error) { + if err := validateTransferRules(req); err != nil { + return nil, err + } + + hash := computeHash(req.FromWalletID, req.ToWalletID, req.AmountMinorUnits) + + cached, err := s.checkCache(ctx, req.ID, hash) + if err != nil || cached != nil { + return cached, err + } + + existing, err := s.checkExistingTransfer(ctx, req.ID, hash) + if err != nil || existing != nil { + return existing, err + } + + return s.lockAndExecuteTransfer(ctx, req, hash) +} + +// GetTransfer reads a transfer by ID with no lock, checking the cache +// first — the same transfer:{id} key POST replay uses (docs/DESIGN.md +// §6) — before falling through to Postgres and self-healing the cache +// on a miss. It returns (nil, nil) if no such transfer exists. +func (s *TransferService) GetTransfer(ctx context.Context, id int64) (*domain.Transfer, error) { + cached, err := s.cache.Get(ctx, id) + if err != nil { + logger.FromContext(ctx, s.logger).Warn("get transfer cache failed; falling back to postgres", slog.Any("error", err)) + } + if cached != nil { + return cached, nil + } + + transfer, err := s.transferRepo.FindByID(ctx, id) + if err != nil { + return nil, err + } + if transfer == nil { + return nil, nil + } + + s.writeCache(transfer) + return transfer, nil +} + +// validateTransferRules checks same-wallet and invalid-amount. It +// touches neither Redis nor Postgres, and its outcome depends only on +// the request body — so a corrected resubmission can freely reuse the +// same id, since nothing was ever claimed against it. Kept in the +// service layer, not the handler: this is request *meaning*, not +// *shape*, and business rules don't belong in handlers. +func validateTransferRules(req TransferRequest) error { + if req.FromWalletID == req.ToWalletID { + return &domain.Error{Code: domain.ErrCodeSameWallet, Message: "fromWalletId and toWalletId must differ"} + } + if req.AmountMinorUnits <= 0 { + return &domain.Error{Code: domain.ErrCodeInvalidAmount, Message: "Invalid transfer amount"} + } + return nil +} + +// computeHash canonicalizes the parts of a transfer request that must +// match on replay. It deliberately excludes the transfer ID itself. +func computeHash(fromWalletID, toWalletID string, amountMinorUnits int64) string { + canonical := fmt.Sprintf("%s|%s|%d", fromWalletID, toWalletID, amountMinorUnits) + sum := sha256.Sum256([]byte(canonical)) + return hex.EncodeToString(sum[:]) +} + +// checkCache is the Redis half of the flow. A nil, nil result means a +// cache miss — the caller should fall through to the Postgres checks. +// Cache is a latency optimization only (docs/DESIGN.md §6): a Get +// failure falls back to Postgres rather than failing the request. +func (s *TransferService) checkCache(ctx context.Context, id int64, hash string) (*domain.Transfer, error) { + cached, err := s.cache.Get(ctx, id) + if err != nil { + logger.FromContext(ctx, s.logger).Warn("check transfer cache failed; falling back to postgres", slog.Any("error", err)) + return nil, nil + } + if cached == nil { + return nil, nil + } + if cached.RequestHash != hash { + return nil, idempotencyMismatchError() + } + return cached, nil +} + +// checkExistingTransfer reads a transfer by ID with no lock and, if +// found, resolves it against hash: a match replays it, a mismatch is a +// 409. A nil, nil result means no such transfer exists (yet). Used both +// before the transaction starts (the common case: an already-committed +// id never touches wallet locks) and after losing the claim race in +// lockAndExecuteTransfer (the rare true-concurrent case) — checking here +// first means a reused id with a mismatched hash always returns 409, +// never a stale WALLET_NOT_FOUND from re-validating against wallets that +// may since look different. +func (s *TransferService) checkExistingTransfer(ctx context.Context, id int64, hash string) (*domain.Transfer, error) { + existing, err := s.transferRepo.FindByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("check existing transfer: %w", err) + } + if existing == nil { + return nil, nil + } + + s.writeCache(existing) + + if existing.RequestHash != hash { + return nil, idempotencyMismatchError() + } + return existing, nil +} + +// lockAndExecuteTransfer is the Postgres half of the flow: BEGIN, lock +// both wallets first (the only place any lock is taken), decide and +// apply the outcome, then claim the transfer ID with that outcome +// already written — resolving a lost race against the already-committed +// winner if we lose it. +// +// Wallets lock before the claim insert, not after: the claim INSERT +// references from/to wallet FKs, and Postgres takes an implicit shared +// FOR KEY SHARE lock on both referenced rows as part of that insert. If +// the claim ran first, N concurrent transfers on the same wallet pair +// would all hold that shared lock simultaneously, then all try to +// upgrade to the explicit FOR UPDATE lock at once — a lock-upgrade +// deadlock, confirmed empirically under the concurrency stress test. +// Locking wallets first means only one transaction can ever be in that +// danger zone at a time. +func (s *TransferService) lockAndExecuteTransfer(ctx context.Context, req TransferRequest, hash string) (*domain.Transfer, error) { + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin transfer transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if err := setLockTimeout(ctx, tx, s.lockTimeout); err != nil { + return nil, err + } + + wallets, err := s.lockAndValidateWallets(ctx, tx, req.FromWalletID, req.ToWalletID) + if err != nil { + return nil, err + } + + status, failureReason, err := s.executeTransfer(ctx, tx, req, wallets) + if err != nil { + return nil, err + } + + claimed, err := s.transferRepo.InsertTransfer(ctx, tx, req.ID, hash, req.FromWalletID, req.ToWalletID, req.Amount, status, failureReason) + if err != nil { + return nil, err + } + if claimed == nil { + _ = tx.Rollback(ctx) + return s.checkConflictAfterRace(ctx, req.ID, hash) + } + + if status == domain.StatusProcessed { + if err := s.transferRepo.InsertLedgerEntries(ctx, tx, req.ID, req.FromWalletID, req.ToWalletID, req.Amount); err != nil { + return nil, err + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit transfer transaction: %w", err) + } + + s.writeCache(claimed) + return claimed, nil +} + +// checkConflictAfterRace handles the rare case where a genuinely +// concurrent request for the same new id won the ON CONFLICT race first +// — by the time our INSERT observed the conflict, the winner has +// already committed (Postgres's speculative-insertion protocol only +// resolves the conflict after that), so the existing row is guaranteed +// visible here. +func (s *TransferService) checkConflictAfterRace(ctx context.Context, id int64, hash string) (*domain.Transfer, error) { + existing, err := s.checkExistingTransfer(ctx, id, hash) + if err != nil { + return nil, err + } + if existing == nil { + return nil, fmt.Errorf("transfer %d: lost the claim race but no row found", id) + } + return existing, nil +} + +// lockAndValidateWallets locks both wallets — GetForUpdate sorts +// walletIDs internally before acquiring locks, so every caller ends up +// requesting the same pair in the same order regardless of the order +// passed here, which can't deadlock — and reports WALLET_NOT_FOUND if +// either is missing. +func (s *TransferService) lockAndValidateWallets(ctx context.Context, tx repository.Tx, fromID, toID string) (map[string]domain.Wallet, error) { + wallets, err := s.walletRepo.GetForUpdate(ctx, tx, fromID, toID) + if err != nil { + return nil, fmt.Errorf("lock wallets: %w", err) + } + if len(wallets) < 2 { + return nil, &domain.Error{Code: domain.ErrCodeWalletNotFound, Message: "fromWalletId or toWalletId does not exist"} + } + + byID := make(map[string]domain.Wallet, len(wallets)) + for _, w := range wallets { + byID[w.ID] = w + } + return byID, nil +} + +// executeTransfer decides whether the source wallet has sufficient +// balance. Insufficient balance debits nothing and returns a +// FAILED/INSUFFICIENT_BALANCE outcome for the caller to persist as-is; +// sufficient balance debits and credits both wallets and returns +// PROCESSED. Insufficient balance is persisted (unlike same-wallet or +// invalid-amount) because it depends on mutable state — a wallet's +// balance can change between one delivery of a request and a later one, +// so the answer must be pinned to whatever it was the first time, +// otherwise the same idempotency key could yield different results. +func (s *TransferService) executeTransfer(ctx context.Context, tx repository.Tx, req TransferRequest, wallets map[string]domain.Wallet) (domain.Status, *domain.ErrorCode, error) { + from := wallets[req.FromWalletID] + to := wallets[req.ToWalletID] + + if from.Balance.LessThan(req.Amount) { + reason := domain.ErrCodeInsufficientBalance + return domain.StatusFailed, &reason, nil + } + + if err := s.walletRepo.UpdateBalances(ctx, tx, from.ID, from.Balance.Sub(req.Amount), to.ID, to.Balance.Add(req.Amount)); err != nil { + return "", nil, err + } + return domain.StatusProcessed, nil, nil +} + +// writeCache best-effort caches transfer, off the request's hot path. +// Cache is a latency optimization (docs/DESIGN.md §6) — a failure here +// must never fail a request that already committed successfully in +// Postgres, and the write itself must never add to the request's +// latency. It runs in its own goroutine with its own bounded-lifetime +// context, deliberately not the request's ctx: that context is +// canceled the moment the handler returns a response, which would +// cancel the write before it had a chance to run. +func (s *TransferService) writeCache(transfer *domain.Transfer) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), cacheWriteTimeout) + defer cancel() + if err := s.cache.Set(ctx, transfer.ID, transfer); err != nil { + logger.FromContext(ctx, s.logger).Warn("cache transfer failed", slog.Any("error", err)) + } + }() +} + +// setLockTimeout requires timeout to already be at least 1ms — +// config.Validate() enforces that at startup. This clamp is a second, +// defensive layer only, for any path that builds a TransferService +// without going through config validation (e.g. a test): under 1ms, +// timeout.Milliseconds() truncates to 0, and "SET LOCAL lock_timeout = +// '0ms'" means "no timeout" in Postgres — the opposite of what a +// lock_timeout is for. +func setLockTimeout(ctx context.Context, tx repository.Tx, timeout time.Duration) error { + if timeout < time.Millisecond { + timeout = time.Millisecond + } + _, err := tx.Exec(ctx, fmt.Sprintf("SET LOCAL lock_timeout = '%dms'", timeout.Milliseconds())) + if err != nil { + return fmt.Errorf("set lock_timeout: %w", err) + } + return nil +} + +func idempotencyMismatchError() error { + return &domain.Error{ + Code: domain.ErrCodeIdempotencyKeyReused, + Message: "idempotencyKey was already used with a different fromWalletId, toWalletId, or amount", + } +} diff --git a/internal/service/transfer_service_integration_test.go b/internal/service/transfer_service_integration_test.go new file mode 100644 index 00000000..eb2ce3a7 --- /dev/null +++ b/internal/service/transfer_service_integration_test.go @@ -0,0 +1,144 @@ +//go:build integration + +package service + +import ( + "context" + "sync" + "testing" + + "github.com/shopspring/decimal" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" +) + +// TestTransfer_ConcurrencyStress is the "hero" test: N goroutines +// transferring from the same wallet pair concurrently must land on the +// exact final balance and exactly 2N ledger rows — no double-spend, no +// lost update. Run with -race. +func TestTransfer_ConcurrencyStress(t *testing.T) { + const n = 25 + const amount = "1.00" + from := newTestWallet(t, "25.00") + to := newTestWallet(t, "0.00") + + svc := newIntegrationTransferService(t) + + // Minted up front, in the test goroutine: t.Fatalf inside a spawned + // goroutine only halts that goroutine (via runtime.Goexit), not the + // test, so any minting error must surface here instead. + ids := make([]int64, n) + for i := range ids { + ids[i] = nextTestTransferID(t) + } + + var wg sync.WaitGroup + errs := make([]error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + req := TransferRequest{ + ID: ids[i], + FromWalletID: from, + ToWalletID: to, + Amount: decimal.RequireFromString(amount), + AmountMinorUnits: 100, + } + _, err := svc.Transfer(context.Background(), req) + errs[i] = err + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("goroutine %d: unexpected error: %v", i, err) + } + } + + ctx := context.Background() + assertWalletBalance(t, from, "0.00") + assertWalletBalance(t, to, "25.00") + + var ledgerCount int + if err := testPool.QueryRow(ctx, ` + SELECT count(*) FROM ledger_entries WHERE wallet_id IN ($1, $2) + `, from, to).Scan(&ledgerCount); err != nil { + t.Fatalf("count ledger entries: %v", err) + } + if ledgerCount != 2*n { + t.Fatalf("expected exactly %d ledger entries, got %d", 2*n, ledgerCount) + } +} + +// TestTransfer_DoubleClickRace is the second "hero" test: two callers +// submitting the identical idempotency key near-simultaneously must +// produce exactly one transfers row and identical responses — Postgres's +// speculative-insert wait behavior resolving the race, not a +// hypothetical. Run with -race. +func TestTransfer_DoubleClickRace(t *testing.T) { + from := newTestWallet(t, "10.00") + to := newTestWallet(t, "0.00") + svc := newIntegrationTransferService(t) + + req := TransferRequest{ + ID: nextTestTransferID(t), + FromWalletID: from, + ToWalletID: to, + Amount: decimal.RequireFromString("5.00"), + AmountMinorUnits: 500, + } + + var wg sync.WaitGroup + results := make([]*domain.Transfer, 2) + errs := make([]error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + results[i], errs[i] = svc.Transfer(context.Background(), req) + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("caller %d: unexpected error: %v", i, err) + } + } + if results[0].ID != results[1].ID || results[0].Status != results[1].Status { + t.Fatalf("expected identical responses, got %+v and %+v", results[0], results[1]) + } + + ctx := context.Background() + var transferCount int + if err := testPool.QueryRow(ctx, `SELECT count(*) FROM transfers WHERE id = $1`, req.ID).Scan(&transferCount); err != nil { + t.Fatalf("count transfers: %v", err) + } + if transferCount != 1 { + t.Fatalf("expected exactly 1 transfers row, got %d", transferCount) + } + + assertWalletBalance(t, from, "5.00") + assertWalletBalance(t, to, "5.00") + + var ledgerCount int + if err := testPool.QueryRow(ctx, `SELECT count(*) FROM ledger_entries WHERE transfer_id = $1`, req.ID).Scan(&ledgerCount); err != nil { + t.Fatalf("count ledger entries: %v", err) + } + if ledgerCount != 2 { + t.Fatalf("expected exactly 2 ledger entries (one execution, not two), got %d", ledgerCount) + } +} + +func assertWalletBalance(t *testing.T, walletID, want string) { + t.Helper() + var got decimal.Decimal + if err := testPool.QueryRow(context.Background(), `SELECT balance FROM wallets WHERE id = $1`, walletID).Scan(&got); err != nil { + t.Fatalf("read wallet balance: %v", err) + } + if !got.Equal(decimal.RequireFromString(want)) { + t.Fatalf("wallet %s: expected balance %s, got %s", walletID, want, got) + } +} diff --git a/internal/service/transfer_service_test.go b/internal/service/transfer_service_test.go new file mode 100644 index 00000000..edcdcce8 --- /dev/null +++ b/internal/service/transfer_service_test.go @@ -0,0 +1,570 @@ +package service + +import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/shopspring/decimal" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/repository" +) + +// --- test doubles ----------------------------------------------------- + +type fakeTx struct { + execCalls int + committed bool + rolledBack bool +} + +func (t *fakeTx) Exec(_ context.Context, _ string, _ ...any) (pgconn.CommandTag, error) { + t.execCalls++ + return pgconn.CommandTag{}, nil +} +func (t *fakeTx) QueryRow(_ context.Context, _ string, _ ...any) pgx.Row { return nil } +func (t *fakeTx) Commit(_ context.Context) error { + t.committed = true + return nil +} +func (t *fakeTx) Rollback(_ context.Context) error { + t.rolledBack = true + return nil +} + +type fakeTransactor struct { + tx *fakeTx + beginErr error +} + +func (f *fakeTransactor) Begin(_ context.Context) (repository.Tx, error) { + if f.beginErr != nil { + return nil, f.beginErr + } + f.tx = &fakeTx{} + return f.tx, nil +} + +type mockWalletRepo struct { + wallets []domain.Wallet + getForUpdateErr error + updateBalancesErr error + updateCalls []struct { + fromWalletID string + fromBalance decimal.Decimal + toWalletID string + toBalance decimal.Decimal + } +} + +func (m *mockWalletRepo) GetForUpdate(_ context.Context, _ repository.Tx, _ ...string) ([]domain.Wallet, error) { + if m.getForUpdateErr != nil { + return nil, m.getForUpdateErr + } + return m.wallets, nil +} + +func (m *mockWalletRepo) UpdateBalances(_ context.Context, _ repository.Tx, fromWalletID string, fromBalance decimal.Decimal, toWalletID string, toBalance decimal.Decimal) error { + m.updateCalls = append(m.updateCalls, struct { + fromWalletID string + fromBalance decimal.Decimal + toWalletID string + toBalance decimal.Decimal + }{fromWalletID, fromBalance, toWalletID, toBalance}) + return m.updateBalancesErr +} + +type mockTransferRepo struct { + insertTransferResult *domain.Transfer + insertTransferErr error + insertTransferCalls int + + // findByIDResults is consumed in order across successive FindByID + // calls (the pre-check, then — only if reached — the post-race + // recheck); the last entry repeats once exhausted. + findByIDResults []*domain.Transfer + findByIDErr error + findByIDCalls int + + insertLedgerErr error + insertLedgerCalls int +} + +func (m *mockTransferRepo) InsertTransfer(_ context.Context, _ repository.Tx, _ int64, _, _, _ string, _ decimal.Decimal, _ domain.Status, _ *domain.ErrorCode) (*domain.Transfer, error) { + m.insertTransferCalls++ + if m.insertTransferErr != nil { + return nil, m.insertTransferErr + } + return m.insertTransferResult, nil +} + +func (m *mockTransferRepo) FindByID(_ context.Context, _ int64) (*domain.Transfer, error) { + if m.findByIDErr != nil { + return nil, m.findByIDErr + } + if len(m.findByIDResults) == 0 { + return nil, nil + } + idx := m.findByIDCalls + if idx >= len(m.findByIDResults) { + idx = len(m.findByIDResults) - 1 + } + m.findByIDCalls++ + return m.findByIDResults[idx], nil +} + +func (m *mockTransferRepo) InsertLedgerEntries(_ context.Context, _ repository.Tx, _ int64, _, _ string, _ decimal.Decimal) error { + m.insertLedgerCalls++ + return m.insertLedgerErr +} + +// mockCache guards setCalls with a mutex because writeCache (the only +// caller of Set) now runs it in its own goroutine, off the request's +// hot path -- Set can race with a test's own goroutine reading back +// what was recorded. +type mockCache struct { + mu sync.Mutex + getResult *domain.Transfer + getErr error + setErr error + setCalls []*domain.Transfer +} + +func (m *mockCache) Get(_ context.Context, _ int64) (*domain.Transfer, error) { + return m.getResult, m.getErr +} + +func (m *mockCache) Set(_ context.Context, _ int64, transfer *domain.Transfer) error { + m.mu.Lock() + defer m.mu.Unlock() + m.setCalls = append(m.setCalls, transfer) + return m.setErr +} + +func (m *mockCache) SetCalls() []*domain.Transfer { + m.mu.Lock() + defer m.mu.Unlock() + return append([]*domain.Transfer(nil), m.setCalls...) +} + +// waitForCacheSet polls until at least one cache Set call has been +// recorded, or fails the test after a short timeout -- writeCache is +// asynchronous, so its effect isn't visible the instant the call that +// triggered it returns. +func waitForCacheSet(t *testing.T, c *mockCache) []*domain.Transfer { + t.Helper() + deadline := time.Now().Add(time.Second) + for { + calls := c.SetCalls() + if len(calls) >= 1 { + return calls + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for a cache Set call, got %d", len(calls)) + } + time.Sleep(time.Millisecond) + } +} + +type mockIDGen struct { + id int64 + err error +} + +func (m *mockIDGen) NextID() (int64, error) { + if m.err != nil { + return 0, m.err + } + return m.id, nil +} + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func newTestService(db *fakeTransactor, wallets *mockWalletRepo, transfers *mockTransferRepo, c *mockCache) *TransferService { + return NewTransferService(db, wallets, transfers, c, &mockIDGen{id: 1}, 5*time.Second, testLogger()) +} + +// --- request builders --------------------------------------------------- + +func validRequest() TransferRequest { + return TransferRequest{ + ID: 42, + FromWalletID: "wallet-a", + ToWalletID: "wallet-b", + Amount: decimal.RequireFromString("10.00"), + AmountMinorUnits: 1000, + } +} + +func requireDomainErrCode(t *testing.T, err error, code domain.ErrorCode) { + t.Helper() + var domainErr *domain.Error + if !errors.As(err, &domainErr) { + t.Fatalf("expected *domain.Error, got %T (%v)", err, err) + } + if domainErr.Code != code { + t.Fatalf("expected code %s, got %s", code, domainErr.Code) + } +} + +// --- Step 0: deterministic checks --------------------------------------- + +func TestTransfer_SameWallet(t *testing.T) { + db := &fakeTransactor{} + c := &mockCache{} + svc := newTestService(db, &mockWalletRepo{}, &mockTransferRepo{}, c) + + req := validRequest() + req.ToWalletID = req.FromWalletID + + _, err := svc.Transfer(context.Background(), req) + requireDomainErrCode(t, err, domain.ErrCodeSameWallet) + + if len(c.SetCalls()) != 0 || db.tx != nil { + t.Fatalf("same-wallet check must short-circuit before any cache/DB call") + } +} + +func TestTransfer_InvalidAmount(t *testing.T) { + db := &fakeTransactor{} + c := &mockCache{} + svc := newTestService(db, &mockWalletRepo{}, &mockTransferRepo{}, c) + + req := validRequest() + req.AmountMinorUnits = 0 + + _, err := svc.Transfer(context.Background(), req) + requireDomainErrCode(t, err, domain.ErrCodeInvalidAmount) + + if db.tx != nil { + t.Fatalf("invalid-amount check must short-circuit before any DB call") + } +} + +// --- cache path ---------------------------------------------------------- + +func TestTransfer_CacheHit_Replay(t *testing.T) { + req := validRequest() + hash := computeHash(req.FromWalletID, req.ToWalletID, req.AmountMinorUnits) + existing := &domain.Transfer{ID: req.ID, RequestHash: hash, Status: domain.StatusProcessed} + + db := &fakeTransactor{} + svc := newTestService(db, &mockWalletRepo{}, &mockTransferRepo{}, &mockCache{getResult: existing}) + + got, err := svc.Transfer(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != existing { + t.Fatalf("expected cached transfer to be returned as-is") + } + if db.tx != nil { + t.Fatalf("cache hit must never open a DB transaction") + } +} + +func TestTransfer_CacheHit_HashMismatch(t *testing.T) { + req := validRequest() + existing := &domain.Transfer{ID: req.ID, RequestHash: "different-hash", Status: domain.StatusProcessed} + + svc := newTestService(&fakeTransactor{}, &mockWalletRepo{}, &mockTransferRepo{}, &mockCache{getResult: existing}) + + _, err := svc.Transfer(context.Background(), req) + requireDomainErrCode(t, err, domain.ErrCodeIdempotencyKeyReused) +} + +func TestTransfer_CacheGetError_FallsBackToPostgres(t *testing.T) { + // Cache is a latency optimization only (docs/DESIGN.md §6) -- a Redis + // failure must never fail a request that Postgres can still answer. + req := validRequest() + hash := computeHash(req.FromWalletID, req.ToWalletID, req.AmountMinorUnits) + existing := &domain.Transfer{ID: req.ID, RequestHash: hash, Status: domain.StatusProcessed} + + db := &fakeTransactor{} + transfers := &mockTransferRepo{findByIDResults: []*domain.Transfer{existing}} + c := &mockCache{getErr: errors.New("redis unavailable")} + svc := newTestService(db, &mockWalletRepo{}, transfers, c) + + got, err := svc.Transfer(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != existing { + t.Fatalf("expected fallback to Postgres to replay the existing transfer") + } +} + +// --- pre-transaction existing-transfer check (cache miss, id already +// committed) -- must resolve without ever touching wallets --------------- + +func TestTransfer_ExistingTransfer_Replay(t *testing.T) { + req := validRequest() + hash := computeHash(req.FromWalletID, req.ToWalletID, req.AmountMinorUnits) + existing := &domain.Transfer{ID: req.ID, RequestHash: hash, Status: domain.StatusProcessed} + + db := &fakeTransactor{} + wallets := &mockWalletRepo{} + transfers := &mockTransferRepo{findByIDResults: []*domain.Transfer{existing}} + c := &mockCache{} + svc := newTestService(db, wallets, transfers, c) + + got, err := svc.Transfer(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != existing { + t.Fatalf("expected existing transfer to be replayed") + } + waitForCacheSet(t, c) + if db.tx != nil { + t.Fatalf("an already-committed id must never open a DB transaction or touch wallet locks") + } + if len(wallets.updateCalls) != 0 { + t.Fatalf("an already-committed id must never touch wallet balances") + } +} + +func TestTransfer_ExistingTransfer_HashMismatch(t *testing.T) { + req := validRequest() + existing := &domain.Transfer{ID: req.ID, RequestHash: "different-hash", Status: domain.StatusProcessed} + + db := &fakeTransactor{} + transfers := &mockTransferRepo{findByIDResults: []*domain.Transfer{existing}} + svc := newTestService(db, &mockWalletRepo{}, transfers, &mockCache{}) + + _, err := svc.Transfer(context.Background(), req) + requireDomainErrCode(t, err, domain.ErrCodeIdempotencyKeyReused) + + if db.tx != nil { + t.Fatalf("a hash-mismatched already-committed id must never open a DB transaction") + } +} + +// --- claim-race path (pre-check found nothing, but InsertTransfer still +// lost a genuine concurrent race) ------------------------------------------ + +func TestTransfer_ClaimRace_Replay(t *testing.T) { + req := validRequest() + hash := computeHash(req.FromWalletID, req.ToWalletID, req.AmountMinorUnits) + winner := &domain.Transfer{ID: req.ID, RequestHash: hash, Status: domain.StatusProcessed} + + db := &fakeTransactor{} + wallets := &mockWalletRepo{wallets: []domain.Wallet{ + {ID: req.FromWalletID, Balance: decimal.RequireFromString("100.00")}, + {ID: req.ToWalletID, Balance: decimal.RequireFromString("5.00")}, + }} + // First FindByID (pre-check) sees nothing; second (post-race) sees the winner. + transfers := &mockTransferRepo{ + insertTransferResult: nil, + findByIDResults: []*domain.Transfer{nil, winner}, + } + c := &mockCache{} + svc := newTestService(db, wallets, transfers, c) + + got, err := svc.Transfer(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != winner { + t.Fatalf("expected the race winner's transfer to be replayed") + } + if transfers.insertTransferCalls != 1 { + t.Fatalf("expected exactly one InsertTransfer attempt, got %d", transfers.insertTransferCalls) + } +} + +func TestTransfer_ClaimRace_HashMismatch(t *testing.T) { + req := validRequest() + winner := &domain.Transfer{ID: req.ID, RequestHash: "different-hash", Status: domain.StatusProcessed} + + db := &fakeTransactor{} + wallets := &mockWalletRepo{wallets: []domain.Wallet{ + {ID: req.FromWalletID, Balance: decimal.RequireFromString("100.00")}, + {ID: req.ToWalletID, Balance: decimal.RequireFromString("5.00")}, + }} + transfers := &mockTransferRepo{ + insertTransferResult: nil, + findByIDResults: []*domain.Transfer{nil, winner}, + } + svc := newTestService(db, wallets, transfers, &mockCache{}) + + _, err := svc.Transfer(context.Background(), req) + requireDomainErrCode(t, err, domain.ErrCodeIdempotencyKeyReused) +} + +// --- fresh-claim path ------------------------------------------------------ + +func TestTransfer_WalletNotFound(t *testing.T) { + req := validRequest() + + db := &fakeTransactor{} + wallets := &mockWalletRepo{wallets: nil} // neither wallet exists + transfers := &mockTransferRepo{} + svc := newTestService(db, wallets, transfers, &mockCache{}) + + _, err := svc.Transfer(context.Background(), req) + requireDomainErrCode(t, err, domain.ErrCodeWalletNotFound) + + if !db.tx.rolledBack || db.tx.committed { + t.Fatalf("wallet-not-found must roll back, never commit") + } + if transfers.insertTransferCalls != 0 { + t.Fatalf("wallet-not-found must never attempt to claim the transfer") + } +} + +func TestTransfer_InsufficientBalance(t *testing.T) { + req := validRequest() + from := domain.Wallet{ID: req.FromWalletID, Balance: decimal.RequireFromString("1.00")} + to := domain.Wallet{ID: req.ToWalletID, Balance: decimal.RequireFromString("0.00")} + reason := domain.ErrCodeInsufficientBalance + failed := &domain.Transfer{ID: req.ID, Status: domain.StatusFailed, FailureReason: &reason} + + db := &fakeTransactor{} + wallets := &mockWalletRepo{wallets: []domain.Wallet{from, to}} + transfers := &mockTransferRepo{insertTransferResult: failed} + c := &mockCache{} + svc := newTestService(db, wallets, transfers, c) + + got, err := svc.Transfer(context.Background(), req) + if err != nil { + t.Fatalf("insufficient balance is a persisted outcome, not a returned error: %v", err) + } + if got.Status != domain.StatusFailed || got.FailureReason == nil || *got.FailureReason != domain.ErrCodeInsufficientBalance { + t.Fatalf("expected a persisted FAILED/INSUFFICIENT_BALANCE transfer, got %+v", got) + } + if !db.tx.committed { + t.Fatalf("insufficient-balance outcome must still be committed") + } + if len(wallets.updateCalls) != 0 { + t.Fatalf("insufficient-balance must never debit or credit") + } + if transfers.insertLedgerCalls != 0 { + t.Fatalf("insufficient-balance must never write ledger entries") + } + waitForCacheSet(t, c) +} + +func TestTransfer_Success(t *testing.T) { + req := validRequest() + from := domain.Wallet{ID: req.FromWalletID, Balance: decimal.RequireFromString("100.00")} + to := domain.Wallet{ID: req.ToWalletID, Balance: decimal.RequireFromString("5.00")} + processed := &domain.Transfer{ID: req.ID, Status: domain.StatusProcessed} + + db := &fakeTransactor{} + wallets := &mockWalletRepo{wallets: []domain.Wallet{from, to}} + transfers := &mockTransferRepo{insertTransferResult: processed} + c := &mockCache{} + svc := newTestService(db, wallets, transfers, c) + + got, err := svc.Transfer(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Status != domain.StatusProcessed { + t.Fatalf("expected PROCESSED, got %s", got.Status) + } + if len(wallets.updateCalls) != 1 { + t.Fatalf("expected a single combined UpdateBalances call, got %d", len(wallets.updateCalls)) + } + call := wallets.updateCalls[0] + if !call.fromBalance.Equal(decimal.RequireFromString("90.00")) { + t.Fatalf("expected debited balance 90.00, got %s", call.fromBalance) + } + if !call.toBalance.Equal(decimal.RequireFromString("15.00")) { + t.Fatalf("expected credited balance 15.00, got %s", call.toBalance) + } + if transfers.insertLedgerCalls != 1 { + t.Fatalf("expected exactly one InsertLedgerEntries call, got %d", transfers.insertLedgerCalls) + } + if !db.tx.committed { + t.Fatalf("success must commit") + } + waitForCacheSet(t, c) +} + +// --- GetTransfer / InitTransfer ------------------------------------------ + +func TestGetTransfer_CacheHit_SkipsPostgres(t *testing.T) { + want := &domain.Transfer{ID: 7, Status: domain.StatusProcessed} + transfers := &mockTransferRepo{} + c := &mockCache{getResult: want} + svc := newTestService(&fakeTransactor{}, &mockWalletRepo{}, transfers, c) + + got, err := svc.GetTransfer(context.Background(), 7) + if err != nil || got != want { + t.Fatalf("expected (%v, nil), got (%v, %v)", want, got, err) + } + if transfers.findByIDCalls != 0 { + t.Fatalf("a cache hit must never touch Postgres, got %d FindByID calls", transfers.findByIDCalls) + } +} + +func TestGetTransfer_CacheMiss_FallsThroughAndSelfHeals(t *testing.T) { + want := &domain.Transfer{ID: 7, Status: domain.StatusProcessed} + transfers := &mockTransferRepo{findByIDResults: []*domain.Transfer{want}} + c := &mockCache{} + svc := newTestService(&fakeTransactor{}, &mockWalletRepo{}, transfers, c) + + got, err := svc.GetTransfer(context.Background(), 7) + if err != nil || got != want { + t.Fatalf("expected (%v, nil), got (%v, %v)", want, got, err) + } + calls := waitForCacheSet(t, c) + if calls[0] != want { + t.Fatalf("expected the cache to be self-healed with the Postgres result, got %+v", calls[0]) + } +} + +func TestGetTransfer_CacheGetError_FallsBackToPostgres(t *testing.T) { + want := &domain.Transfer{ID: 7, Status: domain.StatusProcessed} + transfers := &mockTransferRepo{findByIDResults: []*domain.Transfer{want}} + c := &mockCache{getErr: errors.New("redis unavailable")} + svc := newTestService(&fakeTransactor{}, &mockWalletRepo{}, transfers, c) + + got, err := svc.GetTransfer(context.Background(), 7) + if err != nil || got != want { + t.Fatalf("expected (%v, nil), got (%v, %v)", want, got, err) + } +} + +func TestGetTransfer_NotFound(t *testing.T) { + transfers := &mockTransferRepo{} + svc := newTestService(&fakeTransactor{}, &mockWalletRepo{}, transfers, &mockCache{}) + + got, err := svc.GetTransfer(context.Background(), 7) + if err != nil || got != nil { + t.Fatalf("expected (nil, nil), got (%v, %v)", got, err) + } +} + +func TestInitTransfer(t *testing.T) { + db := &fakeTransactor{} + svc := NewTransferService(db, &mockWalletRepo{}, &mockTransferRepo{}, &mockCache{}, &mockIDGen{id: 999}, 5*time.Second, testLogger()) + + got, err := svc.InitTransfer() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != 999 { + t.Fatalf("expected minted ID 999, got %d", got) + } +} + +func TestInitTransfer_IDGenError(t *testing.T) { + db := &fakeTransactor{} + wantErr := errors.New("clock moved backward") + svc := NewTransferService(db, &mockWalletRepo{}, &mockTransferRepo{}, &mockCache{}, &mockIDGen{err: wantErr}, 5*time.Second, testLogger()) + + _, err := svc.InitTransfer() + requireDomainErrCode(t, err, domain.ErrCodeTransferInitFailed) +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go new file mode 100644 index 00000000..ea62f1aa --- /dev/null +++ b/internal/testutil/testutil.go @@ -0,0 +1,53 @@ +// Package testutil holds fixture helpers shared by the integration test +// suites in internal/repository/postgres and internal/service — each +// test still creates its own fixtures via these helpers (see CLAUDE.md's +// "every test creates its own wallet/transfer fixtures" rule); this only +// avoids duplicating the helpers themselves across packages. +package testutil + +import ( + "context" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/shopspring/decimal" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" +) + +// PostgresDSN points at the postgres brought up by `docker-compose up -d +// postgres redis` (see `make test-integration`), overridable for CI. +func PostgresDSN() string { + if dsn := os.Getenv("TEST_POSTGRES_DSN"); dsn != "" { + return dsn + } + return "postgres://wallet:wallet@localhost:5432/wallet?sslmode=disable" +} + +// NewTestWallet inserts a fresh wallet with a unique ID and the given +// balance directly via SQL. Every integration test creates its own +// wallets — never reuses another test's — so concurrent test runs can +// never corrupt each other's expected balances. +func NewTestWallet(t *testing.T, pool *pgxpool.Pool, balance string) domain.Wallet { + t.Helper() + + w := domain.Wallet{ + ID: uuid.NewString(), + Balance: decimal.RequireFromString(balance), + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := pool.Exec(ctx, ` + INSERT INTO wallets (id, balance, created_at, updated_at) + VALUES ($1, $2, now(), now()) + `, w.ID, w.Balance) + if err != nil { + t.Fatalf("insert test wallet: %v", err) + } + return w +} diff --git a/migrations/0001_create_wallets.down.sql b/migrations/0001_create_wallets.down.sql new file mode 100644 index 00000000..94813a5a --- /dev/null +++ b/migrations/0001_create_wallets.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS wallets; diff --git a/migrations/0001_create_wallets.up.sql b/migrations/0001_create_wallets.up.sql new file mode 100644 index 00000000..289ddb3a --- /dev/null +++ b/migrations/0001_create_wallets.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE wallets ( + id VARCHAR(36) PRIMARY KEY, + balance DECIMAL(18, 2) NOT NULL CHECK (balance >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/migrations/0002_create_transfers.down.sql b/migrations/0002_create_transfers.down.sql new file mode 100644 index 00000000..d58189be --- /dev/null +++ b/migrations/0002_create_transfers.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS transfers; diff --git a/migrations/0002_create_transfers.up.sql b/migrations/0002_create_transfers.up.sql new file mode 100644 index 00000000..2a877cd9 --- /dev/null +++ b/migrations/0002_create_transfers.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE transfers ( + id BIGINT PRIMARY KEY, + request_hash VARCHAR(64) NOT NULL, + from_wallet_id VARCHAR(36) NOT NULL REFERENCES wallets (id), + to_wallet_id VARCHAR(36) NOT NULL REFERENCES wallets (id), + amount DECIMAL(18, 2) NOT NULL CHECK (amount > 0), + status VARCHAR(20) NOT NULL CHECK (status IN ('PENDING', 'PROCESSED', 'FAILED')), + failure_reason VARCHAR(100), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/migrations/0003_create_ledger_entries.down.sql b/migrations/0003_create_ledger_entries.down.sql new file mode 100644 index 00000000..08397dfc --- /dev/null +++ b/migrations/0003_create_ledger_entries.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ledger_entries; diff --git a/migrations/0003_create_ledger_entries.up.sql b/migrations/0003_create_ledger_entries.up.sql new file mode 100644 index 00000000..7a4d065e --- /dev/null +++ b/migrations/0003_create_ledger_entries.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE ledger_entries ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + transfer_id BIGINT NOT NULL REFERENCES transfers (id), + wallet_id VARCHAR(36) NOT NULL REFERENCES wallets (id), + entry_type VARCHAR(6) NOT NULL CHECK (entry_type IN ('DEBIT', 'CREDIT')), + amount DECIMAL(18, 2) NOT NULL CHECK (amount > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_ledger_entries_transfer_id ON ledger_entries (transfer_id); +CREATE INDEX idx_ledger_entries_wallet_id ON ledger_entries (wallet_id); diff --git a/scripts/seed.sql b/scripts/seed.sql new file mode 100644 index 00000000..5761a17c --- /dev/null +++ b/scripts/seed.sql @@ -0,0 +1,13 @@ +-- Manual/demo fixtures only — NOT a golang-migrate migration, schema +-- migrations stay data-free. Run via `make seed`. Safe to re-run: +-- inserts are idempotent (ON CONFLICT DO NOTHING) and never touch a +-- wallet's balance if it already exists, so re-seeding never resets +-- balances a demo session has already spent. +-- +-- These exact UUIDs and starting balances are documented in README.md — +-- keep them in sync if you change either here. +INSERT INTO wallets (id, balance, created_at, updated_at) VALUES + ('018f0000-0000-7000-8000-000000000001', 1000.00, now(), now()), -- Alice + ('018f0000-0000-7000-8000-000000000002', 500.00, now(), now()), -- Bob + ('018f0000-0000-7000-8000-000000000003', 0.00, now(), now()) -- Carol (zero balance, for INSUFFICIENT_BALANCE demos) +ON CONFLICT (id) DO NOTHING;