Skip to content

Feature/wallet transfer ravi soln - #108

Open
ravindranathreddy wants to merge 11 commits into
Robustrade:mainfrom
ravindranathreddy:feature/wallet-transfer-ravi-soln
Open

Feature/wallet transfer ravi soln#108
ravindranathreddy wants to merge 11 commits into
Robustrade:mainfrom
ravindranathreddy:feature/wallet-transfer-ravi-soln

Conversation

@ravindranathreddy

@ravindranathreddy ravindranathreddy commented Jul 5, 2026

Copy link
Copy Markdown

Summary

Describe your solution briefly.

Request flow — POST /transfers

  1. Validation (handler, then re-checked in the domain constructor)
    Reject if idempotencyKey/fromWalletId/toWalletId is empty, fromWalletId == toWalletId, or amount <= 0 → 400.

  2. 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.

  3. Find-or-create the transfer (idempotency)

  • Compute a hash of (fromWalletId, toWalletId, amount).
  • Look up idempotency_records by key.
    • Found: hash matches → reuse that transfer's ID (this is a replay). Hash differs → 409 (same key, different body).
    • Not found: in one DB transaction, insert a new transfers row as PENDING and the idempotency_records row together. If that insert collides with another request that won the race for the same key (unique-violation on the key), treat it exactly like the "found" case above — re-fetch and compare hashes — rather than surfacing an error.
  1. Process the transfer
    This is where the actual money movement happens, all inside one DB transaction:
  • Lock the transfers row itself (FOR UPDATE) and re-read its status. If it's no longer PENDING (already PROCESSED/FAILED by a concurrent call), just return that result — done, no reprocessing. This is the step that makes concurrent replays of the same key safe.
  • Otherwise, lock both wallets with FOR UPDATE, always in ascending wallet-ID order regardless of which is "from" or "to" — this is what prevents deadlocks when transfers form a cycle (A→B, B→C, C→A all running at once).
  • Check the source wallet's locked balance against the amount:
    • Insufficient → mark the transfer FAILED with a reason, write that status, commit. No ledger rows, no balance change.
    • Sufficient → debit the source, credit the destination, insert exactly one DEBIT and one CREDIT ledger row (both tied to this transfer ID), mark PROCESSED, commit.
  1. Respond
    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

  • Same idempotencyKey fired twice: one wins the insert race; both end up pointing at the same transfer row; the transfer-row lock in step 4 means only one of them actually debits/credits — the other just reads the finished result.
  • Two different transfers competing for one wallet's balance: both try to lock that wallet row; whichever gets there second sees the already-updated balance once it acquires the lock, so the insufficient-funds check is never working off stale data.
  • Circular chain of transfers: everyone locks wallets in the same global order (by ID), so there's no cycle of "waiting for the lock someone else is waiting for you to release."

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

  1. What tool you used (Cursor, Claude Code, Antigratvity etc.)
  • Claude Code
  1. How you generally use the tool for your work.
  • Scoped initially to boilerplate/project setup (Go module
    layout, Docker/docker-compose, environment configuration, HTTP routing skeleton,
    dependency wiring in cmd/server/main.go). From there it was used as a senior-engineer
    reviewer 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.
  1. A transcript of your entire session with your AI tool of choice. You can add this to the repo or email it to us with your submission. If for some reason, this is not possible, give us all the prompts that you used with the AI.
    docs/ai-usage-log.md in 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) on ledger_entries makes it structurally impossible to double-debit or double-credit a single transfer.
  • CHECK (balance >= 0) and CHECK (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:

  • Every PK (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 via SELECT ... FOR UPDATE, so every hot-path read is an index lookup, not a scan.
  • The two UNIQUE constraints (idempotency_records.transfer_id, ledger_entries(transfer_id, type)) each carry their own supporting index for free, on top of the constraint they enforce.
  • One explicit secondary index: idx_ledger_entries_wallet_time (wallet_id, created_at DESC) — supports a wallet's transaction history ordered newest-first without a full scan, since ledger_entries isn't otherwise indexed by wallet_id (it's the third column in the composite PK-adjacent lookup path, not the leading column of any other index).
  • One index is deliberately not created yet: 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 in design.md to add if/when a UI actually needs that query.

Idempotency Strategy

Explain how duplicate requests are handled safely.

Each request's idempotencyKey maps 1:1 to a transfer via idempotency_records. A request
hash 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 shift
field boundaries and collide.

On a new key: insert the PENDING transfer and its idempotency_records row in one DB
transaction. 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
(PROCESSED or FAILED) returns the stored result as-is, without re-touching balances or
the ledger.

Concurrency Strategy

Explain how you prevent race conditions and double spending.

Two-level row locking inside a single DB transaction:

  1. Lock the transfers row FOR UPDATE first — this serializes concurrent replays of the
    same idempotencyKey that raced past the idempotency check while an earlier attempt was
    still in flight.
  2. Lock both wallet rows FOR UPDATE, always in ascending wallet-ID order — this serializes
    concurrent transfers touching overlapping wallets and prevents deadlock across circular
    transfer chains (e.g. a→b, b→c, c→a firing concurrently), since every transaction
    acquires 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 UPDATE genuinely
blocks a second transaction; 10 concurrent identical requests collapse to exactly one debit;
two different keys racing the same wallet's balance produce exactly one PROCESSED and one
FAILED; the 3-way circular chain completes with no deadlock and balances fully conserved.
Also manually verified live against a running server with real concurrent curl requests,
not just the Go test suite.

How to Run

  • docker compose up -d postgres
  • make migrate-up
  • make run (or docker compose up --build to run the whole stack, including the app container)
  • Server listens on :8080GET /healthz, POST /transfers

How to Test

  • make test — unit tests only (domain layer), no external dependencies required
  • docker compose up -d postgres && make migrate-up && make test-integration — full suite,
    including repository/service integration and concurrency tests against real Postgres
    (-p 1 is required: the integration test packages share one physical database)
  • CI (.github/workflows/ci.yml) runs both automatically on every PR/push to main

Tradeoffs / Assumptions

  • Wallet creation and currency/cross-currency support are explicitly out of scope per the
    assignment; wallets are seeded directly into the database.
  • No built-in retry mechanism — retries are the client's responsibility via repeated calls
    with the same idempotencyKey, per the assignment's stated scope.
  • Business failures (e.g. insufficient balance) return 201 with status: "FAILED", not a
    4xx/5xx — the transfer resource itself was durably created either way; the outcome lives
    in the response body, not the HTTP status code.
  • idempotencyKey is a single global namespace, not scoped per wallet — matches the flat
    idempotency_records schema.
  • FAILED is a terminal state; there's no automatic retry-from-FAILED path (a client would
    need a new idempotencyKey to try again).

Checklist

  • Tests pass
  • Lint passes
  • Format check passes
  • README or notes updated
  • PR description explains schema, idempotency, and concurrency

ravindranathreddy and others added 10 commits July 5, 2026 11:12
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
Copilot AI review requested due to automatic review settings July 5, 2026 12:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 thread internal/handler/handler.go
Comment thread internal/domain/errors.go
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants