Feature/wallet transfer ravi soln - #108
Open
ravindranathreddy wants to merge 11 commits into
Open
Conversation
Boilerplate only — no transfer/idempotency/ledger business logic. Sets up cmd/internal layering, pgx pool + graceful shutdown wiring, stdlib routing with a stubbed POST /transfers, golang-migrate tooling, Dockerfile/docker-compose, and a committed AI-usage prompt log. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNcfg7Dqj6y4wqMxgPzxsb
Covers API contract, transfer state machine, schema (wallets, transfers, ledger_entries, idempotency_records), and the transfer/idempotency flow (including the check-then-insert race on idempotency_key and how the unique-violation is handled as a signal rather than an error). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNcfg7Dqj6y4wqMxgPzxsb
Transcribes the finalized schema from design.md into a golang-migrate pair. Verified up and down against a real Postgres container: all checks, FKs, and the ledger wallet/time index land as designed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNcfg7Dqj6y4wqMxgPzxsb
Adds Wallet, Transfer (with PENDING/PROCESSED/FAILED state machine and guarded transitions), LedgerEntry, and IdempotencyRecord entities. NewPendingTransfer enforces wallet-required/self-transfer/positive-amount invariants regardless of caller, independent of the handler's own fail-fast validation on the same rules. Wires POST /transfers request validation (idempotency key, wallet ids, self-transfer, amount) with JSON error responses; business logic still pending. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNcfg7Dqj6y4wqMxgPzxsb
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNcfg7Dqj6y4wqMxgPzxsb
Adds WalletRepository, TransferRepository, LedgerRepository, and IdempotencyRepository, and TransferService orchestrating the full create-or-replay-then-process flow: idempotency check-then-insert with unique-violation fallback, pessimistic ascending-order wallet locking, insufficient-balance handling, and double-entry ledger writes. Fixes a concurrency bug found in review: processTransfer now locks the transfer row (FOR UPDATE) before checking status, so two concurrent replays of the same idempotencyKey serialize correctly instead of racing into a double debit/credit that only the ledger's unique constraint would have caught. Also fixes a request-hash field-boundary collision (now length-prefixed) and a Printf verb/arg mismatch caught by go vet. Wires CreateTransfer into the handler with domain-error-to-HTTP-status mapping. Smoke-tested end-to-end against a live Postgres: happy path, same-key replay, conflicting-body replay (409), self-transfer (400), unknown wallet (404), and insufficient balance (FAILED, no ledger writes) all verified. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNcfg7Dqj6y4wqMxgPzxsb
Covers NewPendingTransfer validation (empty/self/non-positive amount), MarkProcessed/MarkFailed state-transition guarding (including the FailureReason default and its non-mutation on rejected transitions), NewLedgerPair's debit/credit pairing, and Wallet.HasSufficientFunds boundary cases. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNcfg7Dqj6y4wqMxgPzxsb
Adds internal/testutil (shared Postgres pool/truncate/seed helpers) and integration tests, tagged //go:build integration, for: - each repository (wallet/transfer/ledger/idempotency): CRUD, not-found mapping, and the unique-constraint/FOR-UPDATE-blocking guarantees the service layer relies on - TransferService.CreateTransfer: happy path, insufficient balance, validation rejections, idempotent replay and conflicting-body replay, and resuming a transfer left PENDING by a simulated crash - concurrency: same-idempotencyKey races (same and conflicting body), concurrent debits racing a shared wallet's balance, and the three-way circular-chain deadlock-avoidance case from design.md Adds `make test` (unit only) / `make test-integration` (tagged tests, run with -p 1 since these share one physical database) to the Makefile. Verified by running the full suite against a real Postgres, including under -race and repeated 5x for flakiness -- all clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNcfg7Dqj6y4wqMxgPzxsb
CI: start postgres via docker-compose, run migrations, and run `make test-integration` after the existing lint/format/unit-test steps, with teardown on always(). Bump actions/setup-go to 1.26.3 to match go.mod (the prior 1.24 pin meant CI silently depended on Go's toolchain auto-download). Handler: introduce a transferService interface so TransferHandler can be unit-tested against a fake instead of a real database. Adds validation-error, service-error-mapping, and success/business-failure response tests, plus router tests for method-not-allowed and health check. TransferRepository.LockForUpdate now maps ErrNoRows to ErrTransferNotFound, matching GetTransferByID. Additional integration coverage: LockForUpdate not-found/blocking on the transfer row, DB CHECK-constraint enforcement independent of app-level guards (self-transfer, negative balance), a ledger-vs-balance reconciliation test computed independently via SUM() over ledger_entries, and idempotent replay of a FAILED transfer. Verified: full unit + integration suite passes, including -race and repeated 3x. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNcfg7Dqj6y4wqMxgPzxsb
gofmt -l always exits 0, so `make fmt` never failed even when files were misformatted -- if wired as FORMAT_CHECK_CMD in CI, it wouldn't have gated anything. Now fails with the offending file list. Adds fmt-write (gofmt -w .) as the corresponding auto-fix target. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNcfg7Dqj6y4wqMxgPzxsb
There was a problem hiding this comment.
Pull request overview
Implements a wallet-to-wallet transfer service with Postgres persistence, idempotent POST /transfers, and concurrency-safe processing (row locks + double-entry ledger), plus Docker/CI/testing scaffolding for verification.
Changes:
- Adds initial Postgres schema + migrations for wallets, transfers, ledger entries, and idempotency records.
- Implements domain models, repositories, service orchestration (idempotency + transactional transfer processing), and HTTP handlers/routes.
- Adds unit + integration/concurrency tests, plus Docker Compose and CI wiring to run the full suite.
Reviewed changes
Copilot reviewed 38 out of 41 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| migrations/000001_init_schema.up.sql | Creates core tables/constraints/indexes for wallets/transfers/ledger/idempotency. |
| migrations/000001_init_schema.down.sql | Drops schema in FK-safe order. |
| Makefile | Adds build/run/migrate/lint/fmt and unit vs integration test targets. |
| internal/testutil/db.go | Integration-test DB helpers (pool, truncate, seed). |
| internal/service/service.go | Transfer orchestration: validation, idempotency, transactional processing w/ locks. |
| internal/service/service_internal_integration_test.go | Seeds pending transfer + idempotency record; verifies retry resumes correctly. |
| internal/service/service_integration_test.go | Integration tests for success/failure/idempotency behaviors. |
| internal/service/service_concurrency_integration_test.go | Concurrency tests for races, deadlock prevention, and ledger/balance reconciliation. |
| internal/repository/wallet.go | Wallet persistence + FOR UPDATE locking + balance updates. |
| internal/repository/wallet_integration_test.go | Wallet repository integration tests including lock blocking and DB constraints. |
| internal/repository/transfer.go | Transfer persistence + status updates + FOR UPDATE lock by transfer ID. |
| internal/repository/transfer_integration_test.go | Transfer repository integration tests including lock blocking and DB constraints. |
| internal/repository/repository.go | Store wrapper + transaction helper + shared Executor interface. |
| internal/repository/ledger.go | Ledger insert operations. |
| internal/repository/ledger_integration_test.go | Ledger insertion tests + unique constraint validation. |
| internal/repository/idempotency.go | Idempotency record insert/get operations. |
| internal/repository/idempotency_integration_test.go | Idempotency integration tests + PK unique violation validation. |
| internal/handler/router.go | Registers /healthz and /transfers routes. |
| internal/handler/router_test.go | Basic router behavior tests (405 + health check). |
| internal/handler/handler.go | HTTP request validation + domain error mapping + JSON responses. |
| internal/handler/handler_test.go | Handler tests with fake service for validation, mapping, and response shape. |
| internal/domain/wallet.go | Wallet entity + sufficient-funds helper. |
| internal/domain/wallet_test.go | Unit tests for wallet balance check helper. |
| internal/domain/transfer.go | Transfer entity + validation + state transitions. |
| internal/domain/transfer_test.go | Unit tests for transfer validation and state transitions. |
| internal/domain/ledger.go | Ledger entry types + helper to build debit/credit pair. |
| internal/domain/ledger_test.go | Unit tests for ledger-pair construction. |
| internal/domain/idempotency.go | Idempotency record model. |
| internal/domain/errors.go | Shared domain error values used by service/handler. |
| internal/config/config.go | Env-driven config + DSN builder. |
| go.sum | Module dependency checksums. |
| go.mod | Declares module + dependencies. |
| docs/ai-usage-log.md | AI usage transcript and disclosure log. |
| Dockerfile | Multi-stage build for static server binary container. |
| docker-compose.yml | Postgres + migrate tool + app service composition for local runs/tests. |
| design.md | Design notes documenting API, schema, idempotency, and concurrency approach. |
| cmd/server/main.go | Server wiring: config, db pool, handler/router, graceful shutdown. |
| .gitignore | Ignores built binaries; keeps .env.example. |
| .github/workflows/ci.yml | CI runs lint/format/unit + starts Postgres, migrates, runs integration tests. |
| .env.example | Sample env configuration for local development. |
Comment on lines
+15
to
+17
| ErrIdempotencyKeyRequired = errors.New("idempotencyKey is required") | ||
| ErrWalletIdRequired = errors.New("WalletId cannot be empty") | ||
| ErrWalletNotFound = errors.New("wallet not found") |
Comment on lines
+105
to
+110
| return s.idempotency.InsertIdempotencyKey(ctx, tx, domain.IdempotencyRecord{ | ||
| IdempotencyKey: req.IdempotencyKey, | ||
| RequestHash: requestHash, | ||
| TransferID: transferID, | ||
| CreatedAt: time.Now().Truncate(time.Microsecond), | ||
| }) |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Describe your solution briefly.
Request flow — POST /transfers
Validation (handler, then re-checked in the domain constructor)
Reject if idempotencyKey/fromWalletId/toWalletId is empty, fromWalletId == toWalletId, or amount <= 0 → 400.
Wallet existence check
Look up both wallets by ID. Missing either → 404, and nothing is persisted yet — a request that fails here never "burns" the idempotency key, so retrying after fixing the wallet ID works cleanly.
Find-or-create the transfer (idempotency)
This is where the actual money movement happens, all inside one DB transaction:
201 either way — PROCESSED or FAILED are both "the transfer resource exists," the outcome is in the body, not the HTTP status.
Why this is safe under concurrency
AI disclosure
Detail how you used AI to help with your submission (including the tools you used, how
you used them and what your prompts were).
Include these points in detail
layout, Docker/docker-compose, environment configuration, HTTP routing skeleton,
dependency wiring in
cmd/server/main.go). From there it was used as a senior-engineerreviewer for my own designs and implementations — bug hunting, race conditions, edge
cases, and improvement suggestions — with testing strategy discussed and agreed jointly
before any tests were written. It did not write the domain model, transfer state
machine, ledger/idempotency logic, or locking strategy, and made no design decisions on
those — those are mine. Later in the session, once the testing strategy and repository/
service/handler design were settled and reviewed, I had it write the actual test suite
and CI wiring directly.
docs/ai-usage-log.mdin this repo.Schema Design
Describe the tables, constraints, and indexes you introduced.
wallets(id TEXT PK, balance BIGINT CHECK (balance >= 0), created_at, updated_at)transfers(id UUID PK, from_wallet_id FK, to_wallet_id FK, amount BIGINT CHECK (amount > 0), status TEXT CHECK (status IN ('PENDING','PROCESSED','FAILED')), failure_reason, created_at, updated_at, CHECK (from_wallet_id <> to_wallet_id))ledger_entries(id UUID PK, transfer_id FK, wallet_id FK, type TEXT CHECK (type IN ('DEBIT','CREDIT')), amount BIGINT CHECK (amount > 0), created_at, UNIQUE (transfer_id, type))idempotency_records(idempotency_key TEXT PK, request_hash TEXT, transfer_id UUID UNIQUE FK, created_at)Constraints do the real enforcement work, not just app-level checks:
idempotency_records's primary key is the actual deduplication mechanism (see below), not an app-level lock.UNIQUE (transfer_id, type)onledger_entriesmakes it structurally impossible to double-debit or double-credit a single transfer.CHECK (balance >= 0)andCHECK (from_wallet_id <> to_wallet_id)hold even if application-level validation is ever bypassed or buggy — verified directly in tests by writing against the DB underneath the app layer.Indexes:
wallets.id,transfers.id,ledger_entries.id,idempotency_records.idempotency_key) is backed by its implicit unique B-tree index — these are exactly the columns the repository layer looks up by (GetWalletByID,GetTransferByID,GetIdempotencyRecord) and locks viaSELECT ... FOR UPDATE, so every hot-path read is an index lookup, not a scan.UNIQUEconstraints (idempotency_records.transfer_id,ledger_entries(transfer_id, type)) each carry their own supporting index for free, on top of the constraint they enforce.idx_ledger_entries_wallet_time (wallet_id, created_at DESC)— supports a wallet's transaction history ordered newest-first without a full scan, sinceledger_entriesisn't otherwise indexed bywallet_id(it's the third column in the composite PK-adjacent lookup path, not the leading column of any other index).idx_transfers_from_wallet_failed (from_wallet_id, created_at) WHERE status = 'FAILED'is left commented out in the migration. There's no read path in the current API that needs it (no failed-transfer-history endpoint exists) — adding it now would be speculative; it's noted indesign.mdto add if/when a UI actually needs that query.Idempotency Strategy
Explain how duplicate requests are handled safely.
Each request's
idempotencyKeymaps 1:1 to a transfer viaidempotency_records. A requesthash is computed over
(fromWalletId, toWalletId, amount)using a length-prefixed encoding(
len(from):from,len(to):to,amount, SHA-256'd) so no combination of wallet IDs can shiftfield boundaries and collide.
On a new key: insert the
PENDINGtransfer and itsidempotency_recordsrow in one DBtransaction. Two concurrent requests with the same key race on that primary key — the
loser's insert fails with a unique violation, which is treated identically to finding an
existing record on a plain read: re-fetch it, validate the request hash still matches, and
proceed to drive that same transfer to completion instead of erroring. A hash mismatch
against an existing key returns
409 Conflict. Replaying an already-terminal transfer(
PROCESSEDorFAILED) returns the stored result as-is, without re-touching balances orthe ledger.
Concurrency Strategy
Explain how you prevent race conditions and double spending.
Two-level row locking inside a single DB transaction:
transfersrowFOR UPDATEfirst — this serializes concurrent replays of thesame
idempotencyKeythat raced past the idempotency check while an earlier attempt wasstill in flight.
FOR UPDATE, always in ascending wallet-ID order — this serializesconcurrent transfers touching overlapping wallets and prevents deadlock across circular
transfer chains (e.g.
a→b,b→c,c→afiring concurrently), since every transactionacquires locks in the same global order.
The balance check, debit/credit, ledger insert, and status update all happen inside that
same locked transaction. Verified with tests against real Postgres:
FOR UPDATEgenuinelyblocks a second transaction; 10 concurrent identical requests collapse to exactly one debit;
two different keys racing the same wallet's balance produce exactly one
PROCESSEDand oneFAILED; the 3-way circular chain completes with no deadlock and balances fully conserved.Also manually verified live against a running server with real concurrent
curlrequests,not just the Go test suite.
How to Run
docker compose up -d postgresmake migrate-upmake run(ordocker compose up --buildto run the whole stack, including the app container):8080—GET /healthz,POST /transfersHow to Test
make test— unit tests only (domain layer), no external dependencies requireddocker compose up -d postgres && make migrate-up && make test-integration— full suite,including repository/service integration and concurrency tests against real Postgres
(
-p 1is required: the integration test packages share one physical database).github/workflows/ci.yml) runs both automatically on every PR/push tomainTradeoffs / Assumptions
assignment; wallets are seeded directly into the database.
with the same
idempotencyKey, per the assignment's stated scope.201withstatus: "FAILED", not a4xx/5xx — the transfer resource itself was durably created either way; the outcome lives
in the response body, not the HTTP status code.
idempotencyKeyis a single global namespace, not scoped per wallet — matches the flatidempotency_recordsschema.FAILEDis a terminal state; there's no automatic retry-from-FAILEDpath (a client wouldneed a new
idempotencyKeyto try again).Checklist