From 22e473eb1414f179b6d54b301b84a594155d78ba Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Fri, 17 Apr 2026 02:52:16 +0000 Subject: [PATCH] Autosave: 20260417-025218 --- config/__pycache__/settings.cpython-311.pyc | Bin 5552 -> 5707 bytes config/__pycache__/urls.cpython-311.pyc | Bin 1557 -> 996 bytes config/settings.py | 41 +- config/urls.py | 21 +- core/__pycache__/admin.cpython-311.pyc | Bin 212 -> 1362 bytes core/__pycache__/forms.cpython-311.pyc | Bin 0 -> 12104 bytes core/__pycache__/models.cpython-311.pyc | Bin 209 -> 10951 bytes core/__pycache__/tests.cpython-311.pyc | Bin 0 -> 3554 bytes core/__pycache__/urls.cpython-311.pyc | Bin 347 -> 2067 bytes core/__pycache__/views.cpython-311.pyc | Bin 1364 -> 30507 bytes core/admin.py | 16 +- core/forms.py | 154 +++++ core/migrations/0001_initial.py | 57 ++ .../__pycache__/0001_initial.cpython-311.pyc | Bin 0 -> 4048 bytes core/models.py | 197 ++++++- core/templates/base.html | 67 ++- core/templates/core/auth.html | 47 ++ core/templates/core/index.html | 333 ++++++----- core/templates/core/profile_form.html | 60 ++ core/templates/core/reports.html | 127 ++++ core/templates/core/transaction_detail.html | 41 ++ core/templates/core/transaction_form.html | 48 ++ core/templates/core/transaction_list.html | 58 ++ core/tests.py | 45 +- core/urls.py | 36 +- core/views.py | 554 +++++++++++++++++- media/business_logos/IMG_4442.JPG | Bin 0 -> 18347 bytes requirements.txt | 1 + static/css/custom.css | 541 ++++++++++++++++- staticfiles/css/custom.css | 548 ++++++++++++++++- 30 files changed, 2749 insertions(+), 243 deletions(-) create mode 100644 core/__pycache__/forms.cpython-311.pyc create mode 100644 core/__pycache__/tests.cpython-311.pyc create mode 100644 core/forms.py create mode 100644 core/migrations/0001_initial.py create mode 100644 core/migrations/__pycache__/0001_initial.cpython-311.pyc create mode 100644 core/templates/core/auth.html create mode 100644 core/templates/core/profile_form.html create mode 100644 core/templates/core/reports.html create mode 100644 core/templates/core/transaction_detail.html create mode 100644 core/templates/core/transaction_form.html create mode 100644 core/templates/core/transaction_list.html create mode 100644 media/business_logos/IMG_4442.JPG diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index d79d6a79890a58443ee5f65d8d558823a19e9aed..f942531228ecd43acd3e249cfe73c399d5b3fead 100644 GIT binary patch delta 803 zcmZ9JO>YuG7=UN?1K4F}VcCU%)k1=nRG_UwYg=p-Db^;fBuHbj7i=;c#k91cjZJ## zrtxf2_h3CV@l+39RQ`cSjfUPfd%{0p){8M7jI$s{cCzm?@4WNOSN3iCQ$~D^5D!R$ zFTXbK2am)k{Pr&OojSldHK}npzwZY?y+{F#e%$jM7f<7vQwo4LB>GLgd-erst zBjvJY@!YU=3dyb{fbp4*meBB|I__Ftz;5HR%ea2@V*#d(Z6;xOV78udP2a#bT}Nbb z@hNawx3uiA^=e}9?*DsSGQKeJ_@tI;QFu{Jv{GT*XUj4%7F*_?`Fci7&A4 z+0<}jebQokeftFMLHNuF2iB3rZfrelG%e=Q)~04W2_#^?{UY!I9$4IJd2zW|U#qTI zd_S#LDz#Rg+@Up1V9|}{ZfnLF&N`TLFz=w?V1dA5ceXbgyRD*=b1Rjl<-5K8LjAN# z4ycw(wO&mowaQwp{X9^f^U-5&Oh4u+5RZyAmokTp;(gfg(w>gpo_NR6-i+L zowq~AygZU+lcEj&f%YoA%?Yf>1`cQuQ95TQfRgQipzSdZzx`3jhEB delta 627 zcmX@Dvq770IWI340}#~ttjKf`naC%>cx9vdN2VwyhE(P(unb5x#W=-e850A;Y9NMy zC}lJirYT%#DpY_fRMAwKrI=4{V3rM4Lld`1;YHJ}j;6vALxl#K3ag3VY=t$^1g%qS zCjPKy)S7I-qF@)Lonqg@fTqGBRU6gn1v)TKBGW03lkc#|3+tw86)~kaMQNruN9m<7 z2Qz58Y?fl3%dzycziIdmxD=_MBKFfcI(Xz-GXhx9G F1OWYMcgX+% diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc index 8cf22af743397374cabf5fa358c4f52631471db9..9c5f7f9c30687dd5488c54189061900b39215729 100644 GIT binary patch delta 576 zcmaKpze^lJ6vyA(%JKym%Wp&} zHUa*}BRvAJkp=KDnnxcG33Cfn*UL8wtdrCT?I?2m{SdVlMxDs%Cco*_`&jD;&+!{6 z#KmqP?ZpG>DyAZW?ty3f(T)s)C^j0M5I2O4RLexOK9-uI*YWp*#cts5Nt(Fq=?jLh zrPT*NlFqK=%FJQ4Uux(iU)kJ5abtDm-P@gyn;+6rCO2EH?c^u3$EK0N4~3B7yrqPY z#NyBC70f0}d_MWa-#nvai(IkV;qu|~@xoy0<*&8D>(=FX`|o&rfRnYqtS5i?>WHqu KTToLz!Ik%QKY$$o literal 1557 zcmb7Ezi-<{6h2a{19y)mR5TN}F(iZ8^v428G3D6*L?bJ<0woHA;AE;cUKy&0B9v{E=z3;s{^2bKQ zKrp_&|C6_=A@qlQC`KhWUKuLNpWRis_=OM%wa0==uJ&zfHjD&0zI z>wYz;b!!Sz5z*Z;QAcSe$vLVQDPF1wy@HvI6j^~)bE_{Q=PQ`mIEx#o-4#+9HHx=v zi>yFY&8>m=T;X+%G35EBel~UtQ4pQt0liwF!G5o>-yY+x93VMc4f`9AY&`7T#|{nq z-XLbS@Mwtpl;s{AF*+iSFb(5yNbr-G`H?LIVIhBleUA&wXe6`PEz6}2H{FpP4rr0ip@Bt0>zaAh5;XE0Zb`5O z>A7X>KC=Vh;%(zj96G7uG;K=yv5B``7uzBBf`~HF#<Hd?r!x3ZnJE87lJ zHoOp-V>i>L{BN~KJ{?%lz#>yqI#S+dt7ZHtk7`RvblVNQu%#xI9G7Ua_3)<3i4af> z2fNG#oFr#lPSI9^#x(~HS;{u9TCfpBa4RWi1_U(VXvF~uMUPP`K5xCBRC+z|^?J#< zyh3PWX diff --git a/config/settings.py b/config/settings.py index 291d043..c03dda4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -37,17 +37,11 @@ CSRF_TRUSTED_ORIGINS = [ for host in CSRF_TRUSTED_ORIGINS ] -# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy. SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True SESSION_COOKIE_SAMESITE = "None" CSRF_COOKIE_SAMESITE = "None" -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ - -# Application definition - INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -65,8 +59,6 @@ MIDDLEWARE = [ 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', - # Disable X-Frame-Options middleware to allow Flatlogic preview iframes. - # 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] X_FRAME_OPTIONS = 'ALLOWALL' @@ -83,7 +75,6 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', - # IMPORTANT: do not remove – injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp 'core.context_processors.project_context', ], }, @@ -92,10 +83,6 @@ TEMPLATES = [ WSGI_APPLICATION = 'config.wsgi.application' - -# Database -# https://docs.djangoproject.com/en/5.2/ref/settings/#databases - DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', @@ -110,10 +97,6 @@ DATABASES = { }, } - -# Password validation -# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', @@ -129,33 +112,22 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] - -# Internationalization -# https://docs.djangoproject.com/en/5.2/topics/i18n/ - LANGUAGE_CODE = 'en-us' - TIME_ZONE = 'UTC' - USE_I18N = True - USE_TZ = True - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.2/howto/static-files/ - STATIC_URL = 'static/' -# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS. STATIC_ROOT = BASE_DIR / 'staticfiles' - STATICFILES_DIRS = [ BASE_DIR / 'static', BASE_DIR / 'assets', BASE_DIR / 'node_modules', ] -# Email +MEDIA_URL = 'media/' +MEDIA_ROOT = BASE_DIR / 'media' + EMAIL_BACKEND = os.getenv( "EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend" @@ -173,10 +145,11 @@ CONTACT_EMAIL_TO = [ if item.strip() ] -# When both TLS and SSL flags are enabled, prefer SSL explicitly if EMAIL_USE_SSL: EMAIL_USE_TLS = False -# Default primary key field type -# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +LOGIN_URL = 'login' +LOGIN_REDIRECT_URL = 'home' +LOGOUT_REDIRECT_URL = 'home' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/config/urls.py b/config/urls.py index bcfc074..87f0aa2 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,23 +1,7 @@ -""" -URL configuration for config project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/5.2/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" -from django.contrib import admin -from django.urls import include, path from django.conf import settings from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), @@ -25,5 +9,6 @@ urlpatterns = [ ] if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets") urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e8987a0cd478c226e501b5189550e758662c560..aaa935c8da5b60a738101c242b88cbf7d3a038f3 100644 GIT binary patch literal 1362 zcma)5KX21O6u+|_H%*$hv4?VL6dNYMlD&%)OM>AxeoP6`nlvJLhvBYtTOeAm8FYGs7p?( zz=SAF6BUO#_o?gEt(?r0v;ab6fe=4riN`^nBIIL2J>{V^&y8Zj+^Fr*jx0r-3a3Rj zUGA~y2Rc*^Z1E6=($2?@4n&%; zBB6e9p~#bY!J9B2FusKiwU;(`NN`%i)w<5}&xTX>Tv*_(exm3Xz(B_s_fhrW>V1SB zAB?*PCemiRmF@dGGl#g^!_|SN;dMMfav7r-85vTTf*CZK zUxE~9GTvfMOv%m6^V4Ly#g~$mn3tZfmzPO4oI Z2T+U=h>K+>*Rn`)UtmxGq9Qh+2mpT#A9(-( diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9ff84cf382612afd02b5d4b98e6dba9f2127a7f4 GIT binary patch literal 12104 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 Rx$DzC&q>}7TW}D)|1W=x^6CHp delta 164 zcmX>edXX_av7r-85vTTf*CZK zUxE~9GTvg#%}+_qDfZK3y2Y82m6(^Fua}Zk#0-?2T+3o^^^3y>sGu|_)vkyGD8>lH X#j-%+12ZEd;|&Ix3)oN*8&CxR!^$J7 diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a57262b620df3cb4926412d3f98ad598d4203b09 GIT binary patch literal 3554 zcmeHKO>7fK6rQ!c>v0_OBOLNaP=F9&Ft#BLP?ZG2U!X+>DWM>%(rU9iaqQY&cXl0! zMFj^A9Jo|Mf=V26N*e_v4z1Knr5<~edFovN+md6yjYQ`xbi6*g$yek2*NxD+jk3->#ixhXjS9a2aV$Bp zxNO1)Z2w{2Q~!bhYrxkounWi`1k=Z6n4ziq8yBs~A+<;fnL=75vZT$J z9gS{rW=_kv8XQp7Q zDLZu2U$hgCC8|W{FzuL2BK2q(88a>g~y^$XeRQC=Udxu(UYa!Hi>^1UtwY^~i zT(;kb&l)U@T*=Q8)JlFpG)sOb$q-skSV+yL^lnO1P7g4Tc2PVVQn6uxXVyu1fD5#j zoc_05v-f!?0h=bH1?Vg}3J#z$?j2Ibna>@W8?Ybr=j_krZv(LYwZEow z%v$u!qIY@TY^2SegN-wBdH#o(EszUI(>s=z$^yU^P`k*RxPem(Ie?{Hh5^t5tsv!9 z0EW3uUngr(pDU8G@M#_O$86NMQ}$lW?m7~4YC266v1uCZ0lN)O8;@tlCN7GXlIB)Q zrYX^8#A&MLMP&axVx}PG)mfs6vr?`^?DcH-x+*%Z84r~|Zw>Ix_+mk7e}Di!*w$-o z8(!(?e)yFQ`L4dz9X*dvRCf#*I|f$!4j6rjT2R>D_8NhB!^C~Qwgm-3587Ygfn_{U z#e)VOtl+^F44STG-1WG_=sx&t{Q1Rdbi{~`RPm^RM=N;LHQ?~Go6m1m-5&+F43@Ktcb?v< z;!_4bRl%p$5w}@b1G1r7fK0+&42u?%i=x>k0{bbY9D#PbC^B|s950HrkG+x@6GI>t zku}+3Ps)D6EWz?sy9DB{^Qn0Pd|M4UVrM&lm8+p9xhu$@0ZnUZufi5cM%qljV*iJ* zFKazYRw|^`n5+VyXQpCONuP;9^dzU*SHEC=081OnZW^XgH?wPlouyXn$61%LleM3> z_@sRXqx2-`*i}gT4n)n%aoh^(t+?w;bg1I4E2zKXt}jud(yG>k{T$nTYx(%%Ys>QH JJ$5VPzX27^86E%t literal 0 HcmV?d00001 diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988a223abc7b5af333902925bc196186bbf5..af00d75d72dc7d2f152a6d87593ca8ba5faf3ac7 100644 GIT binary patch literal 2067 zcmdT^&2QX96d&)t{q%k({YaW-+tL;mEgg{x6eX)YAs{NL8qr=Xq?NNX%^E!R%Jvel z5b80<+{lfdAP9u`Q^t}Hm2ZesZ;^WI0pZzkz}vci0N(Y}o8Rxv`K_H%J8Jtcyv?oFP@ z1A%(2sHsnl(S+)zHyM3x(8H)90MkA&EV-T=#A|!zNuVVRrjGsCFsVeY2*h)A%hku8 zVOt|YJ>4)RdEph(l`u?fMx$yfkeUusTL{o}=+t(+(Ud%vyK53ie5tN+V9lnIx|B|2 z>`>iHvRoh*GEDC<4L7M1Icm}`OJ~$q<2tZx6ppH*D7o;3j^}mYs3P#WF70JWiM?F- zHDRBWha=(kyqkVoP^oNO9L>9(hWy*F84V1}+e+Dt3^K&gMd3M(%9{O>>=urSQt^vP z@&%cA={#wH$EWU>|4p`(Xns58*Toh+#8D>&jbz{bdI}S@q?RMmX#YkEadCDX3yzZ- z%E|fTsFY~_`AFb+NaBY7H!=5n%aKaH)U5;i&df1gM_f=*K@7X=#1|%Vib9lxC<{>$ zLfonI0dQ(Us|(Q(qA5g6h_(>?ZL0{O*1Jh;gt2utKn;+m+*c zPbVC305}9JvCGF>Pe0+%51=1H|IK!*$6uUQIBW#45yD0iCXW6AA3Wf&7rav7r-85vTTQkZj? za+#x;85x)uQW;ZNQkhd&*RU;PW?)zi#1N3q7{!vp9?YQ0@e(AU$#{#UAh9IlB_ouR zVUVAjdW)e5WD!U*FEKaOPm}c)cS=@bUV6S>X;Dsb5y-S#tYw+0<;6v;le<_0V)%d} zAR~&sfW!x8Mn=XP3^Er`(E|qY3#jM=8v`q6gG+}{N5~Ae3oP;%S>&&<$bVpF;%92$ K2Eig8pm6}Xr#`{} diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f0989c7137f7930a63ea76fcd63516c4974e834..6e6731f0f518a590b2b582ba720a6a0c37b3947d 100644 GIT binary patch literal 30507 zcmcJ2dvF^^n%@8zJV=lLNq`{u5DAf#C`u$K>Onm%iF#0P%9dnHrmdwR4oE=)2zmhO z0fTF<*LzDls$0s@Mc7;I9&c;cN1wGhU0rgg+$-9>+l#MqNoJVY2tkEay1Lp*ls^)> zyqntEN^0}_dhnP5AX&c05d6^7{Y`gI_t#&4-`8I^ey6&+lEZcOxBfQt!7#`D9o^(E zOE&P?|1fgg9Zui`Lzo+(Z)4aHF^m`@#u1}|?oDA+gdgEqm=BvHmJtgJo5R+KZNwI- z7^#RKOc;DqdL}PuNYdLOY6Ji@nVmBkUsU&s_Vw+21w<5NsBz6F? zt4da{PQSNDXWD5v#bD5pWeaE9ydc|z3~ z?lxv@LLeTDha$lYPq5pNvF^VS55|t3LeLfo#$thqV65AesR~TTF9j##p)ov=F^8iQ zp~;K|U(x9}P#r8{R1Cg39TI~AVw|r8!Xe}yicTI9#i)qF%m<>=lktq{%yfiu914Yl zr-QMn=wvLIu^x%Xr?za|lBqfti%#aEfmS;ajE_byUJj1oOB^K>8%jVaWAThd3{DC` z5yjcZV&eE{@Om&Z6~|NdY)wWFA3B?<%H6zp>de`UJ9l>pC5}dd@k>!5MsG_EkcdtO zpHMe-n=`Kc)3MMbn&3q-I*z&yMa4*_;sm`p3)Y+tPDRByULzh9Sxn`b(8T1+Q!MN} zD+VTGfiY?Tiop9ji!#jEH8^8=;y~$OMw|)%pVa|;9gJy?8#f4scWpCJ6w{xjF5U=&SxBR#|blg2?oI^nC=<{{+{_m6!?1pmf@jM7N~I~*MggkyXBS)_Ut68jRsFJ`&4({nSaID1w&zM^^A)t2@&ryDn~m`$r4D;xm} z*U+?LHMyHVkmCaqdQ{L2_iE3K-y83}Ge>;ORZR*V+&<`u3CsNt$yv%_LP@N|UYL--H$t`F|D;LWZO&LY`<|bdRBwuSThoUEJxp@5(MOYQjg~fyd z!hB|4_VRh*-K%mbm#cH3_^Mop<1SNMvkSOH;_T9wwNRUj*M94nzhJmq%|?88b;cMH z!05su@Zr(P0GPHV5MeB}CSG7RqwyP4!HlOkW;7hQ7z}3|7=u?sW5Lm}OBi0@iq;}gg! zYZXu5vZqz?tXr&Cx_8K)or-7YBhSGl&q3KUqV|MoW$RKtDuiGTjw3D%_^K&dy%_eJcRHX{@DQrcdZV{h6Qpux&s*$ZZbSI#puypmzcAQTOCypC}}gM+8DmwpHuF#lc7 zmm|9I6qQnP>7SZh6VEduu!B5%)MGsl&bW~^ziEcoVOw$)i04@i%E`0Hq$y6)L-x8{ zV0Umpe?pgLE=pUYdCsCwyJ!6n^zw0@W@c=H?OiTu zJ6JS5&QwfrGnMhRg`6(abkDAX+=Vel1@@V$WW@qWDJ56Zt}l0{I$62kDM3R-(w?kJ zR*SY|wNTNRN0>ASm58lDti2G+ur&7?tg0d`%{^fW)pv_p$-vzd&_50NA~gYB>;6-< zyaT1vj8Srt=B$^37YZ+@!Pbx78E4WdT9eMpO*xcFqVMM1-Aqk3y(JgJ((_`%Ol{Km z{548eCLP+I0Y{@4O{-^PG=0iqOi4ajldKhL?p8vSs2#?nSeu))1M$&O2oDoMHnFnl zS)HiaEKD=_N6^*c(KrNvth7<7-i-$#IM~(j7>OY|QIkYN7ydu<0o)-Sg1cZR^+n(c z4VyIlbK}h_)XAV0O#B~;lF`30V-lm+m_(eNEPh$JTL_KC$D)y`J&Ep%)1j~cb(0jZ z(b!~QDt0LvFUax*)C0&dMpc;Qq`D*O=l+5D%~C_R;#$9WARoylQ1NA8ip1*0GX%~8 zKoc}w3EqIt%LFJhdsWs%8J?OTVec)pWx~`O61ppqLdv3*B##hU$r_uEL;~WCLawZ& zOeK^fyZ|ZT85>ktHB>zojzaUC7-eEurY3irle@ZGMWUyS4XV^t(y}vEW8q+65+WFK z3S=s1T2=d0BOS3qDkFg_!O;LztDs2yC}Tx~UWAq_Qh#Mk5oqkpydY6R8H4^y zvLK|#Fe&0t&Fl+8^^^_imu9b$|zA<#-6*21g0`hRw1-!HZo&o zFDkx9d7h_-EsX9VI8#H!P)Cw6#W6}30Ae)tLe43=RI#VNEcJ?C#Umsl$G!ssgaBl3 zP7S4o7X;b9L9uU`J(%WgUqAWI$*-Jx_tfmEbl+yBZ=cjGrndanp5NRfH*ZjyH%QGJ zetcay{HlETHRbSYKe-T8E=1)EZz>nwl!io^_s$-hw=Fvx6leSQw<{f+?;ray|Ccto zA!<)@sb!u~S?xXl3v z=cs>F-MUoW`hx@aZ6BSyck;($(y$^sLm|N!w6CIcBn{6cW*sfk_x~eT3`Na`hhH z#XvYPITnl|lr?qwE`sd~n0tDB_(m?F@QvPhwD(3ZAck+i)JGF;%r`DZBR-f)eN)kJ zD0V4GnA!6D&RX zENq#Ab{-WnW>z{AOP-MSR5W>Zn^mUof+d({3GJ(qSRw>&j4~#jfxJRDIeCS`Y!Xj6 znVm2M6Y_QcrFbOVT`7`iCX)QdR9A65g-A1H+&N>Jo}y_~j8IIJ0QFNh&)%5k(#d%6 zdR(oSvq(D0nJ=@tFHa^+@ircR1OKuA2mq6VXV;36TQzK4ZdiRQc{{o6XsoOATU2f{TZ+)=t;bCdV z3AyQ{(sXjpnYKB<;dtMXZfH$+tXDd=J?a=*>KJ;MlsnEU9cQJN&q=SG15`TBr<>O= z?opa|rklDKdz7YabpL|VyaW8sOZ?7T%cDS z(H9tFgUA<(fh`1t8-Cvz@SPi!l;W?l|Psme^ABJZX_33!Lp2y)+@yCAel^cF&O|W5LQQakQ>ZCh;Av1>}*q< zZIZ2R*X^ow6HU+=o|HwANITJ)9omox#;*9{QOzhUOzh$vpfVayxUmZA z3r!McK?F%0A7nJIKCBz4`AoDjhi1a|Vft4kj;w^?7r%+Zx~qyuBb%A2%Hp>O&`@MG z%GgNfr9}ZY{={!n0-KxFmt|v5Yfi{yB@Q;|z6)$(0kJ;<_?$uKPMurac5k<|Vef-2 zl6{|S->2C3K{Ww46TN= zrzhPK`T|3X$IGzk_7Va`*6<=rI3^j5NL~=UL6Aj4GcYxiVzKoVVyS63(TMrMcM1H_ zcQF{8q`sYiga}6gKbun^F3~KhGD*QHi_T1YORmOebl8{Tdh=tFI9_1-6qDM^0chfu@X-OV2>W?ZDHtd;wjLhgxbD z6$|1BdeT(Fgfv#5Hc?ojNJz*kO*wtY2a;4ZA<%7(BV{E9CdFt3hALG(plMws zD=>TajCttji9=^JOzJKrQ9rA!i4i>GPg&W3>{lw2BC>+nNHrBz0enGKccovsR z*Lm)Af6#qv!);O@^0R(Wwv8*camhAbcGllav%ae-?b)1eiGno^(5yf31!n#4eW3xE zG!$4#)h>4@;dZ}!h#tV4K<5w0-u#0Ud8at z%l^6C=vzNL#Y4a=uNgd@@tAaPntvAz=_1D+Fz0+KGyqG|RPa*3B9!huFu-Xt&0fgM z2CIjGv;o0hW`I`>C-$EqW3}(%4I0M<(Ks(2$3o<3OCCw~M;@tHbJ_OLFHwgE=u(8h*@L=)p6+DI}q zs9dCFICxE4Nj)10oMyH0k(IeOG})sDFe~*ymB<-NLLzw{^r*Rv2gM$r5WEPC;B_)| zhvLvURCa@P0^E||#B3*FxGMR@MRY;8qrgQn)w#xDJ{~I5_#TkdS+bQvMG_EWE==U1 z^%|{BLgiM>9)1`}MK*{AZIh^r80u{M`@sDH;b#C!7!6k66}xYRGnzZVDQ;HJPe^S~ zR}Y#}+wSg`n)?>FJm`|_`(^un#lHWUtfpPrYTL$Y&7aSlngp{zQxEytL)q+8wwW7j7TgZUot`D9H%H-w8;w{>YnW>YO2E1u2a;is2HOnN1Ti~$(_rmX5vgbk?< zs>43iv1nybJdL^?Wc8@AdPpjTk7-$4PC|<9}t5y0p{2N zkgo6{*o%b`EDSi>mK<$AICJ0o(S>^#ejJmI zza}4kT{-;vPcBELsknT3TDd$eIof2$RmE|YCU>R_Y@^xM+s5YH{&dS2Omalp09b+` zO&b8WIZc~cyihN?Xm(l3-;2)z!Gu`l5B>^6KZgL9NGDy@{oWBvvgrNXb9D+AyksyHPlMv zDINeo7NoU{zej*F6F-lkt8b`MDJiyOVvbz{D528jDp-%mz(p7(8NuWx@0{L1@)A}YdVJS{!7DvvxQt)w@zHup zj}KZ#z&AD>gGd9MICvpBV))@V$2vIA$r81Um~)y4TdFW?Xy&V^W5xtm_mZg}Yu{Pt z2=N0T6|pop#5)fr(9B=?5RkP$0 zs`T#w7stv4;x#1~?3L=KkPyc8o$MT+X4r)%JL3vto}`!c6MTgx>>tI zUFHp*-V!Z^oPq*06o)QxDz_maQ?efSaWafhtT6c#=m-jBh-{|1n`enxk)}Z+L3ElAy+nbzSxLZ zr>8^x-7_epR_HFKkZ1I&Ls(zZa}Hrcw&tKgR`chl#U8Y{FV{|>DWlMfm^^=CF@E%K zpRRw6!bZgP7sYKt+~%UVEr{D%6gPmlZTh$gE1V^3;O4p-PLWTiX-BfQ%-e6*zWvR1 zI7RNz#vs4)?eRrherK7opReU>OS!o=YD2S{+Yec{mj1n zGlM11IE4L0?cJWEDDC%C=aK{Zv7*0YBd0@~Pc94|=YT6So9mK}522BKoYzY5lta+b z#Z#%E42K4N9xTu&s8X7#Lw<+Ux4gnCAT}b$&Rkuz-yh=U<9q~aqMeVQ#$z4HI$@|# zW=R`=ZvV%R=ZoZ!*I`XwFlH1spFVfac`7N&`N%&w=R7SH<$UzHb1v%Jn=*RmSP>2W z-xm!CUgU&viap6s}Guq#u&f=s%WUtFzxlqz|d0j57 zA7cx&eo=A>!+I>t^rw=ph4m$fh|tr#ig|fM4l{e-Q@H=Ob;gZZ{Y8vk$gE6Stv7OT zRCfz*;e@`;ll52FxE2q_U(4kH$(Mv*gmZHcF^i=qo`&M6o>vTGCnYHof%rGGcVn zoAAoF%`={Oppav-Lg-9-?w!-W8Pw$S!^~YCMt93tG#nLaC$({K3&F7npdH@aSOz#3 z65^K_Qeg6GAeLD5;=v(bYo6*g<;RKN{3cz7 zeNO~DHZjgF%f5tDH5_1(b5e+2+X)4qa^=Z!g88Or->Gh2HiL?N*%*G`S!{&HVh8*v z&DW^)GL}oh(8Q&9#x*4dQIo5|L1wEuO?&L&#}tTNnfMQXLzjCW8l`{B=7BAno{%ek z!nt4de?p71Z3q*a+4XmO6E&x@E1;?+3vX1Hi^&l#Z9)kefyY2_lX^OWGsWMoF}A-XZRuHp2hkvd(k+;7GYc%U{>BI7^|G> z*#A0(wh_1hkZq*@yf@*%A`qFV@lU%so{%M#Z6QcF*1X!gCeph`@U1zrbIr+VB0=Ae z{>b-)46jfA@2`IKtAxq#_fPz7!u>A~|7~V(qAC)NM0-`r?M?9HTASegQ^GiCwmN{F z@$q=K!_Gd4PNfEKK~`!m(*6Br%-aOk4=XUIL%po{b8MoCQkb4rC*ERC(~6aMMR zP&_97TS{puq@di3fmo2rhi{u0!Wjw=38hAd0=HuEhZJ3L==xZYIq!zwk?I(gc&hsDzJ-ZlravG8TCjQf?GkkOLaBK`75Jr z)gJgOJn`T0AFG33BTgIOt>%4(v~8WF|DYQfZ`Zx|Kj(N{}&e_4oy!GoR-Z?S9 zO6J=XzD?rW(!BK%?_T2F^RL_){a|$ARk^-Tsqd5djS9a};x{7w>qp)>GWXKFB~_a` z{Y}p;=R*CWVR8RQmisR~cysoM%nvI3pu`Vqa=QJx%(p9iyTrHC^T*#gF4g!J+GT!? z!mp9|H557g&hY#|YGC2OV*76&k;&s)@gI=yhbQ^Phqg~zlzp!tM4#6MDHv5=pHgpSKBn+7iH|)d|5hBYVXj>ZrK$;H36$$MXv&c7yo=?(e(8&W7NMcTYdMy|iGJ`E?4vPU6?)NN-ch zcxzi~Fm?T@DOY8Ft-`OB__b6E_q_4F#;*)#yV$eDd*-j+dHaL67jDQ6{Ypc>%x_Zo zO%lI}3ZP;9%KS?y^Md7@jzxpauUGi>62HDUqmnL7lcO}eq-k>+S{MME(d<=oZ-TpA zeShcNP9pac@1J;Y_~!83aJt5m0+*N@)OJG0!o>G_WLuwNL(lc)Qevsox2`YLe*eIt zY4PyAD!Fs3(z#W(4JcXaP&-jf?Z0(x!SMaf3$exR--{p{r|^9e-&d~dLH;il_-Sops#~e;TsX6Eap8rL%Fbc+1`wMEEo`7auxgRi6<&J ztzSZ60T`<#B!jc7{E0-x$3UReqQeHxa9AnG=Yk_O@~csC66&(oxDjiLSap0AhXbDB z@@ohh-#}=`=HU#NsDWc^n9fbZuD8C+INdB^o{o?AZciBBn(3~<-fh}7MjP1Z+#YpD zcrSP0r_{RtfgrmMD6Rui)q!6u*SM9M&Y!H^E;)D5 zoXcik6{KAa^IPV((A4Y2)ce?8cXPMIH^j*O;XA#9RooA&JcDaZKU`w~)KL)i45Og; z5uk$w*nSR#)vG)O>Fp)uPrcFOz!v9b2W}W=;8cL)I!wjKb&$){ZWMa6zTBB?R83kw z`ixe&?r;RMVk#1RPIUFv7?Y|5Pq-EGT9u=RKx(q>WfQBU$A`csb- zFq4WAgAdA=sA5j!NL%%TVL`-6mc?!bn&Xu;RTEvnJV)54SzXfAPDt3^mb>;x-u@+T zzwF(tcsD=t?pX5fkiEMU?=DgTb~BLO+0ANy|7BEKMVPb9O}|$?SDm)k&YmpwD6pa& zMk&8UK+kK+Q;C+R6kE=8X~k}NR8saT<}de(Rzl&RukjOxV~V+84+bwPIy$BZ*ZM46 z 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 literal 1364 zcmZ`(&1)M+6ra_{dS%J>I!@|TuH%hUh$T{~CE!96oTjQ1+$K)2Q%ZHHP<`ZWm8k%0`!Liw{@%1fY!tdJefM7AQl zBhn19Sa6Ea_&F0f52+#t(zs7E+?3c%aE4Gt31lN`$cK>CaWDY%0l$fN{S4(m*^98b zAQFOZ8L?*M5b8J{)dItqY^-hhUDsF{YgW-QOsAxII>p%0Y9=dd7Ae9|G&s?)vtv@? z*w|s3fmzYCyi6leblsZSXw)-0vAJ^Fb$=8ZkKM6~%pxUIZ^SlnP1|O53-h^$TMxcl z&pj%9yS|;<+IaYARqQc33p$+=>?7eip%aJUUFM01nxMCzWy{3I+3Mq}!>U=li}flaG+QI|spl4T zob~hDF>%eyxOE;8-gFVJ3n+Sj0BfPw>ijEJJ5jZcy4+QlU#Tl6>PkmVchz)jqo<{g zem>D|w6z=i>HYM}h1U1|$#c)H{hoQ2Y2^ko`uNk{{Bl3J(n+Sf$#g%t+)aMbNnYMq_I%*JNq0Rq;#`{q1K zoZG+JUbuEt>cm&O@zr)@^%Rl>0Md)F(RpB8KAS2P06puedS=OZWkMOW!1G(l6GL;A z0R;Uy__hCq=p}LW6JT$Zn>I137G9%QKox%h?+LI$Sdye3inaW&hh|#-_ZofP9$!6l Xr9HlS=yH2}4P;gNa)91vejfh;`i@)& diff --git a/core/admin.py b/core/admin.py index 8c38f3f..ede5b17 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,17 @@ from django.contrib import admin -# Register your models here. +from .models import BusinessProfile, Transaction + + +@admin.register(BusinessProfile) +class BusinessProfileAdmin(admin.ModelAdmin): + list_display = ('business_name', 'user', 'opening_ecash', 'opening_physical_cash', 'current_ecash', 'current_physical_cash') + search_fields = ('business_name', 'user__username', 'user__email') + + +@admin.register(Transaction) +class TransactionAdmin(admin.ModelAdmin): + list_display = ('client_name', 'transaction_type', 'amount', 'service_charge', 'business', 'created_at') + list_filter = ('transaction_type', 'created_at') + search_fields = ('client_name', 'business__business_name', 'created_by__username') + date_hierarchy = 'created_at' diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..f3cfd94 --- /dev/null +++ b/core/forms.py @@ -0,0 +1,154 @@ +from decimal import Decimal + +from django import forms +from django.contrib.auth.forms import AuthenticationForm, UserCreationForm +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.utils import timezone + +from .models import BusinessProfile, Transaction + + +INPUT_CLASS = 'form-control form-control-lg momo-input' +SELECT_CLASS = 'form-select form-select-lg momo-input' + + +class SignUpForm(UserCreationForm): + first_name = forms.CharField(max_length=150, required=True, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'First name'})) + last_name = forms.CharField(max_length=150, required=True, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Last name'})) + email = forms.EmailField(required=True, widget=forms.EmailInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Email address'})) + + class Meta(UserCreationForm.Meta): + model = User + fields = ('first_name', 'last_name', 'username', 'email') + widgets = { + 'username': forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Username'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['username'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Username'}) + self.fields['password1'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Password'}) + self.fields['password2'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Confirm password'}) + + def save(self, commit=True): + user = super().save(commit=False) + user.first_name = self.cleaned_data['first_name'] + user.last_name = self.cleaned_data['last_name'] + user.email = self.cleaned_data['email'] + if commit: + user.save() + return user + + +class LoginForm(AuthenticationForm): + username = forms.CharField(widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Username'})) + password = forms.CharField(widget=forms.PasswordInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Password'})) + + +class BusinessProfileForm(forms.ModelForm): + class Meta: + model = BusinessProfile + fields = ['business_name', 'logo', 'opening_ecash', 'opening_physical_cash'] + widgets = { + 'business_name': forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'e.g. BrightPay MoMo Point'}), + 'logo': forms.ClearableFileInput(attrs={'class': 'form-control momo-file', 'accept': 'image/*'}), + 'opening_ecash': forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0'}), + 'opening_physical_cash': forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0'}), + } + help_texts = { + 'opening_ecash': 'Used as your starting e-cash wallet for the first setup.', + 'opening_physical_cash': 'Used as your starting notes/coins balance for the first setup.', + } + + def save(self, commit=True): + profile = super().save(commit=False) + should_sync = not profile.pk or not profile.transactions.exists() + if should_sync: + profile.sync_current_to_opening() + if commit: + profile.save() + return profile + + +class TransactionForm(forms.Form): + client_name = forms.CharField(max_length=120, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Client name'})) + amount = forms.DecimalField(max_digits=12, decimal_places=2, min_value=Decimal('0.01'), widget=forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0.01'})) + transaction_type = forms.ChoiceField(choices=Transaction.TYPE_CHOICES, widget=forms.Select(attrs={'class': SELECT_CLASS})) + notes = forms.CharField(required=False, widget=forms.Textarea(attrs={'class': 'form-control momo-input', 'rows': 3, 'placeholder': 'Optional note about the transaction'})) + + def __init__(self, *args, business=None, **kwargs): + self.business = business + super().__init__(*args, **kwargs) + + def clean(self): + cleaned_data = super().clean() + transaction_type = cleaned_data.get('transaction_type') + amount = cleaned_data.get('amount') + if not self.business or not transaction_type or amount is None: + return cleaned_data + + ecash_delta, physical_delta, _ = Transaction.calculate_effect(transaction_type, amount) + if self.business.current_ecash + ecash_delta < 0: + self.add_error('amount', 'Not enough e-cash for this transaction.') + if self.business.current_physical_cash + physical_delta < 0: + self.add_error('amount', 'Not enough physical cash for this transaction.') + return cleaned_data + + def save(self, user): + if not self.business: + raise ValidationError('Business profile is required.') + return Transaction.create_logged_transaction( + business=self.business, + user=user, + client_name=self.cleaned_data['client_name'], + amount=self.cleaned_data['amount'], + transaction_type=self.cleaned_data['transaction_type'], + notes=self.cleaned_data['notes'], + ) + + +class ReportFilterForm(forms.Form): + PERIOD_CHOICES = [ + ('daily', 'Daily'), + ('weekly', 'Weekly'), + ('monthly', 'Monthly'), + ('yearly', 'Yearly'), + ('custom', 'Custom range'), + ] + + period = forms.ChoiceField(choices=PERIOD_CHOICES, initial='daily', widget=forms.Select(attrs={'class': 'form-select momo-input'})) + start_date = forms.DateField(required=False, widget=forms.DateInput(attrs={'class': 'form-control momo-input', 'type': 'date'})) + end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={'class': 'form-control momo-input', 'type': 'date'})) + + def clean(self): + cleaned_data = super().clean() + period = cleaned_data.get('period') + start_date = cleaned_data.get('start_date') + end_date = cleaned_data.get('end_date') + if period == 'custom': + if not start_date or not end_date: + raise forms.ValidationError('Choose both a start and end date for a custom report.') + if end_date < start_date: + raise forms.ValidationError('End date cannot be before start date.') + return cleaned_data + + def get_range(self): + today = timezone.localdate() + period = self.cleaned_data.get('period') or 'daily' + if period == 'daily': + return today, today + if period == 'weekly': + start = today - timezone.timedelta(days=today.weekday()) + return start, start + timezone.timedelta(days=6) + if period == 'monthly': + start = today.replace(day=1) + if start.month == 12: + next_month = start.replace(year=start.year + 1, month=1, day=1) + else: + next_month = start.replace(month=start.month + 1, day=1) + return start, next_month - timezone.timedelta(days=1) + if period == 'yearly': + start = today.replace(month=1, day=1) + return start, today.replace(month=12, day=31) + return self.cleaned_data['start_date'], self.cleaned_data['end_date'] diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..ce3ccd4 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.7 on 2026-04-17 02:21 + +import django.db.models.deletion +from decimal import Decimal +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BusinessProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('business_name', models.CharField(blank=True, max_length=120)), + ('logo', models.FileField(blank=True, null=True, upload_to='business_logos/')), + ('opening_ecash', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)), + ('opening_physical_cash', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)), + ('current_ecash', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)), + ('current_physical_cash', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='business_profile', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['user__username'], + }, + ), + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('client_name', models.CharField(max_length=120)), + ('amount', models.DecimalField(decimal_places=2, max_digits=12)), + ('transaction_type', models.CharField(choices=[('cash_out', 'Cash-out'), ('cash_in', 'Cash-In'), ('sending', 'Sending'), ('airtime', 'Airtime'), ('transfer', 'Transfer'), ('debt', 'Debt'), ('expenditure', 'Expenditure'), ('credit', 'Credit')], max_length=20)), + ('service_charge', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)), + ('notes', models.CharField(blank=True, max_length=255)), + ('ecash_before', models.DecimalField(decimal_places=2, max_digits=12)), + ('ecash_after', models.DecimalField(decimal_places=2, max_digits=12)), + ('physical_before', models.DecimalField(decimal_places=2, max_digits=12)), + ('physical_after', models.DecimalField(decimal_places=2, max_digits=12)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='core.businessprofile')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='momo_transactions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at', '-id'], + }, + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c82493e91134611032528b0bfd8de96cec6a42a GIT binary patch literal 4048 zcmcH+OK{WLRZ<*VwiCzZC4?k^oj5?8HjeoP82b9ihXCc1gg6jfK*+v|qaaI0k_pL7 zJ1jb#w|u*%yG$39&dcI0vtW@W%j=oD@Z8nww3{qCV+K}l(e_-)c7Dt>kD2OobkDix z9G%}iy83HtYk-3z^yj~;|7_v7e=wweG}i_nAH(2J9O4kKaTy+-f+iH2GEF?AHEEtg zbEerDdo*vsm+=+EjL35WcY{OCf8dZ;@h$o5O5Zeb-1l(1N5+r%SuWw*X6U^HPkj?g zPA$k93`I+^ZB@@(34sO*YTlG>)zB^KEf`4AtXxfn^Eg^VKK|RoOjR5+GXml>O#-KQ zmg=r>ig(FZ7ktBmDR8?-p3jI5Z^Kk1ASNAH3f6fL52pt;Z+PFpd%lO;Jr)Gyb2uS{ z?g4z_hX0KKTyVPwa0MK$);h(}7u;ys8x-Jcb@)OJz7XJTsBN#sD7+ClAbI4ACBOP& z$@VXn>~JK*4L|vfqkDaXSacrXId*`j%i)RC^(aS{zN(LxoC0UqQhS5eQ6Hi1-4(IX zvp=5s06LCN|K57bltUQKxp zo%#!pPH&t!z&WsAAK-_psKnlPK+O(*rm}yrU;k(A{pF#!h7QG*JQUYqDs0%%RPQ0& z�ahI(x8Y(77+xcm97=V=GkS1#~el9IVH@km*Dt&`d8;|J{1GOw+cplBMd3WlfvL zf~qN0P|+k6ZcQXSv_&r2hLkfE*;Wt@7ES29=8E)OS)qPQF;!VpR~6dwQZciJrAWG5 zP-ttmrq4mQU%^6x{@k3^Wc@j`Y5A3;DSF;sq#n)48>?*%c7|HRi6-+1h)|DS(lqKX z6*WUfl5Nmdqp0Z6?Ik5ATZ`MQX|8%w$y92at&>C(4I%f-AQd$^r&!d7lm)q@+3rh` znpbU$cGR^L7gsDbCu4MZ-i8U_DPmOo1dZ$Yw#% z<8XNwR7FGJrcrc1d%?6R>z15j7!Bq$mHFd{@Fr_^)GHT^l5Qsivl~$JIHQH_9U;kH zDJlsOvL&*hNJhzC6~|#T1S6pO9IC3X`W)gdozW~sN6bv$tUF*dSv74HO2Iek4wy#g zJqr*D(J>Q7K=mLcYg5kzK)`~^D@ZX??UJd`;Po8@=DZMkn4#Wr=ef-~HIRL|MMH&T zyAFmRMlS)Ca*MK=S7@_t*ow9NALeL_Q#MjoSumJ(7n2uku&Aw(<=SkhL2wzwdiH3` zu5eka?K#kutWVZ5OB+>^U7?YJQ81)ELcyV(Dq3wCQqjG{F)B(ZCG+9+pExaYgsN9aL~5Vx%5qGZua_p>F(6T z^*gks!J)#s2to*<*zU4TlIe9~Gp6Q8VWm&9ilY8;h+b_t2@Sy=w$%1r4ra3b8Eu^a zHEBq+%h5WeD`^9Oi+a(Ltmh3Xpez-gI%jq18y}q=ADy^P1CtOtHLu@>UJ`&sS=+q* zjr$0yxu3onUNj2Ia48D~TN+kgDLJsu99}le=T;Gx_b?0n@Xn@hIF(ABm1+@67FSFW zv^aq6)~tVl!LJ+VkD(*4Z&baUf8g5y5zo98$-uR;I7-CPYLn3JeSN1I;QUcGcd`=c z`!UjiI|sjeNje8NJ@5N*M#8#@JFRl3MLI1Uwkwes?mM@Bn)IF9Y=0leefP?J_ekG8 z9GT~EqgPe|7j9DeEuUSxt7w`TD4Lw3zSEl*2iTEd!+ z`;2m*LHZ0FE>g?`QD5#I9!x<@r3B&tv-~9$YRDE|bA!9Deox(s}|% z&#%2C(eqpFI67I5PLk*(4&SIm8WWpo933l1$4GPxhsPb?hnVj}o8EVRa``5?JVP!& z!i%d_j=Lt@6h5)XyZ~7DBjJMsfdpfqL^49PBiJ6Qc5{&)d}0V6PeO7!Le&!yf8YZL z;1dJY{+&#nJVd5W;#g|kOJb?baU8o=j$I?MYdAbwiFDzf zOyTf!B@)M{F0MZyr!In7-;8_q!D-KZMmjy~0S^Fz2LPD|X-_56i{m5fBP2euCF7ZS zc0H2HGtbD(Gh8rmyjYGGNxX=|FDj8^xO;HzH0d7vp?xckyC=)tlcd{?a1{4mSU*a7 zFKmtC`)S-eSMHr7y>mGHpb`O(&NGkBZ=J<6vpAM6$I>L0#^E`rUEF_ZeUkKF+IoTK z9^(G_a{oN(pU2@xdo4>p!10IW_(KwZh{N-{GP*z2kcs5l19Bp{dGuWuxpIqKxlgWS zu(}NX{*Iqzi94BQsRRgt0LTI{Cpaoota?(LBk!(|tGCJ3G`ad1KmR>2ObB-v_k{46 zvB#b;1pt8rkSTa7RC_{D3o)pLwI^?{Uz>}7vOh=bj{vGoJkLYF;d?O`bFT{5gZEyQ`qVC-0nB~os(Mqrx5^y^-vvmx O{{x%&^qE=5&i?|KT4pf- literal 0 HcmV?d00001 diff --git a/core/models.py b/core/models.py index 71a8362..e3297bd 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,196 @@ -from django.db import models +from decimal import Decimal, ROUND_HALF_UP -# Create your models here. +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.db import models, transaction + + +class BusinessProfile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='business_profile') + business_name = models.CharField(max_length=120, blank=True) + logo = models.FileField(upload_to='business_logos/', blank=True, null=True) + opening_ecash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00')) + opening_physical_cash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00')) + current_ecash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00')) + current_physical_cash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00')) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['user__username'] + + def __str__(self): + return self.business_name or f"{self.user.username}'s MoMo Business" + + @property + def owner_label(self): + return self.user.get_full_name() or self.user.username + + @property + def total_cash(self): + return (self.current_ecash or Decimal('0.00')) + (self.current_physical_cash or Decimal('0.00')) + + def sync_current_to_opening(self): + self.current_ecash = self.opening_ecash + self.current_physical_cash = self.opening_physical_cash + + +class Transaction(models.Model): + CASH_OUT = 'cash_out' + CASH_IN = 'cash_in' + SENDING = 'sending' + AIRTIME = 'airtime' + TRANSFER = 'transfer' + DEBT = 'debt' + EXPENDITURE = 'expenditure' + CREDIT = 'credit' + + TYPE_CHOICES = [ + (CASH_OUT, 'Cash-out'), + (CASH_IN, 'Cash-In'), + (SENDING, 'Sending'), + (AIRTIME, 'Airtime'), + (TRANSFER, 'Transfer'), + (DEBT, 'Debt'), + (EXPENDITURE, 'Expenditure'), + (CREDIT, 'Credit'), + ] + + business = models.ForeignKey(BusinessProfile, on_delete=models.CASCADE, related_name='transactions') + created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='momo_transactions') + client_name = models.CharField(max_length=120) + amount = models.DecimalField(max_digits=12, decimal_places=2) + transaction_type = models.CharField(max_length=20, choices=TYPE_CHOICES) + service_charge = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00')) + notes = models.CharField(max_length=255, blank=True) + ecash_before = models.DecimalField(max_digits=12, decimal_places=2) + ecash_after = models.DecimalField(max_digits=12, decimal_places=2) + physical_before = models.DecimalField(max_digits=12, decimal_places=2) + physical_after = models.DecimalField(max_digits=12, decimal_places=2) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at', '-id'] + + def __str__(self): + return f"{self.get_transaction_type_display()} · {self.client_name} · {self.amount}" + + @staticmethod + def _round(value: Decimal) -> Decimal: + return value.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + + @classmethod + def calculate_effect(cls, transaction_type: str, amount: Decimal) -> tuple[Decimal, Decimal, Decimal]: + amount = cls._round(amount) + fee = Decimal('0.00') + ecash_delta = Decimal('0.00') + physical_delta = Decimal('0.00') + + if transaction_type == cls.CASH_IN: + ecash_delta = -amount + physical_delta = amount + elif transaction_type == cls.CASH_OUT: + ecash_delta = amount + physical_delta = -amount + elif transaction_type in {cls.AIRTIME, cls.TRANSFER}: + ecash_delta = -amount + physical_delta = amount + elif transaction_type == cls.SENDING: + fee = cls._round(amount * Decimal('0.01')) + ecash_delta = -amount + physical_delta = amount + fee + elif transaction_type in {cls.DEBT, cls.EXPENDITURE}: + physical_delta = -amount + elif transaction_type == cls.CREDIT: + physical_delta = amount + else: + raise ValidationError('Unsupported transaction type.') + + return cls._round(ecash_delta), cls._round(physical_delta), cls._round(fee) + + @classmethod + @transaction.atomic + def create_logged_transaction( + cls, + *, + business: BusinessProfile, + user: User, + client_name: str, + amount: Decimal, + transaction_type: str, + notes: str = '', + ): + ecash_delta, physical_delta, fee = cls.calculate_effect(transaction_type, amount) + ecash_before = cls._round(business.current_ecash) + physical_before = cls._round(business.current_physical_cash) + ecash_after = cls._round(ecash_before + ecash_delta) + physical_after = cls._round(physical_before + physical_delta) + + if ecash_after < 0: + raise ValidationError('This transaction would make e-cash go below zero.') + if physical_after < 0: + raise ValidationError('This transaction would make physical cash go below zero.') + + entry = cls.objects.create( + business=business, + created_by=user, + client_name=client_name, + amount=cls._round(amount), + transaction_type=transaction_type, + service_charge=fee, + notes=notes, + ecash_before=ecash_before, + ecash_after=ecash_after, + physical_before=physical_before, + physical_after=physical_after, + ) + + business.current_ecash = ecash_after + business.current_physical_cash = physical_after + business.save(update_fields=['current_ecash', 'current_physical_cash', 'updated_at']) + return entry + + @classmethod + def rebalance_business_ledger(cls, business: BusinessProfile): + business = BusinessProfile.objects.select_for_update().get(pk=business.pk) + ecash_balance = cls._round(business.opening_ecash) + physical_balance = cls._round(business.opening_physical_cash) + + entries = list( + cls.objects.select_for_update() + .filter(business=business) + .order_by('created_at', 'id') + ) + for entry in entries: + ecash_delta, physical_delta, fee = cls.calculate_effect(entry.transaction_type, entry.amount) + ecash_before = ecash_balance + physical_before = physical_balance + ecash_after = cls._round(ecash_before + ecash_delta) + physical_after = cls._round(physical_before + physical_delta) + + cls.objects.filter(pk=entry.pk).update( + service_charge=fee, + ecash_before=ecash_before, + ecash_after=ecash_after, + physical_before=physical_before, + physical_after=physical_after, + ) + + ecash_balance = ecash_after + physical_balance = physical_after + + business.current_ecash = ecash_balance + business.current_physical_cash = physical_balance + business.save(update_fields=['current_ecash', 'current_physical_cash', 'updated_at']) + return business + + @transaction.atomic + def delete_logged_transaction(self): + business = BusinessProfile.objects.select_for_update().get(pk=self.business_id) + transaction_id = self.pk + self.delete() + business = self.__class__.rebalance_business_ledger(business) + return { + 'transaction_id': transaction_id, + 'business': business, + } diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..1bae298 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,25 +1,72 @@ - - {% block title %}Knowledge Base{% endblock %} - {% if project_description %} - - - - {% endif %} + + {% block title %}MoMoLedger{% endblock %} + {% if project_image_url %} {% endif %} + + + + + + {% load static %} {% block head %}{% endblock %} - - {% block content %}{% endblock %} - +
+ +
+
+ {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + {% block content %}{% endblock %} +
+
+
+ + {% block scripts %}{% endblock %} + diff --git a/core/templates/core/auth.html b/core/templates/core/auth.html new file mode 100644 index 0000000..d60f0ea --- /dev/null +++ b/core/templates/core/auth.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+
+

{% if mode == 'signup' %}Create your secure agent space{% else %}Welcome back{% endif %}

+

{{ page_title }}

+

{% if mode == 'signup' %}Create a single-agent account, then add your business name, logo, opening balances, and first transactions.{% else %}Log in to continue recording cash movements, viewing balances, and exporting reports.{% endif %}

+
+
Polished dashboardCustom MoMo styling, not default Bootstrap.
+
Smart balance engineEach transaction updates e-cash and physical cash automatically.
+
Printable reportsExport PDF and use browser print for sharing.
+
+
+
+
+
+
+ {% csrf_token %} +
+ {% for field in form %} +
+ + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} + {% for error in form.non_field_errors %}
{{ error }}
{% endfor %} +
+ +
+
+ {% if mode == 'signup' %} + Already have an account? Log in + {% else %} + Need an account? Sign up + {% endif %} +
+
+
+
+{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..da0664e 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,200 @@ {% extends "base.html" %} +{% load static %} -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} {% block content %} -
-
-

