Context
A wallet transfer looks like one action to the person sending it. Underneath it is at least two writes to two different accounts, and the process can die between them. The common implementation performs both writes inside the request handler and quietly assumes nothing goes wrong. I built this as a self-directed study of the version that assumes something will. The design question was narrow and specific: where does the money live while it is in motion, and what happens to it when the machine handling it stops?
My role
Sole developer. I designed the schema, the API contract, the settlement model, the failure policy, and the test suite.
Process
- Split every transfer into two phases. The sender is debited synchronously inside a database transaction holding a row lock, and the transfer is recorded as pending, so the balance the caller sees is immediately truthful and the money in motion has exactly one home.
- Queued the recipient credit as a background job keyed by the transfer id, which makes enqueueing idempotent. A retried HTTP request cannot create a second settlement job for the same transfer.
- Made settlement itself idempotent. A job replayed after a crash finds the transfer no longer pending, returns, and credits nobody twice.
- Gave failure a defined ending. Three attempts with exponential backoff, after which the sender is refunded inside a transaction and the transfer is marked failed with a stored reason. No transfer is left suspended.
- Chose Redis and BullMQ over a full message broker. The problem needs durable jobs, retries, and visible failures. It does not need routing topologies, and importing them would have been weight without benefit.
- Closed the boundary before the logic runs. Schema validation on every route, security headers, per-route rate limiting, hashed PINs, and access plus refresh tokens. A wrong PIN and an unknown phone number return the identical message, so the login endpoint cannot be used to enumerate registered users.