feat: implement wallet transfer service with error handling and ledge…#91
Open
nikhil822 wants to merge 2 commits into
Open
feat: implement wallet transfer service with error handling and ledge…#91nikhil822 wants to merge 2 commits into
nikhil822 wants to merge 2 commits into
Conversation
…r entries - Added TransferHandler to handle transfer requests. - Created repository interfaces and implementations for Ledger, Transfer, and Wallet. - Implemented TransferService to manage transfer logic, including validation and database interactions. - Introduced error handling for various transfer scenarios (e.g., insufficient funds, wallet not found). - Added tests for transfer functionality, including success, failure, and concurrent transfers. - Established ledger entries for each transfer to maintain transaction history.
There was a problem hiding this comment.
Pull request overview
Implements a Go + PostgreSQL wallet transfer API with a service/repository/domain layering, including integration tests for transfer execution, concurrency, and basic idempotency behavior.
Changes:
- Added
POST /transfershandler wired into a minimal HTTP server. - Implemented transfer orchestration with DB transactions, wallet row locking, balance updates, and ledger entry writes.
- Added PostgreSQL-backed repositories and integration tests covering success, validation errors, concurrency, and ledger balancing.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| backend/tests/transfer_service_test.go | Adds integration tests for successful transfers, validation failures, concurrency, and ledger balancing. |
| backend/service/transfer_service.go | Implements the core transfer workflow, including transactions, wallet locking, idempotency insert, balance updates, and ledger writes. |
| backend/repository/wallet_repository.go | Adds wallet repository methods for row locking and balance updates inside a transaction. |
| backend/repository/transfer_repository.go | Adds transfer persistence with duplicate-key detection and status updates. |
| backend/repository/ledger_repository.go | Adds ledger entry inserts for double-entry recording. |
| backend/repository/errors.go | Introduces repository-level error constants (currently unused by the implementation). |
| backend/handlers/transfer_handler.go | Adds HTTP handler that validates JSON and maps service errors to HTTP status codes. |
| backend/go.mod | Defines module, Go version, and dependencies. |
| backend/go.sum | Adds dependency checksums. |
| backend/domain/wallet.go | Introduces the Wallet domain model. |
| backend/domain/transfer.go | Introduces Transfer domain model + basic state transitions (PENDING→PROCESSED/FAILED). |
| backend/domain/ledger.go | Introduces LedgerEntry domain model and DEBIT/CREDIT types. |
| backend/database/postgres.go | Adds pgx pool initialization helper. |
| backend/cmd/server/main.go | Wires DB, repositories, service, handler, and starts the HTTP server. |
| backend/apperrors/errors.go | Adds a shared error for duplicate idempotency key handling. |
Comment on lines
+113
to
+119
| if errors.Is(err, apperrors.ErrDuplicateIdempotencyKey) { | ||
| if rollbackErr := tx.Rollback(ctx); rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) { | ||
| return nil, rollbackErr | ||
| } | ||
| committed = true | ||
| return s.transferRepo.GetByIdempotencyKey(ctx, req.IdempotencyKey) | ||
| } |
Comment on lines
+80
to
+91
| wallets, err := s.walletRepo.LockByIDs( | ||
| ctx, | ||
| tx, | ||
| []string{req.FromWalletID, req.ToWalletID}, | ||
| ) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| if len(wallets) != 2 { | ||
| return nil, ErrWalletNotFound | ||
| } |
Comment on lines
+124
to
+127
| if source.Balance < req.Amount { | ||
|
|
||
| transfer.MarkFailed() | ||
|
|
Comment on lines
+70
to
+74
| if err != nil { | ||
| var pgErr *pgconn.PgError | ||
| if errors.As(err, &pgErr) && pgErr.Code == "23505" { | ||
| return apperrors.ErrDuplicateIdempotencyKey | ||
| } |
Comment on lines
+44
to
+51
| if err != nil { | ||
|
|
||
| switch err { | ||
|
|
||
| case service.ErrInvalidAmount, | ||
| service.ErrSameWallet, | ||
| service.ErrMissingIdempotency: | ||
|
|
Comment on lines
+5
to
+11
| var ( | ||
| ErrInvalidAmount = errors.New("amount must be positive") | ||
| ErrSameWallet = errors.New("source and destination wallet cannot be same") | ||
| ErrWalletNotFound = errors.New("wallet not found") | ||
| ErrInsufficientFunds = errors.New("insufficient funds") | ||
| ErrMissingIdempotency = errors.New("missing idempotency key") | ||
| ) |
Comment on lines
+131
to
+149
| func TestTransferInsufficientFunds(t *testing.T) { | ||
| ctx := context.Background() | ||
|
|
||
| walletA := createWallet(t, 50) | ||
| walletB := createWallet(t, 500) | ||
|
|
||
| _, err := svc.CreateTransfer(ctx, service.CreateTransferRequest{ | ||
| IdempotencyKey: uuid.NewString(), | ||
| FromWalletID: walletA, | ||
| ToWalletID: walletB, | ||
| Amount: 100, | ||
| }) | ||
|
|
||
| require.Error(t, err) | ||
| assert.ErrorIs(t, err, service.ErrInsufficientFunds) | ||
|
|
||
| assertWalletBalance(t, walletA, 50) | ||
| assertWalletBalance(t, walletB, 500) | ||
| } |
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
This submission implements a wallet-to-wallet transfer service in Go backed by PostgreSQL. The service exposes a single endpoint, POST /transfers, and guarantees idempotent, atomic, double-entry transfers with safe concurrent execution.
The implementation follows a clean four-layer architecture: handler → service → repository → domain. The handler handles HTTP concerns only. The service owns all business logic and transaction orchestration. Repositories handle SQL. Domain models own state transition rules.
AI Disclosure
Tool Used
ChatGPT
How I Generally Use the Tool
I use ChatGPT as a code reviewer. My workflow is:
Review my database schema (schema given in the prompt).
Write test cases in go for the wallet transfer
Review my architecture (given my understanding of the flow).
Used ChatGPT to write structured documentation (like this PR description).
Schema Design
Tables
walletsbalance >= 0is a database-level safety net. The application checks balance inside a locked transaction first — this constraint is the last line of defence.BIGINTstores amounts as the smallest currency unit (e.g. cents) to avoid floating-point rounding entirely.transfersUNIQUEonidempotency_keyis the primary deduplication mechanism. A duplicate INSERT raises PostgreSQL error23505, which the repository translates toErrDuplicateIdempotencyKeyfor the service layer to handle.CHECK (amount > 0)is enforced at both the application layer and the database.ledger_entriesDEBITfrom the source wallet, oneCREDITto the destination wallet. This is enforced in code and verifiable by querying the table.Indexes
Enum Types
Idempotency Strategy
Duplicate requests are handled using the
UNIQUEconstraint ontransfers.idempotency_key. The flow is:idempotencyKeyis non-empty. If it is, it returnsErrMissingIdempotencyimmediately — no DB interaction.INSERT INTO transferswith the given key.PROCESSED.23505(unique_violation), the key already exists. The transaction is rolled back. The service fetches the original transfer record by key (outside any transaction) and returns it directly.The caller receives exactly the same response as the first successful call — same transfer ID, same status. No money moves a second time.
Why this approach:
status = FAILED. A retry with the same key returns that FAILED result rather than re-attempting the transfer. This is intentional — a client that wants to retry should use a new idempotency key.23505and return the cached result.Concurrency Strategy
Row-Level Locking with Ordered Acquisition
When a transfer begins, both wallet rows are locked using
SELECT ... FOR UPDATEinside the transaction:The
ORDER BY idis critical. Without it, two concurrent transfers between wallet A and wallet B could deadlock:By always acquiring locks in ascending ID order, both transfers attempt to lock the same wallet first. One wins the lock and proceeds; the other waits. No deadlock is possible.
Why Not Optimistic Locking
Optimistic locking (version columns + retry on conflict) is appropriate when conflicts are rare. For wallets under high concurrency — multiple transfers hitting the same wallet — pessimistic locking with
FOR UPDATEis more predictable. It serialises access at the database level without requiring application-level retry loops that could amplify load.Transaction Boundary
The entire transfer is a single PostgreSQL transaction:
If anything fails between
BEGINandCOMMIT, the deferredtx.Rollback()fires. Balances and ledger entries are never partially applied.The
balance >= 0CHECK constraint on the wallets table is an additional safety net. Even if a bug bypassed the application-level balance check, PostgreSQL would reject any UPDATE that would push a balance below zero.How to Run
Prerequisites: Go 1.22+, PostgreSQL 14+.
How to Test
Tests are integration tests that require a real PostgreSQL instance.
Tradeoffs / Assumptions
Amounts are integers. The
amountfield isBIGINTrepresenting the smallest currency unit (e.g. cents for USD). This avoids all floating-point precision issues. Callers are expected to send100for $1.00.Balance is stored, not derived. The
wallets.balancecolumn is updated on every transfer rather than being computed from the ledger on every read. This is an O(1) read at the cost of keeping the column in sync with the ledger. The ledger remains the audit source of truth — the balance column could be reconstructed from it at any time.FAILED transfers consume their idempotency key. If a transfer fails (e.g. insufficient funds), the
idempotency_keyis committed to the database withstatus = FAILED. A retry with the same key returns the FAILED result. This is a deliberate choice: if a client wants to retry a failed transfer, they should generate a new key. It prevents accidentally retrying a business-logic failure as if it were a network transient.Checklist
gofmt -l .returns no files)