Analyzing your requirements and generating your app…

-
- Loading… +
+
+
+ Mobile money bookkeeping made clear +

Run your MoMo business with branded reports, balanced wallets, and cleaner daily records.

+

Track e-cash, physical cash, cash-in, cash-out, sending, airtime, transfer, debt, expenditure, and credit in one polished workflow. MoMoLedger updates balances automatically and keeps your daily, weekly, monthly, and yearly reports ready to print or export as PDF.

+
+ {% if request.user.is_authenticated %} + Record a transaction + Open reports + {% else %} + Create agent account + Sign in + {% endif %} +
+
+
8Transaction types with smart balance rules
+
1%Sending fee calculated automatically
+
4Report ranges: day, week, month, year
+
+
+
+
+
+
+
+

Welcome page

+

Sign up & login

+

Start with a secure agent account, then return to your dashboard anytime.

+
+ Sign up + Log in +
+
+
+
+
+

Brand your reports

+

Business setup

+

Add your business name, logo, and opening balances so every report looks official.

+ {% if request.user.is_authenticated and profile %} +
+
+ {{ profile.business_name|default:'Business name not set' }} + {{ profile.owner_label }} +
+ Edit +
+ {% else %} + Create account to set profile + {% endif %} +
+
+
+
-

AppWizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} - — UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) -
-{% endblock %} \ No newline at end of file +
+
+ + +{% if request.user.is_authenticated and profile %} +
+
+
+
+
+
+

