From a72d01eb63cff811ca895cceb62da038bebfdad3 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Fri, 10 Jul 2026 22:59:20 +0530 Subject: [PATCH 01/40] Initialize Go module targeting go1.24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the dependency set from docs/DESIGN.md §9 (gin, pgx/v5, go-redis/v9, golang-migrate, decimal, caarlos0/env, godotenv, google/uuid). --- go.mod | 53 ++++++++++++++++++ go.sum | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 go.mod create mode 100644 go.sum diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..fed86e11 --- /dev/null +++ b/go.mod @@ -0,0 +1,53 @@ +module github.com/Nandukrishna-S/wallet-transfer-assignment + +go 1.25.0 + +require ( + github.com/caarlos0/env/v11 v11.4.1 + github.com/gin-gonic/gin v1.12.0 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.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/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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // 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.mongodb.org/mongo-driver/v2 v2.5.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.51.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..6524a2fa --- /dev/null +++ b/go.sum @@ -0,0 +1,174 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/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.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +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.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +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.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +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.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +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.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +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.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +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.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +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= From a5a8197d00fd6a637e1483c4403bf557be0203d8 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Fri, 10 Jul 2026 23:02:00 +0530 Subject: [PATCH 02/40] Fix go.mod/go.sum to actually pin go1.24-compatible versions --- go.mod | 9 ++++----- go.sum | 18 ++++++++---------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index fed86e11..00239b9b 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,13 @@ module github.com/Nandukrishna-S/wallet-transfer-assignment -go 1.25.0 +go 1.24.0 require ( github.com/caarlos0/env/v11 v11.4.1 - github.com/gin-gonic/gin v1.12.0 + 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.10.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 @@ -41,11 +41,10 @@ require ( 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.mongodb.org/mongo-driver/v2 v2.5.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.51.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 diff --git a/go.sum b/go.sum index 6524a2fa..a72e84c5 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,8 @@ github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCK 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.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= -github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +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= @@ -73,8 +73,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI 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.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= -github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +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= @@ -137,8 +137,6 @@ 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.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= -go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= @@ -151,14 +149,14 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 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.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= -go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +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= From e9463943485122f8573d443098a77f560e42f7b1 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Fri, 10 Jul 2026 23:03:11 +0530 Subject: [PATCH 03/40] Add wallets/transfers/ledger_entries migrations wallets (UUID v7 PK, balance with a DB-level CHECK backstop), transfers (Snowflake BIGINT PK doubling as the idempotency key, request_hash for replay detection, a status CHECK allowing PENDING/PROCESSED/FAILED), and ledger_entries (explicit indexes on transfer_id/wallet_id, since Postgres does not auto-index the referencing side of a FK). --- migrations/0001_create_wallets.down.sql | 1 + migrations/0001_create_wallets.up.sql | 6 ++++++ migrations/0002_create_transfers.down.sql | 1 + migrations/0002_create_transfers.up.sql | 11 +++++++++++ migrations/0003_create_ledger_entries.down.sql | 1 + migrations/0003_create_ledger_entries.up.sql | 11 +++++++++++ 6 files changed, 31 insertions(+) create mode 100644 migrations/0001_create_wallets.down.sql create mode 100644 migrations/0001_create_wallets.up.sql create mode 100644 migrations/0002_create_transfers.down.sql create mode 100644 migrations/0002_create_transfers.up.sql create mode 100644 migrations/0003_create_ledger_entries.down.sql create mode 100644 migrations/0003_create_ledger_entries.up.sql 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..4c3969f9 --- /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, + 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..d9038fe5 --- /dev/null +++ b/migrations/0003_create_ledger_entries.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE ledger_entries ( + id BIGINT 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); From 56fcc3e7855d2a0e007e8bde8734d8d7305b9113 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Fri, 10 Jul 2026 23:04:05 +0530 Subject: [PATCH 04/40] Add Snowflake ID generator Time-ordered 64-bit IDs (41-bit ms timestamp, 10-bit worker ID, 12-bit sequence) --- internal/idgen/snowflake.go | 94 +++++++++++++++++++++++ internal/idgen/snowflake_test.go | 127 +++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 internal/idgen/snowflake.go create mode 100644 internal/idgen/snowflake_test.go diff --git a/internal/idgen/snowflake.go b/internal/idgen/snowflake.go new file mode 100644 index 00000000..af7cde91 --- /dev/null +++ b/internal/idgen/snowflake.go @@ -0,0 +1,94 @@ +// 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 (see docs/DESIGN.md §5) — +// 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). See docs/DESIGN.md §5 for how a +// multi-replica deployment would derive this instead (e.g. a StatefulSet +// pod ordinal). +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. +func (g *Generator) NextID() int64 { + g.mu.Lock() + defer g.mu.Unlock() + + now := nowMillis() + + 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++ { + last = g.NextID() + } + + if last <= 0 { + t.Fatalf("expected a valid ID after rollover, got %d", last) + } +} From 82fde01f9b250fc7209dc7d48e5181d5e373bff3 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Fri, 10 Jul 2026 23:05:46 +0530 Subject: [PATCH 05/40] Add domain layer: Wallet, Transfer, Status, typed errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure business types with zero infra imports. Status is restricted to PROCESSED/FAILED — PENDING is never persisted, since the debit/credit/ledger sequence is one atomic DB transaction. --- internal/domain/errors.go | 25 +++++++++++++++++++++++++ internal/domain/transfer.go | 31 +++++++++++++++++++++++++++++++ internal/domain/wallet.go | 15 +++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 internal/domain/errors.go create mode 100644 internal/domain/transfer.go create mode 100644 internal/domain/wallet.go diff --git a/internal/domain/errors.go b/internal/domain/errors.go new file mode 100644 index 00000000..07c406c2 --- /dev/null +++ b/internal/domain/errors.go @@ -0,0 +1,25 @@ +// Package domain holds the pure business types and rules for wallets and +// transfers. It has zero imports of database/sql, gin, or any infra +// package — see docs/DESIGN.md §8. +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 — see docs/DESIGN.md §11. +const ( + ErrCodeInvalidAmount ErrorCode = "INVALID_AMOUNT" + ErrCodeSameWallet ErrorCode = "SAME_WALLET" + ErrCodeWalletNotFound ErrorCode = "WALLET_NOT_FOUND" + ErrCodeInsufficientBalance ErrorCode = "INSUFFICIENT_BALANCE" +) + +// 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 } diff --git a/internal/domain/transfer.go b/internal/domain/transfer.go new file mode 100644 index 00000000..748a8c83 --- /dev/null +++ b/internal/domain/transfer.go @@ -0,0 +1,31 @@ +package domain + +import ( + "time" + + "github.com/shopspring/decimal" +) + +// Status is the final, immutable outcome of a transfer. There is no +// persisted PENDING state — see docs/DESIGN.md §4. +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 doubles +// as the idempotency key (see docs/DESIGN.md §5). +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 +} From 3923b4844798413854f744d88a58fd86de536341 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Fri, 10 Jul 2026 23:06:53 +0530 Subject: [PATCH 06/40] Add config package with env binding and startup validation Binds WORKER_ID, POSTGRES_DSN, REDIS_ADDR, PORT, LOCK_TIMEOUT via caarlos0/env + godotenv. Validate() checks required fields and delegates the WORKER_ID range check to idgen.NewGenerator itself rather than duplicating its bit-width constants, so idgen stays the single source of truth for its own valid range. Called immediately after Load() in main.go, before any connection is attempted. --- internal/config/config.go | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 internal/config/config.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..0416e30d --- /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 <= 0 { + return fmt.Errorf("LOCK_TIMEOUT must be a positive duration") + } + if _, err := idgen.NewGenerator(c.WorkerID); err != nil { + return fmt.Errorf("WORKER_ID invalid: %w", err) + } + return nil +} From e46f3c254842063f737638aed80c47a17e606ef7 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Fri, 10 Jul 2026 23:08:15 +0530 Subject: [PATCH 07/40] Add platform bootstrap: postgres, redisclient, migrate, logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic infra construction. postgres.New and redisclient.New deliberately don't ping — main.go does that explicitly so the server never binds if a dependency is unreachable. migrate.Run applies pending golang-migrate migrations at startup. logger.New sets up structured JSON logging via log/slog. --- internal/platform/logger/logger.go | 14 +++++++++ internal/platform/migrate/migrate.go | 32 ++++++++++++++++++++ internal/platform/postgres/postgres.go | 21 +++++++++++++ internal/platform/redisclient/redisclient.go | 12 ++++++++ 4 files changed, 79 insertions(+) create mode 100644 internal/platform/logger/logger.go create mode 100644 internal/platform/migrate/migrate.go create mode 100644 internal/platform/postgres/postgres.go create mode 100644 internal/platform/redisclient/redisclient.go diff --git a/internal/platform/logger/logger.go b/internal/platform/logger/logger.go new file mode 100644 index 00000000..617c909f --- /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 — see docs/DESIGN.md §8. +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..178e1162 --- /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 — see +// docs/DESIGN.md §8. +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..0214e86c --- /dev/null +++ b/internal/platform/postgres/postgres.go @@ -0,0 +1,21 @@ +// Package postgres bootstraps the pgxpool connection pool. It has no +// domain knowledge — see docs/DESIGN.md §8. 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..bfe2a697 --- /dev/null +++ b/internal/platform/redisclient/redisclient.go @@ -0,0 +1,12 @@ +// Package redisclient bootstraps the Redis client. It has no domain +// knowledge — see docs/DESIGN.md §8. 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}) +} From 5e28b062525740ec0e8c1a37eb9a19156edaa8b3 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Fri, 10 Jul 2026 23:10:05 +0530 Subject: [PATCH 08/40] Add correlation-id middleware Attaches a per-request correlation ID to the request context (reusing an inbound X-Correlation-ID header if present) and echoes it back on the response. --- internal/middleware/correlation_id.go | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 internal/middleware/correlation_id.go diff --git a/internal/middleware/correlation_id.go b/internal/middleware/correlation_id.go new file mode 100644 index 00000000..e2b2cea5 --- /dev/null +++ b/internal/middleware/correlation_id.go @@ -0,0 +1,52 @@ +// Package middleware holds cross-cutting HTTP concerns, attached per +// route-group rather than globally — see docs/DESIGN.md §8. +package middleware + +import ( + "context" + "log/slog" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type correlationIDKey struct{} + +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 for request-scoped data — see CLAUDE.md. +func CorrelationID() gin.HandlerFunc { + return func(c *gin.Context) { + correlationID := c.GetHeader(correlationIDHeader) + if correlationID == "" { + correlationID = uuid.NewString() + } + + ctx := context.WithValue(c.Request.Context(), correlationIDKey{}, correlationID) + c.Request = c.Request.WithContext(ctx) + + c.Header(correlationIDHeader, correlationID) + c.Next() + } +} + +// CorrelationIDFromContext returns the correlation ID set by CorrelationID, +// or "" if none is present. +func CorrelationIDFromContext(ctx context.Context) string { + if id, ok := ctx.Value(correlationIDKey{}).(string); ok { + return id + } + return "" +} + +// LoggerWithCorrelationID returns logger with the request's correlation ID +// attached as a field, or logger unchanged if none is present. +func LoggerWithCorrelationID(ctx context.Context, logger *slog.Logger) *slog.Logger { + if id := CorrelationIDFromContext(ctx); id != "" { + return logger.With(slog.String("correlation_id", id)) + } + return logger +} From 596287a79669f0dd21e7bbb6043e54163ee1bd2d Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Fri, 10 Jul 2026 23:12:09 +0530 Subject: [PATCH 09/40] Add HTTP handler layer: router, health check, DTOs, transfer stubs router.go registers /health with no middleware group and a /transfers group carrying it. health_handler.go checks Postgres and Redis directly with a 2s timeout each, returning 503 with per-dependency status on failure. --- internal/handler/dto.go | 29 ++++++++++++ internal/handler/health_handler.go | 70 ++++++++++++++++++++++++++++ internal/handler/router.go | 29 ++++++++++++ internal/handler/transfer_handler.go | 32 +++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 internal/handler/dto.go create mode 100644 internal/handler/health_handler.go create mode 100644 internal/handler/router.go create mode 100644 internal/handler/transfer_handler.go diff --git a/internal/handler/dto.go b/internal/handler/dto.go new file mode 100644 index 00000000..c93daa95 --- /dev/null +++ b/internal/handler/dto.go @@ -0,0 +1,29 @@ +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 — see docs/DESIGN.md §11. +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} — see docs/DESIGN.md §1. +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..fea51cba --- /dev/null +++ b/internal/handler/health_handler.go @@ -0,0 +1,70 @@ +package handler + +import ( + "context" + "net/http" + "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. +func (h *HealthHandler) Health(c *gin.Context) { + postgresOK := h.checkPostgres(c.Request.Context()) + redisOK := h.checkRedis(c.Request.Context()) + + 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..3c09e0a5 --- /dev/null +++ b/internal/handler/router.go @@ -0,0 +1,29 @@ +// Package handler is the thin HTTP layer: decode request, call service, +// encode response. It contains no business logic — see docs/DESIGN.md §8. +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..11dbdf8c --- /dev/null +++ b/internal/handler/transfer_handler.go @@ -0,0 +1,32 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// TransferHandler serves the /transfers/init, /transfers, and +// /transfers/:id routes. Stubbed for Phase 1; the transfer flow itself +// lands in Phase 2 alongside internal/service. +type TransferHandler struct{} + +// NewTransferHandler constructs a TransferHandler. +func NewTransferHandler() *TransferHandler { + return &TransferHandler{} +} + +// InitTransfer handles POST /transfers/init. +func (h *TransferHandler) InitTransfer(c *gin.Context) { + c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) +} + +// CreateTransfer handles POST /transfers. +func (h *TransferHandler) CreateTransfer(c *gin.Context) { + c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) +} + +// GetTransfer handles GET /transfers/:id. +func (h *TransferHandler) GetTransfer(c *gin.Context) { + c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) +} From 80e52679f237fbceb79e6076f126659be925aba3 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Fri, 10 Jul 2026 23:13:05 +0530 Subject: [PATCH 10/40] Wire cmd/server/main.go Boot order: load config -> validate (exit on failure) -> build logger -> build postgres pool -> ping postgres (exit on failure) -> run migrations (exit on failure) -> build redis client -> ping redis (exit on failure) -> build id generator -> build handlers/router -> serve -> graceful shutdown on SIGTERM/SIGINT. The server never binds if a dependency is unreachable at startup. --- cmd/server/main.go | 131 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 cmd/server/main.go diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 00000000..931b3b00 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,131 @@ +// 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/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) + } + _ = generator // wired for Phase 2's service layer; unused until then + + healthHandler := handler.NewHealthHandler(pool, redisClient) + transferHandler := handler.NewTransferHandler() + 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)) + } +} From 0df12794175776d58440212b29a2c067497a9215 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Fri, 10 Jul 2026 23:27:09 +0530 Subject: [PATCH 11/40] Add Docker, Compose, Makefile, lint config, and env template Dockerfile: multi-stage build, for a reproducible static binary, running as a dedicated non-root user. docker-compose.yml brings up postgres+redis+ app with the app healthcheck pointed at GET /health. Makefile exposes run/migrate/lint/fmt-check/test/test-integration. .golangci.yml adds unconvert/unparam/misspell on top of the standard preset. .env.example documents WORKER_ID/POSTGRES_DSN/REDIS_ADDR/PORT/LOCK_TIMEOUT; --- .env.example | 5 +++++ .gitignore | 1 + .golangci.yml | 14 +++++++++++++ Dockerfile | 26 ++++++++++++++++++++++++ Makefile | 20 +++++++++++++++++++ docker-compose.yml | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 116 insertions(+) create mode 100644 .env.example create mode 100644 .golangci.yml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 docker-compose.yml 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..7d8c5aaa --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: run migrate lint fmt-check test test-integration + +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 ./... + +test-integration: + go test -tags=integration ./... 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: From 6d62ee7b708ca0d86784997bdeda5f59265eeabe Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 12:36:52 +0530 Subject: [PATCH 12/40] Add migration: ledger_entries.id becomes DB-generated identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ledger entry IDs are never a client-facing idempotency key or FK target for anything else, unlike transfers.id — none of the reasons that justified hand-rolling a Snowflake ID apply here, so the DB's own sequence is the simplest correct choice. The table is still empty at this point, so the ALTER is safe. --- migrations/0004_ledger_entries_identity_id.down.sql | 1 + migrations/0004_ledger_entries_identity_id.up.sql | 1 + 2 files changed, 2 insertions(+) create mode 100644 migrations/0004_ledger_entries_identity_id.down.sql create mode 100644 migrations/0004_ledger_entries_identity_id.up.sql diff --git a/migrations/0004_ledger_entries_identity_id.down.sql b/migrations/0004_ledger_entries_identity_id.down.sql new file mode 100644 index 00000000..a2d8b8b3 --- /dev/null +++ b/migrations/0004_ledger_entries_identity_id.down.sql @@ -0,0 +1 @@ +ALTER TABLE ledger_entries ALTER COLUMN id DROP IDENTITY IF EXISTS; diff --git a/migrations/0004_ledger_entries_identity_id.up.sql b/migrations/0004_ledger_entries_identity_id.up.sql new file mode 100644 index 00000000..2b34acff --- /dev/null +++ b/migrations/0004_ledger_entries_identity_id.up.sql @@ -0,0 +1 @@ +ALTER TABLE ledger_entries ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY; From fcad5c3d7342e9fe8c05fcb9f9ac9c0c08c8e7b1 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 12:39:29 +0530 Subject: [PATCH 13/40] Update dependencies for Phase 2 go.sum gained entries for dktest and OpenTelemetry packages after adding golang-migrate. These are pulled in because golang-migrate's own test suite depends on them (dktest spins up real DBs via Docker for its tests) -- not because our code imports or uses them anywhere. --- go.mod | 6 ++++++ go.sum | 38 ++++++++++++++++++-------------------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index 00239b9b..06f0e4e8 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,8 @@ require ( 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 @@ -34,13 +36,17 @@ require ( 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 diff --git a/go.sum b/go.sum index a72e84c5..20d8c7f8 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +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= @@ -30,10 +30,10 @@ 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.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= -github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +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= @@ -60,8 +60,6 @@ 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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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= @@ -91,8 +89,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE 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.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +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= @@ -102,8 +100,8 @@ 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.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +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= @@ -137,16 +135,16 @@ 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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +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= From 16d53987aa82d03e18d9516fab27cc4a999ec9b2 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 12:40:20 +0530 Subject: [PATCH 14/40] Add IDEMPOTENCY_KEY_REUSED error code New typed error for the 409 case: an idempotency key reused with a different fromWalletId/toWalletId/amount than it was first claimed with. --- internal/domain/errors.go | 16 ++++++++-------- internal/domain/transfer.go | 11 +++++++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 07c406c2..7699394e 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -1,18 +1,18 @@ -// Package domain holds the pure business types and rules for wallets and -// transfers. It has zero imports of database/sql, gin, or any infra -// package — see docs/DESIGN.md §8. +// 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 — see docs/DESIGN.md §11. +// 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" + ErrCodeInvalidAmount ErrorCode = "INVALID_AMOUNT" + ErrCodeSameWallet ErrorCode = "SAME_WALLET" + ErrCodeWalletNotFound ErrorCode = "WALLET_NOT_FOUND" + ErrCodeInsufficientBalance ErrorCode = "INSUFFICIENT_BALANCE" + ErrCodeIdempotencyKeyReused ErrorCode = "IDEMPOTENCY_KEY_REUSED" ) // Error is a typed domain failure, distinguishable from unexpected diff --git a/internal/domain/transfer.go b/internal/domain/transfer.go index 748a8c83..d9875197 100644 --- a/internal/domain/transfer.go +++ b/internal/domain/transfer.go @@ -6,8 +6,9 @@ import ( "github.com/shopspring/decimal" ) -// Status is the final, immutable outcome of a transfer. There is no -// persisted PENDING state — see docs/DESIGN.md §4. +// 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. @@ -16,8 +17,10 @@ const ( StatusFailed Status = "FAILED" ) -// Transfer is a single wallet-to-wallet transfer attempt. Its ID doubles -// as the idempotency key (see docs/DESIGN.md §5). +// 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 From 0c6755ad8a8dd2b4643bcc8df40675f62bf6c502 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 12:41:44 +0530 Subject: [PATCH 15/40] Drop doc citations from comments, state the point inline --- internal/idgen/snowflake.go | 6 ++---- internal/middleware/correlation_id.go | 9 ++++++--- internal/platform/logger/logger.go | 2 +- internal/platform/migrate/migrate.go | 4 ++-- internal/platform/postgres/postgres.go | 4 ++-- internal/platform/redisclient/redisclient.go | 4 ++-- 6 files changed, 15 insertions(+), 14 deletions(-) diff --git a/internal/idgen/snowflake.go b/internal/idgen/snowflake.go index af7cde91..d28f21a9 100644 --- a/internal/idgen/snowflake.go +++ b/internal/idgen/snowflake.go @@ -1,6 +1,6 @@ // 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 (see docs/DESIGN.md §5) — +// 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 @@ -44,9 +44,7 @@ type Generator struct { // 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). See docs/DESIGN.md §5 for how a -// multi-replica deployment would derive this instead (e.g. a StatefulSet -// pod ordinal). +// 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) diff --git a/internal/middleware/correlation_id.go b/internal/middleware/correlation_id.go index e2b2cea5..d90cac9d 100644 --- a/internal/middleware/correlation_id.go +++ b/internal/middleware/correlation_id.go @@ -1,5 +1,7 @@ -// Package middleware holds cross-cutting HTTP concerns, attached per -// route-group rather than globally — see docs/DESIGN.md §8. +// 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 ( @@ -17,7 +19,8 @@ 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 for request-scoped data — see CLAUDE.md. +// context.Value here: request-scoped data only, never app-scoped +// dependencies like a DB pool, which are constructor-injected instead. func CorrelationID() gin.HandlerFunc { return func(c *gin.Context) { correlationID := c.GetHeader(correlationIDHeader) diff --git a/internal/platform/logger/logger.go b/internal/platform/logger/logger.go index 617c909f..756f7fc1 100644 --- a/internal/platform/logger/logger.go +++ b/internal/platform/logger/logger.go @@ -1,5 +1,5 @@ // Package logger builds the application's structured logger. It has no -// domain knowledge — see docs/DESIGN.md §8. +// domain knowledge — doesn't know what a "wallet" is. package logger import ( diff --git a/internal/platform/migrate/migrate.go b/internal/platform/migrate/migrate.go index 178e1162..32e74cdf 100644 --- a/internal/platform/migrate/migrate.go +++ b/internal/platform/migrate/migrate.go @@ -1,6 +1,6 @@ // Package migrate runs pending golang-migrate migrations at startup, -// before the server binds. It has no domain knowledge — see -// docs/DESIGN.md §8. +// before the server binds. It has no domain knowledge — doesn't know +// what a "wallet" is. package migrate import ( diff --git a/internal/platform/postgres/postgres.go b/internal/platform/postgres/postgres.go index 0214e86c..5ad45127 100644 --- a/internal/platform/postgres/postgres.go +++ b/internal/platform/postgres/postgres.go @@ -1,6 +1,6 @@ // Package postgres bootstraps the pgxpool connection pool. It has no -// domain knowledge — see docs/DESIGN.md §8. Callers are responsible for -// pinging the returned pool before relying on it. +// domain knowledge — doesn't know what a "wallet" is. Callers are +// responsible for pinging the returned pool before relying on it. package postgres import ( diff --git a/internal/platform/redisclient/redisclient.go b/internal/platform/redisclient/redisclient.go index bfe2a697..888b9960 100644 --- a/internal/platform/redisclient/redisclient.go +++ b/internal/platform/redisclient/redisclient.go @@ -1,6 +1,6 @@ // Package redisclient bootstraps the Redis client. It has no domain -// knowledge — see docs/DESIGN.md §8. Callers are responsible for pinging -// the returned client before relying on it. +// 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" From 52f981716e03e85b7109ef3e2536ba2e9e11b97e Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 12:46:22 +0530 Subject: [PATCH 16/40] Add repository layer: wallet/transfer persistence, redis cache WalletRepo and TransferRepo implement the mechanical locking/read/write side of the transfer flow, with no business decisions. Wallet locks are sequential single-row FOR UPDATE calls in caller-given order, not a combined ANY() query, since Postgres does not guarantee lock acquisition follows ORDER BY output order. InsertTransfer writes the final status directly via ON CONFLICT DO NOTHING, no separate claim-then-update step. TransferCache is the Redis read-through cache, latency optimization only. Includes integration tests against real Postgres. --- internal/repository/cache/redis_cache.go | 73 +++++++ .../postgres/fixtures_integration_test.go | 39 ++++ .../postgres/main_integration_test.go | 50 +++++ internal/repository/postgres/transactor.go | 30 +++ internal/repository/postgres/transfer_repo.go | 91 ++++++++ .../transfer_repo_integration_test.go | 203 ++++++++++++++++++ internal/repository/postgres/wallet_repo.go | 73 +++++++ .../postgres/wallet_repo_integration_test.go | 135 ++++++++++++ internal/repository/tx.go | 35 +++ 9 files changed, 729 insertions(+) create mode 100644 internal/repository/cache/redis_cache.go create mode 100644 internal/repository/postgres/fixtures_integration_test.go create mode 100644 internal/repository/postgres/main_integration_test.go create mode 100644 internal/repository/postgres/transactor.go create mode 100644 internal/repository/postgres/transfer_repo.go create mode 100644 internal/repository/postgres/transfer_repo_integration_test.go create mode 100644 internal/repository/postgres/wallet_repo.go create mode 100644 internal/repository/postgres/wallet_repo_integration_test.go create mode 100644 internal/repository/tx.go 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..edd669ed --- /dev/null +++ b/internal/repository/postgres/fixtures_integration_test.go @@ -0,0 +1,39 @@ +//go:build integration + +package postgres + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/shopspring/decimal" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" +) + +// 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() + + w := domain.Wallet{ + ID: uuid.NewString(), + Balance: decimal.RequireFromString(balance), + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := testPool.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/internal/repository/postgres/main_integration_test.go b/internal/repository/postgres/main_integration_test.go new file mode 100644 index 00000000..eb858852 --- /dev/null +++ b/internal/repository/postgres/main_integration_test.go @@ -0,0 +1,50 @@ +//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" +) + +var testPool *pgxpool.Pool + +// testPostgresDSN points at the postgres brought up by `docker-compose up +// -d postgres redis` (see `make test-integration`), overridable for CI. +func testPostgresDSN() string { + if dsn := os.Getenv("TEST_POSTGRES_DSN"); dsn != "" { + return dsn + } + return "postgres://wallet:wallet@localhost:5432/wallet?sslmode=disable" +} + +func TestMain(m *testing.M) { + ctx := context.Background() + dsn := testPostgresDSN() + + 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..c83bf287 --- /dev/null +++ b/internal/repository/postgres/transfer_repo_integration_test.go @@ -0,0 +1,203 @@ +//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() + return testIDGen.NextID() +} + +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..cb16f3aa --- /dev/null +++ b/internal/repository/postgres/wallet_repo.go @@ -0,0 +1,73 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "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 the order walletIDs is given — callers must pass +// walletIDs already sorted (ascending), so that concurrent transfers +// touching the same wallet pair always request locks in the same order +// and can never deadlock. +// +// 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) { + 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 +} + +// UpdateBalance sets walletID's balance to newBalance. It makes no +// decision about whether newBalance is valid — that's domain/service's +// call, not this repository's. +func (r *WalletRepo) UpdateBalance(ctx context.Context, tx repository.Tx, walletID string, newBalance decimal.Decimal) error { + _, err := tx.Exec(ctx, ` + UPDATE wallets + SET balance = $2, updated_at = now() + WHERE id = $1 + `, walletID, newBalance) + if err != nil { + return fmt.Errorf("update wallet balance: %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..aa7cd787 --- /dev/null +++ b/internal/repository/postgres/wallet_repo_integration_test.go @@ -0,0 +1,135 @@ +//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)) + } +} + +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_UpdateBalance_Persists(t *testing.T) { + ctx := context.Background() + a := newTestWallet(t, "10.00") + + tx, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + + repo := NewWalletRepo() + newBalance := decimal.RequireFromString("42.50") + if err := repo.UpdateBalance(ctx, tx, a.ID, newBalance); err != nil { + t.Fatalf("UpdateBalance: %v", err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("commit: %v", err) + } + + var got decimal.Decimal + if err := testPool.QueryRow(ctx, `SELECT balance FROM wallets WHERE id = $1`, a.ID).Scan(&got); err != nil { + t.Fatalf("read back balance: %v", err) + } + if !got.Equal(newBalance) { + t.Fatalf("expected balance %s, got %s", newBalance, got) + } +} + +// 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..7169e485 --- /dev/null +++ b/internal/repository/tx.go @@ -0,0 +1,35 @@ +// 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) + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, 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) +} From b25e3c81caa455b8578667ce23924d7e8cac69a0 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 12:47:49 +0530 Subject: [PATCH 17/40] Add transfer service: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TransferService.Transfer runs, in order: same-wallet/invalid-amount checks, a Redis cache check, a pre-transaction Postgres check for an already-committed id, then — only if truly new — locks both wallets, decides PROCESSED or FAILED/INSUFFICIENT_BALANCE, and claims the transfer with that outcome already written. Includes unit tests (mocked repo/cache/transactor) covering every branch, plus the concurrency stress test and double-click race test against real Postgres/Redis, run with -race. --- internal/service/fixtures_integration_test.go | 45 ++ internal/service/main_integration_test.go | 87 ++++ internal/service/ports.go | 53 ++ internal/service/transfer_service.go | 300 +++++++++++ .../transfer_service_integration_test.go | 136 +++++ internal/service/transfer_service_test.go | 467 ++++++++++++++++++ 6 files changed, 1088 insertions(+) create mode 100644 internal/service/fixtures_integration_test.go create mode 100644 internal/service/main_integration_test.go create mode 100644 internal/service/ports.go create mode 100644 internal/service/transfer_service.go create mode 100644 internal/service/transfer_service_integration_test.go create mode 100644 internal/service/transfer_service_test.go diff --git a/internal/service/fixtures_integration_test.go b/internal/service/fixtures_integration_test.go new file mode 100644 index 00000000..22202b17 --- /dev/null +++ b/internal/service/fixtures_integration_test.go @@ -0,0 +1,45 @@ +//go:build integration + +package service + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/shopspring/decimal" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/idgen" +) + +var testTransferIDGen = mustTestIDGenerator() + +func mustTestIDGenerator() *idgen.Generator { + gen, err := idgen.NewGenerator(1) + if err != nil { + panic(err) + } + return gen +} + +// 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() + id := uuid.NewString() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := testPool.Exec(ctx, ` + INSERT INTO wallets (id, balance, created_at, updated_at) + VALUES ($1, $2, now(), now()) + `, id, decimal.RequireFromString(balance)) + if err != nil { + t.Fatalf("insert test wallet: %v", err) + } + return id +} diff --git a/internal/service/main_integration_test.go b/internal/service/main_integration_test.go new file mode 100644 index 00000000..72ebb87a --- /dev/null +++ b/internal/service/main_integration_test.go @@ -0,0 +1,87 @@ +//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" +) + +var testPool *pgxpool.Pool +var testRedis *redis.Client + +// The postgres+redis brought up by `docker-compose up -d postgres +// redis` (see `make test-integration`), overridable for CI. +func testPostgresDSN() string { + if dsn := os.Getenv("TEST_POSTGRES_DSN"); dsn != "" { + return dsn + } + return "postgres://wallet:wallet@localhost:5432/wallet?sslmode=disable" +} + +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 := testPostgresDSN() + + 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..b00d9a5b --- /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) + UpdateBalance(ctx context.Context, tx repository.Tx, walletID string, newBalance 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 +} + +// 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..683ac01a --- /dev/null +++ b/internal/service/transfer_service.go @@ -0,0 +1,300 @@ +// 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" + "sort" + "time" + + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/middleware" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/repository" +) + +// 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. +func (s *TransferService) InitTransfer() int64 { + return s.idGen.NextID() +} + +// 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. It returns (nil, nil) +// if no such transfer exists. +func (s *TransferService) GetTransfer(ctx context.Context, id int64) (*domain.Transfer, error) { + return s.transferRepo.FindByID(ctx, id) +} + +// 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: "amount must be greater than zero"} + } + 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. +func (s *TransferService) checkCache(ctx context.Context, id int64, hash string) (*domain.Transfer, error) { + cached, err := s.cache.Get(ctx, id) + if err != nil { + return nil, fmt.Errorf("check transfer cache: %w", err) + } + 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(ctx, 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 { + 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(ctx, 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 in ascending ID order — +// every caller sorting the same way means concurrent transfers on the +// same pair always request locks in the same order, 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) { + ids := []string{fromID, toID} + sort.Strings(ids) + + wallets, err := s.walletRepo.GetForUpdate(ctx, tx, ids...) + 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.UpdateBalance(ctx, tx, from.ID, from.Balance.Sub(req.Amount)); err != nil { + return "", nil, err + } + if err := s.walletRepo.UpdateBalance(ctx, tx, to.ID, to.Balance.Add(req.Amount)); err != nil { + return "", nil, err + } + return domain.StatusProcessed, nil, nil +} + +// writeCache best-effort caches transfer. Cache is a latency optimization +// a failure here must never fail a request that already +// committed successfully in Postgres. +func (s *TransferService) writeCache(ctx context.Context, transfer *domain.Transfer) { + if err := s.cache.Set(ctx, transfer.ID, transfer); err != nil { + middleware.LoggerWithCorrelationID(ctx, s.logger).Warn("cache transfer failed", slog.Any("error", err)) + } +} + +func setLockTimeout(ctx context.Context, tx repository.Tx, timeout time.Duration) error { + _, 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..da26210c --- /dev/null +++ b/internal/service/transfer_service_integration_test.go @@ -0,0 +1,136 @@ +//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) + + 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: testTransferIDGen.NextID(), + 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: testTransferIDGen.NextID(), + 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..fa4cbaf8 --- /dev/null +++ b/internal/service/transfer_service_test.go @@ -0,0 +1,467 @@ +package service + +import ( + "context" + "errors" + "io" + "log/slog" + "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) Query(_ context.Context, _ string, _ ...any) (pgx.Rows, error) { + return nil, 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 + updateBalanceErr error + updateCalls []struct { + walletID string + newBalance 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) UpdateBalance(_ context.Context, _ repository.Tx, walletID string, newBalance decimal.Decimal) error { + m.updateCalls = append(m.updateCalls, struct { + walletID string + newBalance decimal.Decimal + }{walletID, newBalance}) + return m.updateBalanceErr +} + +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 +} + +type mockCache struct { + 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.setCalls = append(m.setCalls, transfer) + return m.setErr +} + +type mockIDGen struct{ id int64 } + +func (m *mockIDGen) NextID() int64 { return m.id } + +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) +} + +// --- 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") + } + if len(c.setCalls) != 1 { + t.Fatalf("expected the pre-check to self-heal the cache, got %d Set calls", len(c.setCalls)) + } + 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") + } + if len(c.setCalls) != 1 { + t.Fatalf("expected the persisted FAILED transfer to be cached") + } +} + +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) != 2 { + t.Fatalf("expected debit + credit UpdateBalance calls, got %d", len(wallets.updateCalls)) + } + if !wallets.updateCalls[0].newBalance.Equal(decimal.RequireFromString("90.00")) { + t.Fatalf("expected debited balance 90.00, got %s", wallets.updateCalls[0].newBalance) + } + if !wallets.updateCalls[1].newBalance.Equal(decimal.RequireFromString("15.00")) { + t.Fatalf("expected credited balance 15.00, got %s", wallets.updateCalls[1].newBalance) + } + if transfers.insertLedgerCalls != 1 { + t.Fatalf("expected exactly one InsertLedgerEntries call, got %d", transfers.insertLedgerCalls) + } + if !db.tx.committed { + t.Fatalf("success must commit") + } + if len(c.setCalls) != 1 { + t.Fatalf("expected the processed transfer to be cached") + } +} + +// --- GetTransfer / InitTransfer ------------------------------------------ + +func TestGetTransfer_Found(t *testing.T) { + want := &domain.Transfer{ID: 7, Status: domain.StatusProcessed} + transfers := &mockTransferRepo{findByIDResults: []*domain.Transfer{want}} + svc := newTestService(&fakeTransactor{}, &mockWalletRepo{}, transfers, &mockCache{}) + + 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()) + + if got := svc.InitTransfer(); got != 999 { + t.Fatalf("expected minted ID 999, got %d", got) + } +} From 3f031dda6cff48963480d27457b28171d483b79d Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 12:52:32 +0530 Subject: [PATCH 18/40] Rewrite HTTP handler layer for the transfer endpoints InitTransfer mints via the service and returns transactionId as a JSON string. CreateTransfer runs tier-1 shape validation (validateAmount, validateWalletID, validateIdempotencyKey), calls the service, and maps *domain.Error to its typed HTTP status via errors.As; anything else is a 500 with details logged and a generic body returned. GetTransfer is always 200 if found, 404 otherwise. toTransferView/statusForTransfer are the one shared response builder used by both POST and GET. Includes handler tests covering status-code mapping with a mocked service. --- internal/handler/dto.go | 8 +- internal/handler/router.go | 3 +- internal/handler/transfer_handler.go | 103 ++++++++++- internal/handler/transfer_handler_test.go | 208 ++++++++++++++++++++++ internal/handler/transfer_view.go | 52 ++++++ internal/handler/validate.go | 60 +++++++ 6 files changed, 423 insertions(+), 11 deletions(-) create mode 100644 internal/handler/transfer_handler_test.go create mode 100644 internal/handler/transfer_view.go create mode 100644 internal/handler/validate.go diff --git a/internal/handler/dto.go b/internal/handler/dto.go index c93daa95..700f9906 100644 --- a/internal/handler/dto.go +++ b/internal/handler/dto.go @@ -8,7 +8,9 @@ type InitTransferResponse struct { } // CreateTransferRequest is the request body of POST /transfers. Amount -// decodes as json.Number, never float64 — see docs/DESIGN.md §11. +// 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"` @@ -17,7 +19,9 @@ type CreateTransferRequest struct { } // TransferView is the shared response representation for both -// POST /transfers and GET /transfers/{id} — see docs/DESIGN.md §1. +// 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"` diff --git a/internal/handler/router.go b/internal/handler/router.go index 3c09e0a5..c9998894 100644 --- a/internal/handler/router.go +++ b/internal/handler/router.go @@ -1,5 +1,6 @@ // Package handler is the thin HTTP layer: decode request, call service, -// encode response. It contains no business logic — see docs/DESIGN.md §8. +// encode response. No business logic here — every domain rule lives in +// service/domain so it's testable independent of HTTP. package handler import ( diff --git a/internal/handler/transfer_handler.go b/internal/handler/transfer_handler.go index 11dbdf8c..cdfc5a6c 100644 --- a/internal/handler/transfer_handler.go +++ b/internal/handler/transfer_handler.go @@ -1,32 +1,119 @@ 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/middleware" + "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 + 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. Stubbed for Phase 1; the transfer flow itself -// lands in Phase 2 alongside internal/service. -type TransferHandler struct{} +// /transfers/:id routes. Decode -> call service -> encode, nothing else. +type TransferHandler struct { + service transferService + logger *slog.Logger +} // NewTransferHandler constructs a TransferHandler. -func NewTransferHandler() *TransferHandler { - return &TransferHandler{} +func NewTransferHandler(svc transferService, logger *slog.Logger) *TransferHandler { + return &TransferHandler{service: svc, logger: logger} } // InitTransfer handles POST /transfers/init. func (h *TransferHandler) InitTransfer(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + id := h.service.InitTransfer() + c.JSON(http.StatusOK, InitTransferResponse{TransactionID: strconv.FormatInt(id, 10)}) } // CreateTransfer handles POST /transfers. func (h *TransferHandler) CreateTransfer(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + 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 { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := validateWalletID(req.FromWalletID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := validateWalletID(req.ToWalletID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + amount, minorUnits, err := validateAmount(req.Amount) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + 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) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + 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 + } + + middleware.LoggerWithCorrelationID(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..53fd2e90 --- /dev/null +++ b/internal/handler/transfer_handler_test.go @@ -0,0 +1,208 @@ +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 + transferResult *domain.Transfer + transferErr error + getResult *domain.Transfer + getErr error +} + +func (m *mockTransferService) InitTransfer() int64 { return m.initResult } + +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 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) + } +} + +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) + } +} + +func TestCreateTransfer_InvalidAmountShape(t *testing.T) { + cases := []string{"abc", "-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) + } + } +} + +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}, + } + 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..d44e5e6d --- /dev/null +++ b/internal/handler/transfer_view.go @@ -0,0 +1,52 @@ +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 { + if code == domain.ErrCodeIdempotencyKeyReused { + return http.StatusConflict + } + return http.StatusUnprocessableEntity +} diff --git a/internal/handler/validate.go b/internal/handler/validate.go new file mode 100644 index 00000000..a1debd89 --- /dev/null +++ b/internal/handler/validate.go @@ -0,0 +1,60 @@ +package handler + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + + "github.com/google/uuid" + "github.com/shopspring/decimal" +) + +// 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, fmt.Errorf("amount must be a positive number with at most 2 decimal places") + } + + parsed, err := decimal.NewFromString(raw) + if err != nil { + return decimal.Decimal{}, 0, fmt.Errorf("amount is not a valid decimal: %w", err) + } + + minorUnits := parsed.Shift(2).IntPart() + 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 fmt.Errorf("invalid wallet id: %w", 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, fmt.Errorf("idempotencyKey must be a valid int64 string: %w", err) + } + return id, nil +} From f2bde80988d8d6ae57dd0e691b4eadf9d8ce08d0 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 12:53:42 +0530 Subject: [PATCH 19/40] Wire cmd/server/main.go for the transfer service Constructs the postgres/cache repos, the transactor, and the TransferService, then passes it into the handler constructor in place of the Phase 1 stub. No other change to the boot sequence. --- cmd/server/main.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 931b3b00..22b8e7aa 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -20,6 +20,9 @@ import ( "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" @@ -81,10 +84,19 @@ func main() { log.Error("create id generator", slog.Any("error", err)) os.Exit(1) } - _ = generator // wired for Phase 2's service layer; unused until then + + 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() + transferHandler := handler.NewTransferHandler(transferService, log) router := handler.NewRouter(healthHandler, transferHandler) srv := &http.Server{ From 2048d268755ad7288cfdaeeeccc4c54e3d87e5ba Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 12:54:04 +0530 Subject: [PATCH 20/40] Add test-integration and seed Makefile targets, fix docker compose test-integration brings up postgres+redis via docker compose and runs the integration suite with -race. seed loads scripts/seed.sql into the running postgres container. Also fixes every docker-compose (hyphenated v1 binary) invocation to docker compose (v2 plugin) -- the standalone binary isn't installed here, so make run/test-integration/seed were all silently broken before this. --- Makefile | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 7d8c5aaa..4497a698 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -.PHONY: run migrate lint fmt-check test test-integration +.PHONY: run migrate lint fmt-check test test-integration seed run: - docker-compose up -d postgres redis + docker compose up -d postgres redis go run ./cmd/server migrate: @@ -16,5 +16,17 @@ fmt-check: 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: - go test -tags=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 From 7a7a0f86720c4d841adba523fc29a0ccb87f0cff Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 12:54:33 +0530 Subject: [PATCH 21/40] Add demo seed fixtures and rewrite README for the service scripts/seed.sql inserts three fixed, documented wallets for manual curl testing -- idempotent, never a golang-migrate migration, schema migrations stay data-free. README.md now documents the actual service (API reference, config, testing, project layout, a full curl walkthrough) instead of the original assignment-template administration content, which no longer describes this repository's purpose now that a solution lives here. --- README.md | 182 ++++++++++++++++++++++++++++++++++++++++------- scripts/seed.sql | 13 ++++ 2 files changed, 170 insertions(+), 25 deletions(-) create mode 100644 scripts/seed.sql diff --git a/README.md b/README.md index 58c62d1a..f629d31d 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/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; From dc8f6f9de2284cc60fa06b8f3c96252437cc3b56 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 15:20:06 +0530 Subject: [PATCH 22/40] Fix silent overflow in amount-to-minor-units conversion decimal.IntPart() truncates (wraps) silently if the value doesn't fit in an int64. Detect that by converting the truncated int64 back to a decimal and comparing against the pre-truncation value -- since the amount regex already guarantees at most 2 decimal places, the shifted value is always an exact integer decimal, so any mismatch here can only mean overflow, never a real fractional truncation. Returns a *domain.Error{Code: INVALID_AMOUNT} instead of a bare fmt.Errorf, and the handler now checks for that specific case and routes it through the existing typed-error mapping (422), instead of every validateAmount failure taking the same flat 400 path regardless of what kind of problem it actually is. --- internal/handler/transfer_handler.go | 5 +++++ internal/handler/validate.go | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/internal/handler/transfer_handler.go b/internal/handler/transfer_handler.go index cdfc5a6c..5e38b523 100644 --- a/internal/handler/transfer_handler.go +++ b/internal/handler/transfer_handler.go @@ -63,6 +63,11 @@ func (h *TransferHandler) CreateTransfer(c *gin.Context) { } amount, minorUnits, err := validateAmount(req.Amount) if err != nil { + var domainErr *domain.Error + if errors.As(err, &domainErr) { + h.respondError(c, err) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/internal/handler/validate.go b/internal/handler/validate.go index a1debd89..8f92220e 100644 --- a/internal/handler/validate.go +++ b/internal/handler/validate.go @@ -8,6 +8,8 @@ import ( "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. @@ -30,7 +32,20 @@ func validateAmount(amount json.Number) (decimal.Decimal, int64, error) { return decimal.Decimal{}, 0, fmt.Errorf("amount is not a valid decimal: %w", err) } - minorUnits := parsed.Shift(2).IntPart() + // 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.Error{ + Code: domain.ErrCodeInvalidAmount, + Message: "amount is too large to represent", + } + } return parsed, minorUnits, nil } From b5b876be4a35404dfebe3b3d758e7c5733ed29fa Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 15:27:42 +0530 Subject: [PATCH 23/40] Remove internal/service's transitive dependency on Gin internal/service imported internal/middleware only for its correlation- ID context helpers, which pulled Gin into the service package's import graph even though the service itself never touches HTTP. Moves WithCorrelationID/CorrelationIDFromContext/FromContext into internal/platform/logger, which already has zero Gin dependency. middleware.CorrelationID (the actual Gin middleware) now calls logger.WithCorrelationID to set the value; internal/service and internal/handler both call logger.FromContext to read it back, with no path to Gin anywhere in internal/service's dependency graph. Verified with `go list -deps ./internal/service/...`. --- internal/handler/transfer_handler.go | 4 +-- internal/middleware/correlation_id.go | 31 ++++++----------------- internal/platform/logger/context.go | 36 +++++++++++++++++++++++++++ internal/service/transfer_service.go | 4 +-- 4 files changed, 47 insertions(+), 28 deletions(-) create mode 100644 internal/platform/logger/context.go diff --git a/internal/handler/transfer_handler.go b/internal/handler/transfer_handler.go index 5e38b523..15760d6e 100644 --- a/internal/handler/transfer_handler.go +++ b/internal/handler/transfer_handler.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" - "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/middleware" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/platform/logger" "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/service" ) @@ -119,6 +119,6 @@ func (h *TransferHandler) respondError(c *gin.Context, err error) { return } - middleware.LoggerWithCorrelationID(c.Request.Context(), h.logger).Error("unexpected transfer error", slog.Any("error", err)) + 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/middleware/correlation_id.go b/internal/middleware/correlation_id.go index d90cac9d..fdde0794 100644 --- a/internal/middleware/correlation_id.go +++ b/internal/middleware/correlation_id.go @@ -5,14 +5,11 @@ package middleware import ( - "context" - "log/slog" - "github.com/gin-gonic/gin" "github.com/google/uuid" -) -type correlationIDKey struct{} + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/platform/logger" +) const correlationIDHeader = "X-Correlation-ID" @@ -21,6 +18,10 @@ const correlationIDHeader = "X-Correlation-ID" // 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) @@ -28,28 +29,10 @@ func CorrelationID() gin.HandlerFunc { correlationID = uuid.NewString() } - ctx := context.WithValue(c.Request.Context(), correlationIDKey{}, correlationID) + ctx := logger.WithCorrelationID(c.Request.Context(), correlationID) c.Request = c.Request.WithContext(ctx) c.Header(correlationIDHeader, correlationID) c.Next() } } - -// CorrelationIDFromContext returns the correlation ID set by CorrelationID, -// or "" if none is present. -func CorrelationIDFromContext(ctx context.Context) string { - if id, ok := ctx.Value(correlationIDKey{}).(string); ok { - return id - } - return "" -} - -// LoggerWithCorrelationID returns logger with the request's correlation ID -// attached as a field, or logger unchanged if none is present. -func LoggerWithCorrelationID(ctx context.Context, logger *slog.Logger) *slog.Logger { - if id := CorrelationIDFromContext(ctx); id != "" { - return logger.With(slog.String("correlation_id", id)) - } - return logger -} 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/service/transfer_service.go b/internal/service/transfer_service.go index 683ac01a..c3cfd46d 100644 --- a/internal/service/transfer_service.go +++ b/internal/service/transfer_service.go @@ -13,7 +13,7 @@ import ( "time" "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" - "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/middleware" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/platform/logger" "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/repository" ) @@ -280,7 +280,7 @@ func (s *TransferService) executeTransfer(ctx context.Context, tx repository.Tx, // committed successfully in Postgres. func (s *TransferService) writeCache(ctx context.Context, transfer *domain.Transfer) { if err := s.cache.Set(ctx, transfer.ID, transfer); err != nil { - middleware.LoggerWithCorrelationID(ctx, s.logger).Warn("cache transfer failed", slog.Any("error", err)) + logger.FromContext(ctx, s.logger).Warn("cache transfer failed", slog.Any("error", err)) } } From efe753fb13cb0bb7c2ac80984fed36647a9516be Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 15:28:33 +0530 Subject: [PATCH 24/40] Release wallet locks before the conflict-check read When InsertTransfer loses a genuine claim race, roll back explicitly before calling checkConflictAfterRace instead of relying on the deferred rollback at function return. The deferred rollback only fires after checkConflictAfterRace has already run its own read (via the pool, not this transaction), so without this, the wallet locks this transaction holds stay held for that entire extra round trip for no reason. --- internal/service/transfer_service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/service/transfer_service.go b/internal/service/transfer_service.go index c3cfd46d..d0ebfbcb 100644 --- a/internal/service/transfer_service.go +++ b/internal/service/transfer_service.go @@ -191,6 +191,7 @@ func (s *TransferService) lockAndExecuteTransfer(ctx context.Context, req Transf return nil, err } if claimed == nil { + _ = tx.Rollback(ctx) return s.checkConflictAfterRace(ctx, req.ID, hash) } From df3757efc1acc2deebbaf4868bbd6c20712618ae Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 15:29:31 +0530 Subject: [PATCH 25/40] Reject LOCK_TIMEOUT under 1ms, clamp defensively at point of use Duration.Milliseconds() truncates to 0 for any value under 1ms, and "SET LOCAL lock_timeout = '0ms'" means "no timeout" in Postgres -- the opposite of what a lock_timeout is supposed to protect against. config.Validate() now rejects LOCK_TIMEOUT < 1ms outright at startup with a clear error. setLockTimeout also clamps to 1ms defensively as a second layer, for any path that constructs a Duration without going through config validation. --- internal/config/config.go | 4 ++-- internal/service/transfer_service.go | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 0416e30d..caa1d7b5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,8 +44,8 @@ func (c *Config) Validate() error { if c.Port == "" { return fmt.Errorf("PORT is required") } - if c.LockTimeout <= 0 { - return fmt.Errorf("LOCK_TIMEOUT must be a positive duration") + 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) diff --git a/internal/service/transfer_service.go b/internal/service/transfer_service.go index d0ebfbcb..b44a0e6d 100644 --- a/internal/service/transfer_service.go +++ b/internal/service/transfer_service.go @@ -285,7 +285,17 @@ func (s *TransferService) writeCache(ctx context.Context, transfer *domain.Trans } } +// 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) From decbc1306d583360491549e6ade1859f358faa96 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 15:29:59 +0530 Subject: [PATCH 26/40] Send amount as a bare JSON number, not a quoted string The DTO's Amount field (json.Number) already decodes a bare JSON number correctly, with no float64 precision loss -- json.Number captures the literal text of the number token directly, it never passes through a float. Sending amount as a quoted string worked too (json.Number's underlying type is string, so any JSON string decodes into it), but didn't match the actual contract or the original assignment spec's payload example. Updates every README curl example and Go test fixture accordingly. No DTO or decode-logic change -- the type was already correct. TestCreateTransfer_InvalidAmountShape's "abc" case now 400s at the JSON-bind step instead of the amount-shape regex, since a bare `abc` isn't valid JSON syntax -- still the correct status, just a different code path than the other three cases in that table. --- README.md | 4 ++-- internal/handler/transfer_handler_test.go | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f629d31d..f7bbbfdf 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ which routinely exceeds what JavaScript's `Number` can represent exactly. "idempotencyKey": "", "fromWalletId": "", "toWalletId": "", - "amount": "10.00" + "amount": 10.00 } ``` @@ -157,7 +157,7 @@ curl -s -X POST localhost:8080/transfers \ "idempotencyKey": "", "fromWalletId": "018f0000-0000-7000-8000-000000000001", "toWalletId": "018f0000-0000-7000-8000-000000000002", - "amount": "10.00" + "amount": 10.00 }' # → 200 {"id":"...","status":"PROCESSED",...} diff --git a/internal/handler/transfer_handler_test.go b/internal/handler/transfer_handler_test.go index 53fd2e90..1096cbcc 100644 --- a/internal/handler/transfer_handler_test.go +++ b/internal/handler/transfer_handler_test.go @@ -49,7 +49,7 @@ 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"}` + return `{"idempotencyKey":"123","fromWalletId":"` + validWalletA + `","toWalletId":"` + validWalletB + `","amount":10.00}` } func doRequest(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder { @@ -87,7 +87,7 @@ func TestCreateTransfer_MalformedJSON(t *testing.T) { func TestCreateTransfer_InvalidIdempotencyKey(t *testing.T) { r := testRouter(&mockTransferService{}) - body := `{"idempotencyKey":"not-a-number","fromWalletId":"` + validWalletA + `","toWalletId":"` + validWalletB + `","amount":"10.00"}` + 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) @@ -96,7 +96,7 @@ func TestCreateTransfer_InvalidIdempotencyKey(t *testing.T) { func TestCreateTransfer_InvalidWalletID(t *testing.T) { r := testRouter(&mockTransferService{}) - body := `{"idempotencyKey":"123","fromWalletId":"not-a-uuid","toWalletId":"` + validWalletB + `","amount":"10.00"}` + 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) @@ -104,9 +104,12 @@ func TestCreateTransfer_InvalidWalletID(t *testing.T) { } func TestCreateTransfer_InvalidAmountShape(t *testing.T) { + // amount is sent as a bare JSON number, same as a valid request. "abc" + // isn't valid JSON syntax as a bare token, so it 400s at the bind step + // rather than the amount-shape regex — still the right status either way. cases := []string{"abc", "-5", "1.234", "0.001"} for _, amount := range cases { - body := `{"idempotencyKey":"123","fromWalletId":"` + validWalletA + `","toWalletId":"` + validWalletB + `","amount":"` + amount + `"}` + body := `{"idempotencyKey":"123","fromWalletId":"` + validWalletA + `","toWalletId":"` + validWalletB + `","amount":` + amount + `}` r := testRouter(&mockTransferService{}) rec := doRequest(r, http.MethodPost, "/transfers", body) if rec.Code != http.StatusBadRequest { From 32ad16fa4e97cf63df616a7d04f6fffe25e3ac50 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 18:39:36 +0530 Subject: [PATCH 27/40] Split AMOUNT_UNREPRESENTABLE from INVALID_AMOUNT An amount that overflows int64 once shifted into minor units was being reported as INVALID_AMOUNT (422, business-meaning failure), the same code used for amount <= 0. The two are different failure tiers: an overflowing amount is a tier-1 structural problem (the value literally doesn't fit), not a tier-2 business rule violation, so it needs its own code and its own 400 status rather than sharing INVALID_AMOUNT's 422. Co-Authored-By: Claude Sonnet 5 --- internal/domain/errors.go | 21 +++++++++++++----- internal/handler/transfer_handler_test.go | 27 +++++++++++++++++++++++ internal/handler/transfer_view.go | 11 +++++++-- internal/handler/validate.go | 5 +---- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 7699394e..8a7fc47e 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -8,11 +8,12 @@ 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" + ErrCodeInvalidAmount ErrorCode = "INVALID_AMOUNT" + ErrCodeSameWallet ErrorCode = "SAME_WALLET" + ErrCodeWalletNotFound ErrorCode = "WALLET_NOT_FOUND" + ErrCodeInsufficientBalance ErrorCode = "INSUFFICIENT_BALANCE" + ErrCodeIdempotencyKeyReused ErrorCode = "IDEMPOTENCY_KEY_REUSED" + ErrCodeAmountUnrepresentable ErrorCode = "AMOUNT_UNREPRESENTABLE" ) // Error is a typed domain failure, distinguishable from unexpected @@ -23,3 +24,13 @@ type Error struct { } func (e *Error) Error() string { return e.Message } + +// ErrAmountUnrepresentable is a tier-1 structural failure: the client's +// amount, once shifted into minor units, doesn't fit in an int64. 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. +var ErrAmountUnrepresentable = &Error{ + Code: ErrCodeAmountUnrepresentable, + Message: "amount is too large or too precise to represent", +} diff --git a/internal/handler/transfer_handler_test.go b/internal/handler/transfer_handler_test.go index 1096cbcc..a24dc6a7 100644 --- a/internal/handler/transfer_handler_test.go +++ b/internal/handler/transfer_handler_test.go @@ -118,6 +118,28 @@ func TestCreateTransfer_InvalidAmountShape(t *testing.T) { } } +func TestCreateTransfer_AmountUnrepresentable(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, not overflow. This amount passes + // the regex (all digits, 2 decimal places) but can't be converted. + 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.ErrCodeAmountUnrepresentable) { + t.Fatalf("expected error code %s, got %+v", domain.ErrCodeAmountUnrepresentable, resp) + } +} + func TestCreateTransfer_DomainErrorMapping(t *testing.T) { cases := []struct { code domain.ErrorCode @@ -128,6 +150,11 @@ func TestCreateTransfer_DomainErrorMapping(t *testing.T) { {domain.ErrCodeWalletNotFound, http.StatusUnprocessableEntity}, {domain.ErrCodeInsufficientBalance, http.StatusUnprocessableEntity}, {domain.ErrCodeIdempotencyKeyReused, http.StatusConflict}, + // The service itself never returns this code (it's 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.ErrCodeAmountUnrepresentable, http.StatusBadRequest}, } for _, tc := range cases { svc := &mockTransferService{transferErr: &domain.Error{Code: tc.code, Message: "boom"}} diff --git a/internal/handler/transfer_view.go b/internal/handler/transfer_view.go index d44e5e6d..5e738888 100644 --- a/internal/handler/transfer_view.go +++ b/internal/handler/transfer_view.go @@ -45,8 +45,15 @@ func statusForTransfer(t *domain.Transfer) int { // 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 { - if code == domain.ErrCodeIdempotencyKeyReused { + switch code { + case domain.ErrCodeIdempotencyKeyReused: return http.StatusConflict + case domain.ErrCodeAmountUnrepresentable: + // Tier-1 structural failure (the client's amount doesn't fit in + // an int64 at all), never a persisted FailureReason — this case + // only fires via the live-rejection path. + return http.StatusBadRequest + default: + return http.StatusUnprocessableEntity } - return http.StatusUnprocessableEntity } diff --git a/internal/handler/validate.go b/internal/handler/validate.go index 8f92220e..74c21327 100644 --- a/internal/handler/validate.go +++ b/internal/handler/validate.go @@ -41,10 +41,7 @@ func validateAmount(amount json.Number) (decimal.Decimal, int64, error) { shifted := parsed.Shift(2) minorUnits := shifted.IntPart() if !shifted.Equal(decimal.NewFromInt(minorUnits)) { - return decimal.Decimal{}, 0, &domain.Error{ - Code: domain.ErrCodeInvalidAmount, - Message: "amount is too large to represent", - } + return decimal.Decimal{}, 0, domain.ErrAmountUnrepresentable } return parsed, minorUnits, nil } From 84dde19eaaee399cd5745e9c423d70f4c5279c30 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 18:39:49 +0530 Subject: [PATCH 28/40] Squash ledger_entries identity column and amount check into base migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ledger_entries.id was hand-rolled as a plain BIGINT PK in migration 0003, then converted to GENERATED ALWAYS AS IDENTITY by a follow-up migration 0004 — but the table doesn't exist anywhere outside local dev yet, so there's no deployed schema to preserve compatibility with. Folding the identity column and the amount > 0 check directly into 0002/0003 and dropping 0004 keeps the migration history reflecting the intended schema from the start instead of its work-in-progress path. Co-Authored-By: Claude Sonnet 5 --- migrations/0002_create_transfers.up.sql | 2 +- migrations/0003_create_ledger_entries.up.sql | 2 +- migrations/0004_ledger_entries_identity_id.down.sql | 1 - migrations/0004_ledger_entries_identity_id.up.sql | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) delete mode 100644 migrations/0004_ledger_entries_identity_id.down.sql delete mode 100644 migrations/0004_ledger_entries_identity_id.up.sql diff --git a/migrations/0002_create_transfers.up.sql b/migrations/0002_create_transfers.up.sql index 4c3969f9..2a877cd9 100644 --- a/migrations/0002_create_transfers.up.sql +++ b/migrations/0002_create_transfers.up.sql @@ -3,7 +3,7 @@ CREATE TABLE transfers ( 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, + 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(), diff --git a/migrations/0003_create_ledger_entries.up.sql b/migrations/0003_create_ledger_entries.up.sql index d9038fe5..7a4d065e 100644 --- a/migrations/0003_create_ledger_entries.up.sql +++ b/migrations/0003_create_ledger_entries.up.sql @@ -1,5 +1,5 @@ CREATE TABLE ledger_entries ( - id BIGINT PRIMARY KEY, + 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')), diff --git a/migrations/0004_ledger_entries_identity_id.down.sql b/migrations/0004_ledger_entries_identity_id.down.sql deleted file mode 100644 index a2d8b8b3..00000000 --- a/migrations/0004_ledger_entries_identity_id.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE ledger_entries ALTER COLUMN id DROP IDENTITY IF EXISTS; diff --git a/migrations/0004_ledger_entries_identity_id.up.sql b/migrations/0004_ledger_entries_identity_id.up.sql deleted file mode 100644 index 2b34acff..00000000 --- a/migrations/0004_ledger_entries_identity_id.up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE ledger_entries ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY; From 78c127c0794c700d41d0436b898a3b25c4516039 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 19:06:29 +0530 Subject: [PATCH 29/40] Fail fast on clock regression in Snowflake ID generation instead of colliding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NextID only handled the clock staying flat or moving forward — if it moved backward (NTP step, VM migration/snapshot restore), the old code fell through the same-timestamp branch's guard and could mint a sequence value that collides with an ID already issued at that now-revisited millisecond. A regression can be seconds or minutes long, so spinning until the clock catches up (as sequence rollover already does within a millisecond) isn't the right response here — NextID now returns an error immediately and lets the caller decide, rather than blocking a request indefinitely or risking a duplicate transfer ID. Co-Authored-By: Claude Sonnet 5 --- internal/handler/transfer_handler.go | 9 ++++- internal/handler/transfer_handler_test.go | 12 +++++- internal/idgen/snowflake.go | 13 +++++- internal/idgen/snowflake_test.go | 49 ++++++++++++++++++++--- internal/service/ports.go | 2 +- internal/service/transfer_service.go | 2 +- internal/service/transfer_service_test.go | 29 ++++++++++++-- 7 files changed, 101 insertions(+), 15 deletions(-) diff --git a/internal/handler/transfer_handler.go b/internal/handler/transfer_handler.go index 15760d6e..3baa253f 100644 --- a/internal/handler/transfer_handler.go +++ b/internal/handler/transfer_handler.go @@ -17,7 +17,7 @@ import ( // 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 + InitTransfer() (int64, error) Transfer(ctx context.Context, req service.TransferRequest) (*domain.Transfer, error) GetTransfer(ctx context.Context, id int64) (*domain.Transfer, error) } @@ -36,7 +36,12 @@ func NewTransferHandler(svc transferService, logger *slog.Logger) *TransferHandl // InitTransfer handles POST /transfers/init. func (h *TransferHandler) InitTransfer(c *gin.Context) { - id := h.service.InitTransfer() + id, err := h.service.InitTransfer() + if err != nil { + logger.FromContext(c.Request.Context(), h.logger).Error("mint transfer id failed", slog.Any("error", err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } c.JSON(http.StatusOK, InitTransferResponse{TransactionID: strconv.FormatInt(id, 10)}) } diff --git a/internal/handler/transfer_handler_test.go b/internal/handler/transfer_handler_test.go index a24dc6a7..eaa7ea72 100644 --- a/internal/handler/transfer_handler_test.go +++ b/internal/handler/transfer_handler_test.go @@ -18,13 +18,14 @@ import ( type mockTransferService struct { initResult int64 + initErr error transferResult *domain.Transfer transferErr error getResult *domain.Transfer getErr error } -func (m *mockTransferService) InitTransfer() int64 { return m.initResult } +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 @@ -77,6 +78,15 @@ func TestInitTransferHandler(t *testing.T) { } } +func TestInitTransferHandler_IDGenError(t *testing.T) { + 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") diff --git a/internal/idgen/snowflake.go b/internal/idgen/snowflake.go index d28f21a9..3849fb53 100644 --- a/internal/idgen/snowflake.go +++ b/internal/idgen/snowflake.go @@ -66,12 +66,21 @@ var nowMillis = func() int64 { // 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. -func (g *Generator) NextID() int64 { +// +// 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 { @@ -88,5 +97,5 @@ func (g *Generator) NextID() int64 { g.lastTimestamp = now - return (now-epoch)< Date: Sat, 11 Jul 2026 19:24:08 +0530 Subject: [PATCH 30/40] Move wallet lock ordering into GetForUpdate itself --- .../transfer_repo_integration_test.go | 6 +++- internal/repository/postgres/wallet_repo.go | 11 +++--- .../postgres/wallet_repo_integration_test.go | 35 +++++++++++++++++++ internal/service/fixtures_integration_test.go | 9 +++++ internal/service/transfer_service.go | 15 ++++---- .../transfer_service_integration_test.go | 12 +++++-- 6 files changed, 72 insertions(+), 16 deletions(-) diff --git a/internal/repository/postgres/transfer_repo_integration_test.go b/internal/repository/postgres/transfer_repo_integration_test.go index c83bf287..0b3585b7 100644 --- a/internal/repository/postgres/transfer_repo_integration_test.go +++ b/internal/repository/postgres/transfer_repo_integration_test.go @@ -24,7 +24,11 @@ func mustIDGenerator() *idgen.Generator { func nextTestTransferID(t *testing.T) int64 { t.Helper() - return testIDGen.NextID() + id, err := testIDGen.NextID() + if err != nil { + t.Fatalf("mint test transfer id: %v", err) + } + return id } func TestTransferRepo_InsertTransfer_Success(t *testing.T) { diff --git a/internal/repository/postgres/wallet_repo.go b/internal/repository/postgres/wallet_repo.go index cb16f3aa..0318d5e4 100644 --- a/internal/repository/postgres/wallet_repo.go +++ b/internal/repository/postgres/wallet_repo.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sort" "github.com/jackc/pgx/v5" "github.com/shopspring/decimal" @@ -21,10 +22,10 @@ func NewWalletRepo() *WalletRepo { } // GetForUpdate locks and returns the wallets identified by walletIDs, one -// row at a time, in the order walletIDs is given — callers must pass -// walletIDs already sorted (ascending), so that concurrent transfers -// touching the same wallet pair always request locks in the same order -// and can never deadlock. +// 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 @@ -35,6 +36,8 @@ func NewWalletRepo() *WalletRepo { // 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, ` diff --git a/internal/repository/postgres/wallet_repo_integration_test.go b/internal/repository/postgres/wallet_repo_integration_test.go index aa7cd787..ab3db81b 100644 --- a/internal/repository/postgres/wallet_repo_integration_test.go +++ b/internal/repository/postgres/wallet_repo_integration_test.go @@ -36,6 +36,41 @@ func TestWalletRepo_GetForUpdate_ReturnsLockedWallets(t *testing.T) { } } +// 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") diff --git a/internal/service/fixtures_integration_test.go b/internal/service/fixtures_integration_test.go index 22202b17..43464f9a 100644 --- a/internal/service/fixtures_integration_test.go +++ b/internal/service/fixtures_integration_test.go @@ -23,6 +23,15 @@ func mustTestIDGenerator() *idgen.Generator { 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 diff --git a/internal/service/transfer_service.go b/internal/service/transfer_service.go index 22362131..c9c87675 100644 --- a/internal/service/transfer_service.go +++ b/internal/service/transfer_service.go @@ -9,7 +9,6 @@ import ( "encoding/hex" "fmt" "log/slog" - "sort" "time" "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/domain" @@ -226,15 +225,13 @@ func (s *TransferService) checkConflictAfterRace(ctx context.Context, id int64, return existing, nil } -// lockAndValidateWallets locks both wallets in ascending ID order — -// every caller sorting the same way means concurrent transfers on the -// same pair always request locks in the same order, which can't -// deadlock — and reports WALLET_NOT_FOUND if either is missing. +// 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) { - ids := []string{fromID, toID} - sort.Strings(ids) - - wallets, err := s.walletRepo.GetForUpdate(ctx, tx, ids...) + wallets, err := s.walletRepo.GetForUpdate(ctx, tx, fromID, toID) if err != nil { return nil, fmt.Errorf("lock wallets: %w", err) } diff --git a/internal/service/transfer_service_integration_test.go b/internal/service/transfer_service_integration_test.go index da26210c..eb2ce3a7 100644 --- a/internal/service/transfer_service_integration_test.go +++ b/internal/service/transfer_service_integration_test.go @@ -24,6 +24,14 @@ func TestTransfer_ConcurrencyStress(t *testing.T) { 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++ { @@ -31,7 +39,7 @@ func TestTransfer_ConcurrencyStress(t *testing.T) { go func(i int) { defer wg.Done() req := TransferRequest{ - ID: testTransferIDGen.NextID(), + ID: ids[i], FromWalletID: from, ToWalletID: to, Amount: decimal.RequireFromString(amount), @@ -75,7 +83,7 @@ func TestTransfer_DoubleClickRace(t *testing.T) { svc := newIntegrationTransferService(t) req := TransferRequest{ - ID: testTransferIDGen.NextID(), + ID: nextTestTransferID(t), FromWalletID: from, ToWalletID: to, Amount: decimal.RequireFromString("5.00"), From 14abd793c12d276f24a0ecb552f002a5abd4661b Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 20:08:26 +0530 Subject: [PATCH 31/40] Give InitTransfer failures a typed, client-safe error --- internal/domain/errors.go | 11 +++++++++++ internal/handler/transfer_handler.go | 8 +++++--- internal/handler/transfer_handler_test.go | 22 ++++++++++++++++++++++ internal/handler/transfer_view.go | 4 ++++ internal/service/transfer_service.go | 12 +++++++++++- internal/service/transfer_service_test.go | 4 +--- 6 files changed, 54 insertions(+), 7 deletions(-) diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 8a7fc47e..5332e4fe 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -14,6 +14,7 @@ const ( ErrCodeInsufficientBalance ErrorCode = "INSUFFICIENT_BALANCE" ErrCodeIdempotencyKeyReused ErrorCode = "IDEMPOTENCY_KEY_REUSED" ErrCodeAmountUnrepresentable ErrorCode = "AMOUNT_UNREPRESENTABLE" + ErrCodeTransferInitFailed ErrorCode = "TRANSFER_INIT_FAILED" ) // Error is a typed domain failure, distinguishable from unexpected @@ -34,3 +35,13 @@ var ErrAmountUnrepresentable = &Error{ Code: ErrCodeAmountUnrepresentable, Message: "amount is too large or too precise to represent", } + +// 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/handler/transfer_handler.go b/internal/handler/transfer_handler.go index 3baa253f..41d7a247 100644 --- a/internal/handler/transfer_handler.go +++ b/internal/handler/transfer_handler.go @@ -34,12 +34,14 @@ func NewTransferHandler(svc transferService, logger *slog.Logger) *TransferHandl return &TransferHandler{service: svc, logger: logger} } -// InitTransfer handles POST /transfers/init. +// 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 { - logger.FromContext(c.Request.Context(), h.logger).Error("mint transfer id failed", slog.Any("error", err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + h.respondError(c, err) return } c.JSON(http.StatusOK, InitTransferResponse{TransactionID: strconv.FormatInt(id, 10)}) diff --git a/internal/handler/transfer_handler_test.go b/internal/handler/transfer_handler_test.go index eaa7ea72..302bdefb 100644 --- a/internal/handler/transfer_handler_test.go +++ b/internal/handler/transfer_handler_test.go @@ -79,6 +79,28 @@ func TestInitTransferHandler(t *testing.T) { } 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", "") diff --git a/internal/handler/transfer_view.go b/internal/handler/transfer_view.go index 5e738888..43b88240 100644 --- a/internal/handler/transfer_view.go +++ b/internal/handler/transfer_view.go @@ -53,6 +53,10 @@ func domainErrorHTTPStatus(code domain.ErrorCode) int { // an int64 at all), never a persisted FailureReason — this case // only fires via the live-rejection path. 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/service/transfer_service.go b/internal/service/transfer_service.go index c9c87675..59ad3953 100644 --- a/internal/service/transfer_service.go +++ b/internal/service/transfer_service.go @@ -50,8 +50,18 @@ func NewTransferService( } // 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) { - return s.idGen.NextID() + 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 diff --git a/internal/service/transfer_service_test.go b/internal/service/transfer_service_test.go index c815a2ed..c6802dbb 100644 --- a/internal/service/transfer_service_test.go +++ b/internal/service/transfer_service_test.go @@ -484,7 +484,5 @@ func TestInitTransfer_IDGenError(t *testing.T) { svc := NewTransferService(db, &mockWalletRepo{}, &mockTransferRepo{}, &mockCache{}, &mockIDGen{err: wantErr}, 5*time.Second, testLogger()) _, err := svc.InitTransfer() - if !errors.Is(err, wantErr) { - t.Fatalf("expected idgen error to propagate, got %v", err) - } + requireDomainErrCode(t, err, domain.ErrCodeTransferInitFailed) } From 831b287a4b42ee551e9964a9bbd90ff8ad709eda Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 20:11:13 +0530 Subject: [PATCH 32/40] Restore checkCache's fall-back-to-Postgres behavior on Redis errors --- internal/service/transfer_service.go | 5 ++++- internal/service/transfer_service_test.go | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/service/transfer_service.go b/internal/service/transfer_service.go index 59ad3953..defd1178 100644 --- a/internal/service/transfer_service.go +++ b/internal/service/transfer_service.go @@ -119,10 +119,13 @@ func computeHash(fromWalletID, toWalletID string, amountMinorUnits int64) string // 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 { - return nil, fmt.Errorf("check transfer cache: %w", err) + 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 diff --git a/internal/service/transfer_service_test.go b/internal/service/transfer_service_test.go index c6802dbb..973b811a 100644 --- a/internal/service/transfer_service_test.go +++ b/internal/service/transfer_service_test.go @@ -248,6 +248,27 @@ func TestTransfer_CacheHit_HashMismatch(t *testing.T) { 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 --------------- From a898fc482e28c6729c2fb6e83d280a84d0ce04d0 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 20:14:20 +0530 Subject: [PATCH 33/40] Check cache before Postgres in GetTransfer --- internal/service/transfer_service.go | 25 ++++++++++++++-- internal/service/transfer_service_test.go | 35 +++++++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/internal/service/transfer_service.go b/internal/service/transfer_service.go index defd1178..e6af5428 100644 --- a/internal/service/transfer_service.go +++ b/internal/service/transfer_service.go @@ -87,10 +87,29 @@ func (s *TransferService) Transfer(ctx context.Context, req TransferRequest) (*d return s.lockAndExecuteTransfer(ctx, req, hash) } -// GetTransfer reads a transfer by ID with no lock. It returns (nil, nil) -// if no such transfer exists. +// 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) { - return s.transferRepo.FindByID(ctx, id) + 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(ctx, transfer) + return transfer, nil } // validateTransferRules checks same-wallet and invalid-amount. It diff --git a/internal/service/transfer_service_test.go b/internal/service/transfer_service_test.go index 973b811a..94424910 100644 --- a/internal/service/transfer_service_test.go +++ b/internal/service/transfer_service_test.go @@ -465,10 +465,41 @@ func TestTransfer_Success(t *testing.T) { // --- GetTransfer / InitTransfer ------------------------------------------ -func TestGetTransfer_Found(t *testing.T) { +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}} - svc := newTestService(&fakeTransactor{}, &mockWalletRepo{}, transfers, &mockCache{}) + 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) + } + if len(c.setCalls) != 1 || c.setCalls[0] != want { + t.Fatalf("expected a cache miss to self-heal by writing back the Postgres result, got %d Set calls", len(c.setCalls)) + } +} + +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 { From dc8eac60449106e0d964e709344d333718b983bf Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 21:02:02 +0530 Subject: [PATCH 34/40] Combine debit and credit into one UpdateBalances round trip --- internal/repository/postgres/wallet_repo.go | 21 ++++++---- .../postgres/wallet_repo_integration_test.go | 26 ++++++++----- internal/service/ports.go | 2 +- internal/service/transfer_service.go | 5 +-- internal/service/transfer_service_test.go | 39 +++++++++++-------- 5 files changed, 54 insertions(+), 39 deletions(-) diff --git a/internal/repository/postgres/wallet_repo.go b/internal/repository/postgres/wallet_repo.go index 0318d5e4..83450daa 100644 --- a/internal/repository/postgres/wallet_repo.go +++ b/internal/repository/postgres/wallet_repo.go @@ -60,17 +60,22 @@ func (r *WalletRepo) GetForUpdate(ctx context.Context, tx repository.Tx, walletI return wallets, nil } -// UpdateBalance sets walletID's balance to newBalance. It makes no -// decision about whether newBalance is valid — that's domain/service's -// call, not this repository's. -func (r *WalletRepo) UpdateBalance(ctx context.Context, tx repository.Tx, walletID string, newBalance decimal.Decimal) error { +// 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 { _, err := tx.Exec(ctx, ` UPDATE wallets - SET balance = $2, updated_at = now() - WHERE id = $1 - `, walletID, newBalance) + SET balance = CASE id + WHEN $1 THEN $2 + WHEN $3 THEN $4 + END, + updated_at = now() + WHERE id IN ($1, $3) + `, fromWalletID, fromBalance, toWalletID, toBalance) if err != nil { - return fmt.Errorf("update wallet balance: %w", err) + 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 index ab3db81b..ae4b5b54 100644 --- a/internal/repository/postgres/wallet_repo_integration_test.go +++ b/internal/repository/postgres/wallet_repo_integration_test.go @@ -91,9 +91,10 @@ func TestWalletRepo_GetForUpdate_MissingWalletReturnsFewer(t *testing.T) { } } -func TestWalletRepo_UpdateBalance_Persists(t *testing.T) { +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 { @@ -101,20 +102,27 @@ func TestWalletRepo_UpdateBalance_Persists(t *testing.T) { } repo := NewWalletRepo() - newBalance := decimal.RequireFromString("42.50") - if err := repo.UpdateBalance(ctx, tx, a.ID, newBalance); err != nil { - t.Fatalf("UpdateBalance: %v", err) + 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 got decimal.Decimal - if err := testPool.QueryRow(ctx, `SELECT balance FROM wallets WHERE id = $1`, a.ID).Scan(&got); err != nil { - t.Fatalf("read back balance: %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 !got.Equal(newBalance) { - t.Fatalf("expected balance %s, got %s", newBalance, got) + 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) } } diff --git a/internal/service/ports.go b/internal/service/ports.go index d3f269cb..d737f362 100644 --- a/internal/service/ports.go +++ b/internal/service/ports.go @@ -13,7 +13,7 @@ import ( // wallets. Implemented by internal/repository/postgres.WalletRepo. type WalletRepository interface { GetForUpdate(ctx context.Context, tx repository.Tx, walletIDs ...string) ([]domain.Wallet, error) - UpdateBalance(ctx context.Context, tx repository.Tx, walletID string, newBalance decimal.Decimal) 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 diff --git a/internal/service/transfer_service.go b/internal/service/transfer_service.go index e6af5428..2df4d4fb 100644 --- a/internal/service/transfer_service.go +++ b/internal/service/transfer_service.go @@ -296,10 +296,7 @@ func (s *TransferService) executeTransfer(ctx context.Context, tx repository.Tx, return domain.StatusFailed, &reason, nil } - if err := s.walletRepo.UpdateBalance(ctx, tx, from.ID, from.Balance.Sub(req.Amount)); err != nil { - return "", nil, err - } - if err := s.walletRepo.UpdateBalance(ctx, tx, to.ID, to.Balance.Add(req.Amount)); err != 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 diff --git a/internal/service/transfer_service_test.go b/internal/service/transfer_service_test.go index 94424910..46928409 100644 --- a/internal/service/transfer_service_test.go +++ b/internal/service/transfer_service_test.go @@ -55,12 +55,14 @@ func (f *fakeTransactor) Begin(_ context.Context) (repository.Tx, error) { } type mockWalletRepo struct { - wallets []domain.Wallet - getForUpdateErr error - updateBalanceErr error - updateCalls []struct { - walletID string - newBalance decimal.Decimal + wallets []domain.Wallet + getForUpdateErr error + updateBalancesErr error + updateCalls []struct { + fromWalletID string + fromBalance decimal.Decimal + toWalletID string + toBalance decimal.Decimal } } @@ -71,12 +73,14 @@ func (m *mockWalletRepo) GetForUpdate(_ context.Context, _ repository.Tx, _ ...s return m.wallets, nil } -func (m *mockWalletRepo) UpdateBalance(_ context.Context, _ repository.Tx, walletID string, newBalance decimal.Decimal) error { +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 { - walletID string - newBalance decimal.Decimal - }{walletID, newBalance}) - return m.updateBalanceErr + fromWalletID string + fromBalance decimal.Decimal + toWalletID string + toBalance decimal.Decimal + }{fromWalletID, fromBalance, toWalletID, toBalance}) + return m.updateBalancesErr } type mockTransferRepo struct { @@ -443,14 +447,15 @@ func TestTransfer_Success(t *testing.T) { if got.Status != domain.StatusProcessed { t.Fatalf("expected PROCESSED, got %s", got.Status) } - if len(wallets.updateCalls) != 2 { - t.Fatalf("expected debit + credit UpdateBalance calls, got %d", len(wallets.updateCalls)) + if len(wallets.updateCalls) != 1 { + t.Fatalf("expected a single combined UpdateBalances call, got %d", len(wallets.updateCalls)) } - if !wallets.updateCalls[0].newBalance.Equal(decimal.RequireFromString("90.00")) { - t.Fatalf("expected debited balance 90.00, got %s", wallets.updateCalls[0].newBalance) + call := wallets.updateCalls[0] + if !call.fromBalance.Equal(decimal.RequireFromString("90.00")) { + t.Fatalf("expected debited balance 90.00, got %s", call.fromBalance) } - if !wallets.updateCalls[1].newBalance.Equal(decimal.RequireFromString("15.00")) { - t.Fatalf("expected credited balance 15.00, got %s", wallets.updateCalls[1].newBalance) + 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) From 4b7938b1cbd5f0aa85b5cbf8893902536bd7a0a6 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 21:04:10 +0530 Subject: [PATCH 35/40] Run health checks concurrently --- internal/handler/health_handler.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/internal/handler/health_handler.go b/internal/handler/health_handler.go index fea51cba..aea5ea3d 100644 --- a/internal/handler/health_handler.go +++ b/internal/handler/health_handler.go @@ -3,6 +3,7 @@ package handler import ( "context" "net/http" + "sync" "time" "github.com/gin-gonic/gin" @@ -25,10 +26,22 @@ func NewHealthHandler(db *pgxpool.Pool, redis *redis.Client) *HealthHandler { } // Health returns 200 when both Postgres and Redis are reachable, 503 -// otherwise, reporting per-dependency status in the body. +// 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) { - postgresOK := h.checkPostgres(c.Request.Context()) - redisOK := h.checkRedis(c.Request.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 { From de11dd3887f6ca66678dc514b54adb7fab50b292 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 22:12:51 +0530 Subject: [PATCH 36/40] Give every request validator a typed *domain.Error --- internal/domain/errors.go | 22 ++++---- internal/handler/transfer_handler.go | 13 ++--- internal/handler/transfer_handler_test.go | 63 ++++++++++++++++++----- internal/handler/transfer_view.go | 8 +-- internal/handler/validate.go | 16 ++++-- internal/service/transfer_service.go | 2 +- 6 files changed, 83 insertions(+), 41 deletions(-) diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 5332e4fe..1a87037e 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -13,8 +13,10 @@ const ( ErrCodeWalletNotFound ErrorCode = "WALLET_NOT_FOUND" ErrCodeInsufficientBalance ErrorCode = "INSUFFICIENT_BALANCE" ErrCodeIdempotencyKeyReused ErrorCode = "IDEMPOTENCY_KEY_REUSED" - ErrCodeAmountUnrepresentable ErrorCode = "AMOUNT_UNREPRESENTABLE" 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 @@ -26,14 +28,16 @@ type Error struct { func (e *Error) Error() string { return e.Message } -// ErrAmountUnrepresentable is a tier-1 structural failure: the client's -// amount, once shifted into minor units, doesn't fit in an int64. 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. -var ErrAmountUnrepresentable = &Error{ - Code: ErrCodeAmountUnrepresentable, - Message: "amount is too large or too precise to represent", +// 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 diff --git a/internal/handler/transfer_handler.go b/internal/handler/transfer_handler.go index 41d7a247..b0222de0 100644 --- a/internal/handler/transfer_handler.go +++ b/internal/handler/transfer_handler.go @@ -57,25 +57,20 @@ func (h *TransferHandler) CreateTransfer(c *gin.Context) { id, err := validateIdempotencyKey(req.IdempotencyKey) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + h.respondError(c, err) return } if err := validateWalletID(req.FromWalletID); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + h.respondError(c, err) return } if err := validateWalletID(req.ToWalletID); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + h.respondError(c, err) return } amount, minorUnits, err := validateAmount(req.Amount) if err != nil { - var domainErr *domain.Error - if errors.As(err, &domainErr) { - h.respondError(c, err) - return - } - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + h.respondError(c, err) return } diff --git a/internal/handler/transfer_handler_test.go b/internal/handler/transfer_handler_test.go index 302bdefb..f0814c16 100644 --- a/internal/handler/transfer_handler_test.go +++ b/internal/handler/transfer_handler_test.go @@ -124,6 +124,14 @@ func TestCreateTransfer_InvalidIdempotencyKey(t *testing.T) { 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) { @@ -133,13 +141,31 @@ func TestCreateTransfer_InvalidWalletID(t *testing.T) { 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) { - // amount is sent as a bare JSON number, same as a valid request. "abc" - // isn't valid JSON syntax as a bare token, so it 400s at the bind step - // rather than the amount-shape regex — still the right status either way. - cases := []string{"abc", "-5", "1.234", "0.001"} + // 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{}) @@ -147,15 +173,24 @@ func TestCreateTransfer_InvalidAmountShape(t *testing.T) { 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_AmountUnrepresentable(t *testing.T) { +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, not overflow. This amount passes - // the regex (all digits, 2 decimal places) but can't be converted. + // 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) @@ -167,8 +202,8 @@ func TestCreateTransfer_AmountUnrepresentable(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } - if resp["error"] != string(domain.ErrCodeAmountUnrepresentable) { - t.Fatalf("expected error code %s, got %+v", domain.ErrCodeAmountUnrepresentable, resp) + if resp["error"] != string(domain.ErrCodeInvalidAmountFormat) { + t.Fatalf("expected error code %s, got %+v", domain.ErrCodeInvalidAmountFormat, resp) } } @@ -182,11 +217,13 @@ func TestCreateTransfer_DomainErrorMapping(t *testing.T) { {domain.ErrCodeWalletNotFound, http.StatusUnprocessableEntity}, {domain.ErrCodeInsufficientBalance, http.StatusUnprocessableEntity}, {domain.ErrCodeIdempotencyKeyReused, http.StatusConflict}, - // The service itself never returns this code (it's caught in the - // handler before the service is ever called) -- included here to - // pin domainErrorHTTPStatus's mapping directly, independent of + // 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.ErrCodeAmountUnrepresentable, http.StatusBadRequest}, + {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"}} diff --git a/internal/handler/transfer_view.go b/internal/handler/transfer_view.go index 43b88240..fd9d5edf 100644 --- a/internal/handler/transfer_view.go +++ b/internal/handler/transfer_view.go @@ -48,10 +48,10 @@ func domainErrorHTTPStatus(code domain.ErrorCode) int { switch code { case domain.ErrCodeIdempotencyKeyReused: return http.StatusConflict - case domain.ErrCodeAmountUnrepresentable: - // Tier-1 structural failure (the client's amount doesn't fit in - // an int64 at all), never a persisted FailureReason — this case - // only fires via the live-rejection path. + 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), diff --git a/internal/handler/validate.go b/internal/handler/validate.go index 74c21327..40c05c7b 100644 --- a/internal/handler/validate.go +++ b/internal/handler/validate.go @@ -24,12 +24,12 @@ var amountPattern = regexp.MustCompile(`^\d+(\.\d{1,2})?$`) func validateAmount(amount json.Number) (decimal.Decimal, int64, error) { raw := amount.String() if !amountPattern.MatchString(raw) { - return decimal.Decimal{}, 0, fmt.Errorf("amount must be a positive number with at most 2 decimal places") + return decimal.Decimal{}, 0, domain.ErrInvalidAmountFormat } parsed, err := decimal.NewFromString(raw) if err != nil { - return decimal.Decimal{}, 0, fmt.Errorf("amount is not a valid decimal: %w", err) + return decimal.Decimal{}, 0, domain.ErrInvalidAmountFormat } // IntPart silently truncates (wraps) if the value doesn't fit in an @@ -41,7 +41,7 @@ func validateAmount(amount json.Number) (decimal.Decimal, int64, error) { shifted := parsed.Shift(2) minorUnits := shifted.IntPart() if !shifted.Equal(decimal.NewFromInt(minorUnits)) { - return decimal.Decimal{}, 0, domain.ErrAmountUnrepresentable + return decimal.Decimal{}, 0, domain.ErrInvalidAmountFormat } return parsed, minorUnits, nil } @@ -53,7 +53,10 @@ func validateAmount(amount json.Number) (decimal.Decimal, int64, error) { // change a breaking API change for no correctness benefit. func validateWalletID(raw string) error { if _, err := uuid.Parse(raw); err != nil { - return fmt.Errorf("invalid wallet id: %w", err) + return &domain.Error{ + Code: domain.ErrCodeInvalidWalletID, + Message: fmt.Sprintf("invalid wallet id: %v", err), + } } return nil } @@ -66,7 +69,10 @@ func validateWalletID(raw string) error { func validateIdempotencyKey(raw string) (int64, error) { id, err := strconv.ParseInt(raw, 10, 64) if err != nil { - return 0, fmt.Errorf("idempotencyKey must be a valid int64 string: %w", err) + 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/service/transfer_service.go b/internal/service/transfer_service.go index 2df4d4fb..dc8f408b 100644 --- a/internal/service/transfer_service.go +++ b/internal/service/transfer_service.go @@ -123,7 +123,7 @@ func validateTransferRules(req TransferRequest) error { return &domain.Error{Code: domain.ErrCodeSameWallet, Message: "fromWalletId and toWalletId must differ"} } if req.AmountMinorUnits <= 0 { - return &domain.Error{Code: domain.ErrCodeInvalidAmount, Message: "amount must be greater than zero"} + return &domain.Error{Code: domain.ErrCodeInvalidAmount, Message: "Invalid transfer amount"} } return nil } From b0e65e682585eae587d0041072aa6bac356af34e Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 22:15:40 +0530 Subject: [PATCH 37/40] Remove unused Query from the Querier interface --- internal/repository/tx.go | 1 - internal/service/transfer_service_test.go | 3 --- 2 files changed, 4 deletions(-) diff --git a/internal/repository/tx.go b/internal/repository/tx.go index 7169e485..19cecc3a 100644 --- a/internal/repository/tx.go +++ b/internal/repository/tx.go @@ -15,7 +15,6 @@ import ( // 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) - Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row } diff --git a/internal/service/transfer_service_test.go b/internal/service/transfer_service_test.go index 46928409..6d251512 100644 --- a/internal/service/transfer_service_test.go +++ b/internal/service/transfer_service_test.go @@ -28,9 +28,6 @@ func (t *fakeTx) Exec(_ context.Context, _ string, _ ...any) (pgconn.CommandTag, t.execCalls++ return pgconn.CommandTag{}, nil } -func (t *fakeTx) Query(_ context.Context, _ string, _ ...any) (pgx.Rows, error) { - return nil, nil -} func (t *fakeTx) QueryRow(_ context.Context, _ string, _ ...any) pgx.Row { return nil } func (t *fakeTx) Commit(_ context.Context) error { t.committed = true From 1264fb778b14fa0973c8983c71ffcd8a43d346c5 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 22:23:32 +0530 Subject: [PATCH 38/40] Consolidate duplicated integration test fixtures into internal/testutil --- .../postgres/fixtures_integration_test.go | 24 +-------- .../postgres/main_integration_test.go | 12 +---- internal/service/fixtures_integration_test.go | 20 +------ internal/service/main_integration_test.go | 12 +---- internal/testutil/testutil.go | 53 +++++++++++++++++++ 5 files changed, 61 insertions(+), 60 deletions(-) create mode 100644 internal/testutil/testutil.go diff --git a/internal/repository/postgres/fixtures_integration_test.go b/internal/repository/postgres/fixtures_integration_test.go index edd669ed..05ddbba1 100644 --- a/internal/repository/postgres/fixtures_integration_test.go +++ b/internal/repository/postgres/fixtures_integration_test.go @@ -3,14 +3,10 @@ package postgres import ( - "context" "testing" - "time" - - "github.com/google/uuid" - "github.com/shopspring/decimal" "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 @@ -19,21 +15,5 @@ import ( // never corrupt each other's expected balances. func newTestWallet(t *testing.T, 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 := testPool.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 + return testutil.NewTestWallet(t, testPool, balance) } diff --git a/internal/repository/postgres/main_integration_test.go b/internal/repository/postgres/main_integration_test.go index eb858852..f65f743f 100644 --- a/internal/repository/postgres/main_integration_test.go +++ b/internal/repository/postgres/main_integration_test.go @@ -11,22 +11,14 @@ import ( "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 -// testPostgresDSN points at the postgres brought up by `docker-compose up -// -d postgres redis` (see `make test-integration`), overridable for CI. -func testPostgresDSN() string { - if dsn := os.Getenv("TEST_POSTGRES_DSN"); dsn != "" { - return dsn - } - return "postgres://wallet:wallet@localhost:5432/wallet?sslmode=disable" -} - func TestMain(m *testing.M) { ctx := context.Background() - dsn := testPostgresDSN() + dsn := testutil.PostgresDSN() if err := migrate.Run(dsn, "../../../migrations"); err != nil { fmt.Fprintln(os.Stderr, "run migrations:", err) diff --git a/internal/service/fixtures_integration_test.go b/internal/service/fixtures_integration_test.go index 43464f9a..00ab0aa7 100644 --- a/internal/service/fixtures_integration_test.go +++ b/internal/service/fixtures_integration_test.go @@ -3,14 +3,10 @@ package service import ( - "context" "testing" - "time" - - "github.com/google/uuid" - "github.com/shopspring/decimal" "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/idgen" + "github.com/Nandukrishna-S/wallet-transfer-assignment/internal/testutil" ) var testTransferIDGen = mustTestIDGenerator() @@ -38,17 +34,5 @@ func nextTestTransferID(t *testing.T) int64 { // never corrupt each other's expected balances. func newTestWallet(t *testing.T, balance string) string { t.Helper() - id := uuid.NewString() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - _, err := testPool.Exec(ctx, ` - INSERT INTO wallets (id, balance, created_at, updated_at) - VALUES ($1, $2, now(), now()) - `, id, decimal.RequireFromString(balance)) - if err != nil { - t.Fatalf("insert test wallet: %v", err) - } - return id + return testutil.NewTestWallet(t, testPool, balance).ID } diff --git a/internal/service/main_integration_test.go b/internal/service/main_integration_test.go index 72ebb87a..9aee6938 100644 --- a/internal/service/main_integration_test.go +++ b/internal/service/main_integration_test.go @@ -16,20 +16,12 @@ import ( "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 -// The postgres+redis brought up by `docker-compose up -d postgres -// redis` (see `make test-integration`), overridable for CI. -func testPostgresDSN() string { - if dsn := os.Getenv("TEST_POSTGRES_DSN"); dsn != "" { - return dsn - } - return "postgres://wallet:wallet@localhost:5432/wallet?sslmode=disable" -} - func testRedisAddr() string { if addr := os.Getenv("TEST_REDIS_ADDR"); addr != "" { return addr @@ -39,7 +31,7 @@ func testRedisAddr() string { func TestMain(m *testing.M) { ctx := context.Background() - dsn := testPostgresDSN() + dsn := testutil.PostgresDSN() if err := migrate.Run(dsn, "../../migrations"); err != nil { fmt.Fprintln(os.Stderr, "run migrations:", err) 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 +} From 52c2fc89b1ee1db8b1330a758de0f4eae65f2d92 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 22:40:53 +0530 Subject: [PATCH 39/40] Move writeCache off the request's hot path --- internal/service/transfer_service.go | 33 +++++++++----- internal/service/transfer_service_test.go | 52 +++++++++++++++++------ 2 files changed, 63 insertions(+), 22 deletions(-) diff --git a/internal/service/transfer_service.go b/internal/service/transfer_service.go index dc8f408b..ffdaece7 100644 --- a/internal/service/transfer_service.go +++ b/internal/service/transfer_service.go @@ -16,6 +16,10 @@ import ( "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 { @@ -108,7 +112,7 @@ func (s *TransferService) GetTransfer(ctx context.Context, id int64) (*domain.Tr return nil, nil } - s.writeCache(ctx, transfer) + s.writeCache(transfer) return transfer, nil } @@ -173,7 +177,7 @@ func (s *TransferService) checkExistingTransfer(ctx context.Context, id int64, h return nil, nil } - s.writeCache(ctx, existing) + s.writeCache(existing) if existing.RequestHash != hash { return nil, idempotencyMismatchError() @@ -236,7 +240,7 @@ func (s *TransferService) lockAndExecuteTransfer(ctx context.Context, req Transf return nil, fmt.Errorf("commit transfer transaction: %w", err) } - s.writeCache(ctx, claimed) + s.writeCache(claimed) return claimed, nil } @@ -302,13 +306,22 @@ func (s *TransferService) executeTransfer(ctx context.Context, tx repository.Tx, return domain.StatusProcessed, nil, nil } -// writeCache best-effort caches transfer. Cache is a latency optimization -// a failure here must never fail a request that already -// committed successfully in Postgres. -func (s *TransferService) writeCache(ctx context.Context, transfer *domain.Transfer) { - if err := s.cache.Set(ctx, transfer.ID, transfer); err != nil { - logger.FromContext(ctx, s.logger).Warn("cache transfer failed", slog.Any("error", err)) - } +// 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 — diff --git a/internal/service/transfer_service_test.go b/internal/service/transfer_service_test.go index 6d251512..edcdcce8 100644 --- a/internal/service/transfer_service_test.go +++ b/internal/service/transfer_service_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "log/slog" + "sync" "testing" "time" @@ -124,7 +125,12 @@ func (m *mockTransferRepo) InsertLedgerEntries(_ context.Context, _ repository.T 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 @@ -136,10 +142,37 @@ func (m *mockCache) Get(_ context.Context, _ int64) (*domain.Transfer, error) { } 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 @@ -196,7 +229,7 @@ func TestTransfer_SameWallet(t *testing.T) { _, err := svc.Transfer(context.Background(), req) requireDomainErrCode(t, err, domain.ErrCodeSameWallet) - if len(c.setCalls) != 0 || db.tx != nil { + if len(c.SetCalls()) != 0 || db.tx != nil { t.Fatalf("same-wallet check must short-circuit before any cache/DB call") } } @@ -291,9 +324,7 @@ func TestTransfer_ExistingTransfer_Replay(t *testing.T) { if got != existing { t.Fatalf("expected existing transfer to be replayed") } - if len(c.setCalls) != 1 { - t.Fatalf("expected the pre-check to self-heal the cache, got %d Set calls", len(c.setCalls)) - } + waitForCacheSet(t, c) if db.tx != nil { t.Fatalf("an already-committed id must never open a DB transaction or touch wallet locks") } @@ -420,9 +451,7 @@ func TestTransfer_InsufficientBalance(t *testing.T) { if transfers.insertLedgerCalls != 0 { t.Fatalf("insufficient-balance must never write ledger entries") } - if len(c.setCalls) != 1 { - t.Fatalf("expected the persisted FAILED transfer to be cached") - } + waitForCacheSet(t, c) } func TestTransfer_Success(t *testing.T) { @@ -460,9 +489,7 @@ func TestTransfer_Success(t *testing.T) { if !db.tx.committed { t.Fatalf("success must commit") } - if len(c.setCalls) != 1 { - t.Fatalf("expected the processed transfer to be cached") - } + waitForCacheSet(t, c) } // --- GetTransfer / InitTransfer ------------------------------------------ @@ -492,8 +519,9 @@ func TestGetTransfer_CacheMiss_FallsThroughAndSelfHeals(t *testing.T) { if err != nil || got != want { t.Fatalf("expected (%v, nil), got (%v, %v)", want, got, err) } - if len(c.setCalls) != 1 || c.setCalls[0] != want { - t.Fatalf("expected a cache miss to self-heal by writing back the Postgres result, got %d Set calls", len(c.setCalls)) + 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]) } } From eb3d74c13bcce8d12fde0da7db3d29258c298b82 Mon Sep 17 00:00:00 2001 From: Nandukrishna-S Date: Sat, 11 Jul 2026 22:50:20 +0530 Subject: [PATCH 40/40] Cast UpdateBalances' parameters to numeric explicitly --- internal/repository/postgres/wallet_repo.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/repository/postgres/wallet_repo.go b/internal/repository/postgres/wallet_repo.go index 83450daa..ce5e3c35 100644 --- a/internal/repository/postgres/wallet_repo.go +++ b/internal/repository/postgres/wallet_repo.go @@ -65,11 +65,16 @@ func (r *WalletRepo) GetForUpdate(ctx context.Context, tx repository.Tx, walletI // 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 - WHEN $3 THEN $4 + WHEN $1 THEN $2::numeric + WHEN $3 THEN $4::numeric END, updated_at = now() WHERE id IN ($1, $3)