From af9a6148a1bad5885c2c5c355fbf9cd8534c1069 Mon Sep 17 00:00:00 2001 From: Azeez Muibi Date: Thu, 20 Mar 2025 13:35:44 +0100 Subject: [PATCH] Initial commit --- .env | 6 + .idea/FirstBankSimbrellaApi.iml | 21 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + .idea/workspace.xml | 130 +++++ README.md | 16 + __pycache__/app.cpython-313.pyc | Bin 0 -> 3690 bytes __pycache__/config.cpython-313.pyc | Bin 0 -> 2910 bytes api/__pycache__/middleware.cpython-313.pyc | Bin 0 -> 5749 bytes api/__pycache__/models.cpython-313.pyc | Bin 0 -> 20142 bytes api/__pycache__/routes.cpython-313.pyc | Bin 0 -> 2513 bytes .../__pycache__/collection.cpython-313.pyc | Bin 0 -> 3050 bytes .../__pycache__/consent.cpython-313.pyc | Bin 0 -> 4133 bytes .../__pycache__/disbursement.cpython-313.pyc | Bin 0 -> 3035 bytes .../__pycache__/eligibility.cpython-313.pyc | Bin 0 -> 3456 bytes .../__pycache__/lien.cpython-313.pyc | Bin 0 -> 2318 bytes .../__pycache__/loan.cpython-313.pyc | Bin 0 -> 4992 bytes .../__pycache__/notification.cpython-313.pyc | Bin 0 -> 2605 bytes .../__pycache__/offers.cpython-313.pyc | Bin 0 -> 3507 bytes .../__pycache__/penal.cpython-313.pyc | Bin 0 -> 2482 bytes .../__pycache__/rac.cpython-313.pyc | Bin 0 -> 2612 bytes .../__pycache__/repayment.cpython-313.pyc | Bin 0 -> 2408 bytes .../__pycache__/sms.cpython-313.pyc | Bin 0 -> 4062 bytes .../__pycache__/token.cpython-313.pyc | Bin 0 -> 2535 bytes .../__pycache__/transaction.cpython-313.pyc | Bin 0 -> 6316 bytes api/controllers/collection.py | 81 ++++ api/controllers/consent.py | 123 +++++ api/controllers/disbursement.py | 78 +++ api/controllers/eligibility.py | 91 ++++ api/controllers/lien.py | 65 +++ api/controllers/loan.py | 147 ++++++ api/controllers/notification.py | 70 +++ api/controllers/offers.py | 92 ++++ api/controllers/penal.py | 67 +++ api/controllers/rac.py | 81 ++++ api/controllers/repayment.py | 68 +++ api/controllers/sms.py | 132 +++++ api/controllers/token.py | 68 +++ api/controllers/transaction.py | 169 +++++++ api/middleware.py | 115 +++++ api/models.py | 459 ++++++++++++++++++ api/routes.py | 55 +++ app.py | 76 +++ config.py | 40 ++ requirements.txt | 2 + test_api.py | 173 +++++++ 48 files changed, 2451 insertions(+) create mode 100644 .env create mode 100644 .idea/FirstBankSimbrellaApi.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml create mode 100644 README.md create mode 100644 __pycache__/app.cpython-313.pyc create mode 100644 __pycache__/config.cpython-313.pyc create mode 100644 api/__pycache__/middleware.cpython-313.pyc create mode 100644 api/__pycache__/models.cpython-313.pyc create mode 100644 api/__pycache__/routes.cpython-313.pyc create mode 100644 api/controllers/__pycache__/collection.cpython-313.pyc create mode 100644 api/controllers/__pycache__/consent.cpython-313.pyc create mode 100644 api/controllers/__pycache__/disbursement.cpython-313.pyc create mode 100644 api/controllers/__pycache__/eligibility.cpython-313.pyc create mode 100644 api/controllers/__pycache__/lien.cpython-313.pyc create mode 100644 api/controllers/__pycache__/loan.cpython-313.pyc create mode 100644 api/controllers/__pycache__/notification.cpython-313.pyc create mode 100644 api/controllers/__pycache__/offers.cpython-313.pyc create mode 100644 api/controllers/__pycache__/penal.cpython-313.pyc create mode 100644 api/controllers/__pycache__/rac.cpython-313.pyc create mode 100644 api/controllers/__pycache__/repayment.cpython-313.pyc create mode 100644 api/controllers/__pycache__/sms.cpython-313.pyc create mode 100644 api/controllers/__pycache__/token.cpython-313.pyc create mode 100644 api/controllers/__pycache__/transaction.cpython-313.pyc create mode 100644 api/controllers/collection.py create mode 100644 api/controllers/consent.py create mode 100644 api/controllers/disbursement.py create mode 100644 api/controllers/eligibility.py create mode 100644 api/controllers/lien.py create mode 100644 api/controllers/loan.py create mode 100644 api/controllers/notification.py create mode 100644 api/controllers/offers.py create mode 100644 api/controllers/penal.py create mode 100644 api/controllers/rac.py create mode 100644 api/controllers/repayment.py create mode 100644 api/controllers/sms.py create mode 100644 api/controllers/token.py create mode 100644 api/controllers/transaction.py create mode 100644 api/middleware.py create mode 100644 api/models.py create mode 100644 api/routes.py create mode 100644 app.py create mode 100644 config.py create mode 100644 requirements.txt create mode 100644 test_api.py diff --git a/.env b/.env new file mode 100644 index 0000000..bb2ec8f --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +DEBUG=True +PORT=5000 +API_USERNAME=admin +API_PASSWORD=password +SIMBRELLA_APP_ID=your_app_id +SIMBRELLA_API_KEY=your_api_key \ No newline at end of file diff --git a/.idea/FirstBankSimbrellaApi.iml b/.idea/FirstBankSimbrellaApi.iml new file mode 100644 index 0000000..1dceaec --- /dev/null +++ b/.idea/FirstBankSimbrellaApi.iml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b1b05a8 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..0d1ba17 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..57cd243 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + "associatedIndex": 8 +} + + + + + + + { + "keyToString": { + "Flask server.FirstBankSimbrellaApi.executor": "Run", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.git.unshallow": "true", + "git-widget-placeholder": "master", + "last_opened_file_path": "C:/Users/amuibi/PycharmProjects/FirstBankSimbrellaApi", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "vue.rearranger.settings.migration": "true" + } +} + + + + + + + + + + + + + + + + + + + + + + + 1742456795887 + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e67cc9 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Simbrella FirstAdvance API Flask Implementation + +This project implements the Simbrella FirstAdvance API as defined in the OpenAPI 3.0 specification. + +## Features + +- Complete implementation of all API endpoints +- Authentication middleware for both Basic Auth and API Key auth +- Request/response validation +- Comprehensive error handling +- Logging + +## Setup + +1. Clone the repository +2. Create a virtual environment: \ No newline at end of file diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..352eec1f4a57f3d25f9f4b4a9d18e704172c782b GIT binary patch literal 3690 zcmbVO&2JmW6`v({Nt#@d5-s{e@yEonW6Nz#FNDv==D(e3r*QPxPNDfrY!uGlbQuO4TQaM18_Ru%8qA1A{ik5cgD&kNQVs zDhpa3RYn3dAZTSYI1-{EK?g?njf81f(819NjeuToRHdqzJ7`BXkdm}e)|c{W`+Cql z`JP0h*_akiPa~vHAe4=sKo>Vmp`F>zjIg+6q&H`Jx3Bq1i)iXau$=#B{1^hw)81<9 z_&=Q2p+#HgX|Zfv>%8K}s2nT@-$hw9b7t$Q9vwt_5x$+sCNk&V-qpHVHIraQ=p12KXV{q~7)0niuG)s>;F3)R&pSe|R!yTI^poM&+~7XR zRm2_;`!j@9ZHti%acb21NH=Q)*nuJ2Dj8)iKuOtP4xza`Y*i_IfNf(}jAGFw4|Gb# z+yE7)6gKW&+>QdHAO6?>4BXGr%;w(A2C5Kh-IpazNf}vaZ5uMdZA-D3+SI~mCe+&B z4b>;M?yD`y_ROR43Pj>eti_v>S{4DfYd0>~xxdA4&)O#HGq?%8*F}C-a|XQhjwJ!x0NaBBYz1Pa|y+PA1=q9_C+#F2$n%^Y0MgwDVmTq^9^K9S&23&L7}plYH_~IPvGK;8 z$wq9l5t^ijVdM4hcbd%8)c^U&0|WIqBu=B~L0G(48hfr_IF`owu+y8)xlNUvP;pQ0 z=G0CpUUC>e)$?s|>>Jk5roXPQn2q$zDm==s^hQi?gmll(H#`0O?@x0PK${r0cv$PE zS7s;{7J4q7;@%wP!%mk}QIqI~I~xgMJH)n1q~MIL&va)smT64hZ2-PPcZKGKwn?kKG=pymV0BgSo2kVOF=G zTv5u)OkcoNw0X zLZ5uNF!Wpw{&wUyBhTcXRk`QUU{gNxCV&+6nSAhD`QTrpi6wK*^?55=zp-l#}9 z^vK_ok8l}}S_`1SzGq5eRY@!to+>?SK1und)I4^69dTmaj{@&_yxkC(XS-O7bd=f(r z#X2q;r4pggIC&?W(ew5k!RPx2QhjNFy@R>=S1>ufiDu_Vj%ar##`gVZCZ3sa+_1|Ii={RqRFUy`Kn zeSS&)A%vtuuh6+y=F7fYts^v)ra+`{=+ZA z-JhIW7!t5tN-w6Dt}R}B^xor>Pa~%nZmtC)O84_nWGT29TrPZZ@6&r2 zJ&^kH%1Y{C@Y~SDS{IUg{<#)MYJ6#Mac~{&1Eq+%bZPO@GI<(#=Y`tU=pJ4$9(#QLac?93UQ<2ItqA=twA{56 pS&aN^Z9jbDN5=2wkMF*JBZ|I?cHZ>)zdClkXXvW`PgfOK{0GtPbvOV3 literal 0 HcmV?d00001 diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..82b34d3a584b94093f2e3e3d91fa468509656b97 GIT binary patch literal 2910 zcmb7G&2Jmm5r6x^50UzyC`zJbIbMCZ4())BVkxl&*8*wMGAWr7xRRSX3vXBCQr?){ zRo*UbNC*nu>YO4KFeF|g^;GxfTYU+qG%L}z9A&~r&t$$%AF7nuClR? zjfuc5LoaEoRjgQ=p$iqGSS_nU$-sj3P!(>M74wmxtgV%`f};=*{6GP6F^+HveVyDTG=2x<8TtsQJMzslw$^mc zxBPPICG|_|d-A?hf+j0+Q;f@wqZq$&c~-TATMKznFtE0&>84P^MnzEd#~L>DimF?} zV+Cu9&SVE2-#@M6I%d)pA)$tZ7og@qK)4orC~WE*RL9 zn`guk!1cppjsn?4-v_%spZ;w6^P8XD+>JjUvxD)&;AA~G`9idV*AMvXI1Fk(bF&v- z(9!rWxb+#55F`@9tjJ*Q0uouwOP+kEt8$v=drq%hUgSjn#NoaFFK;E4=$XEQ5blry zQc&bBf?^vsylvU&bZmGX8(pLA*zujZ49zsb-BK5v*w^NXJH$ZCE4W7r0YXQc2tl#) z1fkbK2)2pPC3c@6gdK#gHW7MGA@n&2J#8X{#NLxLL>z=rn+RdC?*yUWK?pkt0}$Ck zF>;;3QR$2nqO}0L-0#SS!~y9n=nhY^&9XO0%U&xN@i{Sy-&#Er8zNq}#{EFEedA10 zF;&66d9AAFl&m=Mq|(XdS>m~^luecJ7Otwqx0Du_GPzm8FD~Slw5{`S+dC7B8F^Vu z=W~g>Y2s0e6-{?vE+$0rHw*a`@vlJLQKoc`OHSh`~<3tI(_BJ6)3*(;tJ`QUznBW)A!QzgwNz|FT@zqQ&dYz zwQR{HrC=F&9nyp%}Yy5u*86#a1(hf@KIrXhP7v6ujgqC{ZDH~5zjH8t2R}7;}V5_3* zhUIj86Ly@XGEUPiJWRJiEMaL{7{hHqc!ZKMN~lZl1xjeBofhUMCB2{|=v=dkYCSZH zn5PQV&lvIvk$zc*uvl6_RxAr^D^*LShUo`8oy`da9-vD~=uI}~Aj|)NzT?M^!jYXz z+n09Y+wuLu=NIkpdxzm`_3$-2JpHm%52rTMPbY0Ybrc!c$!=$Na@)E6>(5v1$oq$p z8}-NyJ96{Yuj-LIo3l@sZ2r#Ip}wu;iQ@auC+*0)c4%TV^>obUCm_^k&h7yLkus`==!0x}c znRyD^uD$l6^Wz7Tsrp-KP#)hhw_`S+{?-#ZW;xHyF25`8kL`X~>uw-eLK1p=8&u%> znR++Yi9ev8rintl*7};Juez~vqUHwkuB^F^!72&GSj=`yJju+69V?wkJ#Y9*|QAX6d-VP LHu#z@oumH`8S-mZ literal 0 HcmV?d00001 diff --git a/api/__pycache__/middleware.cpython-313.pyc b/api/__pycache__/middleware.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bcf09c8a47d5b87e7baa6802365bc30597120d11 GIT binary patch literal 5749 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 literal 0 HcmV?d00001 diff --git a/api/__pycache__/routes.cpython-313.pyc b/api/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e3aea0ce40a23c2a56e3d947b63f4114eab4ca5 GIT binary patch literal 2513 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 literal 0 HcmV?d00001 diff --git a/api/controllers/__pycache__/consent.cpython-313.pyc b/api/controllers/__pycache__/consent.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0519532865f7561ffdf970728be8e2d3515efecf GIT binary patch literal 4133 zcmd^CO>7j&6|U}{o}Zq7Px}Y6u^VGB1FUU<2?o|lVDM%!!AquX1?`M7YEQTA#xvcM z>K+#RutGVpr)3YTO>W60g3F2{tE@Hu^{e;2*^I{p1kL{ApB65q5c-M^{Kk9C<}+YEK{CQfW@Pp}GsR+dio@Jg z00$&0;F`vFu;9%I}w zP8Hb!BnLsldXjW8o!{49?Q?QS4v#Q$DgJ1M9x&|S`CAcgs}FL zx@G}$*)k1%emTpM%$#EB<&sjj7fWD=|LWQ7jRX*%< z?S24k^G`RpLe!s;BeEcKCs-p;i21U7(&yT}9G&IzaHp4B0?9F$>3CU=5Lg@VPXy~) zzL*WW$0^bJ-_#gHrwU(sPtP|Ry~9UIn3L4r0VzdJ2q%?)hnecfJcJypwA?7Afo zZftb)g?P@mqEvL%Rj4V;u&a8DB?9QyD|XIQHIjDuvzk@Ldd=2NxK<%)Vt)2Ue!p{ducONKU9W`8d?QRI&$Pi&jsDG zbYlU`ce8>tuWJ?cr1bPxa4KO(M^`dl4v@fASSxG#6-`|ky6AdWQ(IT{PTZcdUW5)y z)NE;GU=m|+r1w?mk)Bn~(U7lBN`yNGUg|t&*cvvJisNrcdm2@I`W)<;&L(gGLWqPH zG`mF88>VrK>4PGXd2Cios$RBngpPw1b74WlB%mAc1-t3GUMdgSrn8DfCa;z?$Hjzq z&LEs+<6A)zP!(GtN#7+U$0Ziwot|(YCt?3{6GpS>%cgN4N#tVgWN{Ya#40M)x<02D zFD`?>aTVNkSu5LC@ogPj_8G-klJ)8w)+!a{bWJZpCN6?IysU3go@U?S+A>M`AGqWR z@faLOAGEa}TyhPqcMb8WuajMC$^J*l{?+83>w!NfGfy(T&Az?OeWx~f?v2DI;>6^7 z5T(WS5GRW3h>MHQV$ALwb3OT0=iv3pW>Wk^QhL(e*BlsX9?IRFynDQnJrBB42k7=> zK)168bbET9Auips!K1Ey&r>M5Z!IzWC^3A~UQLW$=bp!qc&OREyV-XXR7K%>__=^0 z$&aERMjKrdcgnvpJ~tW@7k>Yv#_XlFS@q$p+L+NA;{4-~3n8l7hzhjXCR?Co#v8Hdw)1TZue z1w-R0Ff^6=8{#6Vf2{MM|H2vufBp2(nF;h+49J%vkb9ZaS?GQ{A)IBo`=Qa?1b6>9 zL-`3F=!!V91AJ{mA{urM5{>^~AQ9k53xnY1UIS^u=i5D?R#c9i-~qQ3e_gx(H@HRL_)Bd^-0JZcwQ#F#V+*1DH3-Sc0-(R!UIVvMa(WwXb$Gay z@o>xOx8Rnz^2N@$sg-3YBGR3fwDvBq zK;vrwn_1JaYm5hNnEDQ%!-VoF1!Zcu3-fV-Dd*u<=Yc0gtR#jt( z>Ku3lo_16{Dd9czoB>Mq0-4Sx9Z151RN|l!{d0r&QR3L`AP>DV-cQL8B@~D-twnK` zl7o~SqU11;EqH>D!kBcrrj*=Uv~U1QF7!5CKknhc!T%ZuCjVC)$gU-hJW3q7J+_({ zcX42(nI3L-_kQ}p#~(Dic70m?xat7I&_*=q07I$;47t0x-%a0}Zsg>K{9dE*DK4Db&`9%#0cgi0mVESE}96I2vy2~!ks!+=HrNdTS?S9KNY$@dkkVOk(y z`oDz;&I}ZK70anwoKgil5krH8glN$M#Sw`>h4TZqfC-mMhEml^B`1KO;&T&2X|6_i zY}TPzq7?zA;cyFW0!41vgx{)~YQ3U;2Y(-A5F^O?C6INFVVDh;Wdh#>5i|S{z4;gP zy>D2KVF5sagb_na1tGl_>3tOG{n_|xWbj&WBZv~+HiK{58VFt?E}vGQ%xql!5($jchL23 LZ3JM5X;A+YhAGv9 literal 0 HcmV?d00001 diff --git a/api/controllers/__pycache__/disbursement.cpython-313.pyc b/api/controllers/__pycache__/disbursement.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e39ea38c7f6c05d6d8fe05010087c8d46ee596e5 GIT binary patch literal 3035 zcma)8&2JmW6`$SZ@?#}_NTev0wxkgy*I^XPACYBSs+~BJf>??jmn*deM1WXvhtkH& zU3O;aSUMy|Ps%CwA#nbJBE19#+Cy?lQ=kEQq^z_gHtq+ITzZot7cJTz`ew)Je{%-WViZPO$amu-qoTeGlku+Hp=I| zNTN3e5wgUe;TZ^$4yW7%vUJ!7)zGpSLobU*b_q|4OvGpm_=M3)(Dy1I_NBu<3%>cM zTha;;u=OoV`EFl6>_Hnl9QhJxIX2(>K->F06%0X}I#zoOIbrR<^J~?Js=!R<`5Dlj zJXSk&tQJ47RlBZ4t-wmwpUI!{rNdtBhI5X&Sq!Ps<2{D>1bmDk9tY814&+ZC?(0i_ z68RQmXaueJ^g$t{%>X0h4j;#T%fnuct(@^k%d)={=ZPbK@oMU%sYm&Zdl%0 zrd0@bsAe$@7TOlLpC?X@T5MXu@GYos-2&%ZRmL+saNjF*%+Z`0Q(S5C+_}xv4ePFE z8hXpBs%?NLypAcbLCth#ZJoG1EqRWx5;dy)NaF#3o(sE?HbyseH>i_U2l{A-XE0QJ zoIsAWq2eN3U$2!sgBB9xdWFO2Mm#j>1}u^$FI?ChxM?uP;owK{B8$rgG4*L|{1SX{ zMYzM*o4lCkTg4X7+LfwFINWNj#EDR@0pKIKDCL!b_V9-13{CQeR{+|_8=m!)mlPU@ z#_elIqUF{N%OQY*<0g-&Z>~A76XK?iC~sJ-2GJ=IH*rM&lj}>HW4Bx61n+!Gc`@Nn zl8+SLjJ$+34-LFIJWnai0Y>~P9EVQ3Y3xcD0f9cxKT9()BlIqil5i{I86b2k*G~id zLg6*y6#3&tdDhbq*F|_D6m_HIP+pM#AB1?ZqbGR73mk{I*V1-T)C#oAX z!tcTx9((p4;C3JFW-iEyZ{z7keE3Oxcqcxx75Ga$`81hn4vjWX&Fsn2$=E)UQt{m& z>PhW}q*Q7bNzv4I-NK2yuoeHNZ)|J68BhH&j-U1qHM6IiV;-hKG4Kigh(ID4WwG}p|YYGh}&volRR+Q65#@ulYI4;!Obw@0rw z2XSNY{Py7ay%VvNvK4-YQ6&CB*ZW=d^z^4M|MuEv*Xq*?f5_FBe%e?f+e@Ubmg}jt zufpq3)Wo>K9c3c({SvA?)SUv{ZqeTpPv8q-N)g`1QLmVASsgg&#nyi|7H^qh+mA)O`%V^q4_)o z&F_-4S?GR0rOeMrU!rsKQ_`2`1TIg>(B^vFP_d}nrD74NWNPdV<^Pd9_IsRnV?cw6 zilIY!e@CN)a$dQLr-V9ZGA}8-OmIV-(vWU$1k&cs7QMs8q6M|LSfnG|Fy2X`;%e2E zsSRb`qtUO>J}g0$KYecaM-?b`Cb>qhz!12F*cvpuk{}3sq9_FZ8AQUwHahcXH2zog z>ena+m?%AsNajf-^U>v<$k_ehUJxZuest^OH+Fh2-jDxHNHm1>wvc}C-sAM+a9v2( bg)2>=XHUE;B=#jF_VQ+WF91V&8P572p?~q* literal 0 HcmV?d00001 diff --git a/api/controllers/__pycache__/eligibility.cpython-313.pyc b/api/controllers/__pycache__/eligibility.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..617f47b045ee86dfdcd1066e3100ca950589e4f0 GIT binary patch literal 3456 zcma(TTW}NC_3o~;dcSO0wq!7iu>&GtGvU!-LoCHH0Xa5Ci^5E;8OVUu61G`uKp7*GE5O#MJ7BkapUS@@Hc@ZQ7rnyV4??hPGFu zd(J)Q-gC~L*S(v8fEU5gfB)6O4nIPFrVD#G+sNj9K;A_nB1mLJcACksgw1e-%Q%Q5 z<0MXj(z$6q<038#cTBr89^$cZ=d?HDBfgBE_%i_#uxR{rkObk3YdVw(lW-gb=O>a%U}egDa5i8TW)j)KpY%b3i95XVb$(J;BR zx@02F&vDXw1BHPs}%AtL2R%sZad566(>$0&96d60{0-C2fts$}^Cpt!0nkUS-@YzC4&$Ih`cL4^;{SDa+n0Iim(9aBv_=-JesT7%UbobR^J-LZ-_-Owt~(6*Gl)!Yz!ireFf=ecMD`7Lz!e7FAk;qj;hR*ypbc3X8**Rn^Rk0Z`2)IHjQr4Lg^lYsL z*T9l>*lYU_7#^u;u>kcdGFH0)^%B302^C9Tqw&}Dd}UvGJ8M( zgP(g&{wZi)u##it=czW*NIk$ndO*`G=`=kRS(RF%Jr(Jyje_S(wW0yaU6zOBlIc8; zRgJWz+-{4z^w-pbG8K0MCX+pM$n=V}A`M!(rX+bnOO*6dUa*xC?aM+ZJ(NQ$-Q+8} zte4c~3UrvDo7^uSzM5`1^ zaM2OknhAZ-Z23-?p|zO%t#h(k)=baT)grc%!SvwAD5o1F=`kH8$&k#D9mYZ{jHc6q zX0Xx209j1Gip7 z>#+%E_%ET@T4>ju(5^;k_lo0wsQaG3>#f;0XY2iAcm3n{x)aTTJxGM%NJ+h(7c){V8T8^rwR-8u9GMg`Z4)d44r@?!)1a3M;{SJbT|4w=u+0GK6O8ppXs>sLe>8-m2b#T! z_uhEt4LG@$cy=}M>>rL#H2PD&id}bpIP<~GkI()xw%VU+_Vzai_BXp@?~T1Pwh?ee zy({iVUC0x9%lD?Q9-FwyeG$ABtWTV+N9XRk&)o~f-oColyLYvBZ=*Na2<@-C_ka8F z0)rAF^XBkY4{1i}!FaQJVC^MwDk?gU}~)Xzbbf0#y7qv#gr zrnAp8HSD<6$J2Qr9RbpB52Zr@ZcoIg_WM8WOZ#E**|3++qu!Sh_u26ir^dO@k1~LN zKJJ7$88=;pLP;wY3Xu7Vq+cfV#b*_#Re^+urRld=BhW}`Wx6OS2)Q~^kxL~7L&aj^ zrbMqrgnq2(R?)VKl$NXMqV)}@O};IKzIRA~PV~KFdY}`$(rN+jLIHXsE)>W?%4v5n z>xEvpST&tQt3hi?0q-NU2{R%SFq!TX6=gs?0an4VRpryfgQKXv{2I$+F+CKFv}Pcuxo-aoR5D6+{Rwueq9Hym_LX26Dj13Wrl Ai~s-t literal 0 HcmV?d00001 diff --git a/api/controllers/__pycache__/lien.cpython-313.pyc b/api/controllers/__pycache__/lien.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a7f18f83e5082490d160c22c782111d20015590 GIT binary patch literal 2318 zcmZWqO>7fK6rTO_&)V@1aef+_WJyDVp*0Z%HG~osLQqKftCt|8STf7*I9XWly0dE_ zQ4h42h7(+nIC84m3$4^1=%uCQ(xV}?u?G4hl}m34p=#A$`eyA70anSpH*e;>dGoz* zp4VDhWCWl0^P}0-FhYN@N$^QcW$iX7H;{@5QaP2M;wE{*PYOhs6p5%Xn=mC!hDgZQ z#i{UQghVEzBpmvCQ8X6ie+M3(dV#!LBY0BaZKBDbMl|e zJ1fxWL92=@1dKFH@CQ}wf^yz0f{iRsQ%51<7z>zhI;1yP z+=^qn_&62WxeM~n^(88vJfWU~nBg+^<{ZP-gPSo4ML+`luf+j|8>k1(FsuLNo4^Ef zCi~W}Z=-3nt+`~R5Sj_f=NC;Ca(n=_Dh~1eXeUyo_c@!J=33E2H$palM%f3&FyNG9 zK#C3iAvHYBC(v&Gg(#6B-sKh4k77tcGoc`>d?e7G%Rb00INcUzV!?_URb@4%#`g-g zI1>-7`DCCymukzjkcV5n{3Vc@fR#-z+Yy3%#o$cHZyk+kG2iy;?tnajM?czvX4WYi zM%q3=NZOF&Yp=#olQWwG>$DU^Vz>qR#yr%NntlP#O$^Uoq_*190ja+!OI!)<7kW{N z%Vrk7kjIMl%F^D4OC{ux^%2eM77~_Nv5oM`Q)QN%h2Wh2RFol}n~K_d*Z@dzxvvG8Kjf z2NyC&?2DRZ8V#?CHg7o^dp%04`;rdz=th ztTen>B7U8Gw-JR3djOZVBesW$tyzkT$wg>VnE9B`Ai?9=1QFp}8i86BSr;OVzl1dh z8Z8m0Tr^DGBkZZskmbzIVnRjJhJ?~=AA1fo>^c4}8Xdo^V;@v1`DakU^++~EMMLv6 znhY>1`WU%X@+B3(P9s5;DTiJ5###9lg&rFlDNIA&Zb2(o&2whqEd!b~RROAHLK2UC%c? zwc&{$<(hhWSv`ASJzL9bwamGP5&dzp{rctQj)D6f14|v*rR4TnWcxo)Vwp80NvX$? z#I^XRh2tZ}u2(Q+1eL^-^7-lR4qSGi)_Gp8Vkz&!- z%D7l0tS{0qt9J2Rg-XP!0`*y;KSygYA~L`>MBXhsM%BWjWC*5EedHQ2tOy*(t@1o4 z{vASG?ml|^7qsIyH1Y@~01G+29PN4#?YcR<6z#tfS`DFC=KA5A6HD^eE8*X{$S2(& lbuWH+t90wkmF^mst8?j9evnJ835egshMlV-Ov!FI=zjt&KYjoJ literal 0 HcmV?d00001 diff --git a/api/controllers/__pycache__/loan.cpython-313.pyc b/api/controllers/__pycache__/loan.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..90b24f0c985f048a2dc78a7a827fb0cfbe02e1f5 GIT binary patch literal 4992 zcmds4Z%iD=6`$R`z1!RS3wLk_w()`?_JIx9#x|UtHW-X-Fpk5TG!-bP_1yxS-0hLs zJ;(6Da-&Km?T0v(Qb$#!&C4rHDCMsVHfCh$xG-$WoBOw}^ z42?<7l3R5PnXEYh>tdm`lWYr-Js@E#lE`FaEK%mGsVvCeE=Csn*h#nSYa}NsolZ^b zo}%Zk(hKaOS3m7e1m_Ql19{z~`D|9D(o~*G*}S43p=wq!)r_R-nc2Lin?|Sj2-i(; zmiKJ7pw3bdS{|ytR!|LKUNQ2zHZ_-EtjJZx(9$WTV9unlTcc_w!CSSLY5tm)Q7^*m zV->B)HkmP-*A3OG#}OBFuq3CLT7GkI?7!p{1s8TCSWC!&#io{1EiclHnl+V6mMaUr z^Zu@?r7YLw=jAJ4swbzKGx>~>u0C!*G&uM+ehByS4rwA2o4sR|o7xlr)3SLVIvFR2 zDqWc{Arp?T*&Zfyovd^Bva5$}C0#`3Utn}*oQaV0`v}q5m!#9+9T`|<6g;>}-z|H_ z*&sR2Zkt8uVGULyt;9zpGU0Zt8WS9Dt8M|?oYi%1!sqmib7Q#Q9R;Mp9U^Fg6n3?< zfV6g}j1bu?ixYK@e4LYgj+Z+pq`ViJ-0UyA1{?@%)H(k#uVRl~3!DmJcZ2D}rg z@)qHxidSYdLn=GBG^6O5tZGQ+jOq-jOoGo*Qp(f7w2G827-l}FQkY>(H49WXlA;7< zSbqKz=$7A`^txuw>|EP06|-Q77T43={UmxpzoulhOj&PMnS&>o`H;0ln4*w12l5%! zswwk>s*$GJEWYzcx4`!kz9)REIX4S$xU!uK87p8?MK_c*j)JD>mC^V1 zVGxt1G&P$^N{@a4hFWZQ_k1Hpp7IT3px-E@@m{71+3cL<&Ka7K(Jv)Dv;lmBHX=bU zrhAd#+o0(D^dOM=XCOSGF3f`FRNX2@WM{+DeDgUkY{!^Xas}<2W`;8+@xVG~=PW-QBEEnW3MjmC0$70qiciPEy`3vH?ptl_SZ?en#Tswlc>9JUj_oNmB_B2)T8u2t ztTvxoZa#JE`0eCRlXtGHhlHrOAUuu|Z|E)Go4#WGnLG0DUjFFi;+c!Z=;#CC(!)^w zt?R3?gUhjlE3w2%sJ$q(fAzU9xl+;dN%CtykdLE4K8c)t2CBPB zaqzp`Jy-W&lDpT#AfM!c&WF1$!lhJ!fHcgpSulHX@NGna{-0)O8^Pj^cgxRJn8P*h znh4=IESPosm?NO8^Sj7lgzlcGv4`Sl-#9;pKEI>LekX&;0XgU*_qkF3J|89AH+!G-$3I^Ln3>FX&posj!7{B8D5Uxd_Da;#s8dHx7R0 z3aZV~VeH1=5!#ES4~T(FBPOU6nuN9$wzaD^NiTzV6vuy5N$RJ+Wl|@5E}n$(lD?zuryX|IA4lOtMSBgJW*_aZYAEobYSUNF+Nmk zIaF#%l=d{0nwlX?uh)13!8OiTBQEfd_mZds9{${Ue5JPMSLz3u+h^WB^Rw@Ks4my` zln_d^m-e=-``k8g2yX(1p(W!FH$J&h9D1=B{oVuNrQLwTKfef($ce8W*Ajo-b^vkq z-tnc^?w(vaRID3$AYAa4|6h^wlNJZ(??kZizYr0mSXnH>DhWYzD`Nsxj2661X!YaGKc>nzK-zH}3& zbD13Z3+eol=#j(Xu^WxBAFAnap S2M;n;qfe~6uuUhx#{U3Qx`-(N literal 0 HcmV?d00001 diff --git a/api/controllers/__pycache__/notification.cpython-313.pyc b/api/controllers/__pycache__/notification.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b28ce20a46ba4d05d6692534d7392b054835255 GIT binary patch literal 2605 zcmZuy-EZ4Q5?_*{K4@8#BiRz{oV{_~OO>RGuSxFWeB8ltT*P+cxMB<(U;zS6u530G zsoSN}SWm${xlip&fc^!C-b-i z9a36Mi(eG9#FSW$YRWLW(me=QnqHF&ea+t~Gp^%N*R~18RhMGN^{lF8=$_>|*wAfz zRW}}B;+S>Uay(X4{>v2=BtLP>Zjd?!rXQo^FAc(gc#pY`Roy5Eer8o?mQm3g-g<=} zTa=gu(H~jt(Ld8wU+P{z{p*LU?mCPt`pI73nu8aAP1=tv-P7)a{aB57>#oU+Fd%%5 z!zaOaFagnff=1B_=LVzHjc2&$>-bUB9JGwy=pLDAgjT|=2f5ayq8MgKi(D2b&_$%l z?+T8vEM(A~-yr0Of5z7#hbCCL3s~y8AJt;ZVhUXnPiTo>7MY0A1nNT=twckwO8f<{ zjB)TSwC+eN0aCe0ss9y0*lLMosg(MEehO&{oWRRo9dObIC#PQJBLqH2L%ZPPU?`y_ zwSG;S5}nBLE0-gsTzG!GhFA=>OrSGpB_E#0kbWH^C-=N)Bs@D_wbTm4w=>F6++VjC?w}9@)nLR7D$H`A&T5vkhBd3UN{MajSYLG;9`@D=?kaPu zy(Sn|ymj5fZ(Edkw{+(LUe_6}Q`aC2)Pk`R@fy@&*Axt6Uc0vlo_6X6L;X7L`!+Gx zxXs`Jbq|Aq-f~iW>GI@cGdt%z)NRY`sHp23wyX29RX|(A_GVm@_yZmKHem*}>O2D0 zM*!`D;-{;t&i&)S%$a_FPn=+@7!BsRH9~<-=!W4o91qB-Nmlu)_DJ8~s1rY>*SN?M zbst(L0fEkbhPNxj14c4@W#CyR-1Ybd?|SR4X8x|lSm*hjR)nh-vCV7P`Wv|3{B5a| zR`Wzmf^7A;gqyJo*aN?CGjSnIlrK(B`ij;tcq*z5yZOc}rO-#a$>BD8<9yUouVG)h z0udjab38&F-Nua2htPxxziu6ZTZ;viMgZAtyj_s&tHX~oEnU|U2 z_ULQv*RSu((&^LzlCtVv6b)qeVp2A{hot`O_epVNTG&(%qG;&#&Dpk^{kMu==0@A& zXWQpzp3Xk~L#wa=xA7^sozKAS!93i~=f6kNKz?6_TZd_+p4&}bdXc*HkJ~$`sZHrH zi4y9EeIN9-hHh+WyEhlNZ!WfO+}lIKk}%R98QmQzZjTgO!xul<*vjlq&23N3wXV+Z zNy6Ye!XXk8cZI_^%1*X(Bkj?1U_7gA#t#*g&VBS~H+ObBcXlUN*ip~5;^%()wl8~t zWI6pZp87EPL9&(m({t(R^t0&Jqt@V^f5qosszdGJvG$p(peC=t8`6F7hW<3XA)WpK zNs06?dotYrnH3?Je?32=pih%9eU^pkpPA|3!}3f~ZmZI>?Bs1ldL9vgey+$c7Y6)T zrDD29r2+u4b@qVrpK5^B0HIK~b*osjOw%Us>y%KA7GDXN&_Qe#K)4G{4qm7dKLH?r zrvv18rQ+x{QmIhPFRCYyuv+(J3f+E_$I<;L%AEdq@sm3{r>;C! szZTNF!qB!b)XEjNhPL7@poHnRkllyhOZq@U;vk=H3HuS))2m?Zf7MW`1^@s6 literal 0 HcmV?d00001 diff --git a/api/controllers/__pycache__/offers.cpython-313.pyc b/api/controllers/__pycache__/offers.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a5fcdaeeda7d5985b5f544d692cb9196b248148 GIT binary patch literal 3507 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 ZD!K#zvZ(MbgXlIgjqErGj%PsIe*tH7Xw?7! literal 0 HcmV?d00001 diff --git a/api/controllers/__pycache__/penal.cpython-313.pyc b/api/controllers/__pycache__/penal.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ae8699b88918e610113a96dabb3d0c81048f0a2 GIT binary patch literal 2482 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 LVth?r0c-yS4$zyo literal 0 HcmV?d00001 diff --git a/api/controllers/__pycache__/repayment.cpython-313.pyc b/api/controllers/__pycache__/repayment.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48d7a04f259c909664ada8be383481b26348005c GIT binary patch literal 2408 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 W-ij06ev9|`7T$wv3=Huk81)}&uchw* literal 0 HcmV?d00001 diff --git a/api/controllers/__pycache__/token.cpython-313.pyc b/api/controllers/__pycache__/token.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..88cacf5f85ff086d3dbc3ea49e745427d8742aef GIT binary patch literal 2535 zcmZ`)-E$k&6~DWy)yGQKO0i@+2JCCckSGa`Cw3BW3PU2h#dZ>c)^0mkGh(B?vX`uO z#l0(n^_2Fd=85pYz+ccwUl@2I9hjC5`~f1SjpioM&Xkw-p=0B8=(I0AccrzHFx;8F z_uP+j?zzA7J7;(M`ZNUXUw^r~_Rly%e;1RmsXbx$0}$Rt2BOH240%afl&QR^P-QVf zBUr2{OX^~jMguvr6kCkb_+o-4g1Ne+QLU_%212v@<2Wj(PD$N4?JLXU$cVy<+*?VP z)1}e<)1hRVAh zrf+*LCa%@+Y}aR#+CPMig2EH$ohE5eSmZHEUT+cx!fVWP?b=2`;u*7HSFVzcirBL$ zu?jNJ{k&_DEDQUko>KmiRo3uaMlNwR@S>GI@z6exTz<(|h6`eK;;(xatA>viBg6$M z@a;YY$h?b2(5hGsTB(OsI2Ysi9wIJUK}UL9RtBNfFrq;m4P{ae(KjM9@&r184E1Hn zl~$yF^x_i;x$-aZS%{AXw_F6CJfI&nVk>eQP0J5iiOV7X{g zf-ig2H=Rjs!QZ>LdyIKC|HQgUtzI%#J&QmZ`UFx5U)cyu2?xQI67ieVWwRQFwrIR` z2~*NdB7DvE*MFGJu-$Zgrg3FvYO0l6aDyjzRU77p@}H3I}|O~=`IFebzryQWJibQ!iYTO1kD6$OH3 zMJGJuQO8?bBa}yMx90Jf?**rLV*br42?E8{;0~@ZpBAD#0u|lldWe4|z@Kq77`X!L zJPyGQYA}}w6O~Eu3S)k$I9nDWW@WS9w6ECZ%Ntc-XdN(mjZ}SB-s8Jr*RN3GIOe&A zU4|b)xw`j%VdVgu$;JjxhnZFhmgq6KN{5e~1URmv?V*C2`BopeqmSO!N4NB`&B!-; z|Goa<&d8z8lV^8S~>$^08BiOm1D zRnY%0n*{8BeXN*7AEcoBLk_w>^`AQf!{{FI^d{@JWjW*;)GI3Jz_nlz>Vm@pdIp^2F~LM2KTiOGFLYTM zuT)&KPAU~TA{666pet7zT%}$UGC_p=cW4)SM2`k1ep2_WrbC{mXJ86EK#V}Qtw@rz zBg<0c`zVqozeG=ejgI{loxh9H0EU*mlNi387=Hhmt;EFj=uQ-+@;83*{`yw(@b&oL rq{MGVel^n0;G3s!9=Sf!mZm#Wc1NC;GP??r4~TANCjwJ?8gBYO2Lg{K literal 0 HcmV?d00001 diff --git a/api/controllers/__pycache__/transaction.cpython-313.pyc b/api/controllers/__pycache__/transaction.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fb4023b6efdd729e18de559fd4d339966dab0aa4 GIT binary patch literal 6316 zcmeI0OKcm*8GvV(@8w&fUZg~=Y&kY#TasZ`?{Qt~9?{9X~>tzr;9p&FI{IV6HKamgfu~j0QQ3|2=5Qi}0D2~2J#c52( zE!YybVk=A1EEjEYJGSd_>qST0iJf}fcF`4QFcWuUcie+LaWD49eb}ex*)RIBA8Z{L z1MwgZ#v5=0i)Q_am(1#`;Vv{A8UP>2gPUgQU5Il+3SCKQo^4L_8u41mxj3em;@l_b zStsY2wfEDUH;k@Y2P#Tth+B8C_q|~6uWR47V2=e>4l<*8Nx}JSR>16h9YA{eA|@ghGBKOhGF`22lvJ0f6xaG zshd|Z^w(IO~@BfpgZkzL3fPYET_zDibR4?;6mQ!n8#W7UwO%$dca z8JC&P(VT^|_RwY?XX~fCQ7_``Qz1sXTe87xPg)a!=QR=HJcF+x#KV|zUdeW4*A7}fqLl&r9rFEpDbSl- zK(fy?n)Pr&t^q7U=65wDeNsOMQk$!KW-Mt-+PTL1wfhhkIzn?zC!CU_X7;q1w$+ep z-m!!h$O%_9%^3J;HT$x5?2?W|hpAHy%{gsabi<5e;g#rl)P-iErlte&V+cvk8gIMI zylTj`&g?e7l1{VC9cNP;7v4#K+YE~I!&>M>T)X5Padub2d1@Y=vUH+(D%QaxSQ^Z^ z%I?Q1`O1D+vrVwF!Yc*X7@6rsQD%*efrVH>7Ba*llP}C=g@eXMsw@gDuI!8uNekk2 z(3;2j9D7!j=L%RBF6DWN;iU{aN_OP5LcUpUSxPVByp%7~#T)^P(Vc z+pwYZLROJk*t^e*SXR#P(lv0-*oNgnhJ`22U74u5aYIxVw-sTuh|LHJFJ@)tS;x3^ zozIFH!)}RR&gS{dmiL;azpw9EsGiO(T4M}p|`Rbd60cZA7H`n!-pUL6dd@AIkhy8IS4sHQWo^&)lY{tI3V?*0FO1tq#*HG zW!cC@(P2#XUM+b3%*5foLnjXP9XWKUufM-W31zDai zWV6dXF{j2T;A=|vOmj-S0yAPfgUJHHoup*n63$;2GeTx0M>?F>UvdP4&9%Pj{OHTG@wgc?=S$3uS9LDeoB@=bhMK9vt2WFJ3;o|}j@U~))l&ILh9k<(wZXY&gSFp`cNJeHtjOkhm2UIl!>P7-yI*`CV4 znTxyNTkpP_=2}GwJZ&eM4kfSaX|8i`qy>G#Xf9zZYLOKjb7|HLoa36`9Al#=WzA;v zs4r*C0W;YGdYa$t(iCs>X<(~Q%@n7(N;OK5$q18mqd(g8=-_M;Izyi2a|LluoSj@w zFY-7CoqrY9t3124?l>_whlOmGA6XJ-fqZ7uTc9P+*7SJK(y|t;`A+GZ1-<~)Ho+_Z z6}tK@w9$Ip7WlKjx#aJB;O|`bcdc5#@Ham4G~AqcdqRy2KJ*MdY7Cd7yUTlqp4cqy zzD;Ba`8Vt+7}{`HLZJ<0@rJ(gP?0fe)&IwXC)e8&KTCah?*4RfcMuv4s;Qbuav!kl9w%d z*$*$Rd;8W#OZ{WT{;~Tf*88W+ox4>}`%^m#9VoXPD!1(WsPX5~TTyl2akc40xh4Gm zoA16^Zf<)&_inBndAZyUT9I;Bcd4tt*wwGH1M0xE8cjY4xcfBRH?tfWzzWn#E z#wir$sJ}hEL?KVpCbHQAkDR`ev$g1KRU@ZAv)mc^)PDO7we7+~=Qxy54tJJ!4}N^) z_UpesdV8PRH41&!%D^}U+%Qhw0E|;0@HMiy0{_^sL1EA2voQWY9zo|B^ohkuq}z4g zX8okqPGoc>2uZ)~8x28l*JdM+-a4W(~AWOV}l zpv{j!{-0Zc@cx&$6}DNzt(;GCTxqM%cAUYcL=)JQ3xcMVYk*iI(Bc-Uf^8j2)u}U- zbO2l0fYw^fSp(YS{CVz4IunuSlHk*1LN3HLb1f&VlB
    M$hNTE87Yxuk`4>fMo1 zUH{4-lPAB0QJETV$;tKHvyC|!v&;L!k^6QzOzC`;UhT0}`yL|~4Jci#bE4O)GUd<U3QTY zcN5V~1YF(`CjW!*UJwQ<4&NMJY1d&MK;gP5ljg%Up^Af;+_WpYKN?=?&~>Zixm~$} zpX0+TVI4@>Q9zId=(ueq?uQa%HXJ9O^bkS*HdTR9b8yMg(Q_OZvl=?DY|CEh22U$I zDG4`f=TQw);yy51+4llb*0mU9@gY*jVIq!zFbNX(lN7Q;;G;wkYQ)EgAiw$WaU#f= zU~=Ebmx&;M8Vt7wN$dm>Lqwb;;uMIO8|y7PO%jHQ7$M>e5u-$q(^YFQt|h5V^046?1^v{EkNxc;oBmo{Hg)~8PQ?*70!c9RQ`TZkX;g0}8xHHs$hT*3E3k(-4 z`3^tu9bW5S_Z|NhhU;FvQtmok^hCFlT)DFw;H}&>3^`xwAXn}>rFx=(bM5=f;f_+c zw;1lNa$B_TiQC@J03<$bK%rh;V}CKczZ~o;1!KivY(2Q2@LhE86PHZ~I^P!14c z^1lUiNC!HQZr8aX0(3+YphE=c7zlnhWCM8@F=Ii?UFPJN$8yh3LHwS_1~L}b9H~?$ zpH8LVu9M~EYZ$N%>DMvi&gIcld*FIWu3>gEOMP$&>B)(iOjdyFnSdQ6kV%2!7Kj;#D;~Us3$Z{S--I(s;7t!evS@& zj`n{;TPgZ$1Y(mxl>ZN?9SE8UmR#WnuJ8xP*InIj*`L@^L*#=~AMINY9(l|6CFL(s zO+~6nZH}#-UpuZsj5=ASf*bT1iVCQao=rp|n-)Yj5OHe5O1?KP(1;G0rU%xpuPy4P br*+dy#I*D9CL)n7(_!j~l_cOFKokB6U6Ip3 literal 0 HcmV?d00001 diff --git a/api/controllers/collection.py b/api/controllers/collection.py new file mode 100644 index 0000000..743afc1 --- /dev/null +++ b/api/controllers/collection.py @@ -0,0 +1,81 @@ +""" +Controller for loan collection endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import CollectLoanRequest, CollectLoanResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +collection_bp = Blueprint('collection', __name__) + +@collection_bp.route('/CollectLoan', methods=['POST']) +@api_key_required +def collect_loan(): + """ + Endpoint to process loan collection requests from Simbrella. + + This method handles requests to collect money from user accounts. + When a request is received, FirstBank should check all user accounts + and collect as much money as possible to cover the existing loan + either partially or fully. + + Returns: + JSON response with collection status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['transactionId', 'fbnTransactionId', 'debtId', 'customerId', + 'accountId', 'productId', 'collectAmount', 'collectionMethod', + 'lienAmount', 'countryId'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = CollectLoanRequest.from_dict(data) + + # Process collection request (this would connect to the business logic) + # For demonstration, we'll return a mock response with partial collection + + # Assume we collected 75% of the requested amount + collected_amount = req.collectAmount * 0.75 + remaining_lien = req.lienAmount - collected_amount + + # Create response + response = CollectLoanResponse( + transactionId=req.transactionId, + debtId=req.debtId, + customerId=req.customerId, + accountId=req.accountId, + productId=req.productId, + collectAmount=collected_amount, + lienAmount=remaining_lien, + resultCode="00", + resultDescription="Loan Collection Successful", + penalCharge=req.penalCharge if hasattr(req, 'penalCharge') else 0.0 + ) + + logger.info(f"Processed collection for customer {req.customerId}, collected {collected_amount}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing collection: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/consent.py b/api/controllers/consent.py new file mode 100644 index 0000000..27d8d2e --- /dev/null +++ b/api/controllers/consent.py @@ -0,0 +1,123 @@ +""" +Controller for customer consent endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import basic_auth_required, api_key_required +from api.models import ( + CustomerConsentRequest, CustomerConsentResponse, + RevokeEnableConsentRequest, RevokeEnableConsentResponse +) +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +consent_bp = Blueprint('consent', __name__) + +@consent_bp.route('/CustomerConsent', methods=['POST']) +@basic_auth_required +def customer_consent(): + """ + Endpoint to process customer consent requests. + + This method handles customer consent for loan services. + + Returns: + JSON response with consent status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['$type', 'transactionId', 'customerId', 'accountId', + 'requestTime', 'consentType', 'channel'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = CustomerConsentRequest.from_dict(data) + + # Process consent request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = CustomerConsentResponse( + resultCode="00", + resultDescription="Request is received" + ) + + logger.info(f"Processed consent request for customer {req.customerId}, type {req.consentType}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing consent request: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 + +@consent_bp.route('/RevokeEnableConsent', methods=['POST']) +@api_key_required +def revoke_enable_consent(): + """ + Endpoint to process consent revocation or enablement. + + This method handles requests from Simbrella to revoke or enable customer consent. + + Returns: + JSON response with operation status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['transactionId', 'fbnTransactionId', 'customerId', 'accountId', + 'processTime', 'consentType', 'countryId'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = RevokeEnableConsentRequest.from_dict(data) + + # Process revoke/enable consent request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = RevokeEnableConsentResponse( + type="RevokeEnableConsentResponse", + customerId=req.customerId, + accountId=req.accountId, + resultCode="00", + resultDescription="Success" + ) + + logger.info(f"Processed revoke/enable consent for customer {req.customerId}, type {req.consentType}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing revoke/enable consent: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/disbursement.py b/api/controllers/disbursement.py new file mode 100644 index 0000000..6dbc8b2 --- /dev/null +++ b/api/controllers/disbursement.py @@ -0,0 +1,78 @@ +""" +Controller for loan disbursement endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import DisbursementRequest, DisbursementResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +disbursement_bp = Blueprint('disbursement', __name__) + +@disbursement_bp.route('/Disbursement', methods=['POST']) +@api_key_required +def disbursement(): + """ + Endpoint to process loan disbursement requests from Simbrella. + + This method handles requests to disburse loans to customer accounts. + The operation should be executed atomically, providing the loan and + collecting upfront fees within the same transaction. + + Returns: + JSON response with disbursement status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['requestId', 'debtId', 'transactionId', 'customerId', + 'accountId', 'productId', 'provideAmount', 'countryId'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = DisbursementRequest.from_dict(data) + + # Process disbursement request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = DisbursementResponse( + requestId=req.requestId, + debtId=req.debtId, + transactionId=req.transactionId, + customerId=req.customerId, + accountId=req.accountId, + productId=req.productId, + provideAmount=req.provideAmount, + resultCode="00", + resultDescription="Loan Request Completed Successfully!", + collectAmountInterest=req.collectAmountInterest, + collectAmountMgtFee=req.collectAmountMgtFee, + collectAmountInsurance=req.collectAmountInsurance, + collectAmountVAT=req.collectAmountVAT + ) + + logger.info(f"Processed disbursement for customer {req.customerId}, amount {req.provideAmount}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing disbursement: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/eligibility.py b/api/controllers/eligibility.py new file mode 100644 index 0000000..20e3ac2 --- /dev/null +++ b/api/controllers/eligibility.py @@ -0,0 +1,91 @@ +""" +Controller for eligibility check endpoints. +""" +from flask import Blueprint, request, jsonify, current_app +from flask.typing import ResponseReturnValue +from api.middleware import basic_auth_required +from api.models import EligibilityCheckRequest, EligibilityCheckResponse, EligibleOffer +import logging +from typing import Dict, Any, List + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +eligibility_bp = Blueprint('eligibility', __name__) + +@eligibility_bp.route('/EligibilityCheck', methods=['POST']) +@basic_auth_required +def eligibility_check() -> ResponseReturnValue: + """ + Endpoint to check customer eligibility for loans. + + This endpoint initiates the eligibility check process and performs RAC checks. + + Returns: + JSON response with eligibility status and available offers + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + logger.warning("Invalid JSON payload received") + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['$type', 'transactionId', 'countryCode', 'customerId', + 'accountId', 'lienAmount', 'channel'] + missing_fields = [field for field in required_fields if field not in data] + if missing_fields: + logger.warning(f"Missing required fields: {', '.join(missing_fields)}") + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required fields: {", ".join(missing_fields)}' + }), 422 + + # Create request model + req = EligibilityCheckRequest.from_dict(data) + + # Process eligibility check (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create sample offers + offers: List[EligibleOffer] = [ + EligibleOffer( + minamount=5000.0, + maxamount=20000.0, + productId=101, + offerid=101, + Tenor=30 + ), + EligibleOffer( + minamount=10000.0, + maxamount=50000.0, + productId=102, + offerid=102, + Tenor=60 + ) + ] + + # Create response + response = EligibilityCheckResponse( + customerId=req.customerId, + transactionId=req.transactionId, + eligibleOffers=[offer.to_dict() for offer in offers], + resultCode="00", + resultDescription="Successful", + msisdn=req.msisdn + ) + + logger.info(f"Processed eligibility check for customer {req.customerId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.exception(f"Error processing eligibility check: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': f'Internal server error: {str(e)}' + }), 500 \ No newline at end of file diff --git a/api/controllers/lien.py b/api/controllers/lien.py new file mode 100644 index 0000000..dae3ca0 --- /dev/null +++ b/api/controllers/lien.py @@ -0,0 +1,65 @@ +""" +Controller for lien check endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import LienCheckRequest, LienCheckResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +lien_bp = Blueprint('lien', __name__) + +@lien_bp.route('/LienCheck', methods=['POST']) +@api_key_required +def lien_check(): + """ + Endpoint to check lien amount on an account. + + This method is used to get the applied lien amount for a specific account. + + Returns: + JSON response with lien amount details + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['transactionId', 'customerId', 'accountId', 'countryId'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = LienCheckRequest.from_dict(data) + + # Process lien check (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = LienCheckResponse( + lienAmount=20000.0, + resultCode="00", + resultDescription="Successful" + ) + + logger.info(f"Processed lien check for customer {req.customerId}, account {req.accountId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing lien check: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/loan.py b/api/controllers/loan.py new file mode 100644 index 0000000..7a7d57e --- /dev/null +++ b/api/controllers/loan.py @@ -0,0 +1,147 @@ +""" +Controller for loan-related endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import basic_auth_required +from api.models import ( + ProvideLoanRequest, ProvideLoanResponse, + LoanInformationRequest, LoanInformationResponse, Loan +) +from datetime import datetime, timedelta +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +loan_bp = Blueprint('loan', __name__) + +@loan_bp.route('/ProvideLoan', methods=['POST']) +@basic_auth_required +def provide_loan(): + """ + Endpoint to process loan provision requests. + + This method handles the request to provide a loan to a customer. + + Returns: + JSON response with loan provision status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['$type', 'requestId', 'transactionId', 'customerId', + 'accountId', 'productId', 'lienAmount', 'requestedAmount', + 'collectionType', 'loanType', 'channel'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = ProvideLoanRequest.from_dict(data) + + # Process loan provision (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = ProvideLoanResponse( + requestId=req.requestId, + transactionId=req.transactionId, + customerId=req.customerId, + accountId=req.accountId, + resultCode="00", + resultDescription="Loan provided successfully", + msisdn=req.msisdn if hasattr(req, 'msisdn') else None + ) + + logger.info(f"Processed loan provision for customer {req.customerId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing loan provision: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 + +@loan_bp.route('/LoanInformation', methods=['POST']) +@basic_auth_required +def loan_information(): + """ + Endpoint to retrieve loan information. + + This method provides information about a customer's existing loans. + + Returns: + JSON response with loan information + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['$type', 'transactionId', 'customerId', 'channel'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = LoanInformationRequest.from_dict(data) + + # Process loan information request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create sample loans + now = datetime.now() + loan_date = now - timedelta(days=15) + due_date = now + timedelta(days=15) + + loans = [ + Loan( + debtId="123456789", + loanDate=loan_date.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3], + dueDate=due_date.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3], + currentLoanAmount=8500.0, + initialLoanAmount=10000.0, + defaultFee=0.0, + continiousFee=0.0, + productId="101" + ) + ] + + # Create response + response = LoanInformationResponse( + customerId=req.customerId, + loans=[loan.to_dict() for loan in loans], + resultCode="00", + resultDescription="Successful", + totalDebtAmount=8500.0 + ) + + logger.info(f"Processed loan information request for customer {req.customerId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing loan information request: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/notification.py b/api/controllers/notification.py new file mode 100644 index 0000000..4e812c1 --- /dev/null +++ b/api/controllers/notification.py @@ -0,0 +1,70 @@ +""" +Controller for notification callback endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import basic_auth_required +from api.models import NotificationCallbackRequest, NotificationCallbackResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +notification_bp = Blueprint('notification', __name__) + +@notification_bp.route('/NotificationCallback', methods=['POST']) +@basic_auth_required +def notification_callback(): + """ + Endpoint to receive transaction status notifications. + + This method is used for informing Simbrella about the status of transactions + that FirstBank has processed. + + Returns: + JSON response acknowledging receipt of notification + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['fbnTransactionId', 'transactionId', 'customerId', 'accountId', + 'debtId', 'transactionType', 'amountProvided', 'amountCollected', + 'responseCode', 'responseDescription'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = NotificationCallbackRequest.from_dict(data) + + # Process notification (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Log the notification details + logger.info(f"Received notification for transaction {req.transactionId}, " + f"type {req.transactionType}, status {req.responseCode}") + + # Create response + response = NotificationCallbackResponse( + resultCode="00", + resultDescription="Successful" + ) + + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing notification callback: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/offers.py b/api/controllers/offers.py new file mode 100644 index 0000000..20fee3a --- /dev/null +++ b/api/controllers/offers.py @@ -0,0 +1,92 @@ +""" +Controller for offer selection endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import basic_auth_required +from api.models import SelectOffersRequest, SelectOffersResponse, Offer +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +offers_bp = Blueprint('offers', __name__) + +@offers_bp.route('/SelectOffer', methods=['POST']) +@basic_auth_required +def select_offer(): + """ + Endpoint to send the offer the customer selected to Simbrella. + + This method processes the customer's selected offer and returns detailed offer information. + + Returns: + JSON response with detailed offer information + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['requestId', 'transactionId', 'customerId', 'accountId', + 'msisdn', 'requestedAmount', 'productid', 'channel'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = SelectOffersRequest.from_dict(data) + + # Process offer selection (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create sample offers + offers = [ + Offer( + offerId="14451", + productId=req.productid, + amount=req.requestedAmount, + upfrontPayment=req.requestedAmount * 0.1, # 10% upfront + interestRate=3.0, + Interest=req.requestedAmount * 0.03, # 3% interest + ManagementRate=1.0, + ManagementFee=req.requestedAmount * 0.01, # 1% management fee + InsuranceRate=1.0, + InsuranceFee=req.requestedAmount * 0.01, # 1% insurance + VATRate=7.5, + VATamount=(req.requestedAmount * 0.01) * 0.075, # VAT on management fee + recommendedRepaymentDates=["2023-04-30", "2023-05-30", "2023-06-29"], + installmentAmount=req.requestedAmount * 1.05 / 3, # Split into 3 payments with 5% total fees + totalRepaymentAmount=req.requestedAmount * 1.05 # Total with 5% fees + ) + ] + + # Create response + response = SelectOffersResponse( + requestId=req.requestId, + transactionId=req.transactionId, + customerId=req.customerId, + accountId=req.accountId, + offers=[offer.to_dict() for offer in offers], + resultCode="00", + resultDescription="Successful", + outstandingDebtAmount=0.0 + ) + + logger.info(f"Processed offer selection for customer {req.customerId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing offer selection: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/penal.py b/api/controllers/penal.py new file mode 100644 index 0000000..a9321ba --- /dev/null +++ b/api/controllers/penal.py @@ -0,0 +1,67 @@ +""" +Controller for penal charge endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import PenalChargeRequest, PenalChargeResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +penal_bp = Blueprint('penal', __name__) + +@penal_bp.route('/PenalCharge', methods=['POST']) +@api_key_required +def penal_charge(): + """ + Endpoint to process penalty charge requests. + + This method handles requests to charge customers for penalties + as per existing debt. Results of these requests will be received + from the NotificationCallback endpoint. + + Returns: + JSON response acknowledging the penalty charge request + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['transactionId', 'fbnTransactionId', 'debtId', 'customerId', + 'accountId', 'penalCharge', 'lienAmount', 'countryId'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = PenalChargeRequest.from_dict(data) + + # Process penal charge request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = PenalChargeResponse( + resultCode="00", + resultDescription="Penal charge debited successfully" + ) + + logger.info(f"Processed penal charge for customer {req.customerId}, amount {req.penalCharge}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing penal charge: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/rac.py b/api/controllers/rac.py new file mode 100644 index 0000000..8cc45c6 --- /dev/null +++ b/api/controllers/rac.py @@ -0,0 +1,81 @@ +""" +Controller for Risk Acceptance Criteria check endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import RACCheckRequest, RACCheckResponse, RACResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +rac_bp = Blueprint('rac', __name__) + +@rac_bp.route('/RACCheck', methods=['POST']) +@api_key_required +def rac_check(): + """ + Endpoint to check if a customer passes the Risk Acceptance Criteria. + + This method evaluates a customer against the bank's risk criteria. + + Returns: + JSON response with RAC check results + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['transactionId', 'fbnTransactionId', 'customerId', + 'accountId', 'RAC_Array'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = RACCheckRequest.from_dict(data) + + # Process RAC check (this would connect to the business logic) + # For demonstration, we'll return a mock response with all checks passing + + # Create RAC response object + rac_response = RACResponse( + SalaryAccount="1", + BVN="1", + BVNAttachedtoAccount="1", + CRMS="1", + CRC="1", + AccountStatus="1", + Lien="1", + NoBounchedCheck="1", + Whitelist="1", + NoPastDueSalaryLoan="1", + NoPastDueOtherLoan="1" + ) + + # Create response + response = RACCheckResponse( + RACResponse=rac_response.to_dict(), + resultCode="00", + resultDescription="RAC Check Successful" + ) + + logger.info(f"Processed RAC check for customer {req.customerId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing RAC check: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/repayment.py b/api/controllers/repayment.py new file mode 100644 index 0000000..7dbffd8 --- /dev/null +++ b/api/controllers/repayment.py @@ -0,0 +1,68 @@ +""" +Controller for repayment endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import basic_auth_required +from api.models import RepaymentRequest, RepaymentResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +repayment_bp = Blueprint('repayment', __name__) + +@repayment_bp.route('/Repayment', methods=['POST']) +@basic_auth_required +def repayment(): + """ + Endpoint to process loan repayment requests. + + This method handles customer repayment of loans. + + Returns: + JSON response with repayment status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['$type', 'transactionId', 'customerId', 'debtId', + 'productId', 'channel'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = RepaymentRequest.from_dict(data) + + # Process repayment request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = RepaymentResponse( + customerId=req.customerId, + productId=req.productId, + debtId=req.debtId, + resultCode="00", + resultDescription="Repayment processed successfully" + ) + + logger.info(f"Processed repayment for customer {req.customerId}, debt {req.debtId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing repayment: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/sms.py b/api/controllers/sms.py new file mode 100644 index 0000000..580a5ff --- /dev/null +++ b/api/controllers/sms.py @@ -0,0 +1,132 @@ +""" +Controller for SMS notification endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import SMSRequest, SMSResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +sms_bp = Blueprint('sms', __name__) + +@sms_bp.route('/SMS', methods=['POST']) +@api_key_required +def send_sms(): + """ + Endpoint to send SMS notifications. + + This method handles requests to send SMS messages to customers. + + Returns: + JSON response with SMS sending status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['text', 'dest', 'unicode'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = SMSRequest.from_dict(data) + + # Process SMS request (this would connect to your business logic) + # For demonstration, we'll return a mock response + + # Create response + response = SMSResponse( + data="", + statusCode=200, + IsSuccessful=True, + errorMessage=None + ) + + logger.info(f"Processed SMS request to {req.dest}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing SMS request: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 + +@sms_bp.route('/BulkSMS', methods=['POST']) +@api_key_required +def send_bulk_sms(): + """ + Endpoint to send bulk SMS notifications. + + This method handles requests to send multiple SMS messages (up to 20) + in a single request. + + Returns: + JSON response with bulk SMS sending status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate that data is an array + if not isinstance(data, list): + return jsonify({ + 'resultCode': '422', + 'resultDescription': 'Request must be an array of SMS messages' + }), 422 + + # Validate array length + if len(data) > 20: + return jsonify({ + 'resultCode': '422', + 'resultDescription': 'Maximum of 20 SMS messages allowed per request' + }), 422 + + # Validate each SMS in the array + for i, sms in enumerate(data): + required_fields = ['text', 'dest', 'unicode'] + for field in required_fields: + if field not in sms: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field} in SMS at index {i}' + }), 422 + + # Process bulk SMS request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = SMSResponse( + data="", + statusCode=200, + IsSuccessful=True, + errorMessage=None + ) + + logger.info(f"Processed bulk SMS request with {len(data)} messages") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing bulk SMS request: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/token.py b/api/controllers/token.py new file mode 100644 index 0000000..2787dd8 --- /dev/null +++ b/api/controllers/token.py @@ -0,0 +1,68 @@ +""" +Controller for token validation endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import ValidateTokenRequest, ValidateTokenResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +token_bp = Blueprint('token', __name__) + +@token_bp.route('/ValidateToken', methods=['POST']) +@api_key_required +def validate_token(): + """ + Endpoint to validate user authentication tokens. + + This method is used when users from FirstBank access the Customer Care Portal. + It validates the soft/hard token code entered by the user. + + Returns: + JSON response with token validation results + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['RequestId', 'UserId', 'CountryId', 'TokenCode'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = ValidateTokenRequest.from_dict(data) + + # Process token validation (this would connect to the business logic) + # For demonstration, we'll return a mock response with successful validation + + # Create response + response = ValidateTokenResponse( + Authenticated=True, + AuthenticatedMessage=f"The user with ID {req.UserId} has successfully authenticated!", + ResponseCode="00", + ResponseMessage="Successful", + RequestId=req.RequestId + ) + + logger.info(f"Processed token validation for user {req.UserId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing token validation: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/transaction.py b/api/controllers/transaction.py new file mode 100644 index 0000000..13bca95 --- /dev/null +++ b/api/controllers/transaction.py @@ -0,0 +1,169 @@ +""" +Controller for transaction check endpoints. +""" +from flask import Blueprint, request, jsonify +from flask.typing import ResponseReturnValue +from api.middleware import api_key_required +from api.models import ( + TransactionCheckRequest, TransactionCheckResponse, + NewTransactionCheckRequest, NewTransactionCheckResponse, + TransactionData +) +import logging +from typing import Dict, Any + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +transaction_bp = Blueprint('transaction', __name__) + +@transaction_bp.route('/TransactionCheck', methods=['POST']) +@api_key_required +def transaction_check() -> ResponseReturnValue: + """ + Endpoint to check transaction status. + + This method is used to double-check the response received from DisburseLoan + and CollectLoan Synchronous APIs. It verifies transaction results on FirstBank. + + Returns: + JSON response with transaction status details + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + logger.warning("Invalid JSON payload received") + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['counter', 'TransactionId', 'requestID', 'customerId', + 'accountId', 'countryId', 'transactionType'] + missing_fields = [field for field in required_fields if field not in data] + if missing_fields: + logger.warning(f"Missing required fields: {', '.join(missing_fields)}") + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required fields: {", ".join(missing_fields)}' + }), 422 + + # Create request model + req = TransactionCheckRequest.from_dict(data) + + # Process transaction check (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response based on transaction type + provided_amount = 0.0 + collected_amount = 0.0 + + if req.transactionType == "Disbursement": + provided_amount = 10000.0 + elif req.transactionType == "Collection" or req.transactionType == "Penalty": + collected_amount = 7.50 + + # Create response + response = TransactionCheckResponse( + type_field="TransactionCheckResponse", # This will be converted to $type in JSON + nativeId=f"FBN20191031104405{req.customerId}", + customerId=req.customerId, + accountId=req.accountId, + providedAmount=provided_amount, + collectedAmount=collected_amount, + resultCode="00", + resultDescription=f"{req.transactionType} Status retrieved successfully." + ) + + logger.info(f"Processed transaction check for {req.transactionType}, ID {req.TransactionId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.exception(f"Error processing transaction check: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': f'Internal server error: {str(e)}' + }), 500 + +@transaction_bp.route('/NewTransactionCheck', methods=['POST']) +@api_key_required +def new_transaction_check() -> ResponseReturnValue: + """ + Endpoint to check status of transactions in asynchronous requests. + + This method is used for checking the status of transactions when Simbrella + doesn't receive a callback notification within 5 minutes of the initial request. + + Returns: + JSON response with detailed transaction status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + logger.warning("Invalid JSON payload received") + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['transactionId', 'debtId', 'transactionType', + 'fbnTransactionId', 'origTransactionId', 'customerId'] + missing_fields = [field for field in required_fields if field not in data] + if missing_fields: + logger.warning(f"Missing required fields: {', '.join(missing_fields)}") + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required fields: {", ".join(missing_fields)}' + }), 422 + + # Create request model + req = NewTransactionCheckRequest.from_dict(data) + + # Process new transaction check (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create transaction data based on transaction type + provided_amount = 0.0 + collected_amount = 0.0 + + if req.transactionType == "Disbursement": + provided_amount = 1000.0 + result_description = "Loan Provision is successful" + elif req.transactionType == "Collection": + collected_amount = 500.0 + result_description = "Loan Collection is successful" + else: # PenalCharge + collected_amount = 50.0 + result_description = "Penal Charge is successful" + + # Create transaction data + transaction_data = TransactionData( + transactionId=req.origTransactionId, + providedAmount=provided_amount, + collectedAmount=collected_amount, + resultCode="00", + resultDescription=result_description + ) + + # Create response + response = NewTransactionCheckResponse( + transactionId=req.transactionId, + data=transaction_data.to_dict(), + resultCode="00", + resultDescription="SUCCESS" + ) + + logger.info(f"Processed new transaction check for {req.transactionType}, ID {req.transactionId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.exception(f"Error processing new transaction check: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': f'Internal server error: {str(e)}' + }), 500 \ No newline at end of file diff --git a/api/middleware.py b/api/middleware.py new file mode 100644 index 0000000..ab2e52b --- /dev/null +++ b/api/middleware.py @@ -0,0 +1,115 @@ +""" +Middleware module for the Flask application. +""" +from flask import Flask, request, jsonify, g, current_app +from flask.typing import ResponseReturnValue +from functools import wraps +import base64 +from typing import Callable, Any, TypeVar, cast +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +F = TypeVar('F', bound=Callable[..., Any]) + +def register_middleware(app: Flask) -> None: + """ + Register middleware with the Flask application. + + Args: + app: Flask application instance + """ + # Register CORS if needed + try: + from flask_cors import CORS + CORS(app, resources={r"/*": {"origins": app.config.get('CORS_ORIGINS', '*')}}) + except ImportError: + logger.warning("flask-cors not installed. CORS support disabled.") + + @app.before_request + def before_request() -> None: + """Process request before it reaches the view function.""" + # Log incoming requests + logger.debug(f"Received {request.method} request to {request.path}") + + # You can add more global middleware here if needed + +def basic_auth_required(f: F) -> F: + """ + Decorator for endpoints that require basic authentication. + + Args: + f: Function to decorate + + Returns: + Decorated function + """ + @wraps(f) + def decorated(*args: Any, **kwargs: Any) -> ResponseReturnValue: + auth = request.headers.get('Authorization') + if not auth or not auth.startswith('Basic '): + logger.warning("Authentication failed: No Basic Auth header") + return jsonify({ + 'resultCode': '01', + 'resultDescription': 'Authentication required' + }), 401 + + try: + auth_decoded = base64.b64decode(auth[6:]).decode('utf-8') + username, password = auth_decoded.split(':', 1) + + if username != current_app.config['API_USERNAME'] or password != current_app.config['API_PASSWORD']: + logger.warning(f"Authentication failed: Invalid credentials for user {username}") + return jsonify({ + 'resultCode': '01', + 'resultDescription': 'Invalid credentials' + }), 401 + + g.user = username + logger.debug(f"Authentication successful for user {username}") + return f(*args, **kwargs) + except Exception as e: + logger.error(f"Authentication error: {str(e)}") + return jsonify({ + 'resultCode': '01', + 'resultDescription': 'Invalid authentication format' + }), 401 + + return cast(F, decorated) + +def api_key_required(f: F) -> F: + """ + Decorator for endpoints that require API key authentication. + + Args: + f: Function to decorate + + Returns: + Decorated function + """ + @wraps(f) + def decorated(*args: Any, **kwargs: Any) -> ResponseReturnValue: + app_id = request.headers.get('appID') + api_key = request.headers.get('apiKey') + + if not app_id or not api_key: + logger.warning("API key authentication failed: Missing appID or apiKey") + return jsonify({ + 'resultCode': '01', + 'resultDescription': 'API key authentication required' + }), 401 + + # Validate against configured API keys + if app_id != current_app.config['SIMBRELLA_APP_ID'] or api_key != current_app.config['SIMBRELLA_API_KEY']: + logger.warning(f"API key authentication failed: Invalid appID or apiKey") + return jsonify({ + 'resultCode': '01', + 'resultDescription': 'Invalid API keys' + }), 401 + + g.api_client = 'simbrella' + logger.debug(f"API key authentication successful") + return f(*args, **kwargs) + + return cast(F, decorated) \ No newline at end of file diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..a986655 --- /dev/null +++ b/api/models.py @@ -0,0 +1,459 @@ +""" +Data models for request and response validation. +""" +from dataclasses import dataclass, field, asdict +from typing import List, Dict, Any, Optional, TypeVar, Type, cast +from datetime import datetime +import json + +T = TypeVar('T', bound='BaseModel') + +class BaseModel: + """Base model with serialization capabilities.""" + + def __init__(self): + self.type_field = None + + def to_dict(self) -> Dict[str, Any]: + """Convert model to dictionary.""" + result = {k: v for k, v in asdict(self).items() if v is not None} + # Handle special case for type_field + if hasattr(self, 'type_field') and self.type_field is not None: + result['$type'] = self.type_field + del result['type_field'] + return result + + def to_json(self) -> str: + """Convert model to JSON string.""" + return json.dumps(self.to_dict()) + + @classmethod + def from_dict(cls: Type[T], data: Dict[str, Any]) -> T: + """Create model instance from dictionary.""" + # Handle special case for $type field + if '$type' in data: + data_copy = data.copy() + data_copy['type_field'] = data_copy.pop('$type') + data = data_copy + + return cls(**{k: v for k, v in data.items() if k in cls.__annotations__}) + +@dataclass +class EligibilityCheckRequest(BaseModel): + """Model for eligibility check request.""" + type_field: str # This will be mapped to $type in JSON + transactionId: str + countryCode: str + customerId: str + accountId: str + lienAmount: float + channel: str + msisdn: Optional[str] = None + +@dataclass +class EligibleOffer(BaseModel): + """Model for eligible offer.""" + minamount: float + maxamount: float + productId: int + offerid: int + Tenor: int + +@dataclass +class EligibilityCheckResponse(BaseModel): + """Model for eligibility check response.""" + customerId: str + transactionId: str + eligibleOffers: List[Dict[str, Any]] + resultCode: str + resultDescription: str + msisdn: Optional[str] = None + +@dataclass +class SelectOffersRequest(BaseModel): + """Model for select offers request.""" + requestId: str + transactionId: str + customerId: str + accountId: str + msisdn: str + requestedAmount: float + productid: str + channel: str + +@dataclass +class Offer(BaseModel): + """Model for offer details.""" + offerId: str + productId: str + amount: float + upfrontPayment: float + interestRate: float + Interest: float + ManagementRate: float + ManagementFee: float + InsuranceRate: float + InsuranceFee: float + VATRate: float + VATamount: float + recommendedRepaymentDates: List[str] + installmentAmount: float + totalRepaymentAmount: float + +@dataclass +class SelectOffersResponse(BaseModel): + """Model for select offers response.""" + requestId: str + transactionId: str + customerId: str + accountId: str + offers: List[Dict[str, Any]] + resultCode: str + resultDescription: str + outstandingDebtAmount: Optional[float] = 0 + +@dataclass +class ProvideLoanRequest(BaseModel): + """Model for provide loan request.""" + type_field: str # This will be mapped to $type in JSON + requestId: str + transactionId: str + customerId: str + accountId: str + productId: str + lienAmount: float + requestedAmount: float + collectionType: int + loanType: int + channel: str + msisdn: Optional[str] = None + +@dataclass +class ProvideLoanResponse(BaseModel): + """Model for provide loan response.""" + requestId: str + transactionId: str + customerId: str + accountId: str + resultCode: str + resultDescription: str + msisdn: Optional[str] = None + +@dataclass +class LoanInformationRequest(BaseModel): + """Model for loan information request.""" + type_field: str # This will be mapped to $type in JSON + transactionId: str + customerId: str + channel: str + msisdn: Optional[str] = None + +@dataclass +class Loan(BaseModel): + """Model for loan details.""" + debtId: str + loanDate: str + dueDate: str + currentLoanAmount: float + initialLoanAmount: float + defaultFee: float + continiousFee: float + productId: str + +@dataclass +class LoanInformationResponse(BaseModel): + """Model for loan information response.""" + customerId: str + loans: List[Dict[str, Any]] + resultCode: str + resultDescription: str + totalDebtAmount: Optional[float] = None + +@dataclass +class RepaymentRequest(BaseModel): + """Model for repayment request.""" + type_field: str # This will be mapped to $type in JSON + transactionId: str + customerId: str + debtId: str + productId: str + channel: str + msisdn: Optional[str] = None + +@dataclass +class RepaymentResponse(BaseModel): + """Model for repayment response.""" + customerId: str + productId: str + debtId: str + resultCode: str + resultDescription: str + +@dataclass +class CustomerConsentRequest(BaseModel): + """Model for customer consent request.""" + type_field: str # This will be mapped to $type in JSON + transactionId: str + customerId: str + accountId: str + requestTime: str + consentType: str + channel: str + +@dataclass +class CustomerConsentResponse(BaseModel): + """Model for customer consent response.""" + resultCode: str + resultDescription: str + +@dataclass +class NotificationCallbackRequest(BaseModel): + """Model for notification callback request.""" + fbnTransactionId: str + transactionId: str + customerId: str + accountId: str + debtId: str + transactionType: str + amountProvided: float + amountCollected: float + responseCode: str + responseDescription: str + +@dataclass +class NotificationCallbackResponse(BaseModel): + """Model for notification callback response.""" + resultCode: str + resultDescription: str + +@dataclass +class RACCheckRequest(BaseModel): + """Model for RAC check request.""" + transactionId: str + fbnTransactionId: str + customerId: str + accountId: str + RAC_Array: List[str] + +@dataclass +class RACResponse(BaseModel): + """Model for RAC response details.""" + SalaryAccount: Optional[str] = None + BVN: Optional[str] = None + BVNAttachedtoAccount: Optional[str] = None + CRMS: Optional[str] = None + CRC: Optional[str] = None + AccountStatus: Optional[str] = None + Lien: Optional[str] = None + NoBounchedCheck: Optional[str] = None + Whitelist: Optional[str] = None + NoPastDueSalaryLoan: Optional[str] = None + NoPastDueOtherLoan: Optional[str] = None + +@dataclass +class RACCheckResponse(BaseModel): + """Model for RAC check response.""" + RACResponse: Dict[str, Any] + resultCode: str + resultDescription: str + +@dataclass +class DisbursementRequest(BaseModel): + """Model for disbursement request.""" + requestId: str + debtId: str + transactionId: str + customerId: str + accountId: str + productId: str + provideAmount: float + countryId: str + collectAmountInterest: Optional[float] = None + collectAmountMgtFee: Optional[float] = None + collectAmountInsurance: Optional[float] = None + collectAmountVAT: Optional[float] = None + comment: Optional[str] = None + +@dataclass +class DisbursementResponse(BaseModel): + """Model for disbursement response.""" + requestId: str + debtId: str + transactionId: str + customerId: str + accountId: str + productId: str + provideAmount: float + resultCode: str + resultDescription: str + collectAmountInterest: Optional[float] = None + collectAmountMgtFee: Optional[float] = None + collectAmountInsurance: Optional[float] = None + collectAmountVAT: Optional[float] = None + +@dataclass +class CollectLoanRequest(BaseModel): + """Model for collect loan request.""" + transactionId: str + fbnTransactionId: str + debtId: str + customerId: str + accountId: str + productId: str + collectAmount: float + collectionMethod: int + lienAmount: float + countryId: str + penalCharge: Optional[float] = 0.0 + comment: Optional[str] = None + +@dataclass +class CollectLoanResponse(BaseModel): + """Model for collect loan response.""" + transactionId: str + debtId: str + customerId: str + accountId: str + productId: str + collectAmount: float + lienAmount: float + resultCode: str + resultDescription: str + penalCharge: Optional[float] = 0.0 + +@dataclass +class TransactionCheckRequest(BaseModel): + """Model for transaction check request.""" + counter: str + TransactionId: str + requestID: str + customerId: str + accountId: str + countryId: str + transactionType: str + +@dataclass +class TransactionCheckResponse(BaseModel): + """Model for transaction check response.""" + type_field: str # This will be mapped to $type in JSON + nativeId: str + customerId: str + accountId: str + providedAmount: float + collectedAmount: float + resultCode: str + resultDescription: str + +@dataclass +class PenalChargeRequest(BaseModel): + """Model for penal charge request.""" + transactionId: str + fbnTransactionId: str + debtId: str + customerId: str + accountId: str + penalCharge: float + lienAmount: float + countryId: str + comment: Optional[str] = None + +@dataclass +class PenalChargeResponse(BaseModel): + """Model for penal charge response.""" + resultCode: str + resultDescription: str + +@dataclass +class RevokeEnableConsentRequest(BaseModel): + """Model for revoke/enable consent request.""" + transactionId: str + fbnTransactionId: str + customerId: str + accountId: str + processTime: str + consentType: str + countryId: str + comment: Optional[str] = None + +@dataclass +class RevokeEnableConsentResponse(BaseModel): + """Model for revoke/enable consent response.""" + type_field: str # This will be mapped to $type in JSON + customerId: str + accountId: str + resultCode: str + resultDescription: str + +@dataclass +class ValidateTokenRequest(BaseModel): + """Model for validate token request.""" + RequestId: str + UserId: str + CountryId: str + TokenCode: str + +@dataclass +class ValidateTokenResponse(BaseModel): + """Model for validate token response.""" + Authenticated: bool + AuthenticatedMessage: str + ResponseCode: str + ResponseMessage: str + RequestId: str + +@dataclass +class LienCheckRequest(BaseModel): + """Model for lien check request.""" + transactionId: str + customerId: str + accountId: str + countryId: str + +@dataclass +class LienCheckResponse(BaseModel): + """Model for lien check response.""" + lienAmount: float + resultCode: str + resultDescription: str + +@dataclass +class NewTransactionCheckRequest(BaseModel): + """Model for new transaction check request.""" + transactionId: str + debtId: str + transactionType: str + fbnTransactionId: str + origTransactionId: str + customerId: str + +@dataclass +class TransactionData(BaseModel): + """Model for transaction data.""" + transactionId: str + providedAmount: float + collectedAmount: float + resultCode: str + resultDescription: str + +@dataclass +class NewTransactionCheckResponse(BaseModel): + """Model for new transaction check response.""" + transactionId: str + data: Dict[str, Any] + resultCode: str + resultDescription: str + +@dataclass +class SMSRequest(BaseModel): + """Model for SMS request.""" + text: str + dest: str + unicode: bool + +@dataclass +class SMSResponse(BaseModel): + """Model for SMS response.""" + data: str + statusCode: int + IsSuccessful: bool + errorMessage: Optional[str] = None \ No newline at end of file diff --git a/api/routes.py b/api/routes.py new file mode 100644 index 0000000..aec5f4f --- /dev/null +++ b/api/routes.py @@ -0,0 +1,55 @@ +""" +Routes module for the Flask application. +""" +from flask import Blueprint, Flask +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +def register_blueprints(app: Flask) -> None: + """ + Register all blueprints with the Flask application. + + Args: + app: Flask application instance + """ + # Import controllers + from api.controllers.eligibility import eligibility_bp + from api.controllers.offers import offers_bp + from api.controllers.loan import loan_bp + from api.controllers.repayment import repayment_bp + from api.controllers.consent import consent_bp + from api.controllers.notification import notification_bp + from api.controllers.rac import rac_bp + from api.controllers.disbursement import disbursement_bp + from api.controllers.collection import collection_bp + from api.controllers.transaction import transaction_bp + from api.controllers.penal import penal_bp + from api.controllers.token import token_bp + from api.controllers.lien import lien_bp + from api.controllers.sms import sms_bp + + # Create main API blueprint + api_bp = Blueprint('api', __name__, url_prefix='/v1/api/salary') + + # Register feature blueprints + api_bp.register_blueprint(eligibility_bp) + api_bp.register_blueprint(offers_bp) + api_bp.register_blueprint(loan_bp) + api_bp.register_blueprint(repayment_bp) + api_bp.register_blueprint(consent_bp) + api_bp.register_blueprint(notification_bp) + api_bp.register_blueprint(rac_bp) + api_bp.register_blueprint(disbursement_bp) + api_bp.register_blueprint(collection_bp) + api_bp.register_blueprint(transaction_bp) + api_bp.register_blueprint(penal_bp) + api_bp.register_blueprint(token_bp) + api_bp.register_blueprint(lien_bp) + api_bp.register_blueprint(sms_bp) + + # Register main blueprint with app + app.register_blueprint(api_bp) + + logger.info("All blueprints registered successfully") \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..0c46ba3 --- /dev/null +++ b/app.py @@ -0,0 +1,76 @@ +""" +Simbrella FirstAdvance API Flask Implementation +This module serves as the entry point for the Flask application. +""" +from flask import Flask +from flask.typing import ResponseReturnValue +from config import Config +from api.routes import register_blueprints +from api.middleware import register_middleware +import logging +import socket +import sys + +def create_app(config_class=Config) -> Flask: + """ + Factory pattern to create the Flask application. + + Args: + config_class: Configuration class to use + + Returns: + Flask application instance + """ + app = Flask(__name__) + app.config.from_object(config_class) + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + # Register middleware + register_middleware(app) + + # Register blueprints + register_blueprints(app) + + # Register error handlers + @app.errorhandler(400) + def bad_request(error) -> ResponseReturnValue: + return {"resultCode": "400", "resultDescription": f"Bad request: {str(error)}"}, 400 + + @app.errorhandler(404) + def not_found(error) -> ResponseReturnValue: + return {"resultCode": "404", "resultDescription": "Resource not found"}, 404 + + @app.errorhandler(422) + def validation_error(error) -> ResponseReturnValue: + return {"resultCode": "422", "resultDescription": f"Validation error: {str(error)}"}, 422 + + @app.errorhandler(500) + def server_error(error) -> ResponseReturnValue: + return {"resultCode": "500", "resultDescription": "Internal server error"}, 500 + + return app + +if __name__ == '__main__': + app = create_app() + + # Get port from config or use a default + port = app.config.get('PORT', 5000) + + # Try to run the app, with better error handling for socket issues + try: + app.run(debug=app.config.get('DEBUG', False), host='0.0.0.0', port=port) + except socket.error as e: + if e.errno == 10013: # Permission denied error on Windows + print(f"Error: Permission denied when trying to bind to port {port}.") + print("Try one of the following solutions:") + print(f"1. Use a different port (above 1024): set PORT=8080 in your environment variables") + print("2. Run the application with administrator privileges") + print("3. Check if another application is already using this port") + else: + print(f"Socket error: {e}") + sys.exit(1) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..9869efe --- /dev/null +++ b/config.py @@ -0,0 +1,40 @@ +""" +Configuration module for the Flask application. +""" +import os +from typing import Any, Dict, List +from dataclasses import dataclass, field + +def get_cors_origins() -> List[str]: + """Get CORS origins from environment variable.""" + return os.environ.get('CORS_ORIGINS', '*').split(',') + +@dataclass +class Config: + """Base configuration class.""" + DEBUG: bool = os.environ.get('DEBUG', 'False').lower() == 'true' + TESTING: bool = os.environ.get('TESTING', 'False').lower() == 'true' + PORT: int = int(os.environ.get('PORT', 8080)) # Changed default port to 8080 + + # API credentials + API_USERNAME: str = os.environ.get('API_USERNAME', 'admin') + API_PASSWORD: str = os.environ.get('API_PASSWORD', 'password') + + # API keys for Simbrella to FirstBank API + SIMBRELLA_APP_ID: str = os.environ.get('SIMBRELLA_APP_ID', '') + SIMBRELLA_API_KEY: str = os.environ.get('SIMBRELLA_API_KEY', '') + + # Database configuration + DATABASE_URI: str = os.environ.get('DATABASE_URI', 'sqlite:///app.db') + + # Logging configuration + LOG_LEVEL: str = os.environ.get('LOG_LEVEL', 'INFO') + + # CORS settings + CORS_ORIGINS: List[str] = field(default_factory=get_cors_origins) + + @classmethod + def to_dict(cls) -> Dict[str, Any]: + """Convert config to dictionary for Flask.""" + return {k: v for k, v in cls.__dict__.items() + if not k.startswith('__') and not callable(v)} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0ad23bf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask~=3.1.0 +requests~=2.32.3 \ No newline at end of file diff --git a/test_api.py b/test_api.py new file mode 100644 index 0000000..9ed7da0 --- /dev/null +++ b/test_api.py @@ -0,0 +1,173 @@ +import requests +import base64 +import json + +# Configuration +BASE_URL = "http://127.0.0.1:8080/v1/api/salary" +USERNAME = "admin" +PASSWORD = "password" +APP_ID = "your_app_id" # Replace with your actual app ID +API_KEY = "your_api_key" # Replace with your actual API key + +# Authentication headers +basic_auth = base64.b64encode(f"{USERNAME}:{PASSWORD}".encode()).decode() +basic_auth_headers = { + "Content-Type": "application/json", + "Authorization": f"Basic {basic_auth}" +} + +api_key_headers = { + "Content-Type": "application/json", + "appID": APP_ID, + "apiKey": API_KEY +} + + +def test_eligibility_check(): + """Test the EligibilityCheck endpoint.""" + url = f"{BASE_URL}/EligibilityCheck" + payload = { + "$type": "EligibilityCheckRequest", + "transactionId": "Tr202503RK9232P115", + "countryCode": "NGR", + "customerId": "CN621868", + "accountId": "ACN8263457", + "msisdn": "2348012345678", + "lienAmount": 4.0, + "channel": "USSD" + } + + response = requests.post(url, headers=basic_auth_headers, json=payload) + print(f"EligibilityCheck Status: {response.status_code}") + print(json.dumps(response.json(), indent=2)) + print("-" * 50) + return response.json() + + +def test_select_offer(): + """Test the SelectOffer endpoint.""" + url = f"{BASE_URL}/SelectOffer" + payload = { + "requestId": "202503170001371256908", + "transactionId": "1231231321232", + "customerId": "1256907", + "accountId": "5948306019", + "msisdn": "2348012345678", + "requestedAmount": 10000.0, + "productid": "101", + "channel": "USSD" + } + + response = requests.post(url, headers=basic_auth_headers, json=payload) + print(f"SelectOffer Status: {response.status_code}") + print(json.dumps(response.json(), indent=2)) + print("-" * 50) + return response.json() + + +def test_provide_loan(): + """Test the ProvideLoan endpoint.""" + url = f"{BASE_URL}/ProvideLoan" + payload = { + "$type": "ProvideLoanRequest", + "requestId": "202503170001371256908", + "transactionId": "Tr202503RK9232P115", + "customerId": "CN621868", + "accountId": "ACN8263457", + "msisdn": "2348012345678", + "productId": "101", + "lienAmount": 400.0, + "requestedAmount": 10000.0, + "collectionType": 1, + "loanType": 0, + "channel": "USSD" + } + + response = requests.post(url, headers=basic_auth_headers, json=payload) + print(f"ProvideLoan Status: {response.status_code}") + print(json.dumps(response.json(), indent=2)) + print("-" * 50) + return response.json() + + +def test_loan_information(): + """Test the LoanInformation endpoint.""" + url = f"{BASE_URL}/LoanInformation" + payload = { + "$type": "LoanInformationRequest", + "transactionId": "Tr202503RK9232P115", + "customerId": "CN621868", + "msisdn": "2348012345678", + "channel": "USSD" + } + + response = requests.post(url, headers=basic_auth_headers, json=payload) + print(f"LoanInformation Status: {response.status_code}") + print(json.dumps(response.json(), indent=2)) + print("-" * 50) + return response.json() + + +def test_rac_check(): + """Test the RACCheck endpoint.""" + url = f"{BASE_URL}/RACCheck" + payload = { + "transactionId": "T001", + "fbnTransactionId": "Tr202503RK9232P115", + "customerId": "CN621868", + "accountId": "2017821799", + "RAC_Array": ["SalaryAccount", "BVN", "CRMS", "CRC", "AccountStatus", "Lien", "NoBounchedCheck", "Whitelist"] + } + + response = requests.post(url, headers=api_key_headers, json=payload) + print(f"RACCheck Status: {response.status_code}") + print(json.dumps(response.json(), indent=2)) + print("-" * 50) + return response.json() + + +def test_disbursement(): + """Test the Disbursement endpoint.""" + url = f"{BASE_URL}/Disbursement" + payload = { + "requestId": "202503170001371256908", + "debtId": "273194670", + "transactionId": "T001", + "customerId": "CN621868", + "accountId": "2017821799", + "productId": "101", + "provideAmount": 100000.0, + "collectAmountInterest": 5000.0, + "collectAmountMgtFee": 1000.0, + "collectAmountInsurance": 1000.0, + "collectAmountVAT": 75.0, + "countryId": "01", + "comment": "Testing LoanRequest" + } + + response = requests.post(url, headers=api_key_headers, json=payload) + print(f"Disbursement Status: {response.status_code}") + print(json.dumps(response.json(), indent=2)) + print("-" * 50) + return response.json() + + +def run_all_tests(): + """Run all test functions.""" + print("Starting API tests...\n") + + # Test endpoints with basic auth + test_eligibility_check() + test_select_offer() + test_provide_loan() + test_loan_information() + + # Test endpoints with API key auth + test_rac_check() + test_disbursement() + + print("All tests completed!") + + +if __name__ == "__main__": + run_all_tests() \ No newline at end of file