Live balances

+

{{ profile.business_name|default:'Your business dashboard' }}

+
+ New transaction +
+
+
+
+ E-cash + {{ profile.current_ecash }} + Opening {{ profile.opening_ecash }} +
+
+
+
+ Physical cash + {{ profile.current_physical_cash }} + Opening {{ profile.opening_physical_cash }} +
+
+
+
+ Today’s volume + {{ today_total }} + {{ today_count }} transactions · fees {{ today_fees }} +
+
+
+
+
+
+
+

Recent activity

+

Latest transactions

+
+ View all +
+ {% if recent_transactions %} +
+ + + + + + + + + + + + + {% for transaction in recent_transactions %} + + + + + + + + + {% endfor %} + +
ClientTypeAmountFeeWhen
{{ transaction.client_name }}{{ transaction.get_transaction_type_display }}{{ transaction.amount }}{{ transaction.service_charge }}{{ transaction.created_at|date:"M d, Y H:i" }}Details
+
+ {% else %} +
+

No transactions yet

+

Record your first cash movement to see balance updates and report activity.

+ Create first transaction +
+ {% endif %} +
+
+
+
+

Quick workflow

+

What you can do now

+
+
+ 1. Business setup + Add name, logo, and opening balances. +
+
+ 2. Log transactions + Client name + amount + type updates wallets instantly. +
+
+ 3. Export reports + Daily, weekly, monthly, yearly, printable, and PDF-ready. +
+
+
+
+

