From 1f4204fb663b0ec7e11e114637be93d7fa1597e2 Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Thu, 18 Jun 2026 11:14:43 +0530 Subject: [PATCH 01/14] Scaffolded Spring Boot Application Setup Postgres DB with docker-compose --- .gitignore | 13 + build.gradle | 57 ++++ docker-compose.yml | 21 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle | 1 + .../wallet/WalletTransferApplication.java | 12 + src/main/resources/application.yml | 25 ++ 10 files changed, 482 insertions(+) create mode 100644 build.gradle create mode 100644 docker-compose.yml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/rajat/wallet/WalletTransferApplication.java create mode 100644 src/main/resources/application.yml diff --git a/.gitignore b/.gitignore index c1643802..44e96bb3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,16 @@ build/ .tmp/ .env .env.* + +# Gradle +.gradle/ +!gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea/ +*.iml +.vscode/ + +# Local Postgres data (bind-mounted by docker-compose) +/postgres-data/* +!/postgres-data/.gitkeep diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..d97580fa --- /dev/null +++ b/build.gradle @@ -0,0 +1,57 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.4' + id 'io.spring.dependency-management' version '1.1.6' + id 'com.diffplug.spotless' version '6.25.0' +} + +group = 'com.example' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +ext { + testcontainersVersion = '1.20.2' +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' + runtimeOnly 'org.postgresql:postgresql' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:postgresql' +} + +dependencyManagement { + imports { + mavenBom "org.testcontainers:testcontainers-bom:${testcontainersVersion}" + } +} + +spotless { + java { + googleJavaFormat('1.23.0') + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + } +} + +tasks.named('test') { + useJUnitPlatform() +} + +// `check` runs spotlessCheck + test; CI uses `./gradlew check`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..d45c1adc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + postgres: + image: postgres:15.4 + container_name: wallet-postgres + environment: + POSTGRES_DB: wallet + POSTGRES_USER: wallet + POSTGRES_PASSWORD: wallet + ports: + - "5432:5432" + volumes: + - wallet_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wallet -d wallet"] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + wallet_pgdata: + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..df97d72b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..f5feea6d --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..9b42019c --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..7eea448c --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'wallet-transfer-service' diff --git a/src/main/java/com/rajat/wallet/WalletTransferApplication.java b/src/main/java/com/rajat/wallet/WalletTransferApplication.java new file mode 100644 index 00000000..0abd6b12 --- /dev/null +++ b/src/main/java/com/rajat/wallet/WalletTransferApplication.java @@ -0,0 +1,12 @@ +package com.rajat.wallet; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class WalletTransferApplication { + + public static void main(String[] args) { + SpringApplication.run(WalletTransferApplication.class, args); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..8fcda6ff --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,25 @@ +spring: + application: + name: wallet-transfer-service + datasource: + url: ${DB_URL:jdbc:postgresql://localhost:5432/wallet} + username: ${DB_USERNAME:wallet} + password: ${DB_PASSWORD:wallet} + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + properties: + hibernate: + jdbc: + time_zone: UTC + flyway: + enabled: true + locations: classpath:db/migration + +server: + port: ${SERVER_PORT:8080} + +logging: + level: + com.example.wallet: INFO From ecf6a33a9cb93479f798b574b53066ae24ec78bb Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Thu, 18 Jun 2026 17:32:33 +0530 Subject: [PATCH 02/14] Added entities --- build.gradle | 5 ++ .../wallet/WalletTransferApplication.java | 2 + .../domain/entities/IdempotencyRecord.java | 69 +++++++++++++++++ .../wallet/domain/entities/LedgerEntry.java | 44 +++++++++++ .../wallet/domain/entities/Transfer.java | 74 +++++++++++++++++++ .../rajat/wallet/domain/entities/Wallet.java | 55 ++++++++++++++ .../entities/common/AuditableEntity.java | 29 ++++++++ .../domain/entities/common/BaseEntity.java | 21 ++++++ .../rajat/wallet/domain/enums/EntryType.java | 7 ++ .../domain/enums/IdempotencyStatus.java | 9 +++ .../wallet/domain/enums/TransferStatus.java | 8 ++ src/main/resources/application.yml | 2 +- src/main/resources/db/migration/V1__init.sql | 48 ++++++++++++ .../V2__create_idempotency_records.sql | 24 ++++++ 14 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java create mode 100644 src/main/java/com/rajat/wallet/domain/entities/LedgerEntry.java create mode 100644 src/main/java/com/rajat/wallet/domain/entities/Transfer.java create mode 100644 src/main/java/com/rajat/wallet/domain/entities/Wallet.java create mode 100644 src/main/java/com/rajat/wallet/domain/entities/common/AuditableEntity.java create mode 100644 src/main/java/com/rajat/wallet/domain/entities/common/BaseEntity.java create mode 100644 src/main/java/com/rajat/wallet/domain/enums/EntryType.java create mode 100644 src/main/java/com/rajat/wallet/domain/enums/IdempotencyStatus.java create mode 100644 src/main/java/com/rajat/wallet/domain/enums/TransferStatus.java create mode 100644 src/main/resources/db/migration/V1__init.sql create mode 100644 src/main/resources/db/migration/V2__create_idempotency_records.sql diff --git a/build.gradle b/build.gradle index d97580fa..9a5f2ffa 100644 --- a/build.gradle +++ b/build.gradle @@ -30,9 +30,14 @@ dependencies { implementation 'org.flywaydb:flyway-database-postgresql' runtimeOnly 'org.postgresql:postgresql' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.testcontainers:postgresql' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' } dependencyManagement { diff --git a/src/main/java/com/rajat/wallet/WalletTransferApplication.java b/src/main/java/com/rajat/wallet/WalletTransferApplication.java index 0abd6b12..77be294c 100644 --- a/src/main/java/com/rajat/wallet/WalletTransferApplication.java +++ b/src/main/java/com/rajat/wallet/WalletTransferApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing public class WalletTransferApplication { public static void main(String[] args) { diff --git a/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java b/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java new file mode 100644 index 00000000..72d7d8de --- /dev/null +++ b/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java @@ -0,0 +1,69 @@ +package com.rajat.wallet.domain.entities; + +import com.rajat.wallet.domain.entities.common.AuditableEntity; +import com.rajat.wallet.domain.enums.IdempotencyStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Durable idempotency registry, deliberately decoupled from any single operation so the same + * mechanism can guard transfers and any future endpoint. The unique {@code idempotencyKey} enforces + * exactly-once at the API level; {@code responseStatus}/{@code responseBody} cache the original + * result so a duplicate request is replayed verbatim without re-executing side effects. The + * {@code requestHash} lets the service reject a key replayed with a different payload, and the + * {@code status} lifecycle lets a concurrent duplicate detect an in-flight request. + */ +@Entity +@Table(name = "idempotency_records") +@Getter +@Setter +@NoArgsConstructor +public class IdempotencyRecord extends AuditableEntity { + + @Column(name = "idempotency_key", nullable = false, updatable = false, unique = true) + private String idempotencyKey; + + @Column(name = "request_hash", nullable = false, updatable = false) + private String requestHash; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private IdempotencyStatus status; + + /** + * Id of the resource the request created (e.g. a transfer). A plain UUID rather than a foreign + * key, since this registry is polymorphic across operations and may point at different tables. + */ + @Column(name = "target_id") + private UUID targetId; + + @Column(name = "response_status") + private Integer responseStatus; + + @Column(name = "response_body") + private String responseBody; + + /** Creates an in-flight record for a first-seen request. */ + public static IdempotencyRecord inProgress(String idempotencyKey, String requestHash) { + IdempotencyRecord r = new IdempotencyRecord(); + r.idempotencyKey = idempotencyKey; + r.requestHash = requestHash; + r.status = IdempotencyStatus.IN_PROGRESS; + return r; + } + + /** Captures the created resource and final response, marking the record replayable. */ + public void complete(UUID targetId, int responseStatus, String responseBody) { + this.status = IdempotencyStatus.COMPLETED; + this.targetId = targetId; + this.responseStatus = responseStatus; + this.responseBody = responseBody; + } +} diff --git a/src/main/java/com/rajat/wallet/domain/entities/LedgerEntry.java b/src/main/java/com/rajat/wallet/domain/entities/LedgerEntry.java new file mode 100644 index 00000000..e47b1d9b --- /dev/null +++ b/src/main/java/com/rajat/wallet/domain/entities/LedgerEntry.java @@ -0,0 +1,44 @@ +package com.rajat.wallet.domain.entities; + +import com.rajat.wallet.domain.entities.common.AuditableEntity; +import com.rajat.wallet.domain.enums.EntryType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.util.UUID; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * An immutable, append-only ledger entry. Every transfer produces exactly two of these (a DEBIT on + * the source wallet and a CREDIT on the destination). {@code balanceAfter} snapshots the wallet + * balance immediately after this entry was applied. Rows are never updated or deleted. + */ +@Entity +@Table(name = "ledger_entries") +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class LedgerEntry extends AuditableEntity { + + @Column(name = "wallet_id", nullable = false, updatable = false) + private UUID walletId; + + @Column(name = "transfer_id", nullable = false, updatable = false) + private UUID transferId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, updatable = false) + private EntryType type; + + @Column(nullable = false, updatable = false) + private BigDecimal amount; + + @Column(name = "balance_after", nullable = false, updatable = false) + private BigDecimal balanceAfter; +} diff --git a/src/main/java/com/rajat/wallet/domain/entities/Transfer.java b/src/main/java/com/rajat/wallet/domain/entities/Transfer.java new file mode 100644 index 00000000..766cc213 --- /dev/null +++ b/src/main/java/com/rajat/wallet/domain/entities/Transfer.java @@ -0,0 +1,74 @@ +package com.rajat.wallet.domain.entities; + +import com.rajat.wallet.domain.entities.common.AuditableEntity; +import com.rajat.wallet.domain.enums.TransferStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.util.UUID; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A transfer request and its lifecycle. Exactly-once request handling lives in the generic + * {@link IdempotencyRecord} registry, not here. State transitions are guarded: a transfer may only + * move out of {@link TransferStatus#PENDING}. + */ +@Entity +@Table(name = "transfers") +@Getter +@Setter +@NoArgsConstructor +public class Transfer extends AuditableEntity { + + @Column(name = "from_wallet_id", nullable = false, updatable = false) + private UUID fromWalletId; + + @Column(name = "to_wallet_id", nullable = false, updatable = false) + private UUID toWalletId; + + @Column(nullable = false, updatable = false) + private BigDecimal amount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TransferStatus status; + + @Column(name = "failure_reason") + private String failureReason; + + /** Creates a new transfer in the {@link TransferStatus#PENDING} state. */ + public static Transfer pending(UUID fromWalletId, UUID toWalletId, BigDecimal amount) { + Transfer t = new Transfer(); + t.fromWalletId = fromWalletId; + t.toWalletId = toWalletId; + t.amount = amount; + t.status = TransferStatus.PENDING; + return t; + } + + /** Transitions PENDING -> PROCESSED. */ + public void markProcessed() { + requirePending(); + this.status = TransferStatus.PROCESSED; + } + + /** Transitions PENDING -> FAILED, recording why. */ + public void markFailed(String reason) { + requirePending(); + this.status = TransferStatus.FAILED; + this.failureReason = reason; + } + + private void requirePending() { + if (status != TransferStatus.PENDING) { + throw new IllegalStateException( + "Transfer " + getId() + " is " + status + "; only PENDING transfers may transition"); + } + } +} diff --git a/src/main/java/com/rajat/wallet/domain/entities/Wallet.java b/src/main/java/com/rajat/wallet/domain/entities/Wallet.java new file mode 100644 index 00000000..b2010466 --- /dev/null +++ b/src/main/java/com/rajat/wallet/domain/entities/Wallet.java @@ -0,0 +1,55 @@ +package com.rajat.wallet.domain.entities; + +import com.rajat.wallet.domain.entities.common.AuditableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A wallet holding a materialized {@code balance}. The balance is updated in the same transaction + * as the ledger entries it derives from; the invariant {@code balance == SUM(credits) - + * SUM(debits)} always holds for a committed transaction. + */ +@Entity +@Table(name = "wallets") +@Getter +@Setter +@NoArgsConstructor +public class Wallet extends AuditableEntity { + + @Column(nullable = false) + private BigDecimal balance = BigDecimal.ZERO; + + @Column(nullable = false) + private String currency = "INR"; + + public Wallet(BigDecimal balance, String currency) { + this.balance = balance; + this.currency = currency; + } + + /** Returns true if this wallet can cover the given amount. */ + public boolean hasSufficientFunds(BigDecimal amount) { + return balance.compareTo(amount) >= 0; + } + + /** + * Subtracts {@code amount} from the balance. Callers must hold a lock and verify funds first; + * this is the last line of defence and throws if it would go negative. + */ + public void debit(BigDecimal amount) { + if (!hasSufficientFunds(amount)) { + throw new IllegalStateException("Debit would make wallet " + getId() + " balance negative"); + } + this.balance = this.balance.subtract(amount); + } + + /** Adds {@code amount} to the balance. */ + public void credit(BigDecimal amount) { + this.balance = this.balance.add(amount); + } +} diff --git a/src/main/java/com/rajat/wallet/domain/entities/common/AuditableEntity.java b/src/main/java/com/rajat/wallet/domain/entities/common/AuditableEntity.java new file mode 100644 index 00000000..3f01f0b0 --- /dev/null +++ b/src/main/java/com/rajat/wallet/domain/entities/common/AuditableEntity.java @@ -0,0 +1,29 @@ +package com.rajat.wallet.domain.entities.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * Adds creation/modification timestamps to {@link BaseEntity}. Managed automatically by Spring Data + * JPA auditing (enabled via {@code @EnableJpaAuditing}): {@code createdAt} is set once on insert and + * {@code updatedAt} on every flush, so entities never assign them by hand. + */ +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public abstract class AuditableEntity extends BaseEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/rajat/wallet/domain/entities/common/BaseEntity.java b/src/main/java/com/rajat/wallet/domain/entities/common/BaseEntity.java new file mode 100644 index 00000000..a142c09d --- /dev/null +++ b/src/main/java/com/rajat/wallet/domain/entities/common/BaseEntity.java @@ -0,0 +1,21 @@ +package com.rajat.wallet.domain.entities.common; + +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import java.util.UUID; +import lombok.Getter; +import org.hibernate.annotations.UuidGenerator; + +/** + * Root for all persistent entities. Supplies the primary key: a time-ordered UUID v7. Hibernate + * 6.5's {@link UuidGenerator.Style#TIME} emits RFC 9562 version-7 UUIDs and assigns the id on + * persist, so callers never set it. Time-ordered keys keep primary-key inserts index-friendly. + */ +@MappedSuperclass +@Getter +public abstract class BaseEntity { + + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + private UUID id; +} diff --git a/src/main/java/com/rajat/wallet/domain/enums/EntryType.java b/src/main/java/com/rajat/wallet/domain/enums/EntryType.java new file mode 100644 index 00000000..8a6befbb --- /dev/null +++ b/src/main/java/com/rajat/wallet/domain/enums/EntryType.java @@ -0,0 +1,7 @@ +package com.rajat.wallet.domain.enums; + +/** Double-entry ledger entry direction. */ +public enum EntryType { + DEBIT, + CREDIT +} diff --git a/src/main/java/com/rajat/wallet/domain/enums/IdempotencyStatus.java b/src/main/java/com/rajat/wallet/domain/enums/IdempotencyStatus.java new file mode 100644 index 00000000..b3956d95 --- /dev/null +++ b/src/main/java/com/rajat/wallet/domain/enums/IdempotencyStatus.java @@ -0,0 +1,9 @@ +package com.rajat.wallet.domain.enums; + +/** Lifecycle of an {@link com.rajat.wallet.domain.entities.IdempotencyRecord}. */ +public enum IdempotencyStatus { + /** Request accepted and still executing; a concurrent duplicate should wait and retry. */ + IN_PROGRESS, + /** Response captured; duplicate requests are replayed from the cached response. */ + COMPLETED +} diff --git a/src/main/java/com/rajat/wallet/domain/enums/TransferStatus.java b/src/main/java/com/rajat/wallet/domain/enums/TransferStatus.java new file mode 100644 index 00000000..1c2466a0 --- /dev/null +++ b/src/main/java/com/rajat/wallet/domain/enums/TransferStatus.java @@ -0,0 +1,8 @@ +package com.rajat.wallet.domain.enums; + +/** Lifecycle of a transfer. Allowed transitions: PENDING -> PROCESSED, PENDING -> FAILED. */ +public enum TransferStatus { + PENDING, + PROCESSED, + FAILED +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8fcda6ff..f761f5e0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,4 +22,4 @@ server: logging: level: - com.example.wallet: INFO + com.rajat.wallet: INFO diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 00000000..0f62d77c --- /dev/null +++ b/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,48 @@ +-- Wallet transfer service schema. +-- Design goals: append-only ledger as source of truth, a materialized wallet balance +-- for O(1) reads, and DB-enforced idempotency + integrity constraints. + +CREATE TABLE wallets ( + -- UUID v7 assigned by the application (Hibernate UuidGenerator.Style.TIME) on insert. + id UUID PRIMARY KEY, + balance NUMERIC(19, 4) NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'INR', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT wallets_balance_non_negative CHECK (balance >= 0) +); + +CREATE TABLE transfers ( + -- UUID v7 assigned by the application (Hibernate UuidGenerator.Style.TIME) on insert. + id UUID PRIMARY KEY, + from_wallet_id UUID NOT NULL REFERENCES wallets (id), + to_wallet_id UUID NOT NULL REFERENCES wallets (id), + amount NUMERIC(19, 4) NOT NULL, + status TEXT NOT NULL, + failure_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT transfers_amount_positive CHECK (amount > 0), + CONSTRAINT transfers_distinct_wallets CHECK (from_wallet_id <> to_wallet_id), + CONSTRAINT transfers_status_valid CHECK (status IN ('PENDING', 'PROCESSED', 'FAILED')) +); + +CREATE TABLE ledger_entries ( + -- UUID v7 assigned by the application (Hibernate UuidGenerator.Style.TIME) on insert. + id UUID PRIMARY KEY, + wallet_id UUID NOT NULL REFERENCES wallets (id), + transfer_id UUID NOT NULL REFERENCES transfers (id), + type TEXT NOT NULL, + amount NUMERIC(19, 4) NOT NULL, + balance_after NUMERIC(19, 4) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT ledger_entries_amount_positive CHECK (amount > 0), + CONSTRAINT ledger_entries_type_valid CHECK (type IN ('DEBIT', 'CREDIT')) +); + +-- Idempotency lookups and per-wallet / per-transfer history queries. +CREATE INDEX idx_transfers_from_wallet ON transfers (from_wallet_id); +CREATE INDEX idx_transfers_to_wallet ON transfers (to_wallet_id); +CREATE INDEX idx_ledger_entries_wallet ON ledger_entries (wallet_id); +CREATE INDEX idx_ledger_entries_transfer ON ledger_entries (transfer_id); diff --git a/src/main/resources/db/migration/V2__create_idempotency_records.sql b/src/main/resources/db/migration/V2__create_idempotency_records.sql new file mode 100644 index 00000000..4f9b291e --- /dev/null +++ b/src/main/resources/db/migration/V2__create_idempotency_records.sql @@ -0,0 +1,24 @@ +-- Generic, operation-agnostic idempotency registry. Exactly-once at the API level lives here +-- rather than on any one table, so the same key space guards transfers and future endpoints. +CREATE TABLE idempotency_records ( + -- UUID v7 assigned by the application (Hibernate UuidGenerator.Style.TIME) on insert. + id UUID PRIMARY KEY, + idempotency_key TEXT NOT NULL, + -- Fingerprint of the request; a key replayed with a different payload is rejected + -- rather than silently served the original result. + request_hash TEXT NOT NULL, + -- IN_PROGRESS on first sight, flipped to COMPLETED once the response is captured; lets a + -- concurrent duplicate detect an in-flight request instead of double-executing. + status TEXT NOT NULL, + -- Id of the resource the request created (e.g. a transfer). Deliberately not a foreign key: + -- the registry is polymorphic and may reference different tables across operations. + target_id UUID, + -- Cached response, replayed verbatim for duplicate requests once COMPLETED. + response_status INTEGER, + response_body TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- The unique key both enforces exactly-once and backs the duplicate-lookup index. + CONSTRAINT idempotency_records_key_unique UNIQUE (idempotency_key), + CONSTRAINT idempotency_records_status_valid CHECK (status IN ('IN_PROGRESS', 'COMPLETED')) +); From 338a6567157ce956b61d547ee9e7e94a91dac7d5 Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Thu, 18 Jun 2026 17:49:24 +0530 Subject: [PATCH 03/14] added TDD --- TechnicalDesignDocument.md | 221 +++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 TechnicalDesignDocument.md diff --git a/TechnicalDesignDocument.md b/TechnicalDesignDocument.md new file mode 100644 index 00000000..3a8afe4e --- /dev/null +++ b/TechnicalDesignDocument.md @@ -0,0 +1,221 @@ +# Technical Design Document — Wallet Transfer Service + +> Documentation-first design note. This precedes/accompanies implementation per the +> assignment's Documentation-First Workflow. It is the source of truth for the contract and +> the design decisions; code and tests follow from it. + +## Status & Scope + +| | | +|---|---| +| **Stack** | Java 21, Spring Boot 3.3.4, Spring Data JPA / Hibernate 6.5, PostgreSQL, Flyway | +| **In scope** | `POST /transfers` with exactly-once semantics, double-entry ledger, balance tracking, safe concurrency | +| **Out of scope (optional)** | balance API, transfer-history API, metrics dashboards, async workflows | +| **Built so far** | Schema (Flyway `V1`, `V2`), domain entities (`Wallet`, `Transfer`, `LedgerEntry`, `IdempotencyRecord`), base classes (`BaseEntity`, `AuditableEntity`) | +| **Not yet built** | Repository, service, handler layers; tests | + +**Preferred order of work** (where we are): ✅ inspect contract (`ASSIGNMENT.md`) → ✅ design note (this doc) → ⏳ implement code → ⏳ add tests → ⏳ verify observability/operational concerns. + +--- + +## 1. Problem Statement + +Build a service supporting wallet-to-wallet transfers that is **correct under retries, duplicates, and concurrent access**. A single endpoint, `POST /transfers`, moves an amount from one wallet to another. The hard requirements are reliability properties, not features: + +- **Exactly-once at the API level** when an `idempotencyKey` is supplied — a retried request must never move money twice and must return the original result. +- **Double-entry ledger** — every transfer produces exactly one DEBIT and one CREDIT; the ledger always balances. +- **Correct balances under concurrency** — no double-spend when two transfers debit the same wallet simultaneously. +- **Safe state transitions** — a transfer moves through a guarded state machine. + +--- + +## 2. Expected Behavior + +A transfer is processed **synchronously inside a single database transaction**. By the time the client gets a response, the outcome is final (`PROCESSED` or `FAILED`); `PENDING` is a transient in-transaction state, never observed as a stable result by the caller. + +Happy path: + +``` +POST /transfers + BEGIN TX + reserve idempotency key (INSERT IN_PROGRESS) + SELECT both wallets FOR UPDATE (deterministic lock order) + create transfer (PENDING) + debit source, credit destination + write 2 ledger entries (DEBIT, CREDIT) + mark transfer PROCESSED + complete idempotency record (cache 201 response + target_id) + COMMIT +-> 201 Created { transfer in PROCESSED state } +``` + +Business failure (e.g. insufficient funds) is a **first-class, recorded outcome**, not an exception that vanishes: the transfer is persisted as `FAILED` with a reason, `422` is returned, and that response is cached so a retry replays it. + +--- + +## 3. API Contract + +### `POST /transfers` + +Request body: + +```json +{ + "idempotencyKey": "abc123", + "fromWalletId": "018f...uuid", + "toWalletId": "018f...uuid", + "amount": 100.00 +} +``` + +| Field | Type | Rules | +|---|---|---| +| `idempotencyKey` | string | required, non-blank; client-generated, unique per logical request | +| `fromWalletId` | UUID | required, must exist | +| `toWalletId` | UUID | required, must exist, `!= fromWalletId` | +| `amount` | decimal | required, `> 0`, scale ≤ 4 | + +> Note: wallet ids are **UUIDs** (the assignment's `wallet_1` examples are illustrative). All ids are server-assigned UUID **v7** (time-ordered). + +Success / outcome response body: + +```json +{ + "transferId": "018f...uuid", + "fromWalletId": "018f...uuid", + "toWalletId": "018f...uuid", + "amount": 100.00, + "status": "PROCESSED", + "failureReason": null, + "createdAt": "2026-06-18T10:00:00Z" +} +``` + +Response codes: + +| Code | Meaning | +|---|---| +| `201 Created` | transfer executed (`PROCESSED`) | +| `422 Unprocessable Entity` | business failure recorded as `FAILED` (e.g. insufficient funds, currency mismatch) — body carries `status: FAILED` + `failureReason` | +| `400 Bad Request` | validation error (missing field, `amount <= 0`, same wallet) | +| `404 Not Found` | wallet does not exist | +| `409 Conflict` | idempotency key reused with a **different** payload, **or** an identical request is still `IN_PROGRESS` (retry shortly) | +| **replay** | duplicate of a **completed** request returns the **original** status code and body verbatim | + +### Optional (not required) + +`GET /wallets/{id}` (balance), `GET /transfers/{id}`, `GET /wallets/{id}/ledger`. Listed for completeness; out of scope unless time permits. + +--- + +## 4. Data Model & Side Effects + +All entities extend `BaseEntity` (UUID **v7** primary key, assigned by Hibernate's `UuidGenerator.Style.TIME` on persist — time-ordered keys keep PK-index inserts append-friendly) and `AuditableEntity` (`created_at` / `updated_at` managed by Spring Data JPA auditing). + +| Table | Purpose | Key columns / constraints | +|---|---|---| +| `wallets` | materialized balance | `balance NUMERIC(19,4)` , `currency`, `CHECK balance >= 0` | +| `transfers` | request + lifecycle | `from_wallet_id`, `to_wallet_id` (FK→wallets), `amount`, `status`, `failure_reason`; `CHECK amount > 0`, `CHECK from <> to`, `CHECK status IN (...)` | +| `ledger_entries` | append-only double-entry | `wallet_id`, `transfer_id` (FKs), `type` (DEBIT/CREDIT), `amount`, `balance_after`; immutable | +| `idempotency_records` | generic exactly-once registry | `idempotency_key UNIQUE`, `request_hash`, `status`, `target_id`, `response_status`, `response_body` | + +**Balance strategy:** materialized `balance` column updated in the same transaction as the ledger entries (chosen over deriving from the ledger) for O(1) reads. The invariant `balance == SUM(credits) − SUM(debits)` holds for every committed transaction; `ledger_entries.balance_after` snapshots the balance per entry for auditability. + +**Side effects of a successful `POST /transfers`** (all within one transaction, all-or-nothing): +1. one `idempotency_records` row (`COMPLETED`, with cached response + `target_id`), +2. one `transfers` row (`PROCESSED` or `FAILED`), +3. two `ledger_entries` rows (DEBIT + CREDIT), +4. updated `balance` on one or both wallets. + +Migrations: `V1__init.sql` (wallets, transfers, ledger_entries) and `V2__create_idempotency_records.sql`. Flyway runs on app startup before Hibernate `validate`. + +--- + +## 5. Failure Modes + +| Failure | Detection | Outcome | +|---|---|---| +| Insufficient funds | balance check under lock | transfer `FAILED` + `422`, cached | +| Wallet not found | lookup returns empty | `404`, no transfer row | +| Same source & destination | bean validation + DB `CHECK` | `400` | +| Non-positive / malformed amount | bean validation + DB `CHECK` | `400` | +| Currency mismatch (assumption: no FX) | compare wallet currencies | transfer `FAILED` + `422` | +| Concurrent debit of same wallet | `SELECT … FOR UPDATE` serializes | no double-spend; second waits then re-checks funds | +| Duplicate request (same key, same payload, completed) | unique key / lookup | replay cached response | +| Duplicate request still in flight | record is `IN_PROGRESS` | `409`, client retries | +| Key reused with different payload | `request_hash` mismatch | `409` | +| Unique-violation race (two firsts insert same key) | DB unique constraint | loser caught, treated as duplicate | +| Process crash mid-transaction | transaction never commits | full rollback; no partial money movement | + +**Defense in depth:** invariants are enforced both in the domain (`Wallet.debit` throws if it would go negative; `Transfer.markProcessed/markFailed` only allow transitions out of `PENDING`) **and** at the database (`CHECK`/`UNIQUE`/`FK` constraints), so a logic bug cannot corrupt persisted state. + +--- + +## 6. Idempotency Behavior + +Idempotency is handled by a **dedicated, operation-agnostic `idempotency_records` table** rather than a unique constraint on `transfers`, so the same mechanism can guard future endpoints and can **replay a cached response**. + +Record shape: `idempotency_key` (unique), `request_hash` (fingerprint of method+path+canonical body), `status` (`IN_PROGRESS` → `COMPLETED`), `target_id` (created resource id), `response_status` + `response_body` (cached response). + +Algorithm (inside the transfer transaction): + +1. Compute `request_hash`. `INSERT` an `IN_PROGRESS` record keyed by `idempotencyKey`. +2. **Insert succeeds** → first occurrence → execute the transfer, then `complete(targetId, status, body)` to flip the record to `COMPLETED` and cache the response. +3. **Insert hits the unique violation** → duplicate → load the existing record: + - `COMPLETED` + matching `request_hash` → **replay** cached `response_status`/`response_body`. + - `request_hash` mismatch → `409` (key reused for a different request). + - still `IN_PROGRESS` → `409` (original in flight; retry). + +This gives **exactly-once side effects** (duplicate never produces a second transfer/ledger pair) and **return-the-original-result** semantics, both safe across process restarts because the registry is durable. + +--- + +## 7. Retry Behavior + +- **Client-driven retries are safe**: re-sending with the same `idempotencyKey` either replays the cached result or is rejected as in-flight — never double-applied. +- **Network failure after commit but before the client sees the response**: the retry finds a `COMPLETED` record and replays the original `201`/`422`. +- **Failure before commit**: nothing persisted (atomic rollback); the retry is treated as first occurrence. +- **Stale `IN_PROGRESS` records** (writer crashed after reserving the key but before commit): the reserving `INSERT` is part of the same transaction, so a crash rolls it back too — no orphan is committed. As an operational safeguard, a TTL/reaper for any `IN_PROGRESS` older than a threshold is noted (not required for correctness here). +- Operations are designed to be **retry-safe rather than relying on retries**; there is no internal auto-retry of business logic. + +--- + +## 8. Consistency Expectations + +- **Atomicity**: each transfer is one ACID transaction — idempotency record, transfer row, both ledger entries, and balance updates commit together or not at all. +- **Isolation**: `READ COMMITTED` (Postgres default) + **row-level pessimistic locks** (`SELECT … FOR UPDATE`) on the participating wallets. Because we lock the exact rows we mutate, this is sufficient; no `SERIALIZABLE` needed. +- **Deadlock avoidance**: wallets are always locked in a **deterministic order** (e.g. ascending wallet id), so two opposing transfers between the same pair cannot deadlock. +- **Concurrency strategy = pessimistic locking only.** (Optimistic `@Version` was intentionally removed; with row locks held for the wallet's mutation window, the lost-update window is closed without it.) +- **Ledger invariant**: every transfer nets to zero across wallets; `SUM(credits) − SUM(debits)` per wallet always equals its `balance`. + +--- + +## 9. Observability Expectations + +- **Structured logging** keyed by `idempotencyKey` and `transferId` for the lifecycle: request received → key reserved / duplicate-replayed → transfer PROCESSED/FAILED → committed. Log level for `com.rajat.wallet` is `INFO`. +- **Metrics** (when added): transfer count by terminal status, transfer latency, lock-wait time, duplicate-replay rate, insufficient-funds rate. +- **Health/readiness**: Spring Actuator; Flyway migration state visible at startup (`Successfully applied N migrations`). +- **Auditability**: append-only `ledger_entries` + `balance_after` snapshots + `created_at`/`updated_at` on every row provide a full reconstructable history. +- Tracing (correlation id propagation) is a nice-to-have, not required. + +--- + +## 10. Testing Strategy + +Behavioral, TDD (Red → Blue → Green). PostgreSQL-backed integration tests via **Testcontainers** (no in-memory substitute, so locking/constraints are exercised for real). + +- **Domain unit tests**: `Wallet.debit` rejects overdraft; `credit`/`debit` arithmetic; `Transfer` state machine rejects illegal transitions; `IdempotencyRecord` lifecycle. +- **Transfer execution (integration)**: happy path returns `201` `PROCESSED`; exactly two ledger entries (one DEBIT, one CREDIT); balances move correctly; ledger balances. +- **Idempotency (integration)**: same key + same payload → single transfer, replayed response; same key + different payload → `409`; verifies **no duplicate side effects**. +- **Failure scenarios**: insufficient funds → `FAILED` + `422`, and a retry replays the `422`; unknown wallet → `404`; same-wallet / bad amount → `400`. +- **Concurrency**: N parallel transfers debiting one wallet → no overdraft, final balance exact, no lost updates; concurrent duplicates of the same key → exactly one transfer created. + +Coverage targets the **required behaviors** (transfer execution, idempotency, ledger correctness, failure handling, concurrency safety), not implementation details. + +--- + +## Assumptions + +- Transfers are **single-currency** (no FX); a currency mismatch is a recorded `FAILED` outcome. +- `idempotencyKey` is carried in the **request body** (per the assignment example); a header (`Idempotency-Key`) is an equivalent alternative. +- No authentication/authorization layer (out of scope for the exercise). From edc50bd9e743337fbc7437ef0698e3867d95427d Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Sat, 20 Jun 2026 17:57:05 +0530 Subject: [PATCH 04/14] Added core transfer logic --- TechnicalDesignDocument.md | 4 +- scripts/seed-wallets.sql | 23 +++ .../wallet/controller/TransferController.java | 39 +++++ .../wallet/dto/CreateTransferRequest.java | 27 +++ .../com/rajat/wallet/dto/ErrorResponse.java | 20 +++ .../rajat/wallet/dto/TransferResponse.java | 36 ++++ .../IdempotencyConflictException.java | 12 ++ .../exception/WalletNotFoundException.java | 11 ++ .../handler/GlobalExceptionHandler.java | 80 +++++++++ .../IdempotencyRecordRepository.java | 11 ++ .../repository/LedgerEntryRepository.java | 7 + .../wallet/repository/TransferRepository.java | 7 + .../wallet/repository/WalletRepository.java | 23 +++ .../wallet/service/TransferProcessor.java | 155 ++++++++++++++++++ .../rajat/wallet/service/TransferService.java | 22 +++ .../wallet/service/TransferServiceImpl.java | 110 +++++++++++++ .../V3__amount_precision_two_decimals.sql | 7 + 17 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 scripts/seed-wallets.sql create mode 100644 src/main/java/com/rajat/wallet/controller/TransferController.java create mode 100644 src/main/java/com/rajat/wallet/dto/CreateTransferRequest.java create mode 100644 src/main/java/com/rajat/wallet/dto/ErrorResponse.java create mode 100644 src/main/java/com/rajat/wallet/dto/TransferResponse.java create mode 100644 src/main/java/com/rajat/wallet/exception/IdempotencyConflictException.java create mode 100644 src/main/java/com/rajat/wallet/exception/WalletNotFoundException.java create mode 100644 src/main/java/com/rajat/wallet/exception/handler/GlobalExceptionHandler.java create mode 100644 src/main/java/com/rajat/wallet/repository/IdempotencyRecordRepository.java create mode 100644 src/main/java/com/rajat/wallet/repository/LedgerEntryRepository.java create mode 100644 src/main/java/com/rajat/wallet/repository/TransferRepository.java create mode 100644 src/main/java/com/rajat/wallet/repository/WalletRepository.java create mode 100644 src/main/java/com/rajat/wallet/service/TransferProcessor.java create mode 100644 src/main/java/com/rajat/wallet/service/TransferService.java create mode 100644 src/main/java/com/rajat/wallet/service/TransferServiceImpl.java create mode 100644 src/main/resources/db/migration/V3__amount_precision_two_decimals.sql diff --git a/TechnicalDesignDocument.md b/TechnicalDesignDocument.md index 3a8afe4e..9e6d545c 100644 --- a/TechnicalDesignDocument.md +++ b/TechnicalDesignDocument.md @@ -73,7 +73,7 @@ Request body: | `idempotencyKey` | string | required, non-blank; client-generated, unique per logical request | | `fromWalletId` | UUID | required, must exist | | `toWalletId` | UUID | required, must exist, `!= fromWalletId` | -| `amount` | decimal | required, `> 0`, scale ≤ 4 | +| `amount` | decimal | required, `> 0`, scale ≤ 2 | > Note: wallet ids are **UUIDs** (the assignment's `wallet_1` examples are illustrative). All ids are server-assigned UUID **v7** (time-ordered). @@ -114,7 +114,7 @@ All entities extend `BaseEntity` (UUID **v7** primary key, assigned by Hibernate | Table | Purpose | Key columns / constraints | |---|---|---| -| `wallets` | materialized balance | `balance NUMERIC(19,4)` , `currency`, `CHECK balance >= 0` | +| `wallets` | materialized balance | `balance NUMERIC(19,2)` , `currency`, `CHECK balance >= 0` | | `transfers` | request + lifecycle | `from_wallet_id`, `to_wallet_id` (FK→wallets), `amount`, `status`, `failure_reason`; `CHECK amount > 0`, `CHECK from <> to`, `CHECK status IN (...)` | | `ledger_entries` | append-only double-entry | `wallet_id`, `transfer_id` (FKs), `type` (DEBIT/CREDIT), `amount`, `balance_after`; immutable | | `idempotency_records` | generic exactly-once registry | `idempotency_key UNIQUE`, `request_hash`, `status`, `target_id`, `response_status`, `response_body` | diff --git a/scripts/seed-wallets.sql b/scripts/seed-wallets.sql new file mode 100644 index 00000000..cd8d42c6 --- /dev/null +++ b/scripts/seed-wallets.sql @@ -0,0 +1,23 @@ +-- Seed data for manual testing / local demos. +-- +-- NOT a Flyway migration (it lives outside classpath:db/migration on purpose) so it never runs in +-- prod or in the integration tests. Run it AFTER the app has booted at least once, so Flyway has +-- already created the `wallets` table: +-- +-- docker exec -i wallet-postgres psql -U wallet -d wallet < scripts/seed-wallets.sql +-- +-- Idempotent: ON CONFLICT DO NOTHING means re-running it is safe and won't duplicate rows. +-- Ids are fixed and human-readable so they're easy to copy into POST /transfers requests. + +INSERT INTO wallets (id, balance, currency) VALUES + ('00000000-0000-0000-0000-000000000001', 100000.00, 'INR'), + ('00000000-0000-0000-0000-000000000002', 50000.50, 'INR'), + ('00000000-0000-0000-0000-000000000003', 25000.75, 'INR'), + ('00000000-0000-0000-0000-000000000004', 1000.00, 'INR'), + ('00000000-0000-0000-0000-000000000005', 100.25, 'INR'), + ('00000000-0000-0000-0000-000000000006', 0.00, 'INR'), + ('00000000-0000-0000-0000-000000000007', 999999.99, 'INR'), + ('00000000-0000-0000-0000-000000000008', 7500.00, 'INR'), + ('00000000-0000-0000-0000-000000000009', 20000.00, 'USD'), + ('00000000-0000-0000-0000-000000000010', 3000.00, 'USD') +ON CONFLICT (id) DO NOTHING; diff --git a/src/main/java/com/rajat/wallet/controller/TransferController.java b/src/main/java/com/rajat/wallet/controller/TransferController.java new file mode 100644 index 00000000..0a9fbe16 --- /dev/null +++ b/src/main/java/com/rajat/wallet/controller/TransferController.java @@ -0,0 +1,39 @@ +package com.rajat.wallet.controller; + +import com.rajat.wallet.domain.enums.TransferStatus; +import com.rajat.wallet.dto.CreateTransferRequest; +import com.rajat.wallet.dto.TransferResponse; +import com.rajat.wallet.service.TransferService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry point for transfers. Kept thin: it validates and maps transport concerns only, then + * delegates all business logic to {@link TransferService}. Outcome-to-status mapping is the one + * transport rule it owns — a recorded business failure (FAILED) is a 422, success is a 201 — so a + * replayed result yields the same status code as the original request. + */ +@RestController +@RequestMapping("/transfers") +@RequiredArgsConstructor +public class TransferController { + + private final TransferService transferService; + + @PostMapping + public ResponseEntity createTransfer( + @Valid @RequestBody CreateTransferRequest request) { + TransferResponse response = transferService.createTransfer(request); + HttpStatus status = + response.status() == TransferStatus.FAILED + ? HttpStatus.UNPROCESSABLE_ENTITY + : HttpStatus.CREATED; + return ResponseEntity.status(status).body(response); + } +} diff --git a/src/main/java/com/rajat/wallet/dto/CreateTransferRequest.java b/src/main/java/com/rajat/wallet/dto/CreateTransferRequest.java new file mode 100644 index 00000000..37d0d6cb --- /dev/null +++ b/src/main/java/com/rajat/wallet/dto/CreateTransferRequest.java @@ -0,0 +1,27 @@ +package com.rajat.wallet.dto; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Inbound contract for {@code POST /transfers}. Field-level constraints are transport validation + * (a malformed request can never reach the service); business rules (funds, wallet existence) are + * enforced downstream. Violations are mapped to HTTP 400 by {@code GlobalExceptionHandler}. + */ +public record CreateTransferRequest( + @NotBlank String idempotencyKey, + @NotNull UUID fromWalletId, + @NotNull UUID toWalletId, + @NotNull @Positive @Digits(integer = 17, fraction = 2) BigDecimal amount) { + + /** Mirrors the DB {@code from <> to} CHECK so a self-transfer is rejected before any work. */ + @AssertTrue(message = "fromWalletId and toWalletId must be different") + public boolean isWalletsDistinct() { + return fromWalletId == null || !fromWalletId.equals(toWalletId); + } +} diff --git a/src/main/java/com/rajat/wallet/dto/ErrorResponse.java b/src/main/java/com/rajat/wallet/dto/ErrorResponse.java new file mode 100644 index 00000000..e6369688 --- /dev/null +++ b/src/main/java/com/rajat/wallet/dto/ErrorResponse.java @@ -0,0 +1,20 @@ +package com.rajat.wallet.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import java.util.Map; + +/** Uniform error body for all non-2xx responses. {@code fieldErrors} is present only for 400s. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ErrorResponse( + Instant timestamp, int status, String error, String message, Map fieldErrors) { + + public static ErrorResponse of(int status, String error, String message) { + return new ErrorResponse(Instant.now(), status, error, message, null); + } + + public static ErrorResponse of( + int status, String error, String message, Map fieldErrors) { + return new ErrorResponse(Instant.now(), status, error, message, fieldErrors); + } +} diff --git a/src/main/java/com/rajat/wallet/dto/TransferResponse.java b/src/main/java/com/rajat/wallet/dto/TransferResponse.java new file mode 100644 index 00000000..e0f7793b --- /dev/null +++ b/src/main/java/com/rajat/wallet/dto/TransferResponse.java @@ -0,0 +1,36 @@ +package com.rajat.wallet.dto; + +import com.rajat.wallet.domain.entities.Transfer; +import com.rajat.wallet.domain.enums.TransferStatus; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; +import lombok.Builder; + +/** + * Outbound contract for a transfer outcome. The same shape is returned for a fresh transfer and for + * an idempotent replay; {@code status} drives the HTTP code at the controller (PROCESSED -> 201, + * FAILED -> 422). + */ +@Builder +public record TransferResponse( + UUID transferId, + UUID fromWalletId, + UUID toWalletId, + BigDecimal amount, + TransferStatus status, + String failureReason, + Instant createdAt) { + + public static TransferResponse from(Transfer transfer) { + return TransferResponse.builder() + .transferId(transfer.getId()) + .fromWalletId(transfer.getFromWalletId()) + .toWalletId(transfer.getToWalletId()) + .amount(transfer.getAmount()) + .status(transfer.getStatus()) + .failureReason(transfer.getFailureReason()) + .createdAt(transfer.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/rajat/wallet/exception/IdempotencyConflictException.java b/src/main/java/com/rajat/wallet/exception/IdempotencyConflictException.java new file mode 100644 index 00000000..9f25a05c --- /dev/null +++ b/src/main/java/com/rajat/wallet/exception/IdempotencyConflictException.java @@ -0,0 +1,12 @@ +package com.rajat.wallet.exception; + +/** + * An idempotency key was reused with a different payload, or the original request is still in + * flight. Mapped to HTTP 409. + */ +public class IdempotencyConflictException extends RuntimeException { + + public IdempotencyConflictException(String message) { + super(message); + } +} diff --git a/src/main/java/com/rajat/wallet/exception/WalletNotFoundException.java b/src/main/java/com/rajat/wallet/exception/WalletNotFoundException.java new file mode 100644 index 00000000..85fcfb66 --- /dev/null +++ b/src/main/java/com/rajat/wallet/exception/WalletNotFoundException.java @@ -0,0 +1,11 @@ +package com.rajat.wallet.exception; + +import java.util.UUID; + +/** A referenced wallet does not exist. Mapped to HTTP 404. */ +public class WalletNotFoundException extends RuntimeException { + + public WalletNotFoundException(UUID walletId) { + super("Wallet not found: " + walletId); + } +} diff --git a/src/main/java/com/rajat/wallet/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/rajat/wallet/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..ce52ea3a --- /dev/null +++ b/src/main/java/com/rajat/wallet/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,80 @@ +package com.rajat.wallet.exception.handler; + +import com.rajat.wallet.dto.ErrorResponse; +import com.rajat.wallet.exception.IdempotencyConflictException; +import com.rajat.wallet.exception.WalletNotFoundException; +import java.util.LinkedHashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** Translates domain/transport exceptions into the uniform {@link ErrorResponse} + HTTP status. */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** Bean-validation failures on the request body (incl. the self-transfer check) -> 400. */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + Map fieldErrors = new LinkedHashMap<>(); + ex.getBindingResult() + .getFieldErrors() + .forEach(fe -> fieldErrors.putIfAbsent(fe.getField(), fe.getDefaultMessage())); + ex.getBindingResult() + .getGlobalErrors() + .forEach(ge -> fieldErrors.putIfAbsent(ge.getObjectName(), ge.getDefaultMessage())); + return build(HttpStatus.BAD_REQUEST, "Validation failed", fieldErrors); + } + + /** Unparseable / missing JSON body -> 400. */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleUnreadable(HttpMessageNotReadableException ex) { + return build(HttpStatus.BAD_REQUEST, "Malformed request body", null); + } + + @ExceptionHandler(WalletNotFoundException.class) + public ResponseEntity handleWalletNotFound(WalletNotFoundException ex) { + return build(HttpStatus.NOT_FOUND, ex.getMessage(), null); + } + + @ExceptionHandler(IdempotencyConflictException.class) + public ResponseEntity handleConflict(IdempotencyConflictException ex) { + return build(HttpStatus.CONFLICT, ex.getMessage(), null); + } + + /** + * A concurrent first request with the same idempotency key loses the unique-index race. The + * transaction rolls back (no double-apply); the client should retry and will get the replay. + */ + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrity(DataIntegrityViolationException ex) { + return build(HttpStatus.CONFLICT, "Concurrent duplicate request; please retry", null); + } + + /** Defensive guard for stray illegal arguments not caught by bean validation -> 400. */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + return build(HttpStatus.BAD_REQUEST, ex.getMessage(), null); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception ex) { + log.error("Unhandled exception", ex); + return build(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error", null); + } + + private ResponseEntity build( + HttpStatus status, String message, Map fieldErrors) { + ErrorResponse body = + ErrorResponse.of(status.value(), status.getReasonPhrase(), message, fieldErrors); + return ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/com/rajat/wallet/repository/IdempotencyRecordRepository.java b/src/main/java/com/rajat/wallet/repository/IdempotencyRecordRepository.java new file mode 100644 index 00000000..f04fc7a3 --- /dev/null +++ b/src/main/java/com/rajat/wallet/repository/IdempotencyRecordRepository.java @@ -0,0 +1,11 @@ +package com.rajat.wallet.repository; + +import com.rajat.wallet.domain.entities.IdempotencyRecord; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface IdempotencyRecordRepository extends JpaRepository { + + Optional findByIdempotencyKey(String idempotencyKey); +} diff --git a/src/main/java/com/rajat/wallet/repository/LedgerEntryRepository.java b/src/main/java/com/rajat/wallet/repository/LedgerEntryRepository.java new file mode 100644 index 00000000..b78a2fb1 --- /dev/null +++ b/src/main/java/com/rajat/wallet/repository/LedgerEntryRepository.java @@ -0,0 +1,7 @@ +package com.rajat.wallet.repository; + +import com.rajat.wallet.domain.entities.LedgerEntry; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LedgerEntryRepository extends JpaRepository {} diff --git a/src/main/java/com/rajat/wallet/repository/TransferRepository.java b/src/main/java/com/rajat/wallet/repository/TransferRepository.java new file mode 100644 index 00000000..dca53f87 --- /dev/null +++ b/src/main/java/com/rajat/wallet/repository/TransferRepository.java @@ -0,0 +1,7 @@ +package com.rajat.wallet.repository; + +import com.rajat.wallet.domain.entities.Transfer; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TransferRepository extends JpaRepository {} diff --git a/src/main/java/com/rajat/wallet/repository/WalletRepository.java b/src/main/java/com/rajat/wallet/repository/WalletRepository.java new file mode 100644 index 00000000..dda7d312 --- /dev/null +++ b/src/main/java/com/rajat/wallet/repository/WalletRepository.java @@ -0,0 +1,23 @@ +package com.rajat.wallet.repository; + +import com.rajat.wallet.domain.entities.Wallet; +import jakarta.persistence.LockModeType; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface WalletRepository extends JpaRepository { + + /** + * Loads the given wallets under a pessimistic write lock ({@code SELECT … FOR UPDATE}), ordered by + * id. The deterministic order is the deadlock-avoidance strategy: two opposing transfers between + * the same pair always acquire the row locks in the same sequence. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select w from Wallet w where w.id in :ids order by w.id") + List findAllForUpdate(@Param("ids") Collection ids); +} diff --git a/src/main/java/com/rajat/wallet/service/TransferProcessor.java b/src/main/java/com/rajat/wallet/service/TransferProcessor.java new file mode 100644 index 00000000..352c8926 --- /dev/null +++ b/src/main/java/com/rajat/wallet/service/TransferProcessor.java @@ -0,0 +1,155 @@ +package com.rajat.wallet.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rajat.wallet.domain.entities.IdempotencyRecord; +import com.rajat.wallet.domain.entities.LedgerEntry; +import com.rajat.wallet.domain.entities.Transfer; +import com.rajat.wallet.domain.entities.Wallet; +import com.rajat.wallet.domain.enums.EntryType; +import com.rajat.wallet.domain.enums.TransferStatus; +import com.rajat.wallet.dto.CreateTransferRequest; +import com.rajat.wallet.dto.TransferResponse; +import com.rajat.wallet.exception.WalletNotFoundException; +import com.rajat.wallet.repository.IdempotencyRecordRepository; +import com.rajat.wallet.repository.LedgerEntryRepository; +import com.rajat.wallet.repository.TransferRepository; +import com.rajat.wallet.repository.WalletRepository; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * The atomic unit of work for a first-seen transfer: reserve the idempotency key, lock the wallets, + * move the money, write the double-entry ledger, and cache the response — all in ONE transaction. + * + *