Report shortcuts

+

Period summaries

+ +
+
+
+
+{% else %} +
+
+
+

First delivery included

+

A real workflow, not just a landing page

+

This first version includes account access, business setup, transaction logging with balance rules, and a branded report center with print and PDF export.

+
+
+
+
Cash-ine-cash decreases, physical cash increases.
+
Cash-outphysical cash decreases, e-cash increases.
+
Sending1% fee added automatically.
+
ReportsDaily to yearly export flow.
+
+
+
+
+{% endif %} +{% endblock %} diff --git a/core/templates/core/profile_form.html b/core/templates/core/profile_form.html new file mode 100644 index 0000000..61c2463 --- /dev/null +++ b/core/templates/core/profile_form.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+ Business setup +

Brand your MoMo reports

+

Set your business name, upload a logo, and define your opening wallet balances. These details appear across the dashboard and report exports.

+
+
+
+
+
+ {% csrf_token %} +
+ {% for field in form %} +
+ + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} +
+ {% for error in form.non_field_errors %}
{{ error }}
{% endfor %} + +
+
+
+
+
+

Report preview

+

How your business appears

+
+
+ {% if profile.logo %} + {{ profile.business_name|default:'Business' }} logo + {% else %} +
Logo
+ {% endif %} +
+ {{ profile.business_name|default:'Business name will appear here' }} + {{ profile.owner_label }} + {{ request.user.email|default:'Email not set yet' }} +
+
+
+
Opening e-cash{{ profile.opening_ecash }}
+
Opening physical cash{{ profile.opening_physical_cash }}
+
Current e-cash{{ profile.current_ecash }}
+
Current physical cash{{ profile.current_physical_cash }}
+
+
+

If you have not posted any transactions yet, saving this form also sets your starting current balances.

+
+
+
+{% endblock %} diff --git a/core/templates/core/reports.html b/core/templates/core/reports.html new file mode 100644 index 0000000..c899b32 --- /dev/null +++ b/core/templates/core/reports.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+ Report centre +

Daily, weekly, monthly, and yearly reports

+

Filter your period, print the page, or download a branded PDF that includes your business name, logo, and user details.

+
+
+ + Download PDF +
+
+
+
+
+
+
+ {% for field in form %} +
+ + {{ field }} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} + {% for error in form.non_field_errors %}
{{ error }}
{% endfor %} +
+ +
+
+
+
+
+
+
+ {% if profile.logo %} + {{ profile.business_name }} logo + {% else %} +
Logo
+ {% endif %} +
+

{{ profile.business_name|default:'MoMoLedger Business' }}

+

{{ profile.owner_label }} · {{ request.user.username }} · {{ request.user.email|default:'Email not set' }}

+
+
+
+ Report window + {{ start_date }} to {{ end_date }} +
+
+
+
Transactions{{ total_count }}
+
Gross amount{{ total_amount }}
+
Service fees{{ total_fees }}
+
Closing cash{{ closing_physical }}
+
+
+
Opening e-cash{{ profile.opening_ecash }}
+
Opening physical cash{{ profile.opening_physical_cash }}
+
Closing e-cash{{ closing_ecash }}
+
Closing physical cash{{ closing_physical }}
+
+
+ + + + + + + + + + + {% for row in summary %} + + + + + + + {% endfor %} + +
TypeCountAmountFees
{{ row.label }}{{ row.count }}{{ row.amount }}{{ row.fees }}
+
+
+ + + + + + + + + + + + + {% for transaction in entries %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
DateClientTypeAmountFeeBalances after
{{ transaction.created_at|date:"M d, Y H:i" }}{{ transaction.client_name }}{{ transaction.get_transaction_type_display }}{{ transaction.amount }}{{ transaction.service_charge }}E {{ transaction.ecash_after }} · P {{ transaction.physical_after }}
+
+

No entries in this period

+

Try another range or add a new transaction to populate the report.

+
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/transaction_detail.html b/core/templates/core/transaction_detail.html new file mode 100644 index 0000000..307b74d --- /dev/null +++ b/core/templates/core/transaction_detail.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+ Transaction detail +

{{ transaction.get_transaction_type_display }} for {{ transaction.client_name }}

+

Saved on {{ transaction.created_at|date:"F d, Y H:i" }}.

+
+ Back to log +
+
+
+
+
+
Business{{ profile.business_name }}
+
Client{{ transaction.client_name }}
+
Amount{{ transaction.amount }}
+
Service charge{{ transaction.service_charge }}
+
Recorded by{{ transaction.created_by.get_full_name|default:transaction.created_by.username }}
+
Notes{{ transaction.notes|default:'—' }}
+
+
+
+
+
+

Balance impact

+

Wallet movement

+
+
E-cash before{{ transaction.ecash_before }}
+
E-cash after{{ transaction.ecash_after }}
+
Physical before{{ transaction.physical_before }}
+
Physical after{{ transaction.physical_after }}
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/transaction_form.html b/core/templates/core/transaction_form.html new file mode 100644 index 0000000..cc34a5c --- /dev/null +++ b/core/templates/core/transaction_form.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+ Transaction input +

Record a new cash movement

+

Choose a transaction type and MoMoLedger applies the wallet movement rules for you. Defaults: Debt and Expenditure reduce physical cash, while Credit increases physical cash.

+
+
+
+
+
+ {% csrf_token %} +
+ {% for field in form %} +
+ + {{ field }} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} +
+ {% for error in form.non_field_errors %}
{{ error }}
{% endfor %} + +
+
+
+
+
+