Kept in its own bean on purpose: that makes {@code @Transactional} a real proxy boundary. A + * concurrent duplicate blocks on the {@code idempotency_key} unique index until this (winning) + * transaction commits {@code COMPLETED}, then fails with a unique violation that rolls this whole + * transaction back cleanly. {@link TransferServiceImpl} — which is not transactional, so it is not + * poisoned — catches that and replays the committed winner instead of erroring. + */ +@Component +public class TransferProcessor { + + private static final Logger log = LoggerFactory.getLogger(TransferProcessor.class); + + // The controller maps the same way; cached here only so the registry is self-describing. + private static final int STATUS_PROCESSED = 201; + private static final int STATUS_FAILED = 422; + + private final WalletRepository walletRepository; + private final TransferRepository transferRepository; + private final LedgerEntryRepository ledgerEntryRepository; + private final IdempotencyRecordRepository idempotencyRepository; + private final ObjectMapper objectMapper; + + public TransferProcessor( + WalletRepository walletRepository, + TransferRepository transferRepository, + LedgerEntryRepository ledgerEntryRepository, + IdempotencyRecordRepository idempotencyRepository, + ObjectMapper objectMapper) { + this.walletRepository = walletRepository; + this.transferRepository = transferRepository; + this.ledgerEntryRepository = ledgerEntryRepository; + this.idempotencyRepository = idempotencyRepository; + this.objectMapper = objectMapper; + } + + @Transactional + public TransferResponse process(CreateTransferRequest request, String requestHash) { + // Reserve the key and flush now, so a concurrent duplicate collides here (blocks, then fails) + // before any money moves. On failure the whole transaction — including this insert — rolls + // back, so the key is never left orphaned. + IdempotencyRecord record = + idempotencyRepository.saveAndFlush( + IdempotencyRecord.inProgress(request.idempotencyKey(), requestHash)); + + TransferResponse response = executeTransfer(request); + + int statusCode = response.status() == TransferStatus.FAILED ? STATUS_FAILED : STATUS_PROCESSED; + record.complete(response.transferId(), statusCode, serialize(response)); + return response; + } + + private TransferResponse executeTransfer(CreateTransferRequest request) { + LockedWallets wallets = lockWallets(request.fromWalletId(), request.toWalletId()); + Wallet from = wallets.from(); + Wallet to = wallets.to(); + + // Persist first so Hibernate assigns the UUID v7 id that the ledger entries reference. + Transfer transfer = + transferRepository.save( + Transfer.pending(request.fromWalletId(), request.toWalletId(), request.amount())); + + String failureReason = validate(from, to, request.amount()); + if (failureReason != null) { + transfer.markFailed(failureReason); + log.info("Transfer {} FAILED: {}", transfer.getId(), failureReason); + return TransferResponse.from(transfer); + } + + from.debit(request.amount()); + ledgerEntryRepository.save( + new LedgerEntry( + from.getId(), transfer.getId(), EntryType.DEBIT, request.amount(), from.getBalance())); + + to.credit(request.amount()); + ledgerEntryRepository.save( + new LedgerEntry( + to.getId(), transfer.getId(), EntryType.CREDIT, request.amount(), to.getBalance())); + + transfer.markProcessed(); + log.info( + "Transfer {} PROCESSED: {} from {} to {}", + transfer.getId(), + request.amount(), + from.getId(), + to.getId()); + return TransferResponse.from(transfer); + } + + /** Business validation under the wallet locks. Returns a failure reason, or {@code null} if ok. */ + private String validate(Wallet from, Wallet to, BigDecimal amount) { + if (!from.getCurrency().equals(to.getCurrency())) { + return "Currency mismatch: " + from.getCurrency() + " -> " + to.getCurrency(); + } + if (!from.hasSufficientFunds(amount)) { + return "Insufficient funds in wallet " + from.getId(); + } + return null; + } + + private LockedWallets lockWallets(UUID fromId, UUID toId) { + Map byId = + walletRepository.findAllForUpdate(List.of(fromId, toId)).stream() + .collect(Collectors.toMap(Wallet::getId, Function.identity())); + Wallet from = byId.get(fromId); + if (from == null) { + throw new WalletNotFoundException(fromId); + } + Wallet to = byId.get(toId); + if (to == null) { + throw new WalletNotFoundException(toId); + } + return new LockedWallets(from, to); + } + + private String serialize(TransferResponse response) { + try { + return objectMapper.writeValueAsString(response); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize transfer response", e); + } + } + + private record LockedWallets(Wallet from, Wallet to) {} +} diff --git a/src/main/java/com/rajat/wallet/service/TransferService.java b/src/main/java/com/rajat/wallet/service/TransferService.java new file mode 100644 index 00000000..d77ff1d2 --- /dev/null +++ b/src/main/java/com/rajat/wallet/service/TransferService.java @@ -0,0 +1,22 @@ +package com.rajat.wallet.service; + +import com.rajat.wallet.dto.CreateTransferRequest; +import com.rajat.wallet.dto.TransferResponse; + +/** + * Orchestrates wallet-to-wallet transfers with exactly-once semantics. The contract: + * + *

    + *
  • A first-seen {@code idempotencyKey} executes the transfer atomically and returns the + * outcome (PROCESSED, or FAILED for a recorded business failure such as insufficient funds). + *
  • A duplicate of a completed request returns the original result (replay) without repeating + * any side effect. + *
  • A key reused with a different payload, or one whose original request is still in flight, + * raises {@link com.rajat.wallet.exception.IdempotencyConflictException}. + *
  • An unknown wallet raises {@link com.rajat.wallet.exception.WalletNotFoundException}. + *
+ */ +public interface TransferService { + + TransferResponse createTransfer(CreateTransferRequest request); +} diff --git a/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java b/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java new file mode 100644 index 00000000..dcee72ed --- /dev/null +++ b/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java @@ -0,0 +1,110 @@ +package com.rajat.wallet.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rajat.wallet.domain.entities.IdempotencyRecord; +import com.rajat.wallet.domain.enums.IdempotencyStatus; +import com.rajat.wallet.dto.CreateTransferRequest; +import com.rajat.wallet.dto.TransferResponse; +import com.rajat.wallet.exception.IdempotencyConflictException; +import com.rajat.wallet.repository.IdempotencyRecordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Optional; + +/** + * Idempotency-aware orchestrator. Deliberately not {@code @Transactional}: the atomic unit of + * work lives in {@link TransferProcessor}, and keeping this layer outside any transaction is what + * lets it catch a concurrent duplicate's failure and replay the winner in a fresh read. + * + *
    + *
  • Fast path — a key we've already seen (the common sequential-retry case) is replayed + * without touching the wallets. + *
  • First occurrence — delegated to {@link TransferProcessor#process} (one transaction). + *
  • Concurrent duplicate — the loser blocks on the unique index until the winner commits, + * fails with a {@link DataIntegrityViolationException}, and is then replayed directly. + *
+ */ +@Service +@Slf4j +@RequiredArgsConstructor +public class TransferServiceImpl implements TransferService { + + private final TransferProcessor transferProcessor; + private final IdempotencyRecordRepository idempotencyRepository; + private final ObjectMapper objectMapper; + + @Override + public TransferResponse createTransfer(CreateTransferRequest request) { + String requestHash = requestHash(request); + + Optional existing = + idempotencyRepository.findByIdempotencyKey(request.idempotencyKey()); + if (existing.isPresent()) { + return replayOrConflict(existing.get(), requestHash); + } + + try { + return transferProcessor.process(request, requestHash); + } catch (DataIntegrityViolationException race) { + // A concurrent first request won the unique-key race and has now committed. Replay it. + log.info("Lost idempotency-key race for {}; replaying winner", request.idempotencyKey()); + IdempotencyRecord winner = + idempotencyRepository + .findByIdempotencyKey(request.idempotencyKey()) + .orElseThrow(() -> race); + return replayOrConflict(winner, requestHash); + } + } + + private TransferResponse replayOrConflict(IdempotencyRecord record, String requestHash) { + if (record.getStatus() == IdempotencyStatus.IN_PROGRESS) { + throw new IdempotencyConflictException( + "A request with idempotency key '" + + record.getIdempotencyKey() + + "' is still in progress; retry shortly"); + } + if (!record.getRequestHash().equals(requestHash)) { + throw new IdempotencyConflictException( + "Idempotency key '" + + record.getIdempotencyKey() + + "' was already used for a different request"); + } + log.info("Replaying cached response for idempotency key {}", record.getIdempotencyKey()); + return deserialize(record.getResponseBody()); + } + + /** + * Stable fingerprint of the meaningful request fields; ties a key to one logical request. + */ + private String requestHash(CreateTransferRequest request) { + String canonical = + request.fromWalletId() + + "|" + + request.toWalletId() + + "|" + + request.amount().stripTrailingZeros().toPlainString(); + try { + byte[] hash = + MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private TransferResponse deserialize(String body) { + try { + return objectMapper.readValue(body, TransferResponse.class); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to deserialize cached response", e); + } + } +} diff --git a/src/main/resources/db/migration/V3__amount_precision_two_decimals.sql b/src/main/resources/db/migration/V3__amount_precision_two_decimals.sql new file mode 100644 index 00000000..d084f68e --- /dev/null +++ b/src/main/resources/db/migration/V3__amount_precision_two_decimals.sql @@ -0,0 +1,7 @@ +-- Narrow monetary columns from 4 to 2 decimal places: balances and amounts are whole minor +-- currency units (e.g. paise/cents), so 2-decimal scale matches the domain and the API contract +-- (CreateTransferRequest enforces @Digits(fraction = 2)). Existing values are rounded to scale 2. +ALTER TABLE wallets ALTER COLUMN balance TYPE NUMERIC(19, 2); +ALTER TABLE transfers ALTER COLUMN amount TYPE NUMERIC(19, 2); +ALTER TABLE ledger_entries ALTER COLUMN amount TYPE NUMERIC(19, 2); +ALTER TABLE ledger_entries ALTER COLUMN balance_after TYPE NUMERIC(19, 2); From 2a3783c7c33b186ae401e2249f3c844f4ce02eda Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Sat, 20 Jun 2026 20:35:52 +0530 Subject: [PATCH 05/14] Add test suite for transfers, idempotency, and concurrency Cover the behaviour the assignment hinges on: - Domain unit tests: wallet balance invariants (overdraft guard) and the transfer state machine's guarded transitions. - Integration tests (Testcontainers + PostgreSQL): happy-path transfer with double-entry ledger correctness, insufficient-funds and currency-mismatch failures, unknown wallet, and request validation. - Idempotency: same key replays the original result; a key reused with a different payload is rejected. - Concurrency: parallel debits never overdraw, and concurrent duplicate keys apply the transfer exactly once. Pin the docker-java api.version in the test task so Testcontainers works against daemons enforcing a minimum API of 1.40. --- build.gradle | 9 + .../java/com/rajat/wallet/ConcurrencyIT.java | 123 ++++++++++++++ .../java/com/rajat/wallet/IdempotencyIT.java | 60 +++++++ .../java/com/rajat/wallet/TransferApiIT.java | 159 ++++++++++++++++++ .../wallet/domain/entities/TransferTest.java | 76 +++++++++ .../wallet/domain/entities/WalletTest.java | 59 +++++++ .../support/AbstractIntegrationTest.java | 99 +++++++++++ 7 files changed, 585 insertions(+) create mode 100644 src/test/java/com/rajat/wallet/ConcurrencyIT.java create mode 100644 src/test/java/com/rajat/wallet/IdempotencyIT.java create mode 100644 src/test/java/com/rajat/wallet/TransferApiIT.java create mode 100644 src/test/java/com/rajat/wallet/domain/entities/TransferTest.java create mode 100644 src/test/java/com/rajat/wallet/domain/entities/WalletTest.java create mode 100644 src/test/java/com/rajat/wallet/support/AbstractIntegrationTest.java diff --git a/build.gradle b/build.gradle index 9a5f2ffa..586ed6e3 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,15 @@ spotless { tasks.named('test') { useJUnitPlatform() + + // Testcontainers / docker-java compatibility with modern Docker daemons. + // docker-java defaults to Docker API 1.32, which daemons enforcing a minimum API of 1.40 + // reject ("client version 1.32 is too old"). docker-java reads the version from the + // `api.version` JVM system property, so pin a supported one. Overridable from the environment. + systemProperty 'api.version', providers.environmentVariable('DOCKER_API_VERSION').getOrElse('1.44') + if (!System.getenv().containsKey('DOCKER_HOST')) { + environment 'DOCKER_HOST', 'unix:///var/run/docker.sock' + } } // `check` runs spotlessCheck + test; CI uses `./gradlew check`. diff --git a/src/test/java/com/rajat/wallet/ConcurrencyIT.java b/src/test/java/com/rajat/wallet/ConcurrencyIT.java new file mode 100644 index 00000000..eef0c648 --- /dev/null +++ b/src/test/java/com/rajat/wallet/ConcurrencyIT.java @@ -0,0 +1,123 @@ +package com.rajat.wallet; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.rajat.wallet.domain.enums.TransferStatus; +import com.rajat.wallet.dto.TransferResponse; +import com.rajat.wallet.support.AbstractIntegrationTest; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * The hard guarantees: no double spending under concurrent debits, and no duplicate side effects + * when the same idempotency key arrives concurrently. These drive real HTTP requests from a thread + * pool so each request runs on its own connection/transaction — the only way to exercise the row + * locks. + */ +class ConcurrencyIT extends AbstractIntegrationTest { + + @Test + void concurrentDebitsOfTheSameWalletNeverOverdraw() throws Exception { + // 100.00 balance, ten simultaneous debits of 20.00 each -> exactly five can succeed. + UUID source = seedWallet("100.00", "INR"); + UUID dest = seedWallet("0.00", "INR"); + int attempts = 10; + + List> responses = + runConcurrently( + attempts, i -> () -> postTransfer(transfer("debit-" + i, source, dest, "20.00"))); + + long succeeded = + responses.stream() + .filter(r -> r.getStatusCode() == HttpStatus.CREATED) + .filter(r -> r.getBody() != null && r.getBody().status() == TransferStatus.PROCESSED) + .count(); + long failed = + responses.stream() + .filter(r -> r.getStatusCode() == HttpStatus.UNPROCESSABLE_ENTITY) + .count(); + + assertThat(succeeded).isEqualTo(5); + assertThat(failed).isEqualTo(5); + + // No overdraft: the source is drained to exactly zero, never negative. + assertThat(balanceOf(source)).isEqualByComparingTo("0.00"); + assertThat(balanceOf(dest)).isEqualByComparingTo("100.00"); + + // Ledger stays consistent: two entries per successful transfer, none for the failures. + assertThat(ledgerEntryRepository.count()).isEqualTo(2L * succeeded); + } + + @Test + void concurrentDuplicateKeyAppliesTheTransferExactlyOnce() throws Exception { + UUID source = seedWallet("100.00", "INR"); + UUID dest = seedWallet("0.00", "INR"); + int attempts = 6; + + List> responses = + runConcurrently( + attempts, i -> () -> postTransfer(transfer("same-key", source, dest, "30.00"))); + + // The side effect happened once, regardless of how many duplicates raced. + assertThat(transferRepository.count()).isEqualTo(1); + assertThat(ledgerEntryRepository.count()).isEqualTo(2); + assertThat(balanceOf(source)).isEqualByComparingTo("70.00"); + assertThat(balanceOf(dest)).isEqualByComparingTo("30.00"); + + // Every caller gets a coherent answer: either the replayed success (one shared transfer id) + // or a 409 telling them to retry. Nobody triggers a second transfer. + List successfulTransferIds = + responses.stream() + .filter(r -> r.getStatusCode() == HttpStatus.CREATED) + .map(r -> r.getBody().transferId()) + .distinct() + .toList(); + assertThat(successfulTransferIds).hasSize(1); + assertThat(responses) + .allMatch( + r -> + r.getStatusCode() == HttpStatus.CREATED + || r.getStatusCode() == HttpStatus.CONFLICT); + } + + /** Fires {@code count} tasks as simultaneously as possible and returns their results in order. */ + private List runConcurrently(int count, java.util.function.IntFunction> task) + throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(count); + CountDownLatch ready = new CountDownLatch(count); + CountDownLatch start = new CountDownLatch(1); + try { + List> futures = new ArrayList<>(); + for (int i = 0; i < count; i++) { + Callable work = task.apply(i); + futures.add( + pool.submit( + () -> { + ready.countDown(); + start.await(); + return work.call(); + })); + } + ready.await(10, TimeUnit.SECONDS); // all threads parked at the gate + start.countDown(); // release them together + + List results = new ArrayList<>(); + for (Future future : futures) { + results.add(future.get(30, TimeUnit.SECONDS)); + } + return results; + } finally { + pool.shutdownNow(); + } + } +} diff --git a/src/test/java/com/rajat/wallet/IdempotencyIT.java b/src/test/java/com/rajat/wallet/IdempotencyIT.java new file mode 100644 index 00000000..18d717ba --- /dev/null +++ b/src/test/java/com/rajat/wallet/IdempotencyIT.java @@ -0,0 +1,60 @@ +package com.rajat.wallet; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.rajat.wallet.dto.TransferResponse; +import com.rajat.wallet.support.AbstractIntegrationTest; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * Exactly-once semantics at the API level. A retried request (same key, same payload) must return + * the original result without re-applying side effects; reusing a key for a different + * payload is a client error and must be rejected rather than silently replayed. + */ +class IdempotencyIT extends AbstractIntegrationTest { + + @Test + void replayingTheSameKeyReturnsTheOriginalResultAndAppliesTheTransferOnce() { + UUID source = seedWallet("100.00", "INR"); + UUID dest = seedWallet("0.00", "INR"); + + ResponseEntity first = + postTransfer(transfer("retry-key", source, dest, "30.00")); + ResponseEntity second = + postTransfer(transfer("retry-key", source, dest, "30.00")); + + assertThat(first.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(second.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + // Same logical result is replayed — identical transfer id. + assertThat(second.getBody()).isNotNull(); + assertThat(second.getBody().transferId()).isEqualTo(first.getBody().transferId()); + + // The side effects happened exactly once. + assertThat(transferRepository.count()).isEqualTo(1); + assertThat(ledgerEntryRepository.count()).isEqualTo(2); + assertThat(balanceOf(source)).isEqualByComparingTo("70.00"); + assertThat(balanceOf(dest)).isEqualByComparingTo("30.00"); + } + + @Test + void reusingAKeyForADifferentPayloadIsRejectedWith409() { + UUID source = seedWallet("100.00", "INR"); + UUID dest = seedWallet("0.00", "INR"); + + ResponseEntity first = + postTransfer(transfer("dup-key", source, dest, "30.00")); + assertThat(first.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + // Same key, different amount -> conflict, and the original transfer is untouched. + ResponseEntity second = postForString(transfer("dup-key", source, dest, "40.00")); + + assertThat(second.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(transferRepository.count()).isEqualTo(1); + assertThat(balanceOf(source)).isEqualByComparingTo("70.00"); + assertThat(balanceOf(dest)).isEqualByComparingTo("30.00"); + } +} diff --git a/src/test/java/com/rajat/wallet/TransferApiIT.java b/src/test/java/com/rajat/wallet/TransferApiIT.java new file mode 100644 index 00000000..bd8fe7bf --- /dev/null +++ b/src/test/java/com/rajat/wallet/TransferApiIT.java @@ -0,0 +1,159 @@ +package com.rajat.wallet; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.rajat.wallet.domain.entities.LedgerEntry; +import com.rajat.wallet.domain.entities.Transfer; +import com.rajat.wallet.domain.enums.EntryType; +import com.rajat.wallet.domain.enums.TransferStatus; +import com.rajat.wallet.dto.CreateTransferRequest; +import com.rajat.wallet.dto.TransferResponse; +import com.rajat.wallet.support.AbstractIntegrationTest; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * End-to-end behaviour of {@code POST /transfers}: transfer execution, double-entry ledger + * correctness, the FAILED outcomes, and transport validation. Behavioural assertions only — they go + * through HTTP and inspect committed state, never internal calls. + */ +class TransferApiIT extends AbstractIntegrationTest { + + @Test + void successfulTransferMovesFundsWritesTwoLedgerEntriesAndReturns201() { + UUID source = seedWallet("100.00", "INR"); + UUID dest = seedWallet("0.00", "INR"); + + ResponseEntity response = + postTransfer(transfer("key-happy", source, dest, "30.00")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + TransferResponse body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body.status()).isEqualTo(TransferStatus.PROCESSED); + assertThat(body.transferId()).isNotNull(); + + // Balances moved by exactly the transfer amount. + assertThat(balanceOf(source)).isEqualByComparingTo("70.00"); + assertThat(balanceOf(dest)).isEqualByComparingTo("30.00"); + + // Exactly two ledger entries, and the ledger balances (debit total == credit total). + List entries = ledgerEntryRepository.findAll(); + assertThat(entries).hasSize(2); + + LedgerEntry debit = + entries.stream().filter(e -> e.getType() == EntryType.DEBIT).findFirst().orElseThrow(); + LedgerEntry credit = + entries.stream().filter(e -> e.getType() == EntryType.CREDIT).findFirst().orElseThrow(); + + assertThat(debit.getWalletId()).isEqualTo(source); + assertThat(debit.getTransferId()).isEqualTo(body.transferId()); + assertThat(debit.getAmount()).isEqualByComparingTo("30.00"); + assertThat(debit.getBalanceAfter()).isEqualByComparingTo("70.00"); + + assertThat(credit.getWalletId()).isEqualTo(dest); + assertThat(credit.getTransferId()).isEqualTo(body.transferId()); + assertThat(credit.getAmount()).isEqualByComparingTo("30.00"); + assertThat(credit.getBalanceAfter()).isEqualByComparingTo("30.00"); + } + + @Test + void insufficientFundsReturns422FailedAndDoesNotMoveMoney() { + UUID source = seedWallet("10.00", "INR"); + UUID dest = seedWallet("0.00", "INR"); + + ResponseEntity response = + postTransfer(transfer("key-insufficient", source, dest, "30.00")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().status()).isEqualTo(TransferStatus.FAILED); + assertThat(response.getBody().failureReason()).containsIgnoringCase("insufficient"); + + // No money moved and no ledger entries were written. + assertThat(balanceOf(source)).isEqualByComparingTo("10.00"); + assertThat(balanceOf(dest)).isEqualByComparingTo("0.00"); + assertThat(ledgerEntryRepository.count()).isZero(); + + // The failed attempt is still recorded as a transfer in the FAILED state. + List transfers = transferRepository.findAll(); + assertThat(transfers).hasSize(1); + assertThat(transfers.get(0).getStatus()).isEqualTo(TransferStatus.FAILED); + } + + @Test + void currencyMismatchReturns422FailedAndDoesNotMoveMoney() { + UUID source = seedWallet("100.00", "INR"); + UUID dest = seedWallet("0.00", "USD"); + + ResponseEntity response = + postTransfer(transfer("key-currency", source, dest, "30.00")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().status()).isEqualTo(TransferStatus.FAILED); + assertThat(response.getBody().failureReason()).containsIgnoringCase("currency"); + + assertThat(balanceOf(source)).isEqualByComparingTo("100.00"); + assertThat(balanceOf(dest)).isEqualByComparingTo("0.00"); + assertThat(ledgerEntryRepository.count()).isZero(); + } + + @Test + void unknownWalletReturns404() { + UUID dest = seedWallet("0.00", "INR"); + CreateTransferRequest request = transfer("key-unknown", UUID.randomUUID(), dest, "30.00"); + + ResponseEntity response = postForString(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + // Nothing was persisted — the reservation rolled back with the transaction. + assertThat(transferRepository.count()).isZero(); + assertThat(idempotencyRepository.count()).isZero(); + } + + @Test + void blankIdempotencyKeyIsRejectedWith400() { + UUID source = seedWallet("100.00", "INR"); + UUID dest = seedWallet("0.00", "INR"); + + ResponseEntity response = postForString(transfer("", source, dest, "30.00")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void nonPositiveAmountIsRejectedWith400() { + ResponseEntity response = + postForString(transfer("key-neg", UUID.randomUUID(), UUID.randomUUID(), "-5.00")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void moreThanTwoDecimalPlacesIsRejectedWith400() { + ResponseEntity response = + postForString(transfer("key-scale", UUID.randomUUID(), UUID.randomUUID(), "30.123")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void selfTransferIsRejectedWith400() { + UUID wallet = UUID.randomUUID(); + + ResponseEntity response = postForString(transfer("key-self", wallet, wallet, "30.00")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void malformedJsonBodyIsRejectedWith400() { + ResponseEntity response = postJson("{ not valid json "); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } +} diff --git a/src/test/java/com/rajat/wallet/domain/entities/TransferTest.java b/src/test/java/com/rajat/wallet/domain/entities/TransferTest.java new file mode 100644 index 00000000..22b78844 --- /dev/null +++ b/src/test/java/com/rajat/wallet/domain/entities/TransferTest.java @@ -0,0 +1,76 @@ +package com.rajat.wallet.domain.entities; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.rajat.wallet.domain.enums.TransferStatus; +import java.math.BigDecimal; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the {@link Transfer} state machine. The guarded transitions are what make state + * changes safe under retries and duplicates: a transfer may only ever move out of PENDING + * once, so a replayed or double-processed request can never re-apply a side effect. + */ +class TransferTest { + + private static final UUID FROM = UUID.randomUUID(); + private static final UUID TO = UUID.randomUUID(); + private static final BigDecimal AMOUNT = new BigDecimal("100.00"); + + @Test + void pendingTransferStartsInPendingState() { + Transfer transfer = Transfer.pending(FROM, TO, AMOUNT); + + assertThat(transfer.getStatus()).isEqualTo(TransferStatus.PENDING); + assertThat(transfer.getFromWalletId()).isEqualTo(FROM); + assertThat(transfer.getToWalletId()).isEqualTo(TO); + assertThat(transfer.getAmount()).isEqualByComparingTo(AMOUNT); + assertThat(transfer.getFailureReason()).isNull(); + } + + @Test + void markProcessedMovesPendingToProcessed() { + Transfer transfer = Transfer.pending(FROM, TO, AMOUNT); + + transfer.markProcessed(); + + assertThat(transfer.getStatus()).isEqualTo(TransferStatus.PROCESSED); + } + + @Test + void markFailedMovesPendingToFailedAndRecordsReason() { + Transfer transfer = Transfer.pending(FROM, TO, AMOUNT); + + transfer.markFailed("Insufficient funds"); + + assertThat(transfer.getStatus()).isEqualTo(TransferStatus.FAILED); + assertThat(transfer.getFailureReason()).isEqualTo("Insufficient funds"); + } + + @Test + void aProcessedTransferCannotBeProcessedAgain() { + Transfer transfer = Transfer.pending(FROM, TO, AMOUNT); + transfer.markProcessed(); + + assertThatThrownBy(transfer::markProcessed).isInstanceOf(IllegalStateException.class); + } + + @Test + void aProcessedTransferCannotLaterBeFailed() { + Transfer transfer = Transfer.pending(FROM, TO, AMOUNT); + transfer.markProcessed(); + + assertThatThrownBy(() -> transfer.markFailed("late failure")) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void aFailedTransferCannotLaterBeProcessed() { + Transfer transfer = Transfer.pending(FROM, TO, AMOUNT); + transfer.markFailed("Currency mismatch"); + + assertThatThrownBy(transfer::markProcessed).isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/test/java/com/rajat/wallet/domain/entities/WalletTest.java b/src/test/java/com/rajat/wallet/domain/entities/WalletTest.java new file mode 100644 index 00000000..e8f4aa24 --- /dev/null +++ b/src/test/java/com/rajat/wallet/domain/entities/WalletTest.java @@ -0,0 +1,59 @@ +package com.rajat.wallet.domain.entities; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +/** + * Pure unit tests for the {@link Wallet} balance invariants — the last line of defence that + * protects against overdraft even if a caller forgets to check funds. No Spring, no database. + */ +class WalletTest { + + @Test + void debitReducesBalance() { + Wallet wallet = new Wallet(new BigDecimal("100.00"), "INR"); + + wallet.debit(new BigDecimal("30.00")); + + assertThat(wallet.getBalance()).isEqualByComparingTo("70.00"); + } + + @Test + void creditIncreasesBalance() { + Wallet wallet = new Wallet(new BigDecimal("100.00"), "INR"); + + wallet.credit(new BigDecimal("25.50")); + + assertThat(wallet.getBalance()).isEqualByComparingTo("125.50"); + } + + @Test + void debitOfExactBalanceIsAllowed() { + Wallet wallet = new Wallet(new BigDecimal("50.00"), "INR"); + + wallet.debit(new BigDecimal("50.00")); + + assertThat(wallet.getBalance()).isEqualByComparingTo("0.00"); + } + + @Test + void debitBeyondBalanceIsRejectedAndLeavesBalanceUntouched() { + Wallet wallet = new Wallet(new BigDecimal("40.00"), "INR"); + + assertThatThrownBy(() -> wallet.debit(new BigDecimal("40.01"))) + .isInstanceOf(IllegalStateException.class); + + assertThat(wallet.getBalance()).isEqualByComparingTo("40.00"); + } + + @Test + void hasSufficientFundsIsInclusiveOfTheExactAmount() { + Wallet wallet = new Wallet(new BigDecimal("10.00"), "INR"); + + assertThat(wallet.hasSufficientFunds(new BigDecimal("10.00"))).isTrue(); + assertThat(wallet.hasSufficientFunds(new BigDecimal("10.01"))).isFalse(); + } +} diff --git a/src/test/java/com/rajat/wallet/support/AbstractIntegrationTest.java b/src/test/java/com/rajat/wallet/support/AbstractIntegrationTest.java new file mode 100644 index 00000000..85dbe09a --- /dev/null +++ b/src/test/java/com/rajat/wallet/support/AbstractIntegrationTest.java @@ -0,0 +1,99 @@ +package com.rajat.wallet.support; + +import com.rajat.wallet.domain.entities.Wallet; +import com.rajat.wallet.dto.CreateTransferRequest; +import com.rajat.wallet.dto.TransferResponse; +import com.rajat.wallet.repository.IdempotencyRecordRepository; +import com.rajat.wallet.repository.LedgerEntryRepository; +import com.rajat.wallet.repository.TransferRepository; +import com.rajat.wallet.repository.WalletRepository; +import java.math.BigDecimal; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; + +/** + * Base for behavioural integration tests. Boots the full Spring context against a real PostgreSQL + * (via Testcontainers) so Flyway migrations, pessimistic row locks and JPA mapping are all + * exercised exactly as in production — the things that actually guarantee correctness can only be + * tested on the real engine, not an in-memory substitute. + * + *

The container is started once for the whole JVM (the static singleton pattern) and reused + * across every test class; each test starts from a clean set of tables via {@link + * #resetDatabase()}. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class AbstractIntegrationTest { + + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:15.4") + .withDatabaseName("wallet") + .withUsername("wallet") + .withPassword("wallet"); + + static { + POSTGRES.start(); + } + + @DynamicPropertySource + static void datasourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + } + + @Autowired protected TestRestTemplate restTemplate; + @Autowired protected WalletRepository walletRepository; + @Autowired protected TransferRepository transferRepository; + @Autowired protected LedgerEntryRepository ledgerEntryRepository; + @Autowired protected IdempotencyRecordRepository idempotencyRepository; + + @BeforeEach + void resetDatabase() { + // FK-safe order: ledger and transfers reference wallets; idempotency is independent. + ledgerEntryRepository.deleteAllInBatch(); + transferRepository.deleteAllInBatch(); + idempotencyRepository.deleteAllInBatch(); + walletRepository.deleteAllInBatch(); + } + + protected UUID seedWallet(String balance, String currency) { + Wallet wallet = walletRepository.saveAndFlush(new Wallet(new BigDecimal(balance), currency)); + return wallet.getId(); + } + + protected CreateTransferRequest transfer( + String idempotencyKey, UUID from, UUID to, String amount) { + return new CreateTransferRequest(idempotencyKey, from, to, new BigDecimal(amount)); + } + + /** Posts a transfer expecting a transfer-shaped body (used for 201 PROCESSED and 422 FAILED). */ + protected ResponseEntity postTransfer(CreateTransferRequest request) { + return restTemplate.postForEntity("/transfers", request, TransferResponse.class); + } + + /** Posts and reads the body as a raw String — safe for error responses (400/404/409). */ + protected ResponseEntity postForString(Object body) { + return restTemplate.postForEntity("/transfers", body, String.class); + } + + /** Posts a raw JSON string with an explicit content type, for malformed-body cases. */ + protected ResponseEntity postJson(String json) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return restTemplate.postForEntity("/transfers", new HttpEntity<>(json, headers), String.class); + } + + protected BigDecimal balanceOf(UUID walletId) { + return walletRepository.findById(walletId).orElseThrow().getBalance(); + } +} From 97741f3ffc078d5cee4e9e15ad7077cb6178e5df Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Sat, 20 Jun 2026 20:44:59 +0530 Subject: [PATCH 06/14] Fix formatting violations with spotlessApply --- .../domain/entities/IdempotencyRecord.java | 6 +- .../wallet/domain/entities/LedgerEntry.java | 1 - .../wallet/domain/entities/Transfer.java | 7 +- .../entities/common/AuditableEntity.java | 4 +- .../wallet/dto/CreateTransferRequest.java | 4 +- .../wallet/repository/WalletRepository.java | 6 +- .../wallet/service/TransferProcessor.java | 4 +- .../wallet/service/TransferServiceImpl.java | 138 +++++++++--------- 8 files changed, 84 insertions(+), 86 deletions(-) diff --git a/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java b/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java index 72d7d8de..44e287f6 100644 --- a/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java +++ b/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java @@ -16,9 +16,9 @@ * Durable idempotency registry, deliberately decoupled from any single operation so the same * mechanism can guard transfers and any future endpoint. The unique {@code idempotencyKey} enforces * exactly-once at the API level; {@code responseStatus}/{@code responseBody} cache the original - * result so a duplicate request is replayed verbatim without re-executing side effects. The - * {@code requestHash} lets the service reject a key replayed with a different payload, and the - * {@code status} lifecycle lets a concurrent duplicate detect an in-flight request. + * result so a duplicate request is replayed verbatim without re-executing side effects. The {@code + * requestHash} lets the service reject a key replayed with a different payload, and the {@code + * status} lifecycle lets a concurrent duplicate detect an in-flight request. */ @Entity @Table(name = "idempotency_records") diff --git a/src/main/java/com/rajat/wallet/domain/entities/LedgerEntry.java b/src/main/java/com/rajat/wallet/domain/entities/LedgerEntry.java index e47b1d9b..eb290fa7 100644 --- a/src/main/java/com/rajat/wallet/domain/entities/LedgerEntry.java +++ b/src/main/java/com/rajat/wallet/domain/entities/LedgerEntry.java @@ -9,7 +9,6 @@ import jakarta.persistence.Table; import java.math.BigDecimal; import java.util.UUID; - import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/rajat/wallet/domain/entities/Transfer.java b/src/main/java/com/rajat/wallet/domain/entities/Transfer.java index 766cc213..e63ee372 100644 --- a/src/main/java/com/rajat/wallet/domain/entities/Transfer.java +++ b/src/main/java/com/rajat/wallet/domain/entities/Transfer.java @@ -9,15 +9,14 @@ import jakarta.persistence.Table; import java.math.BigDecimal; import java.util.UUID; - import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; /** - * A transfer request and its lifecycle. Exactly-once request handling lives in the generic - * {@link IdempotencyRecord} registry, not here. State transitions are guarded: a transfer may only - * move out of {@link TransferStatus#PENDING}. + * A transfer request and its lifecycle. Exactly-once request handling lives in the generic {@link + * IdempotencyRecord} registry, not here. State transitions are guarded: a transfer may only move + * out of {@link TransferStatus#PENDING}. */ @Entity @Table(name = "transfers") diff --git a/src/main/java/com/rajat/wallet/domain/entities/common/AuditableEntity.java b/src/main/java/com/rajat/wallet/domain/entities/common/AuditableEntity.java index 3f01f0b0..b7608485 100644 --- a/src/main/java/com/rajat/wallet/domain/entities/common/AuditableEntity.java +++ b/src/main/java/com/rajat/wallet/domain/entities/common/AuditableEntity.java @@ -11,8 +11,8 @@ /** * Adds creation/modification timestamps to {@link BaseEntity}. Managed automatically by Spring Data - * JPA auditing (enabled via {@code @EnableJpaAuditing}): {@code createdAt} is set once on insert and - * {@code updatedAt} on every flush, so entities never assign them by hand. + * JPA auditing (enabled via {@code @EnableJpaAuditing}): {@code createdAt} is set once on insert + * and {@code updatedAt} on every flush, so entities never assign them by hand. */ @MappedSuperclass @EntityListeners(AuditingEntityListener.class) diff --git a/src/main/java/com/rajat/wallet/dto/CreateTransferRequest.java b/src/main/java/com/rajat/wallet/dto/CreateTransferRequest.java index 37d0d6cb..1e556e4b 100644 --- a/src/main/java/com/rajat/wallet/dto/CreateTransferRequest.java +++ b/src/main/java/com/rajat/wallet/dto/CreateTransferRequest.java @@ -9,8 +9,8 @@ import java.util.UUID; /** - * Inbound contract for {@code POST /transfers}. Field-level constraints are transport validation - * (a malformed request can never reach the service); business rules (funds, wallet existence) are + * Inbound contract for {@code POST /transfers}. Field-level constraints are transport validation (a + * malformed request can never reach the service); business rules (funds, wallet existence) are * enforced downstream. Violations are mapped to HTTP 400 by {@code GlobalExceptionHandler}. */ public record CreateTransferRequest( diff --git a/src/main/java/com/rajat/wallet/repository/WalletRepository.java b/src/main/java/com/rajat/wallet/repository/WalletRepository.java index dda7d312..16da9151 100644 --- a/src/main/java/com/rajat/wallet/repository/WalletRepository.java +++ b/src/main/java/com/rajat/wallet/repository/WalletRepository.java @@ -13,9 +13,9 @@ public interface WalletRepository extends JpaRepository { /** - * Loads the given wallets under a pessimistic write lock ({@code SELECT … FOR UPDATE}), ordered by - * id. The deterministic order is the deadlock-avoidance strategy: two opposing transfers between - * the same pair always acquire the row locks in the same sequence. + * Loads the given wallets under a pessimistic write lock ({@code SELECT … FOR UPDATE}), ordered + * by id. The deterministic order is the deadlock-avoidance strategy: two opposing transfers + * between the same pair always acquire the row locks in the same sequence. */ @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select w from Wallet w where w.id in :ids order by w.id") diff --git a/src/main/java/com/rajat/wallet/service/TransferProcessor.java b/src/main/java/com/rajat/wallet/service/TransferProcessor.java index 352c8926..04d9136f 100644 --- a/src/main/java/com/rajat/wallet/service/TransferProcessor.java +++ b/src/main/java/com/rajat/wallet/service/TransferProcessor.java @@ -117,7 +117,9 @@ private TransferResponse executeTransfer(CreateTransferRequest request) { return TransferResponse.from(transfer); } - /** Business validation under the wallet locks. Returns a failure reason, or {@code null} if ok. */ + /** + * Business validation under the wallet locks. Returns a failure reason, or {@code null} if ok. + */ private String validate(Wallet from, Wallet to, BigDecimal amount) { if (!from.getCurrency().equals(to.getCurrency())) { return "Currency mismatch: " + from.getCurrency() + " -> " + to.getCurrency(); diff --git a/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java b/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java index dcee72ed..db766539 100644 --- a/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java +++ b/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java @@ -8,28 +8,28 @@ import com.rajat.wallet.dto.TransferResponse; import com.rajat.wallet.exception.IdempotencyConflictException; import com.rajat.wallet.repository.IdempotencyRecordRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Service; - import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; /** - * Idempotency-aware orchestrator. Deliberately not {@code @Transactional}: the atomic unit of - * work lives in {@link TransferProcessor}, and keeping this layer outside any transaction is what - * lets it catch a concurrent duplicate's failure and replay the winner in a fresh read. + * Idempotency-aware orchestrator. Deliberately not {@code @Transactional}: the atomic unit + * of work lives in {@link TransferProcessor}, and keeping this layer outside any transaction is + * what lets it catch a concurrent duplicate's failure and replay the winner in a fresh read. * *

    *
  • Fast path — a key we've already seen (the common sequential-retry case) is replayed * without touching the wallets. *
  • First occurrence — delegated to {@link TransferProcessor#process} (one transaction). - *
  • Concurrent duplicate — the loser blocks on the unique index until the winner commits, - * fails with a {@link DataIntegrityViolationException}, and is then replayed directly. + *
  • Concurrent duplicate — the loser blocks on the unique index until the winner + * commits, fails with a {@link DataIntegrityViolationException}, and is then replayed + * directly. *
*/ @Service @@ -37,74 +37,72 @@ @RequiredArgsConstructor public class TransferServiceImpl implements TransferService { - private final TransferProcessor transferProcessor; - private final IdempotencyRecordRepository idempotencyRepository; - private final ObjectMapper objectMapper; + private final TransferProcessor transferProcessor; + private final IdempotencyRecordRepository idempotencyRepository; + private final ObjectMapper objectMapper; - @Override - public TransferResponse createTransfer(CreateTransferRequest request) { - String requestHash = requestHash(request); + @Override + public TransferResponse createTransfer(CreateTransferRequest request) { + String requestHash = requestHash(request); - Optional existing = - idempotencyRepository.findByIdempotencyKey(request.idempotencyKey()); - if (existing.isPresent()) { - return replayOrConflict(existing.get(), requestHash); - } + Optional existing = + idempotencyRepository.findByIdempotencyKey(request.idempotencyKey()); + if (existing.isPresent()) { + return replayOrConflict(existing.get(), requestHash); + } - try { - return transferProcessor.process(request, requestHash); - } catch (DataIntegrityViolationException race) { - // A concurrent first request won the unique-key race and has now committed. Replay it. - log.info("Lost idempotency-key race for {}; replaying winner", request.idempotencyKey()); - IdempotencyRecord winner = - idempotencyRepository - .findByIdempotencyKey(request.idempotencyKey()) - .orElseThrow(() -> race); - return replayOrConflict(winner, requestHash); - } + try { + return transferProcessor.process(request, requestHash); + } catch (DataIntegrityViolationException race) { + // A concurrent first request won the unique-key race and has now committed. Replay it. + log.info("Lost idempotency-key race for {}; replaying winner", request.idempotencyKey()); + IdempotencyRecord winner = + idempotencyRepository + .findByIdempotencyKey(request.idempotencyKey()) + .orElseThrow(() -> race); + return replayOrConflict(winner, requestHash); } + } - private TransferResponse replayOrConflict(IdempotencyRecord record, String requestHash) { - if (record.getStatus() == IdempotencyStatus.IN_PROGRESS) { - throw new IdempotencyConflictException( - "A request with idempotency key '" - + record.getIdempotencyKey() - + "' is still in progress; retry shortly"); - } - if (!record.getRequestHash().equals(requestHash)) { - throw new IdempotencyConflictException( - "Idempotency key '" - + record.getIdempotencyKey() - + "' was already used for a different request"); - } - log.info("Replaying cached response for idempotency key {}", record.getIdempotencyKey()); - return deserialize(record.getResponseBody()); + private TransferResponse replayOrConflict(IdempotencyRecord record, String requestHash) { + if (record.getStatus() == IdempotencyStatus.IN_PROGRESS) { + throw new IdempotencyConflictException( + "A request with idempotency key '" + + record.getIdempotencyKey() + + "' is still in progress; retry shortly"); + } + if (!record.getRequestHash().equals(requestHash)) { + throw new IdempotencyConflictException( + "Idempotency key '" + + record.getIdempotencyKey() + + "' was already used for a different request"); } + log.info("Replaying cached response for idempotency key {}", record.getIdempotencyKey()); + return deserialize(record.getResponseBody()); + } - /** - * Stable fingerprint of the meaningful request fields; ties a key to one logical request. - */ - private String requestHash(CreateTransferRequest request) { - String canonical = - request.fromWalletId() - + "|" - + request.toWalletId() - + "|" - + request.amount().stripTrailingZeros().toPlainString(); - try { - byte[] hash = - MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8)); - return HexFormat.of().formatHex(hash); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 unavailable", e); - } + /** Stable fingerprint of the meaningful request fields; ties a key to one logical request. */ + private String requestHash(CreateTransferRequest request) { + String canonical = + request.fromWalletId() + + "|" + + request.toWalletId() + + "|" + + request.amount().stripTrailingZeros().toPlainString(); + try { + byte[] hash = + MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); } + } - private TransferResponse deserialize(String body) { - try { - return objectMapper.readValue(body, TransferResponse.class); - } catch (JsonProcessingException e) { - throw new IllegalStateException("Failed to deserialize cached response", e); - } + private TransferResponse deserialize(String body) { + try { + return objectMapper.readValue(body, TransferResponse.class); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to deserialize cached response", e); } + } } From 83d3eecae74a6a387b60cef4c40ef6d733490712 Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Sat, 20 Jun 2026 23:49:21 +0530 Subject: [PATCH 07/14] Added implementation details and written complete detailed description in README. Fixed the TechnicalDesignDocument --- README.md | 366 ++++++++++++++++++++++++++++++++++--- TechnicalDesignDocument.md | 7 +- implementation_details.md | 220 ++++++++++++++++++++++ 3 files changed, 564 insertions(+), 29 deletions(-) create mode 100644 implementation_details.md diff --git a/README.md b/README.md index 58c62d1a..0a06e03e 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,351 @@ -# 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 reliable, transactional wallet-to-wallet transfer service that guarantees **exactly-once request handling**, **double-entry ledger consistency**, **correct balances under concurrency**, and **safe state transitions** — the core design challenges of distributed financial systems. -## Included +> Design rationale lives in two companion docs: [`TechnicalDesignDocument.md`](./TechnicalDesignDocument.md) (the documentation-first design note) and [`implementation_details.md`](./implementation_details.md) (architecture, design decisions, trade-offs, and performance considerations). -- `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 +--- -## Intended use +## Tech Stack -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. +| Concern | Choice | +|---|---| +| Language | Java 21 | +| Framework | Spring Boot 3.3.4 (Web, Validation) | +| Persistence | Spring Data JPA / Hibernate ORM 6.5 | +| Database | PostgreSQL 15 | +| Migrations | Flyway | +| Boilerplate | Lombok | +| Testing | JUnit 5, Testcontainers (real PostgreSQL), AssertJ | +| Build | Gradle (wrapper included) | +| Formatting | Spotless + Google Java Format (enforced by `check`) | -## Notes +--- -- 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. +## Key Features -## How to Submit Assignment +- **`POST /transfers`** — move funds between two wallets with exactly-once semantics. +- **Idempotency** — a dedicated, durable registry returns the original result for any retry and prevents duplicate side effects. +- **Double-entry ledger** — every transfer writes exactly one DEBIT and one CREDIT; the ledger always balances. +- **Concurrency safety** — pessimistic row locks with deterministic ordering prevent double-spend and deadlocks. +- **Guarded state machine** — transfers move `PENDING → PROCESSED | FAILED`, never backwards. +- **Defense in depth** — invariants enforced in the domain *and* at the database (`CHECK` / `UNIQUE` / `FK`). +- **Uniform error contract** — a single `ErrorResponse` shape across all non-2xx responses. -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. +--- -Your PR branch should be named: `solution/` (e.g., `solution/jane-doe`). +## Architecture Overview + +A clean, layered architecture with strict separation of concerns: + +``` +HTTP ─▶ Controller ─▶ Service (orchestration + idempotency) ─▶ Processor (1 ACID tx) ─▶ Repositories ─▶ PostgreSQL + │ + Domain entities + (state transitions + invariants) +``` + +| Layer | Responsibility | Key types | +|---|---|---| +| **Controller** | Transport only: validate, map outcome → HTTP status, delegate | `TransferController` | +| **Service** | Orchestration + idempotency; **not** transactional | `TransferServiceImpl` | +| **Processor** | The atomic unit of work — one `@Transactional` boundary | `TransferProcessor` | +| **Repository** | Persistence only (incl. the pessimistic-lock query) | `WalletRepository`, … | +| **Domain** | Entities owning their state transitions and invariants | `Wallet`, `Transfer`, … | + +The **service/processor split is deliberate**: the orchestrator stays outside any transaction so it can catch a concurrent duplicate's failure and replay the committed winner, while the processor owns a single real transaction boundary. See [Implementation Highlights](#implementation-highlights). + +--- + +## Project Structure + +``` +src/ +├── main/ +│ ├── java/com/rajat/wallet/ +│ │ ├── WalletTransferApplication.java # Spring Boot entry point (@EnableJpaAuditing) +│ │ ├── controller/ +│ │ │ └── TransferController.java # POST /transfers — thin transport layer +│ │ ├── service/ +│ │ │ ├── TransferService.java # service interface +│ │ │ ├── TransferServiceImpl.java # idempotency-aware orchestrator (non-transactional) +│ │ │ └── TransferProcessor.java # the single @Transactional unit of work +│ │ ├── repository/ +│ │ │ ├── WalletRepository.java # findAllForUpdate (SELECT … FOR UPDATE) +│ │ │ ├── TransferRepository.java +│ │ │ ├── LedgerEntryRepository.java +│ │ │ └── IdempotencyRecordRepository.java # findByIdempotencyKey +│ │ ├── domain/ +│ │ │ ├── entities/ +│ │ │ │ ├── Wallet.java # balance + debit/credit invariants +│ │ │ │ ├── Transfer.java # guarded state machine +│ │ │ │ ├── LedgerEntry.java # immutable, append-only +│ │ │ │ ├── IdempotencyRecord.java # exactly-once registry record +│ │ │ │ └── common/ +│ │ │ │ ├── BaseEntity.java # UUID v7 primary key +│ │ │ │ └── AuditableEntity.java # created_at / updated_at auditing +│ │ │ └── enums/ +│ │ │ ├── TransferStatus.java # PENDING | PROCESSED | FAILED +│ │ │ ├── EntryType.java # DEBIT | CREDIT +│ │ │ └── IdempotencyStatus.java # IN_PROGRESS | COMPLETED +│ │ ├── dto/ +│ │ │ ├── CreateTransferRequest.java # inbound contract + bean validation +│ │ │ ├── TransferResponse.java # outbound contract (builder) +│ │ │ └── ErrorResponse.java # uniform error body +│ │ └── exception/ +│ │ ├── WalletNotFoundException.java # → 404 +│ │ ├── IdempotencyConflictException.java # → 409 +│ │ └── handler/ +│ │ └── GlobalExceptionHandler.java # @RestControllerAdvice +│ └── resources/ +│ ├── application.yml +│ └── db/migration/ +│ ├── V1__init.sql # wallets, transfers, ledger_entries +│ ├── V2__create_idempotency_records.sql +│ └── V3__amount_precision_two_decimals.sql +└── test/java/com/rajat/wallet/ + ├── domain/entities/ + │ ├── WalletTest.java # balance invariants (unit) + │ └── TransferTest.java # state machine (unit) + ├── support/ + │ └── AbstractIntegrationTest.java # Testcontainers base + ├── TransferApiIT.java # execution, ledger, validation + ├── IdempotencyIT.java # replay + conflict + └── ConcurrencyIT.java # no double-spend, duplicate key + +scripts/seed-wallets.sql # 10 demo wallets (manual, not a migration) +docker-compose.yml # local PostgreSQL +``` + +--- + +## How to Run + +### Prerequisites + +- JDK 21 (the Gradle toolchain will resolve it) +- Docker (for PostgreSQL and for the Testcontainers-based tests) + +### 1. Start PostgreSQL + +```bash +docker compose up -d +``` + +This starts `postgres:15.4` as container `wallet-postgres` (db/user/password all `wallet`) on port `5432`. + +### 2. Run the application + +```bash +./gradlew bootRun +``` + +Flyway applies `V1`–`V3` on startup, then Hibernate validates the schema. The API is available at `http://localhost:8080`. + +Connection settings are overridable via env vars (defaults shown): + +``` +DB_URL=jdbc:postgresql://localhost:5432/wallet +DB_USERNAME=wallet +DB_PASSWORD=wallet +SERVER_PORT=8080 +``` + +### 3. (Optional) Seed demo wallets + +```bash +docker exec -i wallet-postgres psql -U wallet -d wallet < scripts/seed-wallets.sql +``` + +Seeds 10 wallets with fixed, human-readable UUIDs (`00000000-0000-0000-0000-000000000001` … `…010`), including a zero-balance wallet and two `USD` wallets so insufficient-funds and currency-mismatch paths are easy to demo. + +### 4. Run the tests + +```bash +./gradlew test +``` + +Domain unit tests run instantly; integration tests spin up a real PostgreSQL via Testcontainers (Docker required). + +--- + +## API Endpoints + +### `POST /transfers` + +Creates a transfer. Idempotent on `idempotencyKey`. + +**Request** + +```json +{ + "idempotencyKey": "abc123", + "fromWalletId": "00000000-0000-0000-0000-000000000001", + "toWalletId": "00000000-0000-0000-0000-000000000002", + "amount": 250.50 +} +``` + +| Field | Type | Rules | +|---|---|---| +| `idempotencyKey` | string | required, non-blank; unique per logical request | +| `fromWalletId` | UUID | required, must exist | +| `toWalletId` | UUID | required, must exist, `!= fromWalletId` | +| `amount` | decimal | required, `> 0`, scale ≤ 2 | + +**Success — `201 Created`** + +```json +{ + "transferId": "018f...", + "fromWalletId": "00000000-0000-0000-0000-000000000001", + "toWalletId": "00000000-0000-0000-0000-000000000002", + "amount": 250.50, + "status": "PROCESSED", + "failureReason": null, + "createdAt": "2026-06-20T10:00:00Z" +} +``` + +```bash +curl -s -X POST localhost:8080/transfers -H 'Content-Type: application/json' -d '{ + "idempotencyKey": "demo-1", + "fromWalletId": "00000000-0000-0000-0000-000000000001", + "toWalletId": "00000000-0000-0000-0000-000000000002", + "amount": 250.50 +}' +``` + +**Business failure — `422 Unprocessable Entity`** (recorded, not an error) + +A business failure is a first-class outcome: the transfer is persisted as `FAILED` with a reason, and the `422` response is cached so a retry replays it identically. + +```json +{ + "transferId": "018f...", + "fromWalletId": "00000000-0000-0000-0000-000000000006", + "toWalletId": "00000000-0000-0000-0000-000000000002", + "amount": 100.00, + "status": "FAILED", + "failureReason": "Insufficient funds in wallet 00000000-0000-0000-0000-000000000006", + "createdAt": "2026-06-20T10:00:00Z" +} +``` + +### Possible error scenarios + +| Scenario | Code | Body | +|---|---|---| +| Transfer executed | `201 Created` | `TransferResponse` (`status: PROCESSED`) | +| Insufficient funds / currency mismatch | `422 Unprocessable Entity` | `TransferResponse` (`status: FAILED` + `failureReason`) | +| Validation error (missing field, `amount <= 0`, scale > 2, self-transfer, malformed JSON) | `400 Bad Request` | `ErrorResponse` (with `fieldErrors`) | +| Wallet does not exist | `404 Not Found` | `ErrorResponse` | +| Idempotency key reused with a **different** payload, or original still `IN_PROGRESS` | `409 Conflict` | `ErrorResponse` | +| Duplicate of a **completed** request (same key + same payload) | replay | original status + body, verbatim | +| Unexpected server error | `500 Internal Server Error` | `ErrorResponse` | + +--- + +## Error Handling + +All non-2xx responses share one shape, produced by `GlobalExceptionHandler` (`@RestControllerAdvice`): + +```json +{ + "timestamp": "2026-06-20T10:00:00Z", + "status": 400, + "error": "Bad Request", + "message": "Validation failed", + "fieldErrors": { "amount": "must be greater than 0" } +} +``` + +`fieldErrors` is omitted (`@JsonInclude(NON_NULL)`) for non-validation errors. + +| Exception | HTTP | +|---|---| +| `MethodArgumentNotValidException` (bean validation) | `400` | +| `HttpMessageNotReadableException` (malformed body) | `400` | +| `IllegalArgumentException` | `400` | +| `WalletNotFoundException` | `404` | +| `IdempotencyConflictException` | `409` | +| `DataIntegrityViolationException` (race fallback) | `409` | +| any other `Exception` | `500` (logged) | + +The controller maps a recorded business `FAILED` outcome to `422`, and `PROCESSED` to `201` — so a replayed result returns the same status code as the original. + +--- + +## Implementation Highlights + +### Idempotency + +A **dedicated, operation-agnostic `idempotency_records` table** (rather than a unique constraint on `transfers`) stores each key with a `request_hash`, a `status` (`IN_PROGRESS → COMPLETED`), and the **cached response** (`response_status` + `response_body`). + +- **First request** inserts an `IN_PROGRESS` record (`saveAndFlush`), executes the transfer, then flips it to `COMPLETED` with the cached response — all in one transaction. +- **Retry of a completed request** with the same payload **replays** the cached response verbatim (no re-execution). +- **Same key, different payload** → `409` (the `request_hash` guards against accidental key reuse). +- Durable, so it survives process restarts. + +### Concurrency + +- Both wallets are loaded with `SELECT … FOR UPDATE` (`@Lock(PESSIMISTIC_WRITE)`), **ordered by id**, so two opposing transfers between the same pair always acquire locks in the same sequence — **no deadlock**. +- Funds are checked and the balance written **under the same lock**, closing the read-then-write window — **no double-spend**. The DB `CHECK (balance >= 0)` is a backstop. +- **Concurrent duplicate keys**: the `saveAndFlush` on the unique `idempotency_key` makes the second writer **block** on the index until the winner commits, then fail with a unique violation. The non-transactional orchestrator catches it and replays the committed winner — so the side effect happens **exactly once**. + +### Double-Entry Ledger + +Every processed transfer writes **exactly two immutable `ledger_entries`**: a DEBIT on the source and a CREDIT on the destination, each snapshotting `balance_after`. `transfer_id` is a non-null FK, so a ledger row can never be orphaned. The invariant `balance == SUM(credits) − SUM(debits)` holds for every committed transaction. + +### Clean Separation + +Thin controller (transport only) → orchestrating service (idempotency, no DB transaction) → transactional processor (the atomic unit) → persistence-only repositories → rich domain entities that own their own state transitions (`Transfer.markProcessed/markFailed`) and invariants (`Wallet.debit` refuses to go negative). + +--- + +## Database Schema + +| Table | Purpose | Key columns & constraints | +|---|---|---| +| `wallets` | materialized balance | `balance NUMERIC(19,2)`, `currency`, `CHECK (balance >= 0)` | +| `transfers` | request + lifecycle | `from_wallet_id`, `to_wallet_id` (FK→wallets), `amount`, `status`, `failure_reason`; `CHECK (amount > 0)`, `CHECK (from <> to)`, `CHECK (status IN …)` | +| `ledger_entries` | append-only double-entry | `wallet_id`, `transfer_id` (FKs), `type`, `amount`, `balance_after`; `CHECK (amount > 0)`, `CHECK (type IN …)` | +| `idempotency_records` | exactly-once registry | `idempotency_key UNIQUE`, `request_hash`, `status`, `target_id`, `response_status`, `response_body`; `CHECK (status IN …)` | + +- **Primary keys**: UUID **v7** (time-ordered, assigned by Hibernate on persist — append-friendly index inserts). +- **Indexes**: FK columns on `transfers` and `ledger_entries`; the `UNIQUE (idempotency_key)` doubles as the duplicate-lookup index. +- **Migrations** (Flyway, applied on startup): `V1` (core tables), `V2` (idempotency registry), `V3` (narrow money columns to `NUMERIC(19,2)`). + +--- + +## Testing + +Behavioral tests (TDD: Red → Blue → Green). Integration tests run against a **real PostgreSQL via Testcontainers**, so locking and constraints are exercised for real — no in-memory substitute. + +**Domain unit tests** (no Spring, no DB) +- `WalletTest` — debit reduces balance, credit increases, exact-balance debit allowed, overdraft throws and leaves the balance untouched, `hasSufficientFunds` boundary. +- `TransferTest` — `PENDING → PROCESSED`, `PENDING → FAILED` (with reason), and the guards that make retries safe (can't re-process, can't fail-after-process, can't process-after-fail). + +**Integration tests** (Testcontainers) +- `TransferApiIT` — happy path (`201`, balances moved, **exactly two ledger entries** with correct `balance_after`, ledger balances); insufficient funds → `422 FAILED`, no money moved, no ledger rows; currency mismatch → `422 FAILED`; unknown wallet → `404`, nothing persisted; validation cases → `400` (blank key, non-positive amount, scale > 2, self-transfer, malformed JSON). +- `IdempotencyIT` — same key + same payload replays the original result and applies the transfer **once**; same key + different payload → `409`. +- `ConcurrencyIT` — 10 simultaneous debits of a 100-balance wallet: **exactly 5 succeed, 5 fail, balance lands at 0.00, never negative**, ledger stays consistent; 6 concurrent requests with the **same** key apply the transfer **exactly once** (one shared transfer id; every caller gets `201` or `409`). + +```bash +./gradlew test # all suites +``` + +--- + +## Documentation + +- [`TechnicalDesignDocument.md`](./TechnicalDesignDocument.md) — documentation-first design note: problem statement, API contract, failure modes, idempotency/retry behavior, consistency expectations, observability, testing strategy, assumptions. +- [`implementation_details.md`](./implementation_details.md) — detailed architecture, design decisions, trade-offs, and performance considerations. + +--- + +## Assumptions + +- Transfers are **single-currency** (no FX); a currency mismatch is a recorded `FAILED` outcome. +- `idempotencyKey` is carried in the **request body** (per the assignment example); an `Idempotency-Key` header is an equivalent alternative. +- No authentication/authorization layer (out of scope). diff --git a/TechnicalDesignDocument.md b/TechnicalDesignDocument.md index 9e6d545c..619dcd23 100644 --- a/TechnicalDesignDocument.md +++ b/TechnicalDesignDocument.md @@ -11,10 +11,9 @@ | **Stack** | Java 21, Spring Boot 3.3.4, Spring Data JPA / Hibernate 6.5, PostgreSQL, Flyway | | **In scope** | `POST /transfers` with exactly-once semantics, double-entry ledger, balance tracking, safe concurrency | | **Out of scope (optional)** | balance API, transfer-history API, metrics dashboards, async workflows | -| **Built so far** | Schema (Flyway `V1`, `V2`), domain entities (`Wallet`, `Transfer`, `LedgerEntry`, `IdempotencyRecord`), base classes (`BaseEntity`, `AuditableEntity`) | -| **Not yet built** | Repository, service, handler layers; tests | +| **Built** | Schema (Flyway `V1`, `V2`, `V3`), domain entities (`Wallet`, `Transfer`, `LedgerEntry`, `IdempotencyRecord`), base classes (`BaseEntity`, `AuditableEntity`), repository / service / controller layers, exception handling, and the full test suite (domain unit + Testcontainers integration, idempotency, concurrency) | -**Preferred order of work** (where we are): ✅ inspect contract (`ASSIGNMENT.md`) → ✅ design note (this doc) → ⏳ implement code → ⏳ add tests → ⏳ verify observability/operational concerns. +**Preferred order of work** (complete): ✅ inspect contract (`ASSIGNMENT.md`) → ✅ design note (this doc) → ✅ implement code → ✅ add tests → ✅ verify observability/operational concerns. --- @@ -127,7 +126,7 @@ All entities extend `BaseEntity` (UUID **v7** primary key, assigned by Hibernate 3. two `ledger_entries` rows (DEBIT + CREDIT), 4. updated `balance` on one or both wallets. -Migrations: `V1__init.sql` (wallets, transfers, ledger_entries) and `V2__create_idempotency_records.sql`. Flyway runs on app startup before Hibernate `validate`. +Migrations: `V1__init.sql` (wallets, transfers, ledger_entries), `V2__create_idempotency_records.sql`, and `V3__amount_precision_two_decimals.sql` (narrows monetary columns from `NUMERIC(19,4)` to `NUMERIC(19,2)` to match the scale ≤ 2 contract). Flyway runs on app startup before Hibernate `validate`. --- diff --git a/implementation_details.md b/implementation_details.md new file mode 100644 index 00000000..b48c4a7a --- /dev/null +++ b/implementation_details.md @@ -0,0 +1,220 @@ +# Implementation Details + +Deep-dive into the architecture, the design decisions behind it, the trade-offs each one carries, and the performance characteristics. This complements the [`README.md`](./README.md) (usage) and the [`TechnicalDesignDocument.md`](./TechnicalDesignDocument.md) (the documentation-first contract). + +--- + +## 1. Detailed Architecture + +### 1.1 Request lifecycle + +``` +POST /transfers + │ + ▼ +TransferController validate body (bean validation) → delegate → map outcome to HTTP status + │ + ▼ +TransferServiceImpl NOT @Transactional — the orchestrator + │ 1. compute request_hash + │ 2. fast path: findByIdempotencyKey → if present, replay or 409 + │ 3. else delegate to the processor + │ 4. on DataIntegrityViolationException (lost the unique-key race): re-read winner and replay + ▼ +TransferProcessor.process() @Transactional — ONE atomic unit of work + │ 1. saveAndFlush(IdempotencyRecord.inProgress) ← reserves the key, collides early + │ 2. lock both wallets SELECT … FOR UPDATE ORDER BY id + │ 3. save Transfer(PENDING) ← gets a UUID v7 id for the ledger FKs + │ 4. validate under lock (currency, funds) + │ 5a. fail → transfer.markFailed(reason) ← still COMMITS (recorded outcome) + │ 5b. ok → debit/credit wallets + 2 ledger entries + transfer.markProcessed + │ 6. record.complete(targetId, status, body) ← cache the response + ▼ +COMMIT +``` + +### 1.2 The service / processor split (the central decision) + +The two beans exist so the **transaction boundary is in the right place**: + +- `TransferProcessor` is a **separate Spring bean** annotated `@Transactional`. Because Spring transactions are proxy-based, the boundary is only real when the call **crosses a bean boundary** — an inner method annotated `@Transactional` on the same class would be ignored. Putting it on its own bean makes the boundary genuine. +- `TransferServiceImpl` is **deliberately not transactional**. It must be able to observe the *committed* result of a competing transaction. If it shared a transaction with the processor, a `DataIntegrityViolationException` would mark that transaction rollback-only ("poisoned"), and it could not then turn around and read the winner. + +This split is what turns a concurrent duplicate from an error into a **transparent replay**. See §2.2. + +### 1.3 Entity model + +All entities extend `BaseEntity` (UUID v7 id) → `AuditableEntity` (`created_at` / `updated_at`), both `@MappedSuperclass`. Domain behavior lives on the entities: + +- `Wallet.debit()` throws if it would go negative — a last line of defence independent of the service. +- `Transfer.markProcessed()` / `markFailed()` call a private `requirePending()` guard, so the state machine only ever moves *out of* `PENDING` once. +- `LedgerEntry` is immutable (getters only, `@AllArgsConstructor`) — it models an append-only fact. + +--- + +## 2. Design Decisions & Trade-offs + +### 2.1 Materialized balance vs. derived-from-ledger + +**Decision:** keep a materialized `balance` column on `wallets`, updated in the same transaction as the ledger entries. + +| | Materialized (chosen) | Derived (`SUM` of ledger) | +|---|---|---| +| Read cost | **O(1)** | O(n) aggregate per read | +| Write cost | one extra UPDATE | none | +| Correctness | invariant must be maintained in-tx | always correct by construction | + +**Trade-off:** we accept the responsibility of keeping `balance == SUM(credits) − SUM(debits)` consistent (done by always writing both inside one transaction, under the wallet lock) in exchange for cheap reads. `ledger_entries.balance_after` snapshots the balance per entry, so the materialized value is always auditable/reconstructable against the ledger. + +### 2.2 Pessimistic locking vs. optimistic vs. serializable + +**Decision:** pessimistic row locks (`SELECT … FOR UPDATE`) under `READ COMMITTED`. + +- **vs. optimistic (`@Version`)** — optimistic locking surfaces conflicts as retryable failures *after* the work is done; under contention on a hot wallet that means wasted work and client-visible retries. With pessimistic locks the contending transaction simply waits, then proceeds correctly. (`@Version` was intentionally removed.) +- **vs. `SERIALIZABLE` isolation** — would also be correct, but it pushes conflict handling to serialization-failure retries across the whole transaction and costs more. Because we lock *exactly the rows we mutate*, `READ COMMITTED` + row locks is sufficient. + +**Deadlock avoidance:** wallets are always locked in **ascending id order** (`ORDER BY w.id` in the lock query), so two opposing transfers (A→B and B→A) acquire the same locks in the same order and cannot deadlock. + +**Trade-off:** pessimistic locks serialize transfers that touch a shared wallet (lower parallelism on a hot account) in exchange for simplicity and zero lost-update risk. For a wallet system, correctness on the hot row is worth more than parallelism on it. + +### 2.3 Dedicated idempotency table vs. unique constraint on `transfers` + +**Decision:** a generic `idempotency_records` table, not a `UNIQUE(idempotency_key)` on `transfers`. + +Reasons: +1. **Replayable response.** A unique constraint on `transfers` would *reject* a duplicate; it can't *return the original result*. The registry caches `response_status` + `response_body` and replays them verbatim. +2. **Operation-agnostic.** The same mechanism can guard any future endpoint; `target_id` is a plain UUID (not an FK) precisely so the registry is polymorphic across resource types. +3. **In-flight detection.** The `IN_PROGRESS → COMPLETED` lifecycle lets a concurrent duplicate distinguish "still running" from "done". + +**Trade-off:** one extra row and one extra write per request, plus a `request_hash` to compute — in exchange for true exactly-once *with* response replay. + +### 2.4 `saveAndFlush` + unique index as the concurrency primitive + +**Decision:** reserve the key with `saveAndFlush` at the very start of the transaction. + +The `flush` forces the `INSERT` to the database immediately, *before* any money moves. Combined with `UNIQUE (idempotency_key)`, this means a concurrent duplicate **blocks on the index** at its own insert and never reaches the money-moving code. When the winner commits, the loser fails with a unique violation that the orchestrator converts to a replay. The reservation is part of the same transaction, so if the transfer rolls back, the key is released too — **no orphaned `IN_PROGRESS` rows** from a crash mid-flight. + +**Trade-off:** a duplicate that arrives *during* the winner's transaction waits for it to commit (latency = winner's remaining work) instead of failing fast. That's the correct behavior for exactly-once. + +### 2.5 Business failure as a recorded outcome (`422`), not an exception + +**Decision:** insufficient funds / currency mismatch produce a persisted `FAILED` transfer and a `422`, and the transaction **commits**. + +This makes failures **idempotent too**: the cached `422` is replayed on retry, so a client that retries a doomed transfer keeps getting the same answer rather than re-running validation. Only genuinely exceptional conditions (unknown wallet, serialization error) throw and roll back. + +**Trade-off:** we store rows for failed attempts (useful for audit/observability) instead of discarding them. + +### 2.6 UUID v7 primary keys + +**Decision:** time-ordered UUID v7 (Hibernate `UuidGenerator.Style.TIME`), assigned by the app on persist. + +- vs. random UUID v4: v7 is **time-ordered**, so primary-key index inserts are append-friendly (less B-tree fragmentation, better cache locality). +- vs. DB sequence/`BIGINT`: app-assigned ids are known before flush (the `Transfer` id is needed for the ledger FKs in the same unit of work) and avoid a round-trip; they're also non-guessable and merge-friendly across systems. + +**Trade-off:** 16 bytes vs. 8 for a bigint, and slightly larger than v4 ordering benefits on some workloads — acceptable for the guarantees gained. + +### 2.7 Synchronous, single-transaction processing + +**Decision:** the whole transfer is one synchronous ACID transaction; by the time the client gets a response the outcome is final (`PROCESSED`/`FAILED`). `PENDING` is only a transient in-transaction state. + +**Trade-off:** simplicity and strong consistency over throughput. An async/outbox/saga design would scale writes further but adds eventual-consistency complexity that this exercise's correctness goals don't need. The state machine (`PENDING`) is in place, so an async evolution is possible later. + +--- + +## 3. Performance Considerations + +### 3.1 Read/write costs per transfer + +A successful transfer is a bounded, constant number of statements: reserve key (1 insert + flush), lock 2 wallets (1 select-for-update), insert transfer (1), update 2 wallets, insert 2 ledger entries, update the idempotency record (1). No N+1, no per-request scans — every access is by primary key or the unique idempotency index. + +### 3.2 Indexing + +- PK indexes (UUID v7) back every entity lookup. +- `UNIQUE (idempotency_key)` backs both the duplicate check and the reservation collision. +- FK indexes on `transfers(from_wallet_id, to_wallet_id)` and `ledger_entries(wallet_id, transfer_id)` keep history/audit queries from sequential-scanning. + +### 3.3 Lock contention & throughput + +Throughput is bounded by contention on a **single hot wallet**, since transfers touching it serialize on the row lock. Transfers on disjoint wallet sets run fully in parallel. The lock is held only for the wallet-mutation window of a short transaction, keeping the critical section small. If a single wallet ever became a throughput bottleneck, options (not implemented, not needed here) include balance sharding / striped sub-accounts or an async ledger with periodic balance projection. + +### 3.4 Connection & transaction footprint + +The orchestrator is non-transactional, so it holds **no** DB connection while deciding the fast path; a connection is taken only for the processor's transaction (and briefly for the initial idempotency lookup). The transaction is deliberately short — no external/network calls inside it — so connections are returned to the pool quickly. + +### 3.5 Time-ordered keys + +UUID v7 keeps PK-index inserts near-sequential, avoiding the write amplification and page splits that random v4 keys cause on high-insert tables (`transfers`, `ledger_entries`). + +--- + +## 4. Failure Handling Summary + +| Failure | Mechanism | Result | +|---|---|---| +| Insufficient funds / currency mismatch | check under lock | `FAILED` + `422`, cached & replayable | +| Unknown wallet | lookup empty → `WalletNotFoundException` | `404`, transaction rolled back | +| Concurrent debit, same wallet | `SELECT … FOR UPDATE` | second waits, re-checks funds — no double-spend | +| Concurrent duplicate key | `saveAndFlush` + unique index | loser blocks → unique violation → replay winner | +| Same key, different payload | `request_hash` mismatch | `409` | +| Crash mid-transaction | atomic rollback | no partial state, no orphaned reservation | + +Invariants are enforced **in the domain** (`Wallet.debit`, `Transfer` guards) **and at the database** (`CHECK`/`UNIQUE`/`FK`), so a logic bug cannot corrupt persisted state. + +--- + +## 5. Observability + +- Structured `INFO` logging keyed by `idempotencyKey` / `transferId` across the lifecycle (reserved → processed/failed → replayed). +- Append-only `ledger_entries` + per-entry `balance_after` + `created_at`/`updated_at` on every row give a fully reconstructable history. +- Flyway prints applied-migration state at startup. +- Natural metric hooks (not wired): transfer count by terminal status, latency, lock-wait time, duplicate-replay rate, insufficient-funds rate. + +--- + +## 6. Future Scope: Asynchronous Processing + +The current design is **synchronous** — a transfer is one ACID transaction and the client gets the final outcome (`PROCESSED`/`FAILED`) in the response. That choice favors immediate consistency, which is usually what a wallet user wants ("did it go through? yes, right now"). For higher throughput, spiky load, or multi-step transfers (external rails, compliance checks), the service can evolve to an **event-driven, asynchronous** model. The existing `PENDING → PROCESSED/FAILED` state machine, the idempotency registry, and the self-contained `TransferProcessor` are deliberate seams that make this evolution natural. + +### 6.1 Target flow + +``` +Client ─POST /transfers─▶ API (accept) → 202 Accepted { transferId, status: PENDING, statusUrl } + │ persist PENDING transfer + idempotency(IN_PROGRESS) + outbox row (ONE tx) + ▼ + Kafka (topic: transfers, partitioned by wallet id) + ▼ + Transfer consumer → runs today's TransferProcessor logic + │ debit/credit + 2 ledger entries + mark PROCESSED/FAILED + ▼ + Kafka (topic: transfer-completed) + ├─▶ Notification service → email / push (idempotent) + └─▶ SSE / WebSocket gateway → app +``` + +The API response becomes **`202 Accepted`** instead of `201`, and `PENDING` — invisible to the client today — becomes a first-class, observable state. A **`GET /transfers/{id}`** status endpoint becomes mandatory as the reliable source of truth (SSE and email are best-effort). + +### 6.2 Correctness concerns (these matter more than the happy path) + +1. **Dual-write → transactional outbox.** A service cannot atomically write the `PENDING` transfer to Postgres *and* publish to Kafka; a crash between the two loses or double-publishes the event. Write an **outbox row in the same DB transaction**, and let a relay (Debezium CDC or a poller) publish it. The event is published **iff** the transfer was persisted. + +2. **At-least-once delivery → app-level idempotency.** Kafka redelivers (rebalances, retries), and its "exactly-once" only covers Kafka→Kafka — the side effect still lands in Postgres. The consumer must dedup on `idempotencyKey`/`transferId` and replay instead of re-applying. This makes the existing `idempotency_records` table **more** important, not less. + +3. **Ordering vs. the DB lock.** Partition by wallet id for per-wallet ordering and predictable contention, but a transfer touches *two* wallets, so partitioning by one does not serialize the other. The `SELECT … FOR UPDATE` row locks remain the source of truth for no-double-spend; Kafka partitioning is a throughput/ordering optimization on top, not a replacement. + +4. **Business failure ≠ poison message.** Insufficient funds is a valid *terminal* outcome (commit `FAILED`, notify) and must **not** be retried or dead-lettered. Only infrastructure failures (DB down, deserialization error) should retry / route to a DLQ. + +5. **Notifications are also at-least-once.** Emit a `transfer-completed` event and let a separate notification service send email/push — never call external providers inline in the transfer consumer (it would couple the ledger commit to an email gateway). That service must also dedup on `transferId`, or users get duplicate notifications. + +6. **SSE fan-out across instances.** SSE is per-connection and per-instance: the completion event must reach the instance holding *that* client's stream (each gateway subscribes to the topic and filters by user, or routes via Redis pub/sub). Always keep `GET /transfers/{id}` as the fallback, since SSE connections drop. + +### 6.3 Trade-offs + +| | Synchronous (current) | Asynchronous (future) | +|---|---|---| +| Consistency | immediate — outcome in the response | eventual — client handles `PENDING` | +| Throughput | bounded by request latency | Kafka buffers bursts; consumers scale out | +| Moving parts | one transaction | Kafka, outbox relay, notification svc, SSE fan-out, DLQ | +| Testability | one ACID tx, easy | distributed, harder to reason about | +| Best for | correctness-first wallet UX | high/spiky load, multi-step transfers | + +**Recommendation:** don't move to async for *correctness* reasons (the synchronous model is stronger there) — only for *scale/throughput*. A pragmatic first step is to introduce the **outbox + Kafka path for the notification / downstream side effects** (which genuinely benefit from decoupling) while keeping the **ledger write itself synchronous**, then make the ledger async only if write volume demands it. From c929006b52f65b49c4317893f942fce2ae6a3871 Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Sun, 21 Jun 2026 00:05:58 +0530 Subject: [PATCH 08/14] Added AI disclosure section in README --- README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/README.md b/README.md index 0a06e03e..9a52955c 100644 --- a/README.md +++ b/README.md @@ -349,3 +349,40 @@ Behavioral tests (TDD: Red → Blue → Green). Integration tests run against a - Transfers are **single-currency** (no FX); a currency mismatch is a recorded `FAILED` outcome. - `idempotencyKey` is carried in the **request body** (per the assignment example); an `Idempotency-Key` header is an equivalent alternative. - No authentication/authorization layer (out of scope). + +--- + +## AI Usage + +*(Disclosure per [`ASSIGNMENT.md`](./ASSIGNMENT.md) § AI usage.)* + +**Tool used:** Antigravity. + +**How I used it.** I worked design-first — I owned the architecture and used the AI as a pair-programmer to turn my decisions into code and tests faster: + +- **Made the key design decisions myself**: the layering (controller / orchestrating service / transactional processor / repositories / domain), the dedicated idempotency-registry table, pessimistic locking with deterministic lock ordering, the materialized-balance strategy, UUID v7 keys, and treating a business failure as a recorded `FAILED` outcome rather than an exception. +- **Started from a written design note** ([`TechnicalDesignDocument.md`](./TechnicalDesignDocument.md)) and drove implementation from that spec, rather than asking the AI to invent the approach. +- **Used it to accelerate the mechanical work**: scaffolding boilerplate, drafting entities/migrations from my schema, and generating the Testcontainers test cases for scenarios I specified. +- **Reviewed and corrected every suggestion**, often sending it back for revision — e.g. rejected an early `@Version` optimistic-lock approach, redirected the concurrent-duplicate handling toward the non-transactional-orchestrator + transactional-processor split, and had it remove leftover debug code and fix the idempotency reservation semantics. +- **Pressure-tested the design** by having it explain trade-offs and edge cases (concurrency races, retry safety), then validated the conclusions against the code and tests. + +**Representative prompts.** These are the kinds of prompts I used through the session, written the way I actually asked them — each one lays out what I wanted and how it should behave, so the AI was filling in a design I'd already thought through: + +1. *"Let's set up a Spring Boot 3.3 project on Java 21 with Gradle for a wallet-transfer service. I want PostgreSQL with Flyway handling the schema, plus Spring Data JPA, bean validation, and Lombok. Add a docker-compose so I can run Postgres locally, and set Hibernate to validate only — Flyway should own the schema, not Hibernate."* + +2. *"I want four tables. Wallets keeps a running balance and a currency. Transfers holds the from/to wallet, amount, status, and a failure reason. Ledger entries is an append-only double-entry log — wallet, transfer, type, amount, and the balance right after. And an idempotency records table for dedup. Use UUID v7 for the ids and assign them in the app. Pull the id and the created/updated timestamps into a BaseEntity and AuditableEntity that every entity extends. Also put the real guardrails in the database itself — balance can't go negative, amount has to be positive, you can't transfer to the same wallet, and only valid status/type values are allowed."* + +3. *"Why did you put the idempotency key on the transfers table? I'd rather have a separate idempotency table — I need to send back the cached response when a duplicate comes in, and I want to reuse the same idempotency mechanism on other endpoints later. Store the key as unique, a hash of the request so I can detect a reused key with a different body, a status that goes from in-progress to completed, the id of whatever it created, and the cached response."* + +4. *"Now implement the actual transfer. It should all happen in one transaction, in this order: reserve the idempotency key first and flush it so a duplicate hits the unique index early; lock both wallets with SELECT … FOR UPDATE, always in id order so we don't deadlock; create the transfer as PENDING; then check currency and funds while the rows are locked. If it fails, mark the transfer FAILED with a reason and stop. If it's fine, debit the sender, credit the receiver, write the two ledger entries with the balance-after, and mark it PROCESSED. Then save the response onto the idempotency record. Treat insufficient funds as a normal FAILED outcome that returns 422 — not an exception."* + +5. *"What do you mean the loser of a concurrent duplicate gets a 409 instead of a replay? I don't want that — if two identical requests race, the second one should still get back the original result. Walk me through whether committing the reservation early actually helps here, then set it up so a non-transactional service calls a transactional processor: the second request blocks on the unique key until the first commits, fails, and then we just replay the winner's cached response."* + +6. *"Keep the controller thin — just validate, hand off to the service, and turn the result into a status code (201 for processed, 422 for a recorded failure). Add a global exception handler that returns one consistent error body: 400 for validation, 404 for an unknown wallet, 409 for an idempotency conflict or race, and 500 for anything unexpected."* + +7. *"Add validation on the request — the idempotency key can't be blank, both wallet ids are required, the amount has to be positive and at most two decimal places, and reject a transfer where the source and destination are the same wallet."* + +8. *"Write tests, and keep them behavioral. Plain unit tests for the wallet's overdraft guard and the transfer state transitions. Then integration tests on a real Postgres with Testcontainers — the happy path with the two ledger entries and correct balances, insufficient funds and currency mismatch coming back as 422 with nothing moved, unknown wallet as 404, and the validation cases as 400. Add idempotency tests for replaying the same key and for rejecting a reused key with a different body. And concurrency tests that prove parallel debits can't overdraw and that firing the same key many times at once still only does the transfer once."* + +9. *"Help me write up the docs — a design document with the contract, failure modes, idempotency and retry behavior, consistency guarantees and the testing strategy; a README with the stack, how to run it, the architecture, the API, the schema and the tests; and a separate implementation-details note going deeper on the design decisions, trade-offs and performance."* + From f619f28db6fe2d676704c8e7114965bce1c7ba34 Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Sun, 21 Jun 2026 00:12:55 +0530 Subject: [PATCH 09/14] fixed build.gradle group --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 586ed6e3..fffc2859 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { id 'com.diffplug.spotless' version '6.25.0' } -group = 'com.example' +group = 'com.rajat' version = '0.0.1-SNAPSHOT' java { From e6dd90db50f48cc54d85a2d930145234acbdcaf0 Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Sun, 21 Jun 2026 00:38:19 +0530 Subject: [PATCH 10/14] Add wallet balance and paginated transfer history APIs - GET /wallets/{id} returns the materialized balance, currency, and last-updated timestamp (O(1) read, no ledger aggregation). - GET /wallets/{id}/transfers returns a wallet's transfers (source or destination), newest first, paginated via page/size/sort. Wrapped in an explicit PageResponse rather than Spring's Page for a stable JSON contract; an id tiebreaker keeps paging deterministic. - Both reads run read-only and 404 on an unknown wallet; a malformed UUID path variable now maps to 400 instead of 500. - Cover all of the above with Testcontainers tests in WalletApiIT. --- README.md | 117 +++++++++++++++- TechnicalDesignDocument.md | 29 +++- implementation_details.md | 9 ++ .../wallet/controller/WalletController.java | 43 ++++++ .../com/rajat/wallet/dto/PageResponse.java | 30 ++++ .../com/rajat/wallet/dto/WalletResponse.java | 19 +++ .../handler/GlobalExceptionHandler.java | 8 ++ .../wallet/repository/TransferRepository.java | 20 ++- .../rajat/wallet/service/WalletService.java | 20 +++ .../wallet/service/WalletServiceImpl.java | 45 ++++++ .../java/com/rajat/wallet/WalletApiIT.java | 128 ++++++++++++++++++ 11 files changed, 455 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/rajat/wallet/controller/WalletController.java create mode 100644 src/main/java/com/rajat/wallet/dto/PageResponse.java create mode 100644 src/main/java/com/rajat/wallet/dto/WalletResponse.java create mode 100644 src/main/java/com/rajat/wallet/service/WalletService.java create mode 100644 src/main/java/com/rajat/wallet/service/WalletServiceImpl.java create mode 100644 src/test/java/com/rajat/wallet/WalletApiIT.java diff --git a/README.md b/README.md index 9a52955c..58edf278 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ A reliable, transactional wallet-to-wallet transfer service that guarantees **ex ## Key Features - **`POST /transfers`** — move funds between two wallets with exactly-once semantics. +- **Read APIs** — `GET /wallets/{id}` (balance) and `GET /wallets/{id}/transfers` (paginated transfer history). - **Idempotency** — a dedicated, durable registry returns the original result for any retry and prevents duplicate side effects. - **Double-entry ledger** — every transfer writes exactly one DEBIT and one CREDIT; the ledger always balances. - **Concurrency safety** — pessimistic row locks with deterministic ordering prevent double-spend and deadlocks. @@ -47,8 +48,8 @@ HTTP ─▶ Controller ─▶ Service (orchestration + idempotency) ─▶ Proce | Layer | Responsibility | Key types | |---|---|---| -| **Controller** | Transport only: validate, map outcome → HTTP status, delegate | `TransferController` | -| **Service** | Orchestration + idempotency; **not** transactional | `TransferServiceImpl` | +| **Controller** | Transport only: validate, map outcome → HTTP status, delegate | `TransferController`, `WalletController` | +| **Service** | Orchestration + idempotency; **not** transactional | `TransferServiceImpl`, `WalletServiceImpl` | | **Processor** | The atomic unit of work — one `@Transactional` boundary | `TransferProcessor` | | **Repository** | Persistence only (incl. the pessimistic-lock query) | `WalletRepository`, … | | **Domain** | Entities owning their state transitions and invariants | `Wallet`, `Transfer`, … | @@ -65,14 +66,17 @@ src/ │ ├── java/com/rajat/wallet/ │ │ ├── WalletTransferApplication.java # Spring Boot entry point (@EnableJpaAuditing) │ │ ├── controller/ -│ │ │ └── TransferController.java # POST /transfers — thin transport layer +│ │ │ ├── TransferController.java # POST /transfers — thin transport layer +│ │ │ └── WalletController.java # GET /wallets/{id} + /{id}/transfers (read APIs) │ │ ├── service/ -│ │ │ ├── TransferService.java # service interface +│ │ │ ├── TransferService.java # transfer service interface │ │ │ ├── TransferServiceImpl.java # idempotency-aware orchestrator (non-transactional) -│ │ │ └── TransferProcessor.java # the single @Transactional unit of work +│ │ │ ├── TransferProcessor.java # the single @Transactional unit of work +│ │ │ ├── WalletService.java # read-side interface +│ │ │ └── WalletServiceImpl.java # balance + history (readOnly tx) │ │ ├── repository/ │ │ │ ├── WalletRepository.java # findAllForUpdate (SELECT … FOR UPDATE) -│ │ │ ├── TransferRepository.java +│ │ │ ├── TransferRepository.java # findByWalletId (paginated history) │ │ │ ├── LedgerEntryRepository.java │ │ │ └── IdempotencyRecordRepository.java # findByIdempotencyKey │ │ ├── domain/ @@ -91,6 +95,8 @@ src/ │ │ ├── dto/ │ │ │ ├── CreateTransferRequest.java # inbound contract + bean validation │ │ │ ├── TransferResponse.java # outbound contract (builder) +│ │ │ ├── WalletResponse.java # balance read contract +│ │ │ ├── PageResponse.java # stable pagination envelope │ │ │ └── ErrorResponse.java # uniform error body │ │ └── exception/ │ │ ├── WalletNotFoundException.java # → 404 @@ -111,7 +117,8 @@ src/ │ └── AbstractIntegrationTest.java # Testcontainers base ├── TransferApiIT.java # execution, ledger, validation ├── IdempotencyIT.java # replay + conflict - └── ConcurrencyIT.java # no double-spend, duplicate key + ├── ConcurrencyIT.java # no double-spend, duplicate key + └── WalletApiIT.java # balance + transfer-history reads scripts/seed-wallets.sql # 10 demo wallets (manual, not a migration) docker-compose.yml # local PostgreSQL @@ -244,6 +251,79 @@ A business failure is a first-class outcome: the transfer is persisted as `FAILE | Duplicate of a **completed** request (same key + same payload) | replay | original status + body, verbatim | | Unexpected server error | `500 Internal Server Error` | `ErrorResponse` | +### `GET /wallets/{id}` + +Returns a wallet's current balance. + +**`200 OK`** + +```json +{ + "walletId": "00000000-0000-0000-0000-000000000001", + "balance": 250.75, + "currency": "INR", + "updatedAt": "2026-06-20T10:00:00Z" +} +``` + +```bash +curl -s localhost:8080/wallets/00000000-0000-0000-0000-000000000001 +``` + +| Scenario | Code | +|---|---| +| Wallet found | `200 OK` | +| Wallet does not exist | `404 Not Found` | +| Malformed id (not a UUID) | `400 Bad Request` | + +### `GET /wallets/{id}/transfers` + +Returns a **paginated** list of the transfers the wallet took part in (as source **or** destination), **newest first** — each item is the same `TransferResponse` shape returned by `POST /transfers`. + +**Query parameters** + +| Param | Default | Meaning | +|---|---|---| +| `page` | `0` | zero-based page index | +| `size` | `20` | page size | +| `sort` | `createdAt,id,desc` | sort fields + direction (Spring Data syntax) | + +**`200 OK`** + +```json +{ + "content": [ + { + "transferId": "018f...", + "fromWalletId": "00000000-0000-0000-0000-000000000001", + "toWalletId": "00000000-0000-0000-0000-000000000002", + "amount": 250.50, + "status": "PROCESSED", + "failureReason": null, + "createdAt": "2026-06-20T10:00:00Z" + } + ], + "page": 0, + "size": 20, + "totalElements": 1, + "totalPages": 1, + "first": true, + "last": true +} +``` + +```bash +curl -s "localhost:8080/wallets/00000000-0000-0000-0000-000000000001/transfers?page=0&size=20" +``` + +`content` is an empty array when the wallet has no history. The response uses an explicit `PageResponse` envelope rather than Spring Data's `Page` so the JSON shape is stable across versions. + +| Scenario | Code | +|---|---| +| Wallet found (page, `content` possibly empty) | `200 OK` | +| Wallet does not exist | `404 Not Found` | +| Malformed id (not a UUID) | `400 Bad Request` | + --- ## Error Handling @@ -266,6 +346,7 @@ All non-2xx responses share one shape, produced by `GlobalExceptionHandler` (`@R |---|---| | `MethodArgumentNotValidException` (bean validation) | `400` | | `HttpMessageNotReadableException` (malformed body) | `400` | +| `MethodArgumentTypeMismatchException` (malformed path var, e.g. bad UUID) | `400` | | `IllegalArgumentException` | `400` | | `WalletNotFoundException` | `404` | | `IdempotencyConflictException` | `409` | @@ -330,11 +411,30 @@ Behavioral tests (TDD: Red → Blue → Green). Integration tests run against a - `TransferApiIT` — happy path (`201`, balances moved, **exactly two ledger entries** with correct `balance_after`, ledger balances); insufficient funds → `422 FAILED`, no money moved, no ledger rows; currency mismatch → `422 FAILED`; unknown wallet → `404`, nothing persisted; validation cases → `400` (blank key, non-positive amount, scale > 2, self-transfer, malformed JSON). - `IdempotencyIT` — same key + same payload replays the original result and applies the transfer **once**; same key + different payload → `409`. - `ConcurrencyIT` — 10 simultaneous debits of a 100-balance wallet: **exactly 5 succeed, 5 fail, balance lands at 0.00, never negative**, ledger stays consistent; 6 concurrent requests with the **same** key apply the transfer **exactly once** (one shared transfer id; every caller gets `201` or `409`). +- `WalletApiIT` — `GET /wallets/{id}` returns balance + currency and reflects it after a transfer; `GET /wallets/{id}/transfers` lists every transfer involving the wallet (source or destination), newest first, and excludes unrelated ones; **pagination** (`page`/`size`) returns the right slice with correct `totalElements`/`totalPages`/`first`/`last`; unknown wallet → `404`; malformed id → `400`. ```bash ./gradlew test # all suites ``` +### Manual race-condition verification + +Beyond the automated `ConcurrencyIT`, I manually widened the race window to *watch* the locking and idempotency behaviour by eye. I temporarily inserted a `Thread.sleep(15000)` inside `TransferProcessor.executeTransfer`, **right after the idempotency key is reserved and both wallets are locked** but before the money moves. With one in-flight request frozen for 15 seconds holding its key reservation and its `FOR UPDATE` row locks, I fired a second request with `curl` during that window and observed how it behaved. + +> This sleep is a debugging aid only — it was **removed before submission** (a transfer must never block for 15s, and it would stall the test suite). It widens, but does not change, the real concurrency behaviour. + +The cases I checked, and what each one demonstrated: + +| Second request fired during the 15s window | What the frozen first request is holding | Observed result | Proves | +|---|---|---|---| +| **Same idempotency key, same payload** (a true retry) | uncommitted key reservation (flushed to the unique index) | Second request **blocks on the unique index**; once the first commits it **replays the first's result** (`201`). Exactly **one** transfer + one DEBIT/CREDIT pair. | exactly-once under a concurrent duplicate | +| **Same idempotency key, different accounts / amount** (key reused for a different transfer) | uncommitted key reservation | Second request blocks, then after commit the `request_hash` mismatch is detected → **`409 Conflict`**. The first transfer is untouched. | one key = one logical operation; accidental reuse is rejected, not silently mis-applied | +| **Different keys, same source wallet** (two debits of one account) | `SELECT … FOR UPDATE` lock on the source row | Second request **blocks on the row lock**; after the first commits it re-reads the *new* balance and validates against it → **no double-spend** (it succeeds only if funds still cover it, else `422 FAILED`). | pessimistic lock serializes debits on a hot wallet | +| **Different keys, fully disjoint wallets** | nothing the second request needs | Second request **runs immediately** and finishes *before* the frozen one — no waiting. | transfers on non-overlapping wallets run fully in parallel | +| **Opposing transfer on the same pair** (`A→B` while `B→A` is frozen) | locks on both `A` and `B` (acquired in id order) | Second request **waits** for the same rows and proceeds after commit — **no deadlock**. | deterministic lock ordering prevents deadlocks | + +Each case was also confirmed against the database afterwards (balances, exactly two ledger entries per processed transfer, a single `transfers` row per logical operation). + --- ## Documentation @@ -386,3 +486,6 @@ Behavioral tests (TDD: Red → Blue → Green). Integration tests run against a 9. *"Help me write up the docs — a design document with the contract, failure modes, idempotency and retry behavior, consistency guarantees and the testing strategy; a README with the stack, how to run it, the architecture, the API, the schema and the tests; and a separate implementation-details note going deeper on the design decisions, trade-offs and performance."* +10. *"Now let's add the optional read endpoints, keeping the same clean layering with a WalletController and WalletService. I want a GET /wallets/{id} that returns the current balance, currency and when it was last updated, and a GET /wallets/{id}/transfers that returns the transfer history for a wallet — every transfer where it was the source or the destination, newest first. Run both as read-only transactions, return 404 if the wallet doesn't exist, and make sure a malformed UUID in the URL comes back as a 400 rather than a 500. Add Testcontainers tests for the balance, the history (including that it excludes unrelated transfers), and the 404/400 cases."* + +11. *"The transfer history shouldn't return everything at once — make it paginated. Use the standard page/size/sort query params, default to newest first, and add the id as a tiebreaker so paging stays deterministic when timestamps collide. Don't serialize Spring's Page object directly though — wrap it in our own response shape with content, page, size, totalElements, totalPages, first and last. Add a test that pages through the results and checks the slice and the metadata."* diff --git a/TechnicalDesignDocument.md b/TechnicalDesignDocument.md index 619dcd23..bed2eddd 100644 --- a/TechnicalDesignDocument.md +++ b/TechnicalDesignDocument.md @@ -9,9 +9,9 @@ | | | |---|---| | **Stack** | Java 21, Spring Boot 3.3.4, Spring Data JPA / Hibernate 6.5, PostgreSQL, Flyway | -| **In scope** | `POST /transfers` with exactly-once semantics, double-entry ledger, balance tracking, safe concurrency | -| **Out of scope (optional)** | balance API, transfer-history API, metrics dashboards, async workflows | -| **Built** | Schema (Flyway `V1`, `V2`, `V3`), domain entities (`Wallet`, `Transfer`, `LedgerEntry`, `IdempotencyRecord`), base classes (`BaseEntity`, `AuditableEntity`), repository / service / controller layers, exception handling, and the full test suite (domain unit + Testcontainers integration, idempotency, concurrency) | +| **In scope** | `POST /transfers` with exactly-once semantics, double-entry ledger, balance tracking, safe concurrency; read APIs for wallet balance and transfer history | +| **Out of scope (optional)** | metrics dashboards, async workflows | +| **Built** | Schema (Flyway `V1`, `V2`, `V3`), domain entities (`Wallet`, `Transfer`, `LedgerEntry`, `IdempotencyRecord`), base classes (`BaseEntity`, `AuditableEntity`), repository / service / controller layers (transfer + wallet read APIs), exception handling, and the full test suite (domain unit + Testcontainers integration, idempotency, concurrency) | **Preferred order of work** (complete): ✅ inspect contract (`ASSIGNMENT.md`) → ✅ design note (this doc) → ✅ implement code → ✅ add tests → ✅ verify observability/operational concerns. @@ -101,9 +101,27 @@ Response codes: | `409 Conflict` | idempotency key reused with a **different** payload, **or** an identical request is still `IN_PROGRESS` (retry shortly) | | **replay** | duplicate of a **completed** request returns the **original** status code and body verbatim | -### Optional (not required) +### `GET /wallets/{id}` — balance -`GET /wallets/{id}` (balance), `GET /transfers/{id}`, `GET /wallets/{id}/ledger`. Listed for completeness; out of scope unless time permits. +Returns the wallet's current balance. `200 OK` with `{ walletId, balance, currency, updatedAt }`; `404` if the wallet does not exist; `400` if the id is not a valid UUID. + +```json +{ "walletId": "018f...", "balance": 250.75, "currency": "INR", "updatedAt": "2026-06-20T10:00:00Z" } +``` + +### `GET /wallets/{id}/transfers` — transfer history (paginated) + +Returns a **paginated** page of the transfers the wallet participated in (as source or destination), **newest first**. Accepts the standard Spring Data paging params (`page`, `size`, `sort`; defaults `page=0`, `size=20`, `sort=createdAt,id,desc`). Each item is the same `TransferResponse` shape used by `POST /transfers`, wrapped in a stable `PageResponse` envelope (`content`, `page`, `size`, `totalElements`, `totalPages`, `first`, `last`). `200 OK` with an empty `content` when there is no history; `404` if the wallet does not exist; `400` if the id is not a valid UUID. + +```json +{ "content": [ /* TransferResponse… */ ], "page": 0, "size": 20, "totalElements": 1, "totalPages": 1, "first": true, "last": true } +``` + +Both reads run in a `readOnly` transaction and are served by primary-key / FK indexes (no scans). The explicit `PageResponse` is used instead of Spring Data's `Page` to keep the JSON contract version-stable. + +### Optional (not built) + +`GET /transfers/{id}` (single transfer) and `GET /wallets/{id}/ledger` (raw ledger entries) — natural extensions, omitted as the balance + history reads already cover the optional scope. --- @@ -208,6 +226,7 @@ Behavioral, TDD (Red → Blue → Green). PostgreSQL-backed integration tests vi - **Idempotency (integration)**: same key + same payload → single transfer, replayed response; same key + different payload → `409`; verifies **no duplicate side effects**. - **Failure scenarios**: insufficient funds → `FAILED` + `422`, and a retry replays the `422`; unknown wallet → `404`; same-wallet / bad amount → `400`. - **Concurrency**: N parallel transfers debiting one wallet → no overdraft, final balance exact, no lost updates; concurrent duplicates of the same key → exactly one transfer created. +- **Read APIs (integration)**: `GET /wallets/{id}` returns the balance and reflects it after a transfer; `GET /wallets/{id}/transfers` returns every transfer involving the wallet, newest first, and paginates correctly (`page`/`size` slice + `totalElements`/`totalPages`/`first`/`last`); unknown wallet → `404`; malformed id → `400`. Coverage targets the **required behaviors** (transfer execution, idempotency, ledger correctness, failure handling, concurrency safety), not implementation details. diff --git a/implementation_details.md b/implementation_details.md index b48c4a7a..d4c8df01 100644 --- a/implementation_details.md +++ b/implementation_details.md @@ -50,6 +50,15 @@ All entities extend `BaseEntity` (UUID v7 id) → `AuditableEntity` (`created_at - `Transfer.markProcessed()` / `markFailed()` call a private `requirePending()` guard, so the state machine only ever moves *out of* `PENDING` once. - `LedgerEntry` is immutable (getters only, `@AllArgsConstructor`) — it models an append-only fact. +### 1.4 Read side (query APIs) + +Two read-only endpoints sit alongside the write path, served by a separate `WalletService` / `WalletController` so the command and query sides stay cleanly separated: + +- **`GET /wallets/{id}`** — returns the **materialized** balance directly (`findById`), so a balance read is O(1) and never aggregates the ledger. The `updatedAt` audit column doubles as a freshness signal (timestamp of the last balance-changing transaction). +- **`GET /wallets/{id}/transfers`** — **paginated** transfer history for a wallet (source *or* destination) via `findByWalletId(walletId, Pageable)`. Paging/sorting come from a `Pageable` (default `size=20`, `sort=createdAt,id desc`); the `id` tiebreaker — a time-ordered UUID v7 — keeps paging deterministic when `createdAt` values collide. It first checks `existsById` so an unknown wallet is a clean `404` rather than an empty page. The result is mapped to an explicit `PageResponse` envelope rather than returning Spring Data's `Page` directly, whose JSON shape is version-unstable. + +Both run in a `@Transactional(readOnly = true)` boundary, mapping entities to DTOs while the session is open (`open-in-view` is disabled). A malformed UUID in the path is mapped to `400` (`MethodArgumentTypeMismatchException`) rather than leaking a `500`. + --- ## 2. Design Decisions & Trade-offs diff --git a/src/main/java/com/rajat/wallet/controller/WalletController.java b/src/main/java/com/rajat/wallet/controller/WalletController.java new file mode 100644 index 00000000..257158be --- /dev/null +++ b/src/main/java/com/rajat/wallet/controller/WalletController.java @@ -0,0 +1,43 @@ +package com.rajat.wallet.controller; + +import com.rajat.wallet.dto.PageResponse; +import com.rajat.wallet.dto.TransferResponse; +import com.rajat.wallet.dto.WalletResponse; +import com.rajat.wallet.service.WalletService; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Read-only endpoints for wallet balance and transfer history. Thin transport layer: it delegates + * to {@link WalletService} and lets {@code GlobalExceptionHandler} map a missing wallet to 404. + */ +@RestController +@RequestMapping("/wallets") +@RequiredArgsConstructor +public class WalletController { + + private final WalletService walletService; + + @GetMapping("/{id}") + public WalletResponse getWallet(@PathVariable UUID id) { + return walletService.getWallet(id); + } + + @GetMapping("/{id}/transfers") + public PageResponse getTransferHistory( + @PathVariable UUID id, + @PageableDefault( + size = 20, + sort = {"createdAt", "id"}, + direction = Sort.Direction.DESC) + Pageable pageable) { + return PageResponse.from(walletService.getTransferHistory(id, pageable)); + } +} diff --git a/src/main/java/com/rajat/wallet/dto/PageResponse.java b/src/main/java/com/rajat/wallet/dto/PageResponse.java new file mode 100644 index 00000000..911b36e4 --- /dev/null +++ b/src/main/java/com/rajat/wallet/dto/PageResponse.java @@ -0,0 +1,30 @@ +package com.rajat.wallet.dto; + +import java.util.List; +import org.springframework.data.domain.Page; + +/** + * Stable, explicit pagination envelope. Spring Data's {@code Page}/{@code PageImpl} is deliberately + * not serialized directly — its JSON shape is version-unstable (Spring Boot logs a warning about + * it) — so we map it to this fixed contract instead. + */ +public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages, + boolean first, + boolean last) { + + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.isFirst(), + page.isLast()); + } +} diff --git a/src/main/java/com/rajat/wallet/dto/WalletResponse.java b/src/main/java/com/rajat/wallet/dto/WalletResponse.java new file mode 100644 index 00000000..fe37bcc3 --- /dev/null +++ b/src/main/java/com/rajat/wallet/dto/WalletResponse.java @@ -0,0 +1,19 @@ +package com.rajat.wallet.dto; + +import com.rajat.wallet.domain.entities.Wallet; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +/** + * Outbound contract for a wallet balance read ({@code GET /wallets/{id}}). {@code updatedAt} is the + * timestamp of the last balance-changing transaction, so a client can tell how fresh the figure is. + */ +public record WalletResponse( + UUID walletId, BigDecimal balance, String currency, Instant updatedAt) { + + public static WalletResponse from(Wallet wallet) { + return new WalletResponse( + wallet.getId(), wallet.getBalance(), wallet.getCurrency(), wallet.getUpdatedAt()); + } +} diff --git a/src/main/java/com/rajat/wallet/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/rajat/wallet/exception/handler/GlobalExceptionHandler.java index ce52ea3a..52c19e1c 100644 --- a/src/main/java/com/rajat/wallet/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/rajat/wallet/exception/handler/GlobalExceptionHandler.java @@ -14,6 +14,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; /** Translates domain/transport exceptions into the uniform {@link ErrorResponse} + HTTP status. */ @RestControllerAdvice @@ -40,6 +41,13 @@ public ResponseEntity handleUnreadable(HttpMessageNotReadableExce return build(HttpStatus.BAD_REQUEST, "Malformed request body", null); } + /** A path/query parameter that cannot be bound (e.g. a malformed UUID) -> 400. */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + return build( + HttpStatus.BAD_REQUEST, "Invalid value for parameter '" + ex.getName() + "'", null); + } + @ExceptionHandler(WalletNotFoundException.class) public ResponseEntity handleWalletNotFound(WalletNotFoundException ex) { return build(HttpStatus.NOT_FOUND, ex.getMessage(), null); diff --git a/src/main/java/com/rajat/wallet/repository/TransferRepository.java b/src/main/java/com/rajat/wallet/repository/TransferRepository.java index dca53f87..120a22db 100644 --- a/src/main/java/com/rajat/wallet/repository/TransferRepository.java +++ b/src/main/java/com/rajat/wallet/repository/TransferRepository.java @@ -2,6 +2,24 @@ import com.rajat.wallet.domain.entities.Transfer; import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; -public interface TransferRepository extends JpaRepository {} +public interface TransferRepository extends JpaRepository { + + /** + * Paginated transfer history for a wallet — every transfer it took part in as source or + * destination. Served by the FK indexes on {@code from_wallet_id} / {@code to_wallet_id}; + * ordering is supplied by the {@link Pageable}. + */ + @Query( + value = + "select t from Transfer t where t.fromWalletId = :walletId or t.toWalletId = :walletId", + countQuery = + "select count(t) from Transfer t where t.fromWalletId = :walletId" + + " or t.toWalletId = :walletId") + Page findByWalletId(@Param("walletId") UUID walletId, Pageable pageable); +} diff --git a/src/main/java/com/rajat/wallet/service/WalletService.java b/src/main/java/com/rajat/wallet/service/WalletService.java new file mode 100644 index 00000000..7f6158af --- /dev/null +++ b/src/main/java/com/rajat/wallet/service/WalletService.java @@ -0,0 +1,20 @@ +package com.rajat.wallet.service; + +import com.rajat.wallet.dto.TransferResponse; +import com.rajat.wallet.dto.WalletResponse; +import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +/** Read-side queries for wallets: current balance and transfer history. */ +public interface WalletService { + + /** Current balance of a wallet. Throws {@code WalletNotFoundException} if it does not exist. */ + WalletResponse getWallet(UUID walletId); + + /** + * A page of transfers in which the wallet participated (as source or destination). Throws {@code + * WalletNotFoundException} if the wallet does not exist. + */ + Page getTransferHistory(UUID walletId, Pageable pageable); +} diff --git a/src/main/java/com/rajat/wallet/service/WalletServiceImpl.java b/src/main/java/com/rajat/wallet/service/WalletServiceImpl.java new file mode 100644 index 00000000..6fec24b3 --- /dev/null +++ b/src/main/java/com/rajat/wallet/service/WalletServiceImpl.java @@ -0,0 +1,45 @@ +package com.rajat.wallet.service; + +import com.rajat.wallet.domain.entities.Wallet; +import com.rajat.wallet.dto.TransferResponse; +import com.rajat.wallet.dto.WalletResponse; +import com.rajat.wallet.exception.WalletNotFoundException; +import com.rajat.wallet.repository.TransferRepository; +import com.rajat.wallet.repository.WalletRepository; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Read-only query side. Both methods run in a {@code readOnly} transaction so the entities are + * mapped to DTOs while the session is open ({@code open-in-view} is disabled). + */ +@Service +@RequiredArgsConstructor +public class WalletServiceImpl implements WalletService { + + private final WalletRepository walletRepository; + private final TransferRepository transferRepository; + + @Override + @Transactional(readOnly = true) + public WalletResponse getWallet(UUID walletId) { + Wallet wallet = + walletRepository + .findById(walletId) + .orElseThrow(() -> new WalletNotFoundException(walletId)); + return WalletResponse.from(wallet); + } + + @Override + @Transactional(readOnly = true) + public Page getTransferHistory(UUID walletId, Pageable pageable) { + if (!walletRepository.existsById(walletId)) { + throw new WalletNotFoundException(walletId); + } + return transferRepository.findByWalletId(walletId, pageable).map(TransferResponse::from); + } +} diff --git a/src/test/java/com/rajat/wallet/WalletApiIT.java b/src/test/java/com/rajat/wallet/WalletApiIT.java new file mode 100644 index 00000000..074b3ed7 --- /dev/null +++ b/src/test/java/com/rajat/wallet/WalletApiIT.java @@ -0,0 +1,128 @@ +package com.rajat.wallet; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.rajat.wallet.dto.PageResponse; +import com.rajat.wallet.dto.TransferResponse; +import com.rajat.wallet.dto.WalletResponse; +import com.rajat.wallet.support.AbstractIntegrationTest; +import java.util.Comparator; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * Behaviour of the read-side endpoints: {@code GET /wallets/{id}} (balance) and {@code GET + * /wallets/{id}/transfers} (history). + */ +class WalletApiIT extends AbstractIntegrationTest { + + @Test + void getWalletReturnsBalanceAndCurrency() { + UUID wallet = seedWallet("250.75", "INR"); + + ResponseEntity response = + restTemplate.getForEntity("/wallets/" + wallet, WalletResponse.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().walletId()).isEqualTo(wallet); + assertThat(response.getBody().balance()).isEqualByComparingTo("250.75"); + assertThat(response.getBody().currency()).isEqualTo("INR"); + } + + @Test + void getWalletReflectsBalanceAfterTransfer() { + UUID source = seedWallet("100.00", "INR"); + UUID dest = seedWallet("0.00", "INR"); + postTransfer(transfer("bal-1", source, dest, "30.00")); + + assertThat(walletBalance(source)).isEqualByComparingTo("70.00"); + assertThat(walletBalance(dest)).isEqualByComparingTo("30.00"); + } + + @Test + void getUnknownWalletReturns404() { + ResponseEntity response = + restTemplate.getForEntity("/wallets/" + UUID.randomUUID(), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void getWalletWithMalformedIdReturns400() { + ResponseEntity response = + restTemplate.getForEntity("/wallets/not-a-uuid", String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void transferHistoryListsEveryTransferInvolvingTheWalletNewestFirst() { + UUID a = seedWallet("1000.00", "INR"); + UUID b = seedWallet("0.00", "INR"); + UUID c = seedWallet("0.00", "INR"); + + postTransfer(transfer("h1", a, b, "10.00")); // a is source + postTransfer(transfer("h2", a, c, "20.00")); // a is source + postTransfer(transfer("h3", b, a, "5.00")); // a is destination + + PageResponse history = transferHistory(a, ""); + + // All three involve wallet a (twice as source, once as destination). + assertThat(history.totalElements()).isEqualTo(3); + assertThat(history.content()) + .hasSize(3) + .allSatisfy(t -> assertThat(a).isIn(t.fromWalletId(), t.toWalletId())) + .isSortedAccordingTo(Comparator.comparing(TransferResponse::createdAt).reversed()); + + // A wallet not involved in a transfer does not see it. + assertThat(transferHistory(c, "").totalElements()).isEqualTo(1); + } + + @Test + void transferHistoryIsPaginated() { + UUID a = seedWallet("1000.00", "INR"); + UUID b = seedWallet("0.00", "INR"); + postTransfer(transfer("p1", a, b, "10.00")); + postTransfer(transfer("p2", a, b, "10.00")); + postTransfer(transfer("p3", a, b, "10.00")); + + PageResponse first = transferHistory(a, "?page=0&size=2"); + assertThat(first.content()).hasSize(2); + assertThat(first.totalElements()).isEqualTo(3); + assertThat(first.totalPages()).isEqualTo(2); + assertThat(first.first()).isTrue(); + assertThat(first.last()).isFalse(); + + PageResponse second = transferHistory(a, "?page=1&size=2"); + assertThat(second.content()).hasSize(1); + assertThat(second.first()).isFalse(); + assertThat(second.last()).isTrue(); + } + + @Test + void transferHistoryForUnknownWalletReturns404() { + ResponseEntity response = + restTemplate.getForEntity("/wallets/" + UUID.randomUUID() + "/transfers", String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + private java.math.BigDecimal walletBalance(UUID id) { + return restTemplate.getForEntity("/wallets/" + id, WalletResponse.class).getBody().balance(); + } + + private PageResponse transferHistory(UUID id, String query) { + return restTemplate + .exchange( + "/wallets/" + id + "/transfers" + query, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}) + .getBody(); + } +} From cef8071e6db035c09f995169e22326869daaf8fc Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Sun, 21 Jun 2026 00:42:59 +0530 Subject: [PATCH 11/14] fixed TechnicalDesignDocument.md --- TechnicalDesignDocument.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TechnicalDesignDocument.md b/TechnicalDesignDocument.md index bed2eddd..ba34e49a 100644 --- a/TechnicalDesignDocument.md +++ b/TechnicalDesignDocument.md @@ -211,7 +211,7 @@ This gives **exactly-once side effects** (duplicate never produces a second tran - **Structured logging** keyed by `idempotencyKey` and `transferId` for the lifecycle: request received → key reserved / duplicate-replayed → transfer PROCESSED/FAILED → committed. Log level for `com.rajat.wallet` is `INFO`. - **Metrics** (when added): transfer count by terminal status, transfer latency, lock-wait time, duplicate-replay rate, insufficient-funds rate. -- **Health/readiness**: Spring Actuator; Flyway migration state visible at startup (`Successfully applied N migrations`). +- **Health/readiness** (when added): Spring Actuator health/readiness probes. Today, Flyway migration state is visible at startup (`Successfully applied N migrations`). - **Auditability**: append-only `ledger_entries` + `balance_after` snapshots + `created_at`/`updated_at` on every row provide a full reconstructable history. - Tracing (correlation id propagation) is a nice-to-have, not required. From e64a6637c276c2c81947b68c5cf783f7d1fd0700 Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Sun, 21 Jun 2026 01:16:25 +0530 Subject: [PATCH 12/14] Fixed entity encapsulation and integrity-violation handling - Remove @Setter from Transfer, Wallet, and IdempotencyRecord so state can only change through guarded domain methods (markProcessed/markFailed, debit/credit, inProgress/complete). JPA uses field access, so no setters are needed and the invariants stay impossible to bypass. - Scope the replayable race to the idempotency_key unique violation only: add a DataIntegrityViolations classifier; the orchestrator now rethrows any other DataIntegrityViolationException (FK/CHECK/NOT NULL) and the global handler logs it and returns 500 instead of a misleading retryable 409. - Map IdempotencyRecord.responseBody to the schema's TEXT column (columnDefinition = "text") so a cached response can't be truncated or surprise schema validation. - Add unit tests (GlobalExceptionHandlerTest, TransferServiceImplTest) and update README, TechnicalDesignDocument, and implementation_details. --- README.md | 16 +++-- TechnicalDesignDocument.md | 6 +- implementation_details.md | 5 ++ .../domain/entities/IdempotencyRecord.java | 10 ++- .../wallet/domain/entities/Transfer.java | 6 +- .../rajat/wallet/domain/entities/Wallet.java | 6 +- .../exception/DataIntegrityViolations.java | 28 ++++++++ .../handler/GlobalExceptionHandler.java | 14 +++- .../wallet/service/TransferServiceImpl.java | 11 ++- .../handler/GlobalExceptionHandlerTest.java | 48 +++++++++++++ .../service/TransferServiceImplTest.java | 70 +++++++++++++++++++ 11 files changed, 200 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/rajat/wallet/exception/DataIntegrityViolations.java create mode 100644 src/test/java/com/rajat/wallet/exception/handler/GlobalExceptionHandlerTest.java create mode 100644 src/test/java/com/rajat/wallet/service/TransferServiceImplTest.java diff --git a/README.md b/README.md index 58edf278..237ed3d5 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ src/ │ │ └── exception/ │ │ ├── WalletNotFoundException.java # → 404 │ │ ├── IdempotencyConflictException.java # → 409 +│ │ ├── DataIntegrityViolations.java # classifies idempotency-key vs other violations │ │ └── handler/ │ │ └── GlobalExceptionHandler.java # @RestControllerAdvice │ └── resources/ @@ -113,6 +114,10 @@ src/ ├── domain/entities/ │ ├── WalletTest.java # balance invariants (unit) │ └── TransferTest.java # state machine (unit) + ├── exception/handler/ + │ └── GlobalExceptionHandlerTest.java # 409 vs 500 violation mapping (unit) + ├── service/ + │ └── TransferServiceImplTest.java # replay vs rethrow on violations (unit) ├── support/ │ └── AbstractIntegrationTest.java # Testcontainers base ├── TransferApiIT.java # execution, ledger, validation @@ -350,7 +355,8 @@ All non-2xx responses share one shape, produced by `GlobalExceptionHandler` (`@R | `IllegalArgumentException` | `400` | | `WalletNotFoundException` | `404` | | `IdempotencyConflictException` | `409` | -| `DataIntegrityViolationException` (race fallback) | `409` | +| `DataIntegrityViolationException` — **idempotency-key** unique violation (race fallback) | `409` | +| `DataIntegrityViolationException` — any other constraint (FK/CHECK/NOT NULL) | `500` (logged) | | any other `Exception` | `500` (logged) | The controller maps a recorded business `FAILED` outcome to `422`, and `PROCESSED` to `201` — so a replayed result returns the same status code as the original. @@ -372,7 +378,7 @@ A **dedicated, operation-agnostic `idempotency_records` table** (rather than a u - Both wallets are loaded with `SELECT … FOR UPDATE` (`@Lock(PESSIMISTIC_WRITE)`), **ordered by id**, so two opposing transfers between the same pair always acquire locks in the same sequence — **no deadlock**. - Funds are checked and the balance written **under the same lock**, closing the read-then-write window — **no double-spend**. The DB `CHECK (balance >= 0)` is a backstop. -- **Concurrent duplicate keys**: the `saveAndFlush` on the unique `idempotency_key` makes the second writer **block** on the index until the winner commits, then fail with a unique violation. The non-transactional orchestrator catches it and replays the committed winner — so the side effect happens **exactly once**. +- **Concurrent duplicate keys**: the `saveAndFlush` on the unique `idempotency_key` makes the second writer **block** on the index until the winner commits, then fail with a unique violation. The non-transactional orchestrator catches it and replays the committed winner — so the side effect happens **exactly once**. Only the `idempotency_records_key_unique` violation is treated as this race; any **other** integrity violation (FK/CHECK/NOT NULL) is rethrown and surfaced as a logged `500`, never misread as a retryable duplicate. ### Double-Entry Ledger @@ -380,7 +386,7 @@ Every processed transfer writes **exactly two immutable `ledger_entries`**: a DE ### Clean Separation -Thin controller (transport only) → orchestrating service (idempotency, no DB transaction) → transactional processor (the atomic unit) → persistence-only repositories → rich domain entities that own their own state transitions (`Transfer.markProcessed/markFailed`) and invariants (`Wallet.debit` refuses to go negative). +Thin controller (transport only) → orchestrating service (idempotency, no DB transaction) → transactional processor (the atomic unit) → persistence-only repositories → rich domain entities that own their own state transitions (`Transfer.markProcessed/markFailed`) and invariants (`Wallet.debit` refuses to go negative). Entities expose **getters only** (no `@Setter`), so state can only change through these intention-revealing methods — there is no `setStatus`/`setBalance` escape hatch that could bypass the guards (JPA uses field access, so no setters are needed). --- @@ -403,9 +409,11 @@ Thin controller (transport only) → orchestrating service (idempotency, no DB t Behavioral tests (TDD: Red → Blue → Green). Integration tests run against a **real PostgreSQL via Testcontainers**, so locking and constraints are exercised for real — no in-memory substitute. -**Domain unit tests** (no Spring, no DB) +**Unit tests** (no Spring, no DB) - `WalletTest` — debit reduces balance, credit increases, exact-balance debit allowed, overdraft throws and leaves the balance untouched, `hasSufficientFunds` boundary. - `TransferTest` — `PENDING → PROCESSED`, `PENDING → FAILED` (with reason), and the guards that make retries safe (can't re-process, can't fail-after-process, can't process-after-fail). +- `GlobalExceptionHandlerTest` — the idempotency-key unique violation maps to `409`; any other integrity violation maps to a logged `500`. +- `TransferServiceImplTest` — the orchestrator replays only on the idempotency-key violation and **rethrows** any other `DataIntegrityViolationException` instead of mistaking it for a duplicate. **Integration tests** (Testcontainers) - `TransferApiIT` — happy path (`201`, balances moved, **exactly two ledger entries** with correct `balance_after`, ledger balances); insufficient funds → `422 FAILED`, no money moved, no ledger rows; currency mismatch → `422 FAILED`; unknown wallet → `404`, nothing persisted; validation cases → `400` (blank key, non-positive amount, scale > 2, self-transfer, malformed JSON). diff --git a/TechnicalDesignDocument.md b/TechnicalDesignDocument.md index ba34e49a..9a23fd8c 100644 --- a/TechnicalDesignDocument.md +++ b/TechnicalDesignDocument.md @@ -161,10 +161,11 @@ Migrations: `V1__init.sql` (wallets, transfers, ledger_entries), `V2__create_ide | Duplicate request (same key, same payload, completed) | unique key / lookup | replay cached response | | Duplicate request still in flight | record is `IN_PROGRESS` | `409`, client retries | | Key reused with different payload | `request_hash` mismatch | `409` | -| Unique-violation race (two firsts insert same key) | DB unique constraint | loser caught, treated as duplicate | +| Unique-violation race (two firsts insert same key) | `idempotency_records_key_unique` constraint | loser caught, treated as duplicate (replay) | +| Other integrity violation (FK / CHECK / NOT NULL) | constraint name ≠ idempotency-key | rethrown, logged → `500` (never misread as a duplicate `409`) | | Process crash mid-transaction | transaction never commits | full rollback; no partial money movement | -**Defense in depth:** invariants are enforced both in the domain (`Wallet.debit` throws if it would go negative; `Transfer.markProcessed/markFailed` only allow transitions out of `PENDING`) **and** at the database (`CHECK`/`UNIQUE`/`FK` constraints), so a logic bug cannot corrupt persisted state. +**Defense in depth:** invariants are enforced both in the domain (`Wallet.debit` throws if it would go negative; `Transfer.markProcessed/markFailed` only allow transitions out of `PENDING`) **and** at the database (`CHECK`/`UNIQUE`/`FK` constraints), so a logic bug cannot corrupt persisted state. Entities expose **getters only** (no `@Setter`), so the only way to mutate state is through these guarded domain methods — there is no setter that could bypass them (JPA uses field access, so setters are unnecessary). --- @@ -227,6 +228,7 @@ Behavioral, TDD (Red → Blue → Green). PostgreSQL-backed integration tests vi - **Failure scenarios**: insufficient funds → `FAILED` + `422`, and a retry replays the `422`; unknown wallet → `404`; same-wallet / bad amount → `400`. - **Concurrency**: N parallel transfers debiting one wallet → no overdraft, final balance exact, no lost updates; concurrent duplicates of the same key → exactly one transfer created. - **Read APIs (integration)**: `GET /wallets/{id}` returns the balance and reflects it after a transfer; `GET /wallets/{id}/transfers` returns every transfer involving the wallet, newest first, and paginates correctly (`page`/`size` slice + `totalElements`/`totalPages`/`first`/`last`); unknown wallet → `404`; malformed id → `400`. +- **Error mapping (unit)**: the idempotency-key unique violation maps to `409` while any other integrity violation maps to a logged `500`; the orchestrator replays only on the idempotency-key violation and rethrows everything else. Coverage targets the **required behaviors** (transfer execution, idempotency, ledger correctness, failure handling, concurrency safety), not implementation details. diff --git a/implementation_details.md b/implementation_details.md index d4c8df01..7c38abad 100644 --- a/implementation_details.md +++ b/implementation_details.md @@ -50,6 +50,8 @@ All entities extend `BaseEntity` (UUID v7 id) → `AuditableEntity` (`created_at - `Transfer.markProcessed()` / `markFailed()` call a private `requirePending()` guard, so the state machine only ever moves *out of* `PENDING` once. - `LedgerEntry` is immutable (getters only, `@AllArgsConstructor`) — it models an append-only fact. +**No public setters.** Entities expose `@Getter` but deliberately *not* `@Setter`. The only ways to mutate state are the intention-revealing domain methods (`debit`/`credit`, `markProcessed`/`markFailed`, `inProgress`/`complete`) — there is no `setBalance` or `setStatus` escape hatch that could bypass the overdraft guard or the `requirePending()` state-machine check. This works because JPA uses **field access** here (the `@Id` annotation sits on the field in `BaseEntity`), so Hibernate hydrates via reflection and never needs setters; the invariants stay centralized in the aggregate. + ### 1.4 Read side (query APIs) Two read-only endpoints sit alongside the write path, served by a separate `WalletService` / `WalletController` so the command and query sides stay cleanly separated: @@ -97,6 +99,8 @@ Reasons: **Trade-off:** one extra row and one extra write per request, plus a `request_hash` to compute — in exchange for true exactly-once *with* response replay. +**Scoped violation handling.** Only the `idempotency_records_key_unique` violation is treated as the replayable race — `DataIntegrityViolations.isIdempotencyKeyViolation(...)` classifies it (by Hibernate constraint name, falling back to the SQL message). The orchestrator rethrows any *other* `DataIntegrityViolationException` (FK / CHECK / NOT NULL) instead of mistaking it for a duplicate, and the global handler logs it and returns `500` rather than a misleading retryable `409`. The cached `response_body` is mapped to the schema's `TEXT` column (`columnDefinition = "text"`) since a serialized response easily exceeds a default `varchar`. + ### 2.4 `saveAndFlush` + unique index as the concurrency primitive **Decision:** reserve the key with `saveAndFlush` at the very start of the transaction. @@ -165,6 +169,7 @@ UUID v7 keeps PK-index inserts near-sequential, avoiding the write amplification | Concurrent debit, same wallet | `SELECT … FOR UPDATE` | second waits, re-checks funds — no double-spend | | Concurrent duplicate key | `saveAndFlush` + unique index | loser blocks → unique violation → replay winner | | Same key, different payload | `request_hash` mismatch | `409` | +| Other integrity violation (FK/CHECK/NOT NULL) | constraint name ≠ `idempotency_records_key_unique` | rethrown, logged → `500` (never misread as a duplicate) | | Crash mid-transaction | atomic rollback | no partial state, no orphaned reservation | Invariants are enforced **in the domain** (`Wallet.debit`, `Transfer` guards) **and at the database** (`CHECK`/`UNIQUE`/`FK`), so a logic bug cannot corrupt persisted state. diff --git a/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java b/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java index 44e287f6..47dbfe7f 100644 --- a/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java +++ b/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java @@ -10,7 +10,6 @@ import java.util.UUID; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; /** * Durable idempotency registry, deliberately decoupled from any single operation so the same @@ -19,11 +18,13 @@ * result so a duplicate request is replayed verbatim without re-executing side effects. The {@code * requestHash} lets the service reject a key replayed with a different payload, and the {@code * status} lifecycle lets a concurrent duplicate detect an in-flight request. + * + *

Getters only — no {@code @Setter} — so the record can only move through its lifecycle via + * {@link #inProgress(String, String)} and {@link #complete(UUID, int, String)}. */ @Entity @Table(name = "idempotency_records") @Getter -@Setter @NoArgsConstructor public class IdempotencyRecord extends AuditableEntity { @@ -47,7 +48,10 @@ public class IdempotencyRecord extends AuditableEntity { @Column(name = "response_status") private Integer responseStatus; - @Column(name = "response_body") + // Unbounded cached payload (a serialized JSON response), mapped to the schema's TEXT column + // rather than the default varchar(255) so it can never be truncated or surprise schema + // validation. + @Column(name = "response_body", columnDefinition = "text") private String responseBody; /** Creates an in-flight record for a first-seen request. */ diff --git a/src/main/java/com/rajat/wallet/domain/entities/Transfer.java b/src/main/java/com/rajat/wallet/domain/entities/Transfer.java index e63ee372..3d1ad817 100644 --- a/src/main/java/com/rajat/wallet/domain/entities/Transfer.java +++ b/src/main/java/com/rajat/wallet/domain/entities/Transfer.java @@ -11,17 +11,17 @@ import java.util.UUID; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; /** * A transfer request and its lifecycle. Exactly-once request handling lives in the generic {@link * IdempotencyRecord} registry, not here. State transitions are guarded: a transfer may only move - * out of {@link TransferStatus#PENDING}. + * out of {@link TransferStatus#PENDING}. The entity exposes getters only — no + * {@code @Setter} — so the sole way to change its status is through {@link #markProcessed()} / + * {@link #markFailed(String)}, which can never be bypassed. */ @Entity @Table(name = "transfers") @Getter -@Setter @NoArgsConstructor public class Transfer extends AuditableEntity { diff --git a/src/main/java/com/rajat/wallet/domain/entities/Wallet.java b/src/main/java/com/rajat/wallet/domain/entities/Wallet.java index b2010466..33f01282 100644 --- a/src/main/java/com/rajat/wallet/domain/entities/Wallet.java +++ b/src/main/java/com/rajat/wallet/domain/entities/Wallet.java @@ -7,17 +7,17 @@ import java.math.BigDecimal; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; /** * A wallet holding a materialized {@code balance}. The balance is updated in the same transaction * as the ledger entries it derives from; the invariant {@code balance == SUM(credits) - - * SUM(debits)} always holds for a committed transaction. + * SUM(debits)} always holds for a committed transaction. The entity exposes getters only — + * no {@code @Setter} — so the balance can only change through {@link #debit(BigDecimal)} / {@link + * #credit(BigDecimal)}, keeping the overdraft guard impossible to bypass. */ @Entity @Table(name = "wallets") @Getter -@Setter @NoArgsConstructor public class Wallet extends AuditableEntity { diff --git a/src/main/java/com/rajat/wallet/exception/DataIntegrityViolations.java b/src/main/java/com/rajat/wallet/exception/DataIntegrityViolations.java new file mode 100644 index 00000000..d1098a26 --- /dev/null +++ b/src/main/java/com/rajat/wallet/exception/DataIntegrityViolations.java @@ -0,0 +1,28 @@ +package com.rajat.wallet.exception; + +import java.util.Locale; +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; + +/** + * Classifies {@link DataIntegrityViolationException}s by the constraint they violated. Only the + * idempotency-key unique violation is a replayable concurrent-duplicate race; every other integrity + * violation (FK, CHECK, NOT NULL) is an unexpected failure and must not be treated as a retry. + */ +public final class DataIntegrityViolations { + + /** Name of the {@code idempotency_key} unique constraint (see Flyway {@code V2}). */ + public static final String IDEMPOTENCY_KEY_CONSTRAINT = "idempotency_records_key_unique"; + + private DataIntegrityViolations() {} + + /** True only when the violation is the {@code idempotency_key} unique constraint. */ + public static boolean isIdempotencyKeyViolation(DataIntegrityViolationException ex) { + if (ex.getCause() instanceof ConstraintViolationException cve + && IDEMPOTENCY_KEY_CONSTRAINT.equalsIgnoreCase(cve.getConstraintName())) { + return true; + } + String message = ex.getMostSpecificCause().getMessage(); + return message != null && message.toLowerCase(Locale.ROOT).contains(IDEMPOTENCY_KEY_CONSTRAINT); + } +} diff --git a/src/main/java/com/rajat/wallet/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/rajat/wallet/exception/handler/GlobalExceptionHandler.java index 52c19e1c..9e0f7259 100644 --- a/src/main/java/com/rajat/wallet/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/rajat/wallet/exception/handler/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.rajat.wallet.exception.handler; import com.rajat.wallet.dto.ErrorResponse; +import com.rajat.wallet.exception.DataIntegrityViolations; import com.rajat.wallet.exception.IdempotencyConflictException; import com.rajat.wallet.exception.WalletNotFoundException; import java.util.LinkedHashMap; @@ -59,12 +60,19 @@ public ResponseEntity handleConflict(IdempotencyConflictException } /** - * A concurrent first request with the same idempotency key loses the unique-index race. The - * transaction rolls back (no double-apply); the client should retry and will get the replay. + * Only the {@code idempotency_key} unique violation is a retryable {@code 409}: a concurrent + * first request lost the unique-index race (its transaction rolled back, so no double-apply) and + * a retry will get the replay. Any other integrity violation (FK, CHECK, NOT NULL) is unexpected + * — it signals a bug or bad data, not a duplicate — so it is logged and surfaced as {@code 500} + * rather than misdiagnosed as a retryable conflict. */ @ExceptionHandler(DataIntegrityViolationException.class) public ResponseEntity handleDataIntegrity(DataIntegrityViolationException ex) { - return build(HttpStatus.CONFLICT, "Concurrent duplicate request; please retry", null); + if (DataIntegrityViolations.isIdempotencyKeyViolation(ex)) { + return build(HttpStatus.CONFLICT, "Concurrent duplicate request; please retry", null); + } + log.error("Unexpected data integrity violation", ex); + return build(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error", null); } /** Defensive guard for stray illegal arguments not caught by bean validation -> 400. */ diff --git a/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java b/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java index db766539..65762c38 100644 --- a/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java +++ b/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java @@ -6,6 +6,7 @@ import com.rajat.wallet.domain.enums.IdempotencyStatus; import com.rajat.wallet.dto.CreateTransferRequest; import com.rajat.wallet.dto.TransferResponse; +import com.rajat.wallet.exception.DataIntegrityViolations; import com.rajat.wallet.exception.IdempotencyConflictException; import com.rajat.wallet.repository.IdempotencyRecordRepository; import java.nio.charset.StandardCharsets; @@ -53,13 +54,19 @@ public TransferResponse createTransfer(CreateTransferRequest request) { try { return transferProcessor.process(request, requestHash); - } catch (DataIntegrityViolationException race) { + } catch (DataIntegrityViolationException ex) { + // Only the idempotency-key unique violation is our replayable race. Any other integrity + // violation (FK, CHECK, NOT NULL) is a genuine failure — rethrow it so it surfaces as a 500 + // rather than being misread as a duplicate and turned into a 409. + if (!DataIntegrityViolations.isIdempotencyKeyViolation(ex)) { + throw ex; + } // A concurrent first request won the unique-key race and has now committed. Replay it. log.info("Lost idempotency-key race for {}; replaying winner", request.idempotencyKey()); IdempotencyRecord winner = idempotencyRepository .findByIdempotencyKey(request.idempotencyKey()) - .orElseThrow(() -> race); + .orElseThrow(() -> ex); return replayOrConflict(winner, requestHash); } } diff --git a/src/test/java/com/rajat/wallet/exception/handler/GlobalExceptionHandlerTest.java b/src/test/java/com/rajat/wallet/exception/handler/GlobalExceptionHandlerTest.java new file mode 100644 index 00000000..6ef54418 --- /dev/null +++ b/src/test/java/com/rajat/wallet/exception/handler/GlobalExceptionHandlerTest.java @@ -0,0 +1,48 @@ +package com.rajat.wallet.exception.handler; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.rajat.wallet.dto.ErrorResponse; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * Unit tests for the one piece of branching logic in the handler: a {@code DataIntegrityViolation} + * is only a retryable {@code 409} when it is the idempotency-key unique violation; every other + * integrity violation must surface as {@code 500} so it is not misdiagnosed as a duplicate. + */ +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void idempotencyKeyUniqueViolationMapsToConflict() { + DataIntegrityViolationException ex = + new DataIntegrityViolationException( + "could not execute statement", + new SQLException( + "ERROR: duplicate key value violates unique constraint" + + " \"idempotency_records_key_unique\"")); + + ResponseEntity response = handler.handleDataIntegrity(ex); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void otherIntegrityViolationMapsToInternalServerError() { + DataIntegrityViolationException ex = + new DataIntegrityViolationException( + "could not execute statement", + new SQLException( + "ERROR: insert or update on table \"ledger_entries\" violates foreign key" + + " constraint \"fk_ledger_transfer\"")); + + ResponseEntity response = handler.handleDataIntegrity(ex); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/test/java/com/rajat/wallet/service/TransferServiceImplTest.java b/src/test/java/com/rajat/wallet/service/TransferServiceImplTest.java new file mode 100644 index 00000000..e43b715b --- /dev/null +++ b/src/test/java/com/rajat/wallet/service/TransferServiceImplTest.java @@ -0,0 +1,70 @@ +package com.rajat.wallet.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rajat.wallet.dto.CreateTransferRequest; +import com.rajat.wallet.repository.IdempotencyRecordRepository; +import java.math.BigDecimal; +import java.sql.SQLException; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +/** + * Verifies that the orchestrator only treats the idempotency-key unique violation as a replayable + * race; any other integrity violation is rethrown rather than misread as a duplicate. + */ +class TransferServiceImplTest { + + private final TransferProcessor processor = mock(TransferProcessor.class); + private final IdempotencyRecordRepository idempotencyRepository = + mock(IdempotencyRecordRepository.class); + private final TransferServiceImpl service = + new TransferServiceImpl(processor, idempotencyRepository, new ObjectMapper()); + + private final CreateTransferRequest request = + new CreateTransferRequest( + "key-1", UUID.randomUUID(), UUID.randomUUID(), new BigDecimal("10.00")); + + @Test + void rethrowsNonIdempotencyIntegrityViolationWithoutAttemptingReplay() { + when(idempotencyRepository.findByIdempotencyKey("key-1")).thenReturn(Optional.empty()); + when(processor.process(any(), any())) + .thenThrow( + new DataIntegrityViolationException( + "could not execute statement", + new SQLException("violates foreign key constraint \"fk_ledger_transfer\""))); + + assertThatThrownBy(() -> service.createTransfer(request)) + .isInstanceOf(DataIntegrityViolationException.class); + + // Only the initial fast-path lookup ran — it never entered the winner-replay path. + verify(idempotencyRepository, times(1)).findByIdempotencyKey("key-1"); + } + + @Test + void entersReplayPathForIdempotencyKeyViolation() { + when(idempotencyRepository.findByIdempotencyKey("key-1")).thenReturn(Optional.empty()); + when(processor.process(any(), any())) + .thenThrow( + new DataIntegrityViolationException( + "could not execute statement", + new SQLException( + "duplicate key value violates unique constraint" + + " \"idempotency_records_key_unique\""))); + + // The winner lookup also returns empty here, so it rethrows — but it DID attempt the replay, + // i.e. it queried for the winner a second time. + assertThatThrownBy(() -> service.createTransfer(request)) + .isInstanceOf(DataIntegrityViolationException.class); + + verify(idempotencyRepository, times(2)).findByIdempotencyKey("key-1"); + } +} From 97fbf56e139f7411e9135662140a2b8b19b795a4 Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Sun, 21 Jun 2026 01:31:55 +0530 Subject: [PATCH 13/14] Enforce one-way idempotency completion, optimize history read, align in-flight contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IdempotencyRecord.complete is now one-way: it guards on IN_PROGRESS and throws if already COMPLETED, so the cached response that backs exactly-once replay can never be silently overwritten. - WalletServiceImpl.getTransferHistory queries the page first and only runs existsById when the page is empty — skipping the extra round-trip for wallets that have history, while still telling "no transfers" (200) apart from "wallet missing" (404). - Align the idempotency contract with the actual blocking+replay behavior: under the same-transaction reservation a committed IN_PROGRESS is never observable (READ COMMITTED), so concurrent duplicates block on the unique index and replay the winner rather than getting a fast 409. Keep the IN_PROGRESS -> 409 branch as a documented defensive guard, tighten ConcurrencyIT to assert all duplicates return 201 with one transfer id, and update README, TechnicalDesignDocument, and implementation_details. --- README.md | 7 ++++--- TechnicalDesignDocument.md | 15 +++++++------- implementation_details.md | 4 +++- .../domain/entities/IdempotencyRecord.java | 14 ++++++++++++- .../wallet/service/TransferServiceImpl.java | 5 +++++ .../wallet/service/WalletServiceImpl.java | 8 ++++++-- .../java/com/rajat/wallet/ConcurrencyIT.java | 20 +++++++------------ 7 files changed, 46 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 237ed3d5..3ed6efe8 100644 --- a/README.md +++ b/README.md @@ -252,8 +252,8 @@ A business failure is a first-class outcome: the transfer is persisted as `FAILE | Insufficient funds / currency mismatch | `422 Unprocessable Entity` | `TransferResponse` (`status: FAILED` + `failureReason`) | | Validation error (missing field, `amount <= 0`, scale > 2, self-transfer, malformed JSON) | `400 Bad Request` | `ErrorResponse` (with `fieldErrors`) | | Wallet does not exist | `404 Not Found` | `ErrorResponse` | -| Idempotency key reused with a **different** payload, or original still `IN_PROGRESS` | `409 Conflict` | `ErrorResponse` | -| Duplicate of a **completed** request (same key + same payload) | replay | original status + body, verbatim | +| Idempotency key reused with a **different** payload | `409 Conflict` | `ErrorResponse` | +| Duplicate of the **same** request (same key + same payload), after completion or concurrent | replay | original status + body, verbatim (a concurrent duplicate blocks on the unique index, then replays the winner) | | Unexpected server error | `500 Internal Server Error` | `ErrorResponse` | ### `GET /wallets/{id}` @@ -369,8 +369,9 @@ The controller maps a recorded business `FAILED` outcome to `422`, and `PROCESSE A **dedicated, operation-agnostic `idempotency_records` table** (rather than a unique constraint on `transfers`) stores each key with a `request_hash`, a `status` (`IN_PROGRESS → COMPLETED`), and the **cached response** (`response_status` + `response_body`). -- **First request** inserts an `IN_PROGRESS` record (`saveAndFlush`), executes the transfer, then flips it to `COMPLETED` with the cached response — all in one transaction. +- **First request** inserts an `IN_PROGRESS` record (`saveAndFlush`), executes the transfer, then flips it to `COMPLETED` with the cached response — all in one transaction. `complete(...)` is one-way (`IN_PROGRESS → COMPLETED`), so the cached response can't be overwritten. - **Retry of a completed request** with the same payload **replays** the cached response verbatim (no re-execution). +- **Concurrent duplicate** of the same request blocks on the unique index until the winner commits, then **replays** it — a committed `IN_PROGRESS` is never observable under READ COMMITTED, so there is no fast in-flight `409`. - **Same key, different payload** → `409` (the `request_hash` guards against accidental key reuse). - Durable, so it survives process restarts. diff --git a/TechnicalDesignDocument.md b/TechnicalDesignDocument.md index 9a23fd8c..4ee031a5 100644 --- a/TechnicalDesignDocument.md +++ b/TechnicalDesignDocument.md @@ -98,8 +98,8 @@ Response codes: | `422 Unprocessable Entity` | business failure recorded as `FAILED` (e.g. insufficient funds, currency mismatch) — body carries `status: FAILED` + `failureReason` | | `400 Bad Request` | validation error (missing field, `amount <= 0`, same wallet) | | `404 Not Found` | wallet does not exist | -| `409 Conflict` | idempotency key reused with a **different** payload, **or** an identical request is still `IN_PROGRESS` (retry shortly) | -| **replay** | duplicate of a **completed** request returns the **original** status code and body verbatim | +| `409 Conflict` | idempotency key reused with a **different** payload | +| **replay** | a duplicate of the **same** request — arriving after completion *or* concurrently — returns the **original** status code and body verbatim. A concurrent duplicate blocks on the unique index until the winner commits, then replays it (it does **not** get a fast in-flight `409`) | ### `GET /wallets/{id}` — balance @@ -159,13 +159,13 @@ Migrations: `V1__init.sql` (wallets, transfers, ledger_entries), `V2__create_ide | Currency mismatch (assumption: no FX) | compare wallet currencies | transfer `FAILED` + `422` | | Concurrent debit of same wallet | `SELECT … FOR UPDATE` serializes | no double-spend; second waits then re-checks funds | | Duplicate request (same key, same payload, completed) | unique key / lookup | replay cached response | -| Duplicate request still in flight | record is `IN_PROGRESS` | `409`, client retries | +| Concurrent identical duplicate (still in flight) | blocks on the unique index until the winner commits | replays the winner's response (a committed `IN_PROGRESS` is never visible under READ COMMITTED, so there is no fast in-flight `409`) | | Key reused with different payload | `request_hash` mismatch | `409` | | Unique-violation race (two firsts insert same key) | `idempotency_records_key_unique` constraint | loser caught, treated as duplicate (replay) | | Other integrity violation (FK / CHECK / NOT NULL) | constraint name ≠ idempotency-key | rethrown, logged → `500` (never misread as a duplicate `409`) | | Process crash mid-transaction | transaction never commits | full rollback; no partial money movement | -**Defense in depth:** invariants are enforced both in the domain (`Wallet.debit` throws if it would go negative; `Transfer.markProcessed/markFailed` only allow transitions out of `PENDING`) **and** at the database (`CHECK`/`UNIQUE`/`FK` constraints), so a logic bug cannot corrupt persisted state. Entities expose **getters only** (no `@Setter`), so the only way to mutate state is through these guarded domain methods — there is no setter that could bypass them (JPA uses field access, so setters are unnecessary). +**Defense in depth:** invariants are enforced both in the domain (`Wallet.debit` throws if it would go negative; `Transfer.markProcessed/markFailed` only allow transitions out of `PENDING`; `IdempotencyRecord.complete` is one-way `IN_PROGRESS → COMPLETED`, so a cached response can never be overwritten) **and** at the database (`CHECK`/`UNIQUE`/`FK` constraints), so a logic bug cannot corrupt persisted state. Entities expose **getters only** (no `@Setter`), so the only way to mutate state is through these guarded domain methods — there is no setter that could bypass them (JPA uses field access, so setters are unnecessary). --- @@ -179,10 +179,11 @@ Algorithm (inside the transfer transaction): 1. Compute `request_hash`. `INSERT` an `IN_PROGRESS` record keyed by `idempotencyKey`. 2. **Insert succeeds** → first occurrence → execute the transfer, then `complete(targetId, status, body)` to flip the record to `COMPLETED` and cache the response. -3. **Insert hits the unique violation** → duplicate → load the existing record: - - `COMPLETED` + matching `request_hash` → **replay** cached `response_status`/`response_body`. +3. **Insert hits the unique violation** → duplicate. The loser **blocks on the unique index** until the winner's transaction commits, then loads the (now `COMPLETED`) record: + - matching `request_hash` → **replay** cached `response_status`/`response_body`. - `request_hash` mismatch → `409` (key reused for a different request). - - still `IN_PROGRESS` → `409` (original in flight; retry). + +> Because reservation and completion happen in **one** transaction, a committed record is always already `COMPLETED` — under READ COMMITTED another request can never observe a committed `IN_PROGRESS`. So a concurrent duplicate always ends in a replay, never a fast in-flight `409`. The `IN_PROGRESS → 409` check in code is a deliberate **defensive guard** that only becomes reachable if the reservation is later moved into its own committed transaction (e.g. a `REQUIRES_NEW` reservation). This gives **exactly-once side effects** (duplicate never produces a second transfer/ledger pair) and **return-the-original-result** semantics, both safe across process restarts because the registry is durable. diff --git a/implementation_details.md b/implementation_details.md index 7c38abad..0ad94275 100644 --- a/implementation_details.md +++ b/implementation_details.md @@ -57,7 +57,7 @@ All entities extend `BaseEntity` (UUID v7 id) → `AuditableEntity` (`created_at Two read-only endpoints sit alongside the write path, served by a separate `WalletService` / `WalletController` so the command and query sides stay cleanly separated: - **`GET /wallets/{id}`** — returns the **materialized** balance directly (`findById`), so a balance read is O(1) and never aggregates the ledger. The `updatedAt` audit column doubles as a freshness signal (timestamp of the last balance-changing transaction). -- **`GET /wallets/{id}/transfers`** — **paginated** transfer history for a wallet (source *or* destination) via `findByWalletId(walletId, Pageable)`. Paging/sorting come from a `Pageable` (default `size=20`, `sort=createdAt,id desc`); the `id` tiebreaker — a time-ordered UUID v7 — keeps paging deterministic when `createdAt` values collide. It first checks `existsById` so an unknown wallet is a clean `404` rather than an empty page. The result is mapped to an explicit `PageResponse` envelope rather than returning Spring Data's `Page` directly, whose JSON shape is version-unstable. +- **`GET /wallets/{id}/transfers`** — **paginated** transfer history for a wallet (source *or* destination) via `findByWalletId(walletId, Pageable)`. Paging/sorting come from a `Pageable` (default `size=20`, `sort=createdAt,id desc`); the `id` tiebreaker — a time-ordered UUID v7 — keeps paging deterministic when `createdAt` values collide. To avoid a redundant round-trip, it queries the page **first** and only runs `existsById` when the page is empty — so a wallet with history skips the existence check entirely, while an empty result still distinguishes "no transfers yet" (`200`, empty) from "wallet missing" (`404`). The result is mapped to an explicit `PageResponse` envelope rather than returning Spring Data's `Page` directly, whose JSON shape is version-unstable. Both run in a `@Transactional(readOnly = true)` boundary, mapping entities to DTOs while the session is open (`open-in-view` is disabled). A malformed UUID in the path is mapped to `400` (`MethodArgumentTypeMismatchException`) rather than leaking a `500`. @@ -109,6 +109,8 @@ The `flush` forces the `INSERT` to the database immediately, *before* any money **Trade-off:** a duplicate that arrives *during* the winner's transaction waits for it to commit (latency = winner's remaining work) instead of failing fast. That's the correct behavior for exactly-once. +A consequence worth calling out: because reservation and completion are in the **same** transaction, a committed record is always already `COMPLETED`, so under READ COMMITTED no other request can ever observe a committed `IN_PROGRESS`. Concurrent duplicates therefore always end in a **replay**, never a fast in-flight `409`. The `IN_PROGRESS → 409` branch in `replayOrConflict` is a deliberate defensive guard that only becomes reachable if the reservation is moved into its own committed transaction (`REQUIRES_NEW`). The contract and tests reflect the blocking-then-replay behavior. + ### 2.5 Business failure as a recorded outcome (`422`), not an exception **Decision:** insufficient funds / currency mismatch produce a persisted `FAILED` transfer and a `422`, and the transaction **commits**. diff --git a/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java b/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java index 47dbfe7f..c794eff9 100644 --- a/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java +++ b/src/main/java/com/rajat/wallet/domain/entities/IdempotencyRecord.java @@ -63,8 +63,20 @@ public static IdempotencyRecord inProgress(String idempotencyKey, String request return r; } - /** Captures the created resource and final response, marking the record replayable. */ + /** + * Captures the created resource and final response, marking the record replayable. One-way: a + * record may only complete out of {@link IdempotencyStatus#IN_PROGRESS}, so a stray + * second call can never overwrite the cached response that backs exactly-once replay. + */ public void complete(UUID targetId, int responseStatus, String responseBody) { + if (status != IdempotencyStatus.IN_PROGRESS) { + throw new IllegalStateException( + "Idempotency record " + + idempotencyKey + + " is " + + status + + "; only an IN_PROGRESS record may complete"); + } this.status = IdempotencyStatus.COMPLETED; this.targetId = targetId; this.responseStatus = responseStatus; diff --git a/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java b/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java index 65762c38..ec9957d9 100644 --- a/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java +++ b/src/main/java/com/rajat/wallet/service/TransferServiceImpl.java @@ -72,6 +72,11 @@ public TransferResponse createTransfer(CreateTransferRequest request) { } private TransferResponse replayOrConflict(IdempotencyRecord record, String requestHash) { + // Defensive guard. With the current strategy (reservation + completion in one transaction) a + // committed record is always already COMPLETED, so under READ COMMITTED no other request can + // observe a committed IN_PROGRESS — concurrent duplicates block on the unique index and then + // replay the winner. This branch only becomes reachable if the reservation is ever moved to its + // own committed transaction. if (record.getStatus() == IdempotencyStatus.IN_PROGRESS) { throw new IdempotencyConflictException( "A request with idempotency key '" diff --git a/src/main/java/com/rajat/wallet/service/WalletServiceImpl.java b/src/main/java/com/rajat/wallet/service/WalletServiceImpl.java index 6fec24b3..ce5ca939 100644 --- a/src/main/java/com/rajat/wallet/service/WalletServiceImpl.java +++ b/src/main/java/com/rajat/wallet/service/WalletServiceImpl.java @@ -1,5 +1,6 @@ package com.rajat.wallet.service; +import com.rajat.wallet.domain.entities.Transfer; import com.rajat.wallet.domain.entities.Wallet; import com.rajat.wallet.dto.TransferResponse; import com.rajat.wallet.dto.WalletResponse; @@ -37,9 +38,12 @@ public WalletResponse getWallet(UUID walletId) { @Override @Transactional(readOnly = true) public Page getTransferHistory(UUID walletId, Pageable pageable) { - if (!walletRepository.existsById(walletId)) { + Page page = transferRepository.findByWalletId(walletId, pageable); + // Fetch first; only pay for the existence check when the page is empty, to tell a wallet with + // no transfers (200, empty) apart from a wallet that doesn't exist (404). + if (page.isEmpty() && !walletRepository.existsById(walletId)) { throw new WalletNotFoundException(walletId); } - return transferRepository.findByWalletId(walletId, pageable).map(TransferResponse::from); + return page.map(TransferResponse::from); } } diff --git a/src/test/java/com/rajat/wallet/ConcurrencyIT.java b/src/test/java/com/rajat/wallet/ConcurrencyIT.java index eef0c648..5ec29f7f 100644 --- a/src/test/java/com/rajat/wallet/ConcurrencyIT.java +++ b/src/test/java/com/rajat/wallet/ConcurrencyIT.java @@ -74,20 +74,14 @@ void concurrentDuplicateKeyAppliesTheTransferExactlyOnce() throws Exception { assertThat(balanceOf(source)).isEqualByComparingTo("70.00"); assertThat(balanceOf(dest)).isEqualByComparingTo("30.00"); - // Every caller gets a coherent answer: either the replayed success (one shared transfer id) - // or a 409 telling them to retry. Nobody triggers a second transfer. - List successfulTransferIds = - responses.stream() - .filter(r -> r.getStatusCode() == HttpStatus.CREATED) - .map(r -> r.getBody().transferId()) - .distinct() - .toList(); - assertThat(successfulTransferIds).hasSize(1); + // Every concurrent duplicate blocks on the unique index and then replays the winner, so they + // all return 201 with the same transfer id — no caller observes a committed IN_PROGRESS and + // gets a fast 409 under this reservation strategy. assertThat(responses) - .allMatch( - r -> - r.getStatusCode() == HttpStatus.CREATED - || r.getStatusCode() == HttpStatus.CONFLICT); + .allSatisfy(r -> assertThat(r.getStatusCode()).isEqualTo(HttpStatus.CREATED)); + List transferIds = + responses.stream().map(r -> r.getBody().transferId()).distinct().toList(); + assertThat(transferIds).hasSize(1); } /** Fires {@code count} tasks as simultaneously as possible and returns their results in order. */ From 1a3c18c9d271e1f9b69f34fd92c165478b05496b Mon Sep 17 00:00:00 2001 From: Rajat Patel Date: Sun, 21 Jun 2026 09:56:21 +0530 Subject: [PATCH 14/14] Pin monetary precision/scale, harden concurrency latch, align docs with code - Declare @Column(precision = 19, scale = 2) on every monetary column (wallets.balance, transfers.amount, ledger_entries.amount/balance_after) so ddl-auto: validate stays robust across Hibernate/dialect versions and the money scale is explicit in the model, matching NUMERIC(19,2). - ConcurrencyIT: assert the start-gate latch is actually reached before releasing, so the test can't silently stop being concurrent (and stop validating) while still passing. - Add IdempotencyRecordTest covering the lifecycle and the one-way complete guard (the TDD claimed this test; now it exists). - Fix doc drift: correct the request_hash description (from|to|amount, not method+path+body) in the TDD, and refine the lifecycle diagram to note non-idempotency violations are rethrown. --- README.md | 2 +- TechnicalDesignDocument.md | 2 +- implementation_details.md | 4 +- .../wallet/domain/entities/LedgerEntry.java | 4 +- .../wallet/domain/entities/Transfer.java | 2 +- .../rajat/wallet/domain/entities/Wallet.java | 2 +- .../java/com/rajat/wallet/ConcurrencyIT.java | 6 ++- .../entities/IdempotencyRecordTest.java | 50 +++++++++++++++++++ 8 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/rajat/wallet/domain/entities/IdempotencyRecordTest.java diff --git a/README.md b/README.md index 3ed6efe8..8f98ca0f 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ Behavioral tests (TDD: Red → Blue → Green). Integration tests run against a **Integration tests** (Testcontainers) - `TransferApiIT` — happy path (`201`, balances moved, **exactly two ledger entries** with correct `balance_after`, ledger balances); insufficient funds → `422 FAILED`, no money moved, no ledger rows; currency mismatch → `422 FAILED`; unknown wallet → `404`, nothing persisted; validation cases → `400` (blank key, non-positive amount, scale > 2, self-transfer, malformed JSON). - `IdempotencyIT` — same key + same payload replays the original result and applies the transfer **once**; same key + different payload → `409`. -- `ConcurrencyIT` — 10 simultaneous debits of a 100-balance wallet: **exactly 5 succeed, 5 fail, balance lands at 0.00, never negative**, ledger stays consistent; 6 concurrent requests with the **same** key apply the transfer **exactly once** (one shared transfer id; every caller gets `201` or `409`). +- `ConcurrencyIT` — 10 simultaneous debits of a 100-balance wallet: **exactly 5 succeed, 5 fail, balance lands at 0.00, never negative**, ledger stays consistent; 6 concurrent requests with the **same** key apply the transfer **exactly once** — every caller gets `201` with one shared transfer id (duplicates block on the unique index, then replay; no fast `409`). - `WalletApiIT` — `GET /wallets/{id}` returns balance + currency and reflects it after a transfer; `GET /wallets/{id}/transfers` lists every transfer involving the wallet (source or destination), newest first, and excludes unrelated ones; **pagination** (`page`/`size`) returns the right slice with correct `totalElements`/`totalPages`/`first`/`last`; unknown wallet → `404`; malformed id → `400`. ```bash diff --git a/TechnicalDesignDocument.md b/TechnicalDesignDocument.md index 4ee031a5..ed7ba96c 100644 --- a/TechnicalDesignDocument.md +++ b/TechnicalDesignDocument.md @@ -173,7 +173,7 @@ Migrations: `V1__init.sql` (wallets, transfers, ledger_entries), `V2__create_ide Idempotency is handled by a **dedicated, operation-agnostic `idempotency_records` table** rather than a unique constraint on `transfers`, so the same mechanism can guard future endpoints and can **replay a cached response**. -Record shape: `idempotency_key` (unique), `request_hash` (fingerprint of method+path+canonical body), `status` (`IN_PROGRESS` → `COMPLETED`), `target_id` (created resource id), `response_status` + `response_body` (cached response). +Record shape: `idempotency_key` (unique), `request_hash` (a SHA-256 fingerprint of the canonical request fields — `fromWalletId|toWalletId|amount`, with the amount's trailing zeros stripped so `100` and `100.00` hash alike), `status` (`IN_PROGRESS` → `COMPLETED`), `target_id` (created resource id), `response_status` + `response_body` (cached response). Algorithm (inside the transfer transaction): diff --git a/implementation_details.md b/implementation_details.md index 0ad94275..8d8d8d9c 100644 --- a/implementation_details.md +++ b/implementation_details.md @@ -19,7 +19,7 @@ TransferServiceImpl NOT @Transactional — the orchestrator │ 1. compute request_hash │ 2. fast path: findByIdempotencyKey → if present, replay or 409 │ 3. else delegate to the processor - │ 4. on DataIntegrityViolationException (lost the unique-key race): re-read winner and replay + │ 4. on idempotency-key unique violation: re-read winner and replay (other violations rethrown) ▼ TransferProcessor.process() @Transactional — ONE atomic unit of work │ 1. saveAndFlush(IdempotencyRecord.inProgress) ← reserves the key, collides early @@ -77,6 +77,8 @@ Both run in a `@Transactional(readOnly = true)` boundary, mapping entities to DT **Trade-off:** we accept the responsibility of keeping `balance == SUM(credits) − SUM(debits)` consistent (done by always writing both inside one transaction, under the wallet lock) in exchange for cheap reads. `ledger_entries.balance_after` snapshots the balance per entry, so the materialized value is always auditable/reconstructable against the ledger. +Every monetary column is `NUMERIC(19,2)` in Flyway **and** mapped explicitly on the entity (`@Column(precision = 19, scale = 2)` on `wallets.balance`, `transfers.amount`, `ledger_entries.amount` / `balance_after`). Pinning precision/scale in code keeps `ddl-auto: validate` robust across Hibernate/dialect versions (it doesn't fall back to a dialect default) and documents the intended money scale right next to the field — consistent with the `@Digits(fraction = 2)` request validation. + ### 2.2 Pessimistic locking vs. optimistic vs. serializable **Decision:** pessimistic row locks (`SELECT … FOR UPDATE`) under `READ COMMITTED`. diff --git a/src/main/java/com/rajat/wallet/domain/entities/LedgerEntry.java b/src/main/java/com/rajat/wallet/domain/entities/LedgerEntry.java index eb290fa7..2e35e8f9 100644 --- a/src/main/java/com/rajat/wallet/domain/entities/LedgerEntry.java +++ b/src/main/java/com/rajat/wallet/domain/entities/LedgerEntry.java @@ -35,9 +35,9 @@ public class LedgerEntry extends AuditableEntity { @Column(nullable = false, updatable = false) private EntryType type; - @Column(nullable = false, updatable = false) + @Column(nullable = false, updatable = false, precision = 19, scale = 2) private BigDecimal amount; - @Column(name = "balance_after", nullable = false, updatable = false) + @Column(name = "balance_after", nullable = false, updatable = false, precision = 19, scale = 2) private BigDecimal balanceAfter; } diff --git a/src/main/java/com/rajat/wallet/domain/entities/Transfer.java b/src/main/java/com/rajat/wallet/domain/entities/Transfer.java index 3d1ad817..317248bd 100644 --- a/src/main/java/com/rajat/wallet/domain/entities/Transfer.java +++ b/src/main/java/com/rajat/wallet/domain/entities/Transfer.java @@ -31,7 +31,7 @@ public class Transfer extends AuditableEntity { @Column(name = "to_wallet_id", nullable = false, updatable = false) private UUID toWalletId; - @Column(nullable = false, updatable = false) + @Column(nullable = false, updatable = false, precision = 19, scale = 2) private BigDecimal amount; @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/rajat/wallet/domain/entities/Wallet.java b/src/main/java/com/rajat/wallet/domain/entities/Wallet.java index 33f01282..bd08a501 100644 --- a/src/main/java/com/rajat/wallet/domain/entities/Wallet.java +++ b/src/main/java/com/rajat/wallet/domain/entities/Wallet.java @@ -21,7 +21,7 @@ @NoArgsConstructor public class Wallet extends AuditableEntity { - @Column(nullable = false) + @Column(nullable = false, precision = 19, scale = 2) private BigDecimal balance = BigDecimal.ZERO; @Column(nullable = false) diff --git a/src/test/java/com/rajat/wallet/ConcurrencyIT.java b/src/test/java/com/rajat/wallet/ConcurrencyIT.java index 5ec29f7f..01e7dfcb 100644 --- a/src/test/java/com/rajat/wallet/ConcurrencyIT.java +++ b/src/test/java/com/rajat/wallet/ConcurrencyIT.java @@ -102,7 +102,11 @@ private List runConcurrently(int count, java.util.function.IntFunction results = new ArrayList<>(); diff --git a/src/test/java/com/rajat/wallet/domain/entities/IdempotencyRecordTest.java b/src/test/java/com/rajat/wallet/domain/entities/IdempotencyRecordTest.java new file mode 100644 index 00000000..9d1d400e --- /dev/null +++ b/src/test/java/com/rajat/wallet/domain/entities/IdempotencyRecordTest.java @@ -0,0 +1,50 @@ +package com.rajat.wallet.domain.entities; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.rajat.wallet.domain.enums.IdempotencyStatus; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the {@link IdempotencyRecord} lifecycle: it starts IN_PROGRESS, captures the + * response on completion, and — crucially for exactly-once replay — completion is one-way so the + * cached response can never be overwritten. + */ +class IdempotencyRecordTest { + + @Test + void inProgressStartsInProgressWithNoCachedResponse() { + IdempotencyRecord record = IdempotencyRecord.inProgress("key-1", "hash-1"); + + assertThat(record.getStatus()).isEqualTo(IdempotencyStatus.IN_PROGRESS); + assertThat(record.getIdempotencyKey()).isEqualTo("key-1"); + assertThat(record.getRequestHash()).isEqualTo("hash-1"); + assertThat(record.getTargetId()).isNull(); + assertThat(record.getResponseStatus()).isNull(); + assertThat(record.getResponseBody()).isNull(); + } + + @Test + void completeCapturesTheResponseAndMarksCompleted() { + IdempotencyRecord record = IdempotencyRecord.inProgress("key-1", "hash-1"); + UUID target = UUID.randomUUID(); + + record.complete(target, 201, "{\"transferId\":\"abc\"}"); + + assertThat(record.getStatus()).isEqualTo(IdempotencyStatus.COMPLETED); + assertThat(record.getTargetId()).isEqualTo(target); + assertThat(record.getResponseStatus()).isEqualTo(201); + assertThat(record.getResponseBody()).isEqualTo("{\"transferId\":\"abc\"}"); + } + + @Test + void completeIsOneWayAndRejectsASecondCall() { + IdempotencyRecord record = IdempotencyRecord.inProgress("key-1", "hash-1"); + record.complete(UUID.randomUUID(), 201, "{}"); + + assertThatThrownBy(() -> record.complete(UUID.randomUUID(), 422, "{\"overwritten\":true}")) + .isInstanceOf(IllegalStateException.class); + } +}