Current wallet state

+

{{ profile.business_name }}

+
+
E-cash available{{ profile.current_ecash }}
+
Physical cash available{{ profile.current_physical_cash }}
+
+
+
Cash-Ine-cash − amount, physical cash + amount
+
Cash-outphysical cash − amount, e-cash + amount
+
Sendinge-cash − amount, physical cash + amount + 1% fee
+
Airtime / Transfere-cash − amount, physical cash + amount
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/transaction_list.html b/core/templates/core/transaction_list.html new file mode 100644 index 0000000..3c23ac5 --- /dev/null +++ b/core/templates/core/transaction_list.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+ Transaction log +

All recorded transactions

+

Review entries, totals, and drill into the exact balance movements for each one.

+
+ Add transaction +
+
+
Total entries{{ summary.total_count|default:0 }}
+
Total amount{{ summary.total_amount|default:0 }}
+
Total service fees{{ summary.total_fees|default:0 }}
+
+
+ {% if transactions %} +
+ + + + + + + + + + + + + + {% for transaction in transactions %} + + + + + + + + + + {% endfor %} + +
ClientTypeAmountService chargeBalances afterDate
{{ transaction.client_name }}{{ transaction.get_transaction_type_display }}{{ transaction.amount }}{{ transaction.service_charge }}E {{ transaction.ecash_after }} · P {{ transaction.physical_after }}{{ transaction.created_at|date:"M d, Y H:i" }}Open
+
+ {% else %} +
+

No entries yet

+

Your transaction log will appear here after the first saved record.

+ Create first transaction +
+ {% endif %} +
+{% endblock %} diff --git a/core/tests.py b/core/tests.py index 7ce503c..17b2c63 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,3 +1,46 @@ +from decimal import Decimal + +from django.contrib.auth.models import User from django.test import TestCase -# Create your tests here. +from .models import BusinessProfile, Transaction + + +class TransactionLogicTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='agent', password='secret123') + self.profile = BusinessProfile.objects.create( + user=self.user, + business_name='BrightPay', + opening_ecash=Decimal('1000.00'), + opening_physical_cash=Decimal('500.00'), + current_ecash=Decimal('1000.00'), + current_physical_cash=Decimal('500.00'), + ) + + def test_cash_in_moves_value_from_ecash_to_physical_cash(self): + entry = Transaction.create_logged_transaction( + business=self.profile, + user=self.user, + client_name='Ama', + amount=Decimal('100.00'), + transaction_type=Transaction.CASH_IN, + ) + self.profile.refresh_from_db() + self.assertEqual(entry.ecash_after, Decimal('900.00')) + self.assertEqual(entry.physical_after, Decimal('600.00')) + self.assertEqual(self.profile.current_ecash, Decimal('900.00')) + self.assertEqual(self.profile.current_physical_cash, Decimal('600.00')) + + def test_sending_adds_one_percent_service_charge_to_physical_cash(self): + entry = Transaction.create_logged_transaction( + business=self.profile, + user=self.user, + client_name='Kojo', + amount=Decimal('200.00'), + transaction_type=Transaction.SENDING, + ) + self.profile.refresh_from_db() + self.assertEqual(entry.service_charge, Decimal('2.00')) + self.assertEqual(entry.ecash_after, Decimal('800.00')) + self.assertEqual(entry.physical_after, Decimal('702.00')) diff --git a/core/urls.py b/core/urls.py index 6299e3d..8a01ab5 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,39 @@ from django.urls import path -from .views import home +from .views import ( + api_health_view, + api_login_view, + api_logout_view, + api_profile_view, + api_transaction_detail_view, + api_transactions_view, + home, + login_view, + logout_view, + profile_view, + report_pdf_view, + reports_view, + signup_view, + transaction_create_view, + transaction_detail_view, + transaction_list_view, +) urlpatterns = [ - path("", home, name="home"), + path('', home, name='home'), + path('api/health/', api_health_view, name='api_health'), + path('api/login/', api_login_view, name='api_login'), + path('api/logout/', api_logout_view, name='api_logout'), + path('api/profile/', api_profile_view, name='api_profile'), + path('api/transactions/', api_transactions_view, name='api_transactions'), + path('api/transactions//', api_transaction_detail_view, name='api_transaction_detail'), + path('signup/', signup_view, name='signup'), + path('login/', login_view, name='login'), + path('logout/', logout_view, name='logout'), + path('profile/', profile_view, name='profile'), + path('transactions/new/', transaction_create_view, name='transaction_create'), + path('transactions/', transaction_list_view, name='transaction_list'), + path('transactions//', transaction_detail_view, name='transaction_detail'), + path('reports/', reports_view, name='reports'), + path('reports/pdf/', report_pdf_view, name='report_pdf'), ] diff --git a/core/views.py b/core/views.py index c9aed12..6c79b4c 100644 --- a/core/views.py +++ b/core/views.py @@ -1,25 +1,543 @@ -import os -import platform +import json +from datetime import datetime, time +from io import BytesIO -from django import get_version as django_version -from django.shortcuts import render +from django.contrib import messages +from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError +from django.db.models import Count, Sum +from django.http import FileResponse, Http404, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET, require_POST, require_http_methods from django.utils import timezone +from .forms import BusinessProfileForm, LoginForm, ReportFilterForm, SignUpForm, TransactionForm +from .models import BusinessProfile, Transaction + + +def api_login_required(view_func): + def wrapped(request, *args, **kwargs): + if not request.user.is_authenticated: + return JsonResponse({ + 'ok': False, + 'error': 'Authentication required.', + }, status=401) + return view_func(request, *args, **kwargs) + + return wrapped + + +def serialize_transaction(entry): + return { + 'id': entry.id, + 'client_name': entry.client_name, + 'amount': str(entry.amount), + 'transaction_type': entry.transaction_type, + 'transaction_type_label': entry.get_transaction_type_display(), + 'service_charge': str(entry.service_charge), + 'notes': entry.notes, + 'ecash_before': str(entry.ecash_before), + 'ecash_after': str(entry.ecash_after), + 'physical_before': str(entry.physical_before), + 'physical_after': str(entry.physical_after), + 'created_by': entry.created_by.username, + 'created_at': timezone.localtime(entry.created_at).isoformat(), + } + + +def get_profile(user): + profile, _ = BusinessProfile.objects.get_or_create(user=user) + return profile + + +def build_report_snapshot(profile, params=None): + form = ReportFilterForm(params or None) + form.is_valid() + start_date, end_date = form.get_range() if form.cleaned_data else (timezone.localdate(), timezone.localdate()) + start_dt = timezone.make_aware(datetime.combine(start_date, time.min)) + end_dt = timezone.make_aware(datetime.combine(end_date, time.max)) + entries = profile.transactions.filter(created_at__range=(start_dt, end_dt)).select_related('created_by') + summary_rows = entries.values('transaction_type').annotate( + total_amount=Sum('amount'), + total_fees=Sum('service_charge'), + total_count=Count('id'), + ) + summary_map = {row['transaction_type']: row for row in summary_rows} + ordered_summary = [] + for value, label in Transaction.TYPE_CHOICES: + row = summary_map.get(value) + ordered_summary.append({ + 'key': value, + 'label': label, + 'count': row['total_count'] if row else 0, + 'amount': row['total_amount'] if row and row['total_amount'] else 0, + 'fees': row['total_fees'] if row and row['total_fees'] else 0, + }) + totals = entries.aggregate(total_amount=Sum('amount'), total_fees=Sum('service_charge'), total_count=Count('id')) + latest_entry = entries.order_by('-created_at', '-id').first() + closing_ecash = latest_entry.ecash_after if latest_entry else profile.current_ecash + closing_physical = latest_entry.physical_after if latest_entry else profile.current_physical_cash + return { + 'form': form, + 'entries': entries.order_by('-created_at', '-id'), + 'summary': ordered_summary, + 'total_amount': totals['total_amount'] or 0, + 'total_fees': totals['total_fees'] or 0, + 'total_count': totals['total_count'] or 0, + 'start_date': start_date, + 'end_date': end_date, + 'closing_ecash': closing_ecash, + 'closing_physical': closing_physical, + } + def home(request): - """Render the landing screen with loader and environment details.""" - host_name = request.get_host().lower() - agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" - now = timezone.now() - context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "current_time": now, - "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + 'page_title': 'MoMoLedger | Agent wallet dashboard', + 'meta_description': 'Manage MoMo transactions, balances, business branding, and daily-to-yearly reports from one polished dashboard.', + 'signup_form': SignUpForm(), + 'login_form': LoginForm(request=request), } - return render(request, "core/index.html", context) + if request.user.is_authenticated: + profile = get_profile(request.user) + report_snapshot = build_report_snapshot(profile, {'period': 'daily'}) + context.update({ + 'profile': profile, + 'recent_transactions': profile.transactions.select_related('created_by')[:5], + 'today_total': report_snapshot['total_amount'], + 'today_fees': report_snapshot['total_fees'], + 'today_count': report_snapshot['total_count'], + }) + return render(request, 'core/index.html', context) + + +def signup_view(request): + if request.user.is_authenticated: + return redirect('home') + form = SignUpForm(request.POST or None) + if request.method == 'POST' and form.is_valid(): + user = form.save() + BusinessProfile.objects.get_or_create(user=user) + login(request, user) + messages.success(request, 'Welcome! Your account is ready. Set your business details next.') + return redirect('profile') + return render(request, 'core/auth.html', { + 'form': form, + 'mode': 'signup', + 'page_title': 'Create your MoMo account', + 'meta_description': 'Create your mobile money business account and start tracking your balances securely.', + }) + + +def login_view(request): + if request.user.is_authenticated: + return redirect('home') + form = LoginForm(request=request, data=request.POST or None) + if request.method == 'POST' and form.is_valid(): + login(request, form.get_user()) + messages.success(request, 'Welcome back to your MoMo dashboard.') + return redirect('home') + return render(request, 'core/auth.html', { + 'form': form, + 'mode': 'login', + 'page_title': 'Log in to MoMoLedger', + 'meta_description': 'Access your mobile money dashboard, balances, transaction log, and reports.', + }) + + +@login_required +def logout_view(request): + logout(request) + messages.info(request, 'You have been logged out.') + return redirect('home') + + +@login_required +def profile_view(request): + profile = get_profile(request.user) + form = BusinessProfileForm(request.POST or None, request.FILES or None, instance=profile) + if request.method == 'POST' and form.is_valid(): + profile = form.save() + messages.success(request, 'Business profile saved. Your branding will now appear on reports.') + return redirect('profile') + return render(request, 'core/profile_form.html', { + 'form': form, + 'profile': profile, + 'page_title': 'Business setup | MoMoLedger', + 'meta_description': 'Update your business name, logo, and opening wallet balances for branded reports.', + }) + + +@login_required +def transaction_create_view(request): + profile = get_profile(request.user) + if not profile.business_name: + messages.info(request, 'Start by saving your business profile before logging transactions.') + return redirect('profile') + form = TransactionForm(request.POST or None, business=profile) + if request.method == 'POST' and form.is_valid(): + entry = form.save(request.user) + messages.success(request, 'Transaction recorded and balances updated automatically.') + return redirect('transaction_detail', transaction_id=entry.id) + return render(request, 'core/transaction_form.html', { + 'form': form, + 'profile': profile, + 'page_title': 'New transaction | MoMoLedger', + 'meta_description': 'Record cash-in, cash-out, sending, airtime, transfer, debt, expenditure, and credit in one place.', + }) + + +@login_required +def transaction_list_view(request): + profile = get_profile(request.user) + entries = profile.transactions.select_related('created_by') + summary = entries.aggregate(total_amount=Sum('amount'), total_fees=Sum('service_charge'), total_count=Count('id')) + return render(request, 'core/transaction_list.html', { + 'profile': profile, + 'transactions': entries, + 'summary': summary, + 'page_title': 'Transactions | MoMoLedger', + 'meta_description': 'Browse recent mobile money transactions and review balance movements.', + }) + + +@login_required +def transaction_detail_view(request, transaction_id): + profile = get_profile(request.user) + entry = get_object_or_404(profile.transactions.select_related('created_by'), id=transaction_id) + return render(request, 'core/transaction_detail.html', { + 'profile': profile, + 'transaction': entry, + 'page_title': f'{entry.get_transaction_type_display()} details | MoMoLedger', + 'meta_description': 'Inspect one MoMo transaction and see the exact e-cash and physical cash balance effect.', + }) + + +@login_required +def reports_view(request): + profile = get_profile(request.user) + snapshot = build_report_snapshot(profile, request.GET or {'period': 'daily'}) + context = { + 'profile': profile, + 'page_title': 'Reports | MoMoLedger', + 'meta_description': 'View printable MoMo business reports by day, week, month, year, or a custom date range.', + **snapshot, + } + return render(request, 'core/reports.html', context) + + +@login_required +def report_pdf_view(request): + try: + from reportlab.lib import colors + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import cm + from reportlab.pdfbase.pdfmetrics import stringWidth + from reportlab.pdfgen import canvas + except Exception as exc: # pragma: no cover + raise Http404('PDF support is not available.') from exc + + profile = get_profile(request.user) + snapshot = build_report_snapshot(profile, request.GET or {'period': 'daily'}) + + buffer = BytesIO() + pdf = canvas.Canvas(buffer, pagesize=A4) + width, height = A4 + margin = 1.5 * cm + y = height - margin + + pdf.setTitle('MoMo report') + pdf.setFillColor(colors.HexColor('#0f6f5c')) + pdf.rect(0, height - 4 * cm, width, 4 * cm, fill=1, stroke=0) + pdf.setFillColor(colors.white) + pdf.setFont('Helvetica-Bold', 18) + pdf.drawString(margin, height - 1.5 * cm, profile.business_name or 'MoMoLedger Business') + pdf.setFont('Helvetica', 10) + owner_text = f"Owner: {profile.owner_label} | User: {request.user.username} | Email: {request.user.email or 'Not set'}" + pdf.drawString(margin, height - 2.2 * cm, owner_text[:100]) + pdf.drawString(margin, height - 2.8 * cm, f"Report window: {snapshot['start_date']} to {snapshot['end_date']}") + + if profile.logo: + try: + pdf.drawImage(profile.logo.path, width - 4.5 * cm, height - 3.2 * cm, width=2.5 * cm, height=2.5 * cm, preserveAspectRatio=True, mask='auto') + except Exception: + pass + + y = height - 5.2 * cm + pdf.setFillColor(colors.HexColor('#143642')) + pdf.setFont('Helvetica-Bold', 12) + pdf.drawString(margin, y, 'Balance summary') + y -= 0.6 * cm + pdf.setFont('Helvetica', 10) + metrics = [ + f"Opening e-cash: {profile.opening_ecash}", + f"Opening physical: {profile.opening_physical_cash}", + f"Closing e-cash: {snapshot['closing_ecash']}", + f"Closing physical: {snapshot['closing_physical']}", + f"Transactions: {snapshot['total_count']}", + f"Gross amount: {snapshot['total_amount']}", + f"Service fees: {snapshot['total_fees']}", + ] + for item in metrics: + pdf.drawString(margin, y, item) + y -= 0.45 * cm + + y -= 0.2 * cm + pdf.setFont('Helvetica-Bold', 12) + pdf.drawString(margin, y, 'Transaction type totals') + y -= 0.6 * cm + pdf.setFont('Helvetica-Bold', 10) + pdf.drawString(margin, y, 'Type') + pdf.drawString(8.2 * cm, y, 'Count') + pdf.drawString(11 * cm, y, 'Amount') + pdf.drawString(15 * cm, y, 'Fees') + y -= 0.35 * cm + pdf.setStrokeColor(colors.HexColor('#d4dfd6')) + pdf.line(margin, y, width - margin, y) + y -= 0.45 * cm + pdf.setFont('Helvetica', 10) + for row in snapshot['summary']: + if y < 3 * cm: + pdf.showPage() + y = height - margin + pdf.drawString(margin, y, row['label']) + pdf.drawString(8.2 * cm, y, str(row['count'])) + pdf.drawString(11 * cm, y, str(row['amount'])) + pdf.drawString(15 * cm, y, str(row['fees'])) + y -= 0.45 * cm + + y -= 0.3 * cm + pdf.setFont('Helvetica-Bold', 12) + pdf.drawString(margin, y, 'Recent entries') + y -= 0.6 * cm + pdf.setFont('Helvetica', 9) + for entry in snapshot['entries'][:12]: + if y < 2.5 * cm: + pdf.showPage() + y = height - margin + label = f"{timezone.localtime(entry.created_at).strftime('%Y-%m-%d %H:%M')} · {entry.get_transaction_type_display()} · {entry.client_name} · {entry.amount}" + max_width = width - (2 * margin) + while stringWidth(label, 'Helvetica', 9) > max_width and len(label) > 3: + label = label[:-4] + '...' + pdf.drawString(margin, y, label) + y -= 0.42 * cm + + pdf.showPage() + pdf.save() + buffer.seek(0) + filename = f"momo-report-{snapshot['start_date']}-to-{snapshot['end_date']}.pdf" + return FileResponse(buffer, as_attachment=True, filename=filename) + + +def parse_api_payload(request): + if (request.content_type or '').startswith('application/json'): + try: + return json.loads(request.body.decode('utf-8') or '{}') + except (json.JSONDecodeError, UnicodeDecodeError): + return None + return request.POST + + +def serialize_form_errors(form): + errors = {} + for field, items in form.errors.get_json_data().items(): + errors[field] = [item['message'] for item in items] + return errors + + +def build_transaction_summary(profile): + summary = profile.transactions.aggregate( + total_amount=Sum('amount'), + total_fees=Sum('service_charge'), + total_count=Count('id'), + ) + return { + 'total_amount': str(summary['total_amount'] or 0), + 'total_fees': str(summary['total_fees'] or 0), + 'total_count': summary['total_count'] or 0, + } + + +@require_GET +def api_health_view(request): + return JsonResponse({ + 'ok': True, + 'app': 'MoMoLedger API', + 'message': 'Android backend starter endpoints are available.', + 'server_time': timezone.now().isoformat(), + 'authenticated': request.user.is_authenticated, + }) + + +@csrf_exempt +@require_POST +def api_login_view(request): + if request.user.is_authenticated: + profile = get_profile(request.user) + return JsonResponse({ + 'ok': True, + 'message': 'Already logged in.', + 'user': { + 'username': request.user.username, + 'email': request.user.email, + 'business_name': profile.business_name, + }, + }) + + payload = parse_api_payload(request) + if payload is None: + return JsonResponse({ + 'ok': False, + 'error': 'Invalid JSON body.', + }, status=400) + + username = (payload.get('username') or '').strip() + password = payload.get('password') or '' + + if not username or not password: + return JsonResponse({ + 'ok': False, + 'error': 'Username and password are required.', + }, status=400) + + user = authenticate(request, username=username, password=password) + if user is None: + return JsonResponse({ + 'ok': False, + 'error': 'Invalid username or password.', + }, status=401) + + login(request, user) + profile = get_profile(user) + return JsonResponse({ + 'ok': True, + 'message': 'Login successful.', + 'user': { + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'business_name': profile.business_name, + }, + }) + + +@csrf_exempt +@require_POST +def api_logout_view(request): + if not request.user.is_authenticated: + return JsonResponse({ + 'ok': True, + 'message': 'No active session.', + }) + + logout(request) + return JsonResponse({ + 'ok': True, + 'message': 'Logout successful.', + }) + + +@require_GET +@api_login_required +def api_profile_view(request): + profile = get_profile(request.user) + return JsonResponse({ + 'ok': True, + 'profile': { + 'username': request.user.username, + 'email': request.user.email, + 'owner_label': profile.owner_label, + 'business_name': profile.business_name, + 'opening_ecash': str(profile.opening_ecash), + 'opening_physical_cash': str(profile.opening_physical_cash), + 'current_ecash': str(profile.current_ecash), + 'current_physical_cash': str(profile.current_physical_cash), + 'total_cash': str(profile.total_cash), + 'created_at': timezone.localtime(profile.created_at).isoformat(), + 'updated_at': timezone.localtime(profile.updated_at).isoformat(), + }, + }) + + +@csrf_exempt +@api_login_required +@require_http_methods(['GET', 'POST']) +def api_transactions_view(request): + profile = get_profile(request.user) + + if request.method == 'POST': + payload = parse_api_payload(request) + if payload is None: + return JsonResponse({ + 'ok': False, + 'error': 'Invalid JSON body.', + }, status=400) + + form = TransactionForm(payload or None, business=profile) + if not form.is_valid(): + return JsonResponse({ + 'ok': False, + 'error': 'Validation failed.', + 'errors': serialize_form_errors(form), + }, status=400) + + try: + entry = form.save(request.user) + except ValidationError as exc: + return JsonResponse({ + 'ok': False, + 'error': exc.messages[0] if exc.messages else 'Could not create transaction.', + }, status=400) + + profile.refresh_from_db(fields=['current_ecash', 'current_physical_cash']) + return JsonResponse({ + 'ok': True, + 'message': 'Transaction created successfully.', + 'transaction': serialize_transaction(entry), + 'balances': { + 'current_ecash': str(profile.current_ecash), + 'current_physical_cash': str(profile.current_physical_cash), + 'total_cash': str(profile.total_cash), + }, + 'summary': build_transaction_summary(profile), + }, status=201) + + try: + limit = int(request.GET.get('limit', 20)) + except (TypeError, ValueError): + limit = 20 + limit = max(1, min(limit, 100)) + + entries = list(profile.transactions.select_related('created_by')[:limit]) + return JsonResponse({ + 'ok': True, + 'count': len(entries), + 'limit': limit, + 'summary': build_transaction_summary(profile), + 'transactions': [serialize_transaction(entry) for entry in entries], + }) + + +@csrf_exempt +@api_login_required +@require_http_methods(['DELETE']) +def api_transaction_detail_view(request, transaction_id): + profile = get_profile(request.user) + entry = get_object_or_404(profile.transactions.select_related('created_by'), id=transaction_id) + deleted_transaction = serialize_transaction(entry) + delete_result = entry.delete_logged_transaction() + profile = delete_result['business'] + return JsonResponse({ + 'ok': True, + 'message': 'Transaction deleted successfully.', + 'deleted_transaction': deleted_transaction, + 'deleted_transaction_id': delete_result['transaction_id'], + 'balances': { + 'current_ecash': str(profile.current_ecash), + 'current_physical_cash': str(profile.current_physical_cash), + 'total_cash': str(profile.total_cash), + }, + 'summary': build_transaction_summary(profile), + }) diff --git a/media/business_logos/IMG_4442.JPG b/media/business_logos/IMG_4442.JPG new file mode 100644 index 0000000000000000000000000000000000000000..49232564af995384a85b8756b84af7aa875f20bd GIT binary patch literal 18347 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