From 9f1aeb32da4f4b313a5fc2874a4d97c6693a7fa6 Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Thu, 4 Dec 2025 15:10:07 +0200 Subject: [PATCH] updates --- .cursor/worktrees.json | 5 + Backend/src/__pycache__/main.cpython-312.pyc | Bin 27679 -> 28210 bytes .../__pycache__/auth_routes.cpython-312.pyc | Bin 27012 -> 30662 bytes Backend/src/auth/routes/auth_routes.py | 132 +++++- .../schemas/__pycache__/auth.cpython-312.pyc | Bin 10221 -> 10101 bytes Backend/src/auth/schemas/auth.py | 5 +- Backend/src/main.py | 8 + Backend/src/models/__init__.py | 16 +- .../__pycache__/__init__.cpython-312.pyc | Bin 7419 -> 8342 bytes .../accountant_session.cpython-312.pyc | Bin 0 -> 3772 bytes .../chart_of_accounts.cpython-312.pyc | Bin 0 -> 3061 bytes .../financial_approval.cpython-312.pyc | Bin 0 -> 4137 bytes .../financial_audit_trail.cpython-312.pyc | Bin 3267 -> 3329 bytes .../__pycache__/fiscal_period.cpython-312.pyc | Bin 0 -> 2356 bytes .../__pycache__/journal_entry.cpython-312.pyc | Bin 0 -> 4451 bytes .../reconciliation_exception.cpython-312.pyc | Bin 0 -> 3834 bytes .../src/payments/models/accountant_session.py | 94 ++++ .../src/payments/models/chart_of_accounts.py | 71 +++ .../src/payments/models/financial_approval.py | 89 ++++ .../payments/models/financial_audit_trail.py | 2 + Backend/src/payments/models/fiscal_period.py | 53 +++ Backend/src/payments/models/journal_entry.py | 104 +++++ .../models/reconciliation_exception.py | 82 ++++ ...accountant_security_routes.cpython-312.pyc | Bin 0 -> 12665 bytes .../approval_routes.cpython-312.pyc | Bin 0 -> 20472 bytes .../audit_trail_routes.cpython-312.pyc | Bin 8943 -> 22792 bytes .../financial_routes.cpython-312.pyc | Bin 20997 -> 31993 bytes .../__pycache__/gl_routes.cpython-312.pyc | Bin 0 -> 12176 bytes .../invoice_routes.cpython-312.pyc | Bin 16515 -> 19141 bytes .../payment_routes.cpython-312.pyc | Bin 77151 -> 84594 bytes .../reconciliation_routes.cpython-312.pyc | Bin 0 -> 11195 bytes .../routes/accountant_security_routes.py | 262 +++++++++++ .../src/payments/routes/approval_routes.py | 433 ++++++++++++++++++ .../src/payments/routes/audit_trail_routes.py | 341 +++++++++++++- .../src/payments/routes/financial_routes.py | 323 +++++++++++-- Backend/src/payments/routes/gl_routes.py | 240 ++++++++++ Backend/src/payments/routes/invoice_routes.py | 59 +++ Backend/src/payments/routes/payment_routes.py | 175 +++++++ .../payments/routes/reconciliation_routes.py | 234 ++++++++++ ...ccountant_security_service.cpython-312.pyc | Bin 0 -> 10638 bytes .../approval_service.cpython-312.pyc | Bin 0 -> 11657 bytes .../financial_audit_service.cpython-312.pyc | Bin 5578 -> 5805 bytes .../__pycache__/gl_service.cpython-312.pyc | Bin 0 -> 14600 bytes .../invoice_service.cpython-312.pyc | Bin 24112 -> 24043 bytes .../reconciliation_service.cpython-312.pyc | Bin 0 -> 14333 bytes .../services/accountant_security_service.py | 289 ++++++++++++ .../src/payments/services/approval_service.py | 257 +++++++++++ .../services/audit_retention_service.py | 93 ++++ .../services/financial_audit_service.py | 6 + .../payments/services/gl_posting_service.py | 270 +++++++++++ Backend/src/payments/services/gl_service.py | 321 +++++++++++++ .../src/payments/services/invoice_service.py | 65 ++- .../services/reconciliation_service.py | 307 +++++++++++++ .../__pycache__/auth.cpython-312.pyc | Bin 8363 -> 9785 bytes Backend/src/security/middleware/auth.py | 45 +- .../src/security/middleware/step_up_auth.py | 87 ++++ .../__pycache__/settings.cpython-312.pyc | Bin 13926 -> 15416 bytes Backend/src/shared/config/settings.py | 61 ++- .../__pycache__/role_helpers.cpython-312.pyc | Bin 3327 -> 7561 bytes Backend/src/shared/utils/role_helpers.py | 224 ++++++++- .../system_settings_routes.cpython-312.pyc | Bin 79231 -> 81691 bytes .../system/routes/system_settings_routes.py | 58 +++ Frontend/src/App.tsx | 46 +- .../payments/services/approvalService.ts | 69 +++ .../services/financialAuditService.ts | 20 + .../services/financialReportService.ts | 98 ++++ .../features/payments/services/glService.ts | 155 +++++++ .../services/reconciliationService.ts | 96 ++++ .../services/accountantSecurityService.ts | 81 ++++ .../accountant/ApprovalManagementPage.tsx | 280 +++++++++++ .../src/pages/accountant/AuditTrailPage.tsx | 257 +++++++++++ .../pages/accountant/FinancialReportsPage.tsx | 415 +++++++++++++++++ .../src/pages/accountant/GLManagementPage.tsx | 281 ++++++++++++ .../pages/accountant/ReconciliationPage.tsx | 372 +++++++++++++++ .../accountant/SecurityManagementPage.tsx | 281 ++++++++++++ Frontend/src/pages/customer/InvoicePage.tsx | 6 +- Frontend/src/routes/accountantRoutes.tsx | 15 +- .../shared/components/SidebarAccountant.tsx | 35 +- 78 files changed, 7204 insertions(+), 114 deletions(-) create mode 100644 .cursor/worktrees.json create mode 100644 Backend/src/payments/models/__pycache__/accountant_session.cpython-312.pyc create mode 100644 Backend/src/payments/models/__pycache__/chart_of_accounts.cpython-312.pyc create mode 100644 Backend/src/payments/models/__pycache__/financial_approval.cpython-312.pyc create mode 100644 Backend/src/payments/models/__pycache__/fiscal_period.cpython-312.pyc create mode 100644 Backend/src/payments/models/__pycache__/journal_entry.cpython-312.pyc create mode 100644 Backend/src/payments/models/__pycache__/reconciliation_exception.cpython-312.pyc create mode 100644 Backend/src/payments/models/accountant_session.py create mode 100644 Backend/src/payments/models/chart_of_accounts.py create mode 100644 Backend/src/payments/models/financial_approval.py create mode 100644 Backend/src/payments/models/fiscal_period.py create mode 100644 Backend/src/payments/models/journal_entry.py create mode 100644 Backend/src/payments/models/reconciliation_exception.py create mode 100644 Backend/src/payments/routes/__pycache__/accountant_security_routes.cpython-312.pyc create mode 100644 Backend/src/payments/routes/__pycache__/approval_routes.cpython-312.pyc create mode 100644 Backend/src/payments/routes/__pycache__/gl_routes.cpython-312.pyc create mode 100644 Backend/src/payments/routes/__pycache__/reconciliation_routes.cpython-312.pyc create mode 100644 Backend/src/payments/routes/accountant_security_routes.py create mode 100644 Backend/src/payments/routes/approval_routes.py create mode 100644 Backend/src/payments/routes/gl_routes.py create mode 100644 Backend/src/payments/routes/reconciliation_routes.py create mode 100644 Backend/src/payments/services/__pycache__/accountant_security_service.cpython-312.pyc create mode 100644 Backend/src/payments/services/__pycache__/approval_service.cpython-312.pyc create mode 100644 Backend/src/payments/services/__pycache__/gl_service.cpython-312.pyc create mode 100644 Backend/src/payments/services/__pycache__/reconciliation_service.cpython-312.pyc create mode 100644 Backend/src/payments/services/accountant_security_service.py create mode 100644 Backend/src/payments/services/approval_service.py create mode 100644 Backend/src/payments/services/audit_retention_service.py create mode 100644 Backend/src/payments/services/gl_posting_service.py create mode 100644 Backend/src/payments/services/gl_service.py create mode 100644 Backend/src/payments/services/reconciliation_service.py create mode 100644 Backend/src/security/middleware/step_up_auth.py create mode 100644 Frontend/src/features/payments/services/approvalService.ts create mode 100644 Frontend/src/features/payments/services/financialReportService.ts create mode 100644 Frontend/src/features/payments/services/glService.ts create mode 100644 Frontend/src/features/payments/services/reconciliationService.ts create mode 100644 Frontend/src/features/security/services/accountantSecurityService.ts create mode 100644 Frontend/src/pages/accountant/ApprovalManagementPage.tsx create mode 100644 Frontend/src/pages/accountant/AuditTrailPage.tsx create mode 100644 Frontend/src/pages/accountant/FinancialReportsPage.tsx create mode 100644 Frontend/src/pages/accountant/GLManagementPage.tsx create mode 100644 Frontend/src/pages/accountant/ReconciliationPage.tsx create mode 100644 Frontend/src/pages/accountant/SecurityManagementPage.tsx diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 00000000..77e9744d --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,5 @@ +{ + "setup-worktree": [ + "npm install" + ] +} diff --git a/Backend/src/__pycache__/main.cpython-312.pyc b/Backend/src/__pycache__/main.cpython-312.pyc index fabf3a55770cd45498ad8d89ee8c0b2c5440275e..faa19b45c719e1e8f82305553ac84c9ec1244468 100644 GIT binary patch delta 3001 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 F{s#(<44nV~ delta 7253 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 lyO~+Ms1?|fS_RijHqqM$1A!QfqK`{|meRZqfh% diff --git a/Backend/src/auth/routes/auth_routes.py b/Backend/src/auth/routes/auth_routes.py index 3b0d62ae..fe50dbc3 100644 --- a/Backend/src/auth/routes/auth_routes.py +++ b/Backend/src/auth/routes/auth_routes.py @@ -12,12 +12,14 @@ from ..schemas.auth import RegisterRequest, LoginRequest, RefreshTokenRequest, F from ...security.middleware.auth import get_current_user from ..models.user import User from ...analytics.services.audit_service import audit_service +from ...shared.config.logging_config import get_logger from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded from functools import wraps router = APIRouter(prefix='/auth', tags=['auth']) +logger = get_logger(__name__) # Stricter rate limits for authentication endpoints AUTH_RATE_LIMIT = "5/minute" # 5 attempts per minute per IP @@ -102,8 +104,6 @@ async def register( ) except Exception as e: # Log error but don't fail registration if session creation fails - from ...shared.config.logging_config import get_logger - logger = get_logger(__name__) logger.warning(f'Failed to create session during registration: {str(e)}') # Log successful registration @@ -157,6 +157,66 @@ async def login( mfa_token=login_request.mfaToken, expected_role=login_request.expectedRole ) + + # After successful login, check if user is accountant/admin and enforce MFA + requires_mfa_setup = False + if result.get('user') and not result.get('requires_mfa'): + user = db.query(User).filter(User.id == result['user']['id']).first() + if user: + try: + from ...payments.services.accountant_security_service import accountant_security_service + from ...shared.utils.role_helpers import is_accountant, is_admin + + if is_accountant(user, db) or is_admin(user, db): + # Check if MFA is required but not enabled + is_enforced, reason = accountant_security_service.is_mfa_enforced(user, db) + if not is_enforced: + # MFA required but not enabled - allow login but flag for setup + requires_mfa_setup = True + await audit_service.log_action( + db=db, + action='login_mfa_setup_required', + resource_type='authentication', + user_id=user.id, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': login_request.email, 'reason': reason}, + status='success' + ) + logger.info(f'User {user.id} logged in but MFA setup required: {reason}') + else: + # MFA is enabled and enforced - create accountant session for tracking + try: + accountant_security_service.create_session( + db=db, + user_id=user.id, + ip_address=client_ip, + user_agent=user_agent, + device_fingerprint=None # Can be enhanced with device fingerprinting + ) + + # Log login activity + is_unusual = accountant_security_service.detect_unusual_activity( + db=db, + user_id=user.id, + ip_address=client_ip + ) + + accountant_security_service.log_activity( + db=db, + user_id=user.id, + activity_type='login', + activity_description='Accountant/admin login successful', + ip_address=client_ip, + user_agent=user_agent, + risk_level='low', + is_unusual=is_unusual + ) + except Exception as e: + logger.warning(f'Error creating accountant session: {e}') + except Exception as e: + logger.warning(f'Error enforcing MFA for accountant: {e}') if result.get('requires_mfa'): # Log MFA required user = db.query(User).filter(User.email == login_request.email.lower().strip()).first() @@ -194,6 +254,66 @@ async def login( status='success' ) return {'status': 'success', 'requires_mfa': True, 'user_id': result['user_id']} + + # After successful login (MFA passed if required), check MFA for accountant/admin roles + if not requires_mfa_setup: + user = db.query(User).filter(User.id == result['user']['id']).first() + if user: + try: + from ...payments.services.accountant_security_service import accountant_security_service + from ...shared.utils.role_helpers import is_accountant, is_admin + + if is_accountant(user, db) or is_admin(user, db): + # Check if MFA is required but not enabled + is_enforced, reason = accountant_security_service.is_mfa_enforced(user, db) + if not is_enforced: + # MFA required but not enabled - allow login but flag for setup + requires_mfa_setup = True + await audit_service.log_action( + db=db, + action='login_mfa_setup_required', + resource_type='authentication', + user_id=user.id, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': login_request.email, 'reason': reason}, + status='success' + ) + logger.info(f'User {user.id} logged in but MFA setup required: {reason}') + else: + # MFA is enabled and enforced - create accountant session for tracking + try: + accountant_session = accountant_security_service.create_session( + db=db, + user_id=user.id, + ip_address=client_ip, + user_agent=user_agent, + device_fingerprint=None # Can be enhanced with device fingerprinting + ) + + # Log login activity + is_unusual = accountant_security_service.detect_unusual_activity( + db=db, + user_id=user.id, + ip_address=client_ip + ) + + accountant_security_service.log_activity( + db=db, + user_id=user.id, + activity_type='login', + activity_description='Accountant/admin login successful', + ip_address=client_ip, + user_agent=user_agent, + risk_level='low', + is_unusual=is_unusual + ) + except Exception as e: + logger.warning(f'Error creating accountant session: {e}') + except Exception as e: + logger.warning(f'Error enforcing MFA for accountant: {e}') + from ...shared.config.settings import settings max_age = 7 * 24 * 60 * 60 if login_request.rememberMe else 1 * 24 * 60 * 60 # Use secure cookies in production (HTTPS required) @@ -236,8 +356,6 @@ async def login( ) except Exception as e: # Log error but don't fail login if session creation fails - from ...shared.config.logging_config import get_logger - logger = get_logger(__name__) logger.warning(f'Failed to create session during login: {str(e)}') # Validate role if expected_role is provided (for role-specific login endpoints) @@ -279,7 +397,11 @@ async def login( ) # Return user data but NOT the token (it's in httpOnly cookie now) - return {'status': 'success', 'data': {'user': result['user']}} + response_data = {'status': 'success', 'data': {'user': result['user']}} + if requires_mfa_setup: + response_data['requires_mfa_setup'] = True + response_data['message'] = 'MFA setup is required for your role. Please enable MFA in your profile settings.' + return response_data except ValueError as e: error_message = str(e) # SECURITY: Sanitize error messages to prevent information disclosure diff --git a/Backend/src/auth/schemas/__pycache__/auth.cpython-312.pyc b/Backend/src/auth/schemas/__pycache__/auth.cpython-312.pyc index 548ca9eae3965e2fb87da331a75d084fe90125a0..90d58157e019c2df9c07de223da54bca960acaf4 100644 GIT binary patch delta 2826 zcmaJ@TWl0n7@o8DYxmmiPPc4#7ihau(gFo3VpAxTT3~4{iDsi&_A+gU?v1lkYWrXz z)>{k)I2sK;7-C{<;KelYQD2Svh^CnbqA@Wc1cV1P@deNSkFwKc%O>;fIp6tjbN>JQ zXYQSPlyd**b~^LMoypi8h$(4S?T529_7O?ARL4lAJT1>0k@L#hH>8yi_u5Yekr3b7AI4uu zBFyDiiE5Q=#ma(2X@t*f1`e?pjq*R*$!?p6>@gU|Ik0~7b~bLA(J2<>8ChwrIOz0) zqNdH-tgn*jTGjd)=>WI12cU_=Zu{pwy|A{E!>Wd9oX<#I*IDsrV<%VektECc|805$*%z;1wH4yytkfHA%^lqGer z-yG*HNIKZpE|Hyg)+}CF39vt%y(bUB@G!s#0J=B|qay$>0e~OXl&e&VbR6c#0Y(Ap zzQ$lW#%5h3ny)eLYj%Hw@lzm#QKNBm(_kkBA}KcM-lEw_Va%7@`z9dfRMJcvY}NyA zurvvl0DAaSvvPP_Sa$|iPH9n3vOvparw@cMxd9fliR^E~eG76SmhDVy`4vH?Nq%>E zN1QQOgMy-oy27?i6^6WfVJ!xLT3vN52(!7Hq5kJLYCQ+j9RPhCHq<#(!crIe*4O2O zHM$1?(v(WtStQh+Gy{P(RIi+dotpxYDR$j|P|Lv-``v%j33W-aaDwbwKw93I2h}r8 zE2T_UQK+1oSEO1)fv6Vk0Mj$v3}hcOvLdXvN8k5o8Od=rm9#Y^ST)LJh1M$)%ZH1$ zJyn8fu20kK@=3nLk5`yv!?mqDSy$Q*EU>~=`h zoX_dBcJFMaQ3VYEOlBEocv!1CP0zIVXgQlJN+(ATy)2!Zmw3}T$0b$&d5Ox?avmE` zMybq6E~tDWbVlaHl51N5im9qiTy>%jB~8x%p}) zUnxjD<*;wt-r$Sc&cjYM?!W?Y3+!$H}idV&=%o;E=l6}0-iR{Dpv1ebI597nV`ysqRfCB(802~Au;!wXaff;76C(d#&-zTn< zciGvk$GYLZg|C2Yu{xW@i&r(zSF8MspbxbF{~}=PTQ4pTaOQN9sNQO&I$z8xQYN>U z!E^>2dJr{(w~GMSexQG8EeX?Xg6UfT*jL|y(R%mNR9 z8f*PckOP3qtvzO!w*MtQbP2)eSIVuxz{L~Vu>yfqGQ_TRMGKl$i?Udi%QISx4lptX zfLH1yjIbAA!^3Vi3mmo<>=u|^JZE@1FbZ8CVr|_C)9BB_D0{K{x@Q~N`|02#f&XDN F{{nRFCoup3 delta 2930 zcma)8+iw(A7@xEEYj?ZbZM)s>t+g!!v{V{|BEn*YRj^Afh_W#*)9$ov*&AoJElXmG z+G6xcJSLi`X?f73;DcuW0QEr=W1)Rlr|CM4S93H zlC}`RAS8vXaZ$*cWOBubP6Q!s<*XT4i;lHjJRexFbCDfH4xPxsStqbA9qZ(*8`vft z>*A~jSg($CbG8{+pN?(ftRL7GmF?vDc{m*ax>cv_WrHM;YUZ*a$U1Z~A7?|rcIsF^ zXT!iobZkpCD#os3AkoAa>8pJ~0))6~KN^CBcx!(euaT}ckgk%tHK*iD^D?C!Fjou! zu^0`*AC0gZ=DqeP4r2f;VeVmR%e2lgo14y6HZ~k~`p{6dW_{LYNw{|2`VQ&Du(S_h zgMd5j%bT{KY%9R3k#58BAVA$ZnU|%aoKZZoD$76ff|}mE#U@FW2lF)2_6NbE(vQ+ z|Kbrf>oL~d^t#iFO3es9HfRentMP_~a{+cWoL1Mj9mN~0?|$!ybG@KwTr=pttHECH z4wPwDJ(^YD-K_?9-fku0bTh&Lz-@Jom2j??ecjx9jMv+C)WT&-iFTU6iT3HVMKGg) z7nW8|4WDxyRmR!JzCG%~jI%#{A3CvEDX%6!yX==25;$LvQ1?vHQXwN%Dl|7STalH9 zs;OH>O2w(%G|hk+Rs&zAMPaQY@~TH&CJES-XlqElZp;-cR4Y?nQ=r;*M_r!4gif&c zTMnyfpWyZ62|RV^D0-$?l6SbL+)8t=)osH^$)fixyByf0de8n}tx@J}^|*M1RkdPS z(butA+b+{+i0~vl>&4l$cu0NgY49Vma=U{i+L~FdHQsRw4H}})AUDHqvQ%Ps(nerYeN)vf`^x*z+`OK^gt*8u|b>Ey!b5prV-i|Vr(rLMv zq8mj~&@aL>@F;Eo*m*9`%ZJz6L*JW1YDk5Ds*~*pj$rFW&Ga*f$o-Jo4OIi8O zHCG!FUzB;UQ2WSX95sBFIppTp-|b!MXPH}nxfQ2_oeb_7N}$Gil~V46JD%9UT@|@1 z`z9Dt-BsDI!613Q=I9tD?QmFk!f_S8f@cl<(!MGYBlQ+m?rMpqDBc3DW09P*ADqqS zV@0VTi$>}KhPv5GsNcDOL%xBA*;=S4hRx@kD<#jz%95hYmuOZrsQO&f$HJXQ$wKW+ zXPVr1ql!oOvZYAO62n;c(Wm0k5$9{D%MI?MML~cq6*SPS?$x4LD)s>!c7wJ!YmMj0 z4(NeF_HI0R@8k4VJh7Gc!Ce?>7-2WU!w8Q6Xs0PM``GEO8HjSN>k_%Z&UPpJ@GQm~ zf|M_xlK7!qH_w*Ka9g;oi?W})Uo;JYF8k8oPeQPgfd#cq9ZQL(#zqv8@^ItGluPB= zyt*+as+p;roX=)(u^Vd>+ZlZm;1zfj8vt}WvHz;AS8I2ozaa=+Fr(Rd1S^8pxOwH6 zkb4P%x9}w#q5p=v0eu5Gox6olkh1204xbVL?2i=_L21I$hS|QWYM0V+WvA8 z^T6){fSM}mr^EmY_eL^xOQl-Q6{pqo_`M?uV#-hRF&y#ZgSS=O4!reI-oJQD!e&AF kzU8}(=cCJ!E%$brc7G@AW}o+7^7N2_w})>E@W&JR534^n#{d8T diff --git a/Backend/src/auth/schemas/auth.py b/Backend/src/auth/schemas/auth.py index 144b6ea4..d5272439 100644 --- a/Backend/src/auth/schemas/auth.py +++ b/Backend/src/auth/schemas/auth.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, EmailStr, Field, validator +from pydantic import BaseModel, EmailStr, Field, validator, ConfigDict from typing import Optional class RegisterRequest(BaseModel): @@ -132,5 +132,4 @@ class UpdateProfileRequest(BaseModel): raise ValueError('Currency must be a 3-letter ISO 4217 code (e.g., USD, EUR, VND)') return v.upper() if v else v - class Config: - allow_population_by_field_name = True \ No newline at end of file + model_config = ConfigDict(populate_by_name=True) # Allows population by field name (alias) \ No newline at end of file diff --git a/Backend/src/main.py b/Backend/src/main.py index 0d767547..9598aa87 100644 --- a/Backend/src/main.py +++ b/Backend/src/main.py @@ -291,6 +291,10 @@ from .rooms.routes import room_routes, advanced_room_routes, rate_plan_routes from .bookings.routes import booking_routes, group_booking_routes from .bookings.routes.upsell_routes import router as upsell_routes from .payments.routes import payment_routes, invoice_routes, financial_routes, audit_trail_routes +from .payments.routes.approval_routes import router as financial_approval_routes +from .payments.routes.gl_routes import router as gl_routes +from .payments.routes.reconciliation_routes import router as reconciliation_routes +from .payments.routes.accountant_security_routes import router as accountant_security_routes from .hotel_services.routes import service_routes, service_booking_routes, inventory_routes, guest_request_routes, staff_shift_routes from .content.routes import ( banner_routes, page_content_routes, home_routes, about_routes, @@ -324,6 +328,10 @@ app.include_router(payment_routes.router, prefix=api_prefix) app.include_router(invoice_routes.router, prefix=api_prefix) app.include_router(financial_routes.router, prefix=api_prefix) app.include_router(audit_trail_routes.router, prefix=api_prefix) +app.include_router(financial_approval_routes, prefix=api_prefix) +app.include_router(gl_routes, prefix=api_prefix) +app.include_router(reconciliation_routes, prefix=api_prefix) +app.include_router(accountant_security_routes, prefix=api_prefix) app.include_router(banner_routes.router, prefix=api_prefix) app.include_router(favorite_routes.router, prefix=api_prefix) app.include_router(service_routes.router, prefix=api_prefix) diff --git a/Backend/src/models/__init__.py b/Backend/src/models/__init__.py index 66fdefae..06873e08 100644 --- a/Backend/src/models/__init__.py +++ b/Backend/src/models/__init__.py @@ -31,10 +31,17 @@ from ..bookings.models.group_booking import GroupBooking, GroupBookingMember, Gr # Payment models from ..payments.models.payment import Payment +from ..payments.models.invoice import Invoice, InvoiceItem +from ..payments.models.financial_audit_trail import FinancialAuditTrail, FinancialActionType +from ..payments.models.financial_approval import FinancialApproval, ApprovalStatus, ApprovalActionType +from ..payments.models.reconciliation_exception import ReconciliationException, ExceptionStatus, ExceptionType +from ..payments.models.accountant_session import AccountantSession, AccountantActivityLog +from ..payments.models.chart_of_accounts import ChartOfAccounts, AccountType, AccountCategory +from ..payments.models.fiscal_period import FiscalPeriod, PeriodStatus +from ..payments.models.journal_entry import JournalEntry, JournalLine, JournalEntryStatus # Guest management models from ..guest_management.models.guest_complaint import GuestComplaint, ComplaintUpdate, ComplaintStatus, ComplaintPriority, ComplaintCategory -from ..payments.models.invoice import Invoice, InvoiceItem # Hotel Services models from ..hotel_services.models.service import Service @@ -108,6 +115,13 @@ __all__ = [ 'Booking', 'CheckInCheckOut', 'GroupBooking', 'GroupBookingMember', 'GroupRoomBlock', 'GroupPayment', 'GroupBookingStatus', 'PaymentOption', # Payments 'Payment', 'Invoice', 'InvoiceItem', + 'FinancialAuditTrail', 'FinancialActionType', + 'FinancialApproval', 'ApprovalStatus', 'ApprovalActionType', + 'ReconciliationException', 'ExceptionStatus', 'ExceptionType', + 'AccountantSession', 'AccountantActivityLog', + 'ChartOfAccounts', 'AccountType', 'AccountCategory', + 'FiscalPeriod', 'PeriodStatus', + 'JournalEntry', 'JournalLine', 'JournalEntryStatus', # Hotel Services 'Service', 'ServiceUsage', 'ServiceBooking', 'ServiceBookingItem', 'ServicePayment', 'ServiceBookingStatus', 'ServicePaymentStatus', 'ServicePaymentMethod', 'HousekeepingTask', 'HousekeepingStatus', 'HousekeepingType', diff --git a/Backend/src/models/__pycache__/__init__.cpython-312.pyc b/Backend/src/models/__pycache__/__init__.cpython-312.pyc index 210ca95be0c9597cd8352f07874a9d008f590ee1..7555c912390b919ccddeb8337ae043c06fb4a0c0 100644 GIT binary patch delta 2385 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 diff --git a/Backend/src/payments/models/__pycache__/accountant_session.cpython-312.pyc b/Backend/src/payments/models/__pycache__/accountant_session.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4cacae50f791450a5b5839a8ae91fb270de54c9a GIT binary patch literal 3772 zcmcInO>7&-6<(67<&qRBQ6gpP$NI6;#!PLpcAeO1oHSM>JCbZAh8fph7+9}4BYLe} zQl4EpmYahLGO3(}` zAyZUDvq$MMB}FpBO4yVY*^DR=Gpa-b=o>O(X0OuA+oBOS<4W92C<*BI5XtB>`<4EH z5ERY}B>bg7WZM5YK~t~h`BlSQ{@u1x4XS1izbb~fPlGHPfxq+#9CSF%A@ zCJ@8M7!j0^kge_E(!f`o)*vx&nVytdooBs4+-E8%0odyx2`C{FRK%b_d#FT0_-{*m zEPCEb*wcfQNzdberzPOG75051=mY-`@L^G5dXc{3q>j`Y`7JtQgmm@N*sH`V>vyKI&!HEno3H(25}uX{*n# ztBe=nrPT~`-N;M2(f?tlu~tlP@7oo85VYf@pGHc7t)#!36QG?W17wh-NV>f)t{Wl; zNUELZQc#&%&18q&WT(_GdzF6wL;*{EQhV(VSo>DJJJ|77TQ|lkY=+#}3e!!Et*XmV z+C4fi>bO?5D|)%es7cHA3%P1hX61BpZW9W-Tr~{sra{>dZ2jK9Kx9L%R4waws+5gD z*E1Z0)@Fb!_OOul2r5Eun9wDyYSlvfM*Dxu?Fq z02LDhy{+GcyUIH{aU;6b*2N9Yvb_$VfIGfV7)eY)|$=ExB0uj}Xw+=wHE6WtX zMPE@hLLdW+#n2x0)>VSdcLxZ)s~4%d1js}g92V%p9QznRQ4yBK!WcM<0&#@JF@#jL zi{;Aa>@Do1Et{^WRhXq}--4ie5x|MK18sF@*(`$o{q6irYHI*wnj3{$z%j5h3@;w* zML!YqWJn)wqP2WgVR52Un<|r&roaGuB_xguGrK#2Ed9~ou7t6 zre%`1STVn%tzx}dc@E%K{`CXTt*o*Eh|2}eYs~=ceJSjUXTDEm*Dn4o()S>|QK+Z3 z3r=KyZTd$^=u1EN?Zex(k)83Ajp~ybCvj=*JgC#758v51{OG-!-JnlSI;l_BF774| zJe1biqiC(*Bu}i(Lw{Y|u$|;lXb(?(BW+lJiq(lT{9aQW;*r0)vVQjKYqgY<$gQ2< zjU~VO`TF43zo>~$?AY22cq9)z_`D|8tJ^b9|J>T#Zfa!xbnWW5>ZZC~Z5FONscUN& z!R_GTjZ^j6?OA7Rz9|mFR2y>rq%(Zln=Yy-3z0-KaiTug_}!CFcI4~3a=e+y)q5Mu zJM#R~p3so=`$cxPy{#`16P6Z!&w!)<55zH}<>nqnm8j|z3!IW=)Q>x^mBQH*rcRmaZhh-cDGR~`F+pJ~FMvkz#e^PFiB7f=+g zAlU|fC5G5JmSjG;oB#@?eCVaj7ORpBLkdd2K_xi>KP*`8U^a zrcEqZ1XMN5&i?9pubD>hYe;UMOwOq9T7`thS zLo>d=_Ir%EnXXIT_EwEb$ttiEteRy&oMXcv?+G8 zh73T#!>F4=@dk>Yp+FpQr(R(MKLw7!_=fXf`+ant*w@)xIRLq(wsygdGTmBM4SJUv z?$~|QB9G#iD2}7>S%vXM`?TUl+lUQhgA~ISQE)Oz`QZt* zicDX@S1QB8VsE23h2k`d3n)05EMSXMLDC=H=7J8%uy@f7w}#UJ`v6*QxRnW~1@;>> zeu#qO%QUuT+TtB(SRb_jeER!T?!N|bgy!%!Bh8~TJ5#gGPYccKH=Lx#p^pRmp+~>2 z_cRJlY8qgDbZX;`dfFL1)f6)vK=()aMCw05`aq^NdbTm^B&R*>4cF3*^fqyl3p|D4 ziFLb1oXoG9VtOCMeifs46C=&hT;q1*x|5g(g7P5yc>Udmu_G^Vz|Pml8ecrQz9TEV zd$d+*e6b^6cp4Tv_>Eh;m>uL512u8S(2U|OYOZz_4zCXWPT(iaO9)TeJ1<@_iO#@W zg_+*r;;Vwe??b^IvTkXNl3cM;UeZfBd>`C|A2h5VJvck_6aEmp{;9u^&v-AW57-qj zz@ZBk7TwcOAQ1R57z~L2l!Sm5_=|AkJK@F;!kd2;j{G3J^<2sZq;>H*{A`k*Uk$to Motb|K@P|kKH@iskz5oCK literal 0 HcmV?d00001 diff --git a/Backend/src/payments/models/__pycache__/chart_of_accounts.cpython-312.pyc b/Backend/src/payments/models/__pycache__/chart_of_accounts.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8badf297dfa4afb87ddf285ebf7fcf55d5950e41 GIT binary patch literal 3061 zcma)8%WoUU8Q&$B0oG~(a#*BnN2FrD-K@K z3`~Uuvu)7C4U1r{Y2L;iDy-mjhej4ngx4!PO%bdsj%Jzmn%084h#=H~(E1UZB3Ohu z#m1^XT_5?N^6D}a9&wnHc;F(0cqxoRl7PZe1PM|UMWh&tN+OC$@s3!D(`2x=l}-z9 zTxDpp6)S@kYi8M5!)0}_6@!KWLbyiNv@`JXb2Q4vKMwTkeBnL|&c?JarMFu4X;{Z>& zSAg7)pVrna1J|17dTk!t_Z_QMyJb08pPjR;`>^q~IYqsXO;odqT5BmC0|SvYk4U@b zElai<@^D$L4jE%ZFi67RJ_qnMcObmiPZivS{@5vZv7auvH~N_g_offY+*kAD^dRcZ zw0sE7{~s+#loBW|B~e03p(I>`l$7bDD;b&%sJafPp=puM#<`%R0cY$?TBNC%+6F9o z$X}Ia)HWfoX4#|5rm2Ruj?ti27_<&pguZA=x6G#OU}DHWF?%G?9KnvFna~4PhzUd4 zG-tKoDmYM<2gIi>I5cEf&5t~S6JdKC(v0snFxD$sGQsw7lED;%X$EBmZ!B3iazjxO03D!rV2-os;)i9ZX$6yT8(L3QU8y7soi6rrc^gwg3Da}eHOp`6584Bsh_aO;HG$#Kvt+X9WGgX3_ zQXMhTy8*7zwy8Y;Nfphut}ClLCbQ6T^iO~aPu^j#koWTJ8^zbgt7_U>f z8bi3Dv~|Z14wLs-qiC7!7E3pt6~tpr(?KkPYyeS7m>0;aY-U&EP%wAyO$e2VkJ*an1~Sg zB;sfiVOu3yi=}NMGE1(d_`2(Fo(yAr%-oqc5cUBBoI0v$eMu8QHY^7A*`W!4`&pT= z{6jM*yP@fO{#JuDo7g5ErI}!1vO=0RB+h>mzAJ1*1{L_*8PFCce6@b@l6&{B@$6>o z$;$KM-bydNeP#7oX(6y|TTsyYPLnSE##p4hmCXD|~zPd8Ai(->;wF`py3I z)#rHc-Cp4qm?mZ(qbDElpSiY+zCYiaSndi%=&UfcRooVO`C0e&!Q`2(FP|p5*B19L zFLi(MtKOsp>UuCX{>{U!)!%MxzrVZC8=G?%Au2z)C2n8-uJ*LHx6sQixl0GRiJ|4z zAC`Z=-2FoB<&e7s&f{mhXRqy@-79vNh z?~At%#B?`P?fz_T4{D$O5DAY*2P*DuP|-^`G+{r`6<3Q7cTjNyK)?j@%hbj ro6e)oH=Sb+D#yhfe{oaYdiQr9JpSOAgUa#c89us+{?5V6OKSfDZ1z0_ literal 0 HcmV?d00001 diff --git a/Backend/src/payments/models/__pycache__/financial_approval.cpython-312.pyc b/Backend/src/payments/models/__pycache__/financial_approval.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..721646904af9f7168779f96b280e350ca4d3a6d0 GIT binary patch literal 4137 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 Zubf%=g#pO(S delta 267 zcmZpaIxNX|nwOW00SLBl(#dS$-pKcZkkOcDU+Wv z=P)`>4q|a+y~rY3q`Y}Ciy?W0CgcJM4~(+LPrtLLPgpjLUXb^$87;Q z5K|sRD1ZpX$wr*ojFyw*IIS3MC(q#0TF62LV9x+ z*F;8Munb60Qxs?r2T!LwSOu!CUmP~M`6;D2sdhynlQ-}fDDp7s%ut?DGQVuM9v6Y#9LJ%sPqy diff --git a/Backend/src/payments/models/__pycache__/fiscal_period.cpython-312.pyc b/Backend/src/payments/models/__pycache__/fiscal_period.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f1363ced3819c4a71870c41e7fc37533a626d980 GIT binary patch literal 2356 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 X`)8gD@bg^tyaJuW{}AAZ!|T5Q!?37W literal 0 HcmV?d00001 diff --git a/Backend/src/payments/models/__pycache__/reconciliation_exception.cpython-312.pyc b/Backend/src/payments/models/__pycache__/reconciliation_exception.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4b7440f5e9cbc22df560210ea9999cb0908981ce GIT binary patch literal 3834 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 TtE|BvX|z2wKuG*MSZV(cUP3W| literal 0 HcmV?d00001 diff --git a/Backend/src/payments/routes/__pycache__/approval_routes.cpython-312.pyc b/Backend/src/payments/routes/__pycache__/approval_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ea69c237ba9899f671f77666f89ab7603c26a83 GIT binary patch literal 20472 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 literal 0 HcmV?d00001 diff --git a/Backend/src/payments/routes/__pycache__/audit_trail_routes.cpython-312.pyc b/Backend/src/payments/routes/__pycache__/audit_trail_routes.cpython-312.pyc index 685956e7a5eb21d60533302522e5e7bedbf0a85c..ca714718070185fa9fd4edebdb99fb26b0a539ea 100644 GIT binary patch literal 22792 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 HX6pY1T?7YZ delta 3459 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 X;9Q3zB=k2xSe@gZ93W;BjOhOlN*-b> delta 7180 zcmbVQYj7Lab>79|{U!hcB=`bJ5d_4CsF!U@)LWKnQKBVL@&kzog4iVq3J>aHDT?$m zOj0G4Yb7y#lh|#PiS4FsT(k9fIBC*m(n>Q0qWI){tq8 zHD;P(O`x=k&UAC8CDxJ&#X_utE8UuDi?wCiW9>|Kr#ECeVjVoE4@RIC$Z zUaS#46a2FGRb5FNks?#W+W0H~qVH9njtU(X9n=blwW6OM*VR7i)mj_Zivh_YZLEhX zVqKZ5m+Hl!WEwY%4HpfsmYR!o-NQ#C?bS6I%B??KX@^#$5%{Gu*qrSoSVUvZeeYt$(MeQC3gKH4`!uXZ)_ zyXopCKOOQf>~tw6#W@cLX~C_yMa#SaxEjT+)G(b#aV>ZikK$#LPu^GB2%MsyIzVf> zf_lYJWOSmHP6h@S-P*nqZC4EPrm}sFrmxa@G@WR_qN~!oG<}t>JU61_4qMpWMIS}m zMHFBl`m+Z)j+hmn;-0s`#&+Vsvb?C*%thA~o#?(|ST1*Lm}}=`s8uW*II?@8Rz9T3 zQ;%p|lbW zQypJVf7KLTG!tojCM!xJX+$$iDxVwAkxW8XEe~HvO4D*GmsO4F+{AM!*Zc(MUW2i7-$XCkYDloeG6k_qYql71vRkPIN%4MgRoydVB)Yv{A}=Ya9SFF&Z~xPRw9{47^+ zXL!TFFX-z}WLF}s{ko8k^VU8eS&CfQ_N|Bh;Gu6H{?nnChHf@&z3JKZ6VJ|7&rW(_ zZwEcw>ObVQ{?^XfYMu^16S?g$dV>W!7YG&{oW))6aDfdkrdIu%F4+oJF4*y+yjtIN z$@$Yj+lwQsfvuP91tZtoal;%e7->)E_k|BX*g}60*+pNQHHJ@h@fU|1hg-RuAs+af zt(H>};pWEvQ|-dL?FQiAjR>c@s2JU<8?dkwUWhIUbgBQDKrf7JO@WgTp|5Y7p!+v( zvso33Vx0$z1smAELPP!2ynSVfxCwl#jKRR;kdQ$qU9_^SJ4Vk0cf^t*Yxxx@d|Ev2mPPTO^XhBPq~#c`Y{Y} zF1xgI4H!_yPSL$y4FIB2BnJ%eEVw`8UV{PN`PRcyRw4;m3hhs%6WOE`8lIHsmv%ZE zJD~?8f&>v)HHp%6E}xRg7LaIWpvR-S6UiiEK|T)0KQWe;=(h$sy?DyxL@FzZaVbHv zsq93aULQEj6YAZyk$;%(-qpEy7@I}(BS(M?MO%pjg=0vDkUWayI1+>(asmm$4>^eh zQxXO|

sv63J;Kk0C+4BqEYJAgXPwIIy?~XfTTE`ZGuq_|clm=K&~0m;lwTftWlu zEiqIg5*m)6p^zBMlW`O$kW39Lz`EU!Z+1uS1u>RJn0gcT77-L1Cv2hk(!Aa-BAv(9Wd)p@&aZQo$bgedS z|9-<&d9{CVwejc;bN#JAg#Pm2AM+dOJBNzMbFz`Yc*1eQ!>!ivz^{5NC+mgPhW?X2 z;T@j=_;>1sla2JNhr3&hMf^|VjA{jQ?%##q`?3*W=}GD`4eeu)xo%m=1DhZX?Diwka@KTS%?kr0LLtuJ#AJAvG{A*g|onoVWzfVtsMMgK(f%s{L+9q%oW_C5_igrCd z=bf%7-{YgVYRwzb8JKrh`lNApR+#F>zJ-}Nz&U_l8FhK?@&|fO(E+3ZPLL&e6aDx| zBi%b_5uEbY=RA#digSV!%`fxIRt<<2T#9SzLF}-R=YZ{}e784H)ufDpmlefHr-|>m zj(NN*Cv@K|FkGv=*e85PJjy8CpUS9PSa z=X0r~6lbvn^m}RibOQv6r%rEJ6DXpc_l^^Y^;Lmk6h*v$GMp!4$bAY4c9TImc^)`O zXQ!tjXCzCgW^hlD`zn&JaRH!TM>Ab2r<#Xl0@3V|N68Cx<*~@32|t(th0iC_Gm>h~ z%VbU|kplekzkziwCbXVMczSJ5_lo+& zmw5j7>11q|@Jnw4O?+yZufr^J@d2>^Jb&~d{^F5_!CvmI7UbUw?Q?)|y$6Nsy_Tcf zh3gOYAMF#~?lS=Y_I3gE^MOsJz}rKlWG@xGQRsaR;#iSRVt_g>Z0?`-&y0o8XeVGmzl}B3l640k9U$hA zfYA$<3Q~xsi=1LT$Z3xp8b&SHh{ztUYNyeGf>;2x zo$H{sf4;Mf+NDzj$W85&_DBiAdn?%eYfMA{kM&|=E>9Q=>j7=e(w7@B$7Q+oD>_xz z+o$r=d2}2Kk!iXj?b4-rdRy9JLC7TE1w#9z(GdA76#p8@4}g?wpyF?U&}-7BjWcjQ z$={;tDw4lM@;xA`D+9SV;Ay-BU_3eQN_i+qwb3iT3Qa!0rg; z>w#)b5(!d!F%FLp4E-6x`_Y`;98MtjXGqut;PxVb`5buy30x^givj}-@s*e*@Y@^nx74fg?_YdOh(blZb}_>3@= z**%_wiU9B~{^CLZpn-cM=sU90_{JtZ(yjZfp!(4+-ymoFu}6>8zpn*UfA_#X7yP|x z+_w$>-r9ln-`a`B*A14VcHz3S|ENiL+hhR#ZM$&PwbGFNP;V(`8@SDyKY5$cKg>sU z-{I+x@}YxQaUyULhoUC(CJMN2#dX3JRc*ybr{aCjDU(DtpGab?iKj%>kj=@Ep>)bo zBj%v60UwjYYd2tM?LK4si0vp5D?z$meUHW6p_+3 ziE6DVi??_yXbHTWLWTlYY&iM^offBz9pM*H{3A5*=O+_H5_=&Z7*9>~V(K%N$V0v()KE>Gde76hK> z-{TJb6L;w6T-$qGc$EwPoNIWGi~KWZrAOwQ>B9Vx{W8xF{M23df_KSL5Ky?~^go+_ zI#V#901t)UK*7XVGdwNcwjley!08$bI;Kqv)bdpK{;}$2P`Kss7YxWi6Hw2DEY@o- z=$Q6*fH(hM@N>aS$8Pe$UxYg7+*93g+llISP$+i?1!&7r`|R-3#|kDUnz45l##+$} ztBzh83Oc48*Ok27uy@?#!}O*3@ctv!E>I}DK%q2Z#ui76EVkKM&@t^3I{LJd*8eBW G^Zx<|3K~}c diff --git a/Backend/src/payments/routes/__pycache__/gl_routes.cpython-312.pyc b/Backend/src/payments/routes/__pycache__/gl_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fd94796f514432747466843500992185c5cd9bb8 GIT binary patch literal 12176 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 F{}14#DDnUR literal 0 HcmV?d00001 diff --git a/Backend/src/payments/routes/__pycache__/invoice_routes.cpython-312.pyc b/Backend/src/payments/routes/__pycache__/invoice_routes.cpython-312.pyc index acd4bb4308dc96002d6936462c59e459a526f678..06922522a1aa87a4ac5f4129340240b408f5eea1 100644 GIT binary patch delta 6711 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 delta 4009 zcmbVOYit|G5x(W|{gh-rOevD62PsjqB+Ig_haK6}%a&8gbsamci-x6iXNh`{%JO6- zDP6ga{}m}>Y?B{Fk@!ynCz8=5L{T6>4O_7t1ZhwLH4x;G{OG^50ScmS0i!?K&g}7| zvmvLC18#P9cV>2G=iAx6^D24iC&c@S$Kw>>`}MWG=?7g`yt~PbD=nX`Ek(~pmttpQ zL@)`mPi|Q!C?p7HTcJ;+Drwg&+3>HM%A;{PK7U%{jZ_NgdZ1;If)Dqk(8g}v0-p~{ z;f-oOQVCZ0P>mFpp4g~e7umPXO((5VootorWvAR81_>zw+#b#~$n8=i{7tf7YL@Ni zyi)YCWxedn*^bA!7h_ZAmW39Bo>gi!=yif#(io7&O9AlB1b>wjm)fNEbx|AF!-K8a zV+hP3$!?e%CNYK%xp~W1yVPk|b1$qpv%{J(-MV4z_H`4xX|6ws7BPwrIl5)lDRmjF z?t4rmdmnH0gl@H4jv1`(Gg$2btLgvF*L8xP_Mw+(SI?7O>M_{s1$#TeXg}tuzW$*+ z^&0dJV03KI9C2 zg>s=z96+DxB$5drISY^oe)Kqd&lj?sfPQ=7Q{NzQxqy8M{`*(Wtit0j#44|t9R9pH zFXm12ZkFiZ#s0ACM4f`p@lSH+ooOO@HoQ8dPYLXmKwwwDF3~K|sSB>WIDe28+iDvQ z>ErY0-y@&oQxfcIFeFxNRI-X-K&*m(YjA-4Kf1fPZj3z$dg=mG^Z=}-I>xhfIxR_Z zhVo7BhC#ZIHHF?L&$3TLwN{?cE>;SSnCK*H3pbMib|M@bd79$(|LGMdbQD8)TZlJO ztSuCGQ^UcN$OVxMA>sRf1i5FBe3xAdx3e$9qo*b8%1F*3!91$YGpWTDd5qF5-6@?s z&v7^9pn4j$wiC-;KFV%X_Z*+ane9Zoq019pCiFNi<(;8&N|BT4%=v73My3i3qU*UD zsMlW!4=xHNb7$dDO@@U3<`*g=SJUr?iqV1MNa~(%x@ez%@P%tP`zrSGDFneKGT|kc z$1BH!!rcJ!cY{?EF7a+_!f~`){HWUvosXVOI3}#($5sn;K6Z&9HG{jRe|BIv=x&+{ z3w1VP^X3Ucjvej~u%Fd?f=WOam27!Cz|FqlfZBF=pxxrgo9Dv-tQPj#fM0Yf_3V!g z;jl{wBM}sYH2)+w%>K49=2s%+5rJd#e=*_VCVITSDaxVqEwDs;*gqRXEvmD;p;#Aq zmGG=AOR&*Wa{2`v(~`r!ZfYcR%-dYo&JXlFT0lh61t9TC%2DLU0RxKIcbdCK`GH@? znH>(ih7hNQ5DvXZV8UTB%PONyJ-iAy!O%81I5g;sXdFov{@f6Ny^M!SmKYvlZ$?jt zzVOupAnJ?JMDeNgJ>PuMKCc5}Py@tR9l1Q*FdP#$qXhWPSk+jKxY?g@j1uuSaYN^} zC*c?iig$t*=-jCh$LiRC*`Fzd-ZB{T^NVw``F zOA`?;35wS+GIhZY0I9^vu>aV;N>+vE#qSBLkKlm1BrQj#s#E#+}1}y9;O-%hOTq;^AKM)$%yQ!RG*O3w#>(F5)DT%kby!11V$A z%MNxNdkA;S_k7P4?a!5Q*U08N7s}WhwUWyTN5U)I@(|!}d8%T&9h+dxuv zD_JGAm@KCUp9HE;7nbBj8R`J9%&IN7GBYFRa&f1g(HSM3WuNp^!o%Uqo;aChw*6B(ayg!A>Ts*RJAv=(lQ-ms06P%5%!|V@_wzWgiV< z4CVQI&to`&+d=T6|2o=poQ5asWES#3-$11w;hp{p$=0h;2#q}Ki* z#oLk~q)curU`rN*u+4UrU=coTuFeYIDAHH&&~68OCRP?KTt9O zS88SPiQ@x4P_vZ;Xx=@=o8qx&wKhX}3)*SZGOP|Fs2wSpfGaf?u21-hc@v`eH|aM- AT>t<8 diff --git a/Backend/src/payments/routes/__pycache__/payment_routes.cpython-312.pyc b/Backend/src/payments/routes/__pycache__/payment_routes.cpython-312.pyc index 039143d3d2f75c906cb0ed209d808611e48cd88a..2010991c75326fd4976db827587d44b132c99abe 100644 GIT binary patch delta 18957 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 literal 0 HcmV?d00001 diff --git a/Backend/src/payments/routes/accountant_security_routes.py b/Backend/src/payments/routes/accountant_security_routes.py new file mode 100644 index 00000000..8726d7b7 --- /dev/null +++ b/Backend/src/payments/routes/accountant_security_routes.py @@ -0,0 +1,262 @@ +""" +Routes for accountant security: step-up auth, session management, activity logs. +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.orm import Session +from typing import Optional +from datetime import datetime +from ...shared.config.database import get_db +from ...shared.config.logging_config import get_logger +from ...security.middleware.auth import authorize_roles, get_current_user +from ...auth.models.user import User +from ..services.accountant_security_service import accountant_security_service +from ...shared.utils.response_helpers import success_response +from ...auth.services.mfa_service import mfa_service + +logger = get_logger(__name__) +router = APIRouter(prefix='/accountant/security', tags=['accountant-security']) + + +@router.post('/step-up/verify') +async def verify_step_up( + request: Request, + step_up_data: dict, + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Verify step-up authentication (MFA token or password re-entry).""" + try: + mfa_token = step_up_data.get('mfa_token') + password = step_up_data.get('password') + session_token = step_up_data.get('session_token') + + if not session_token: + # Try to get from header or cookie + session_token = request.headers.get('X-Session-Token') or request.cookies.get('session_token') + + if not session_token: + raise HTTPException(status_code=400, detail='Session token is required') + + # Verify MFA if token provided + if mfa_token: + try: + is_valid = mfa_service.verify_mfa(db, current_user.id, mfa_token) + if not is_valid: + raise HTTPException(status_code=401, detail='Invalid MFA token') + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Or verify password if provided + elif password: + import bcrypt + if not bcrypt.checkpw(password.encode('utf-8'), current_user.password.encode('utf-8')): + raise HTTPException(status_code=401, detail='Invalid password') + else: + raise HTTPException(status_code=400, detail='Either mfa_token or password is required') + + # Complete step-up authentication + success = accountant_security_service.complete_step_up( + db=db, + session_token=session_token, + user_id=current_user.id + ) + + if not success: + raise HTTPException(status_code=400, detail='Failed to complete step-up authentication') + + # Log step-up activity + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + + accountant_security_service.log_activity( + db=db, + user_id=current_user.id, + activity_type='step_up_authentication', + activity_description='Step-up authentication completed', + ip_address=client_ip, + user_agent=user_agent, + risk_level='low', + metadata={'method': 'mfa' if mfa_token else 'password'} + ) + + db.commit() + + return success_response( + data={'step_up_completed': True}, + message='Step-up authentication completed successfully' + ) + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f'Error verifying step-up: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/sessions') +async def get_active_sessions( + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Get active sessions for current user.""" + try: + from ..models.accountant_session import AccountantSession + + sessions = db.query(AccountantSession).filter( + AccountantSession.user_id == current_user.id, + AccountantSession.is_active == True + ).order_by(AccountantSession.last_activity.desc()).all() + + session_list = [] + for session in sessions: + session_list.append({ + 'id': session.id, + 'ip_address': session.ip_address, + 'user_agent': session.user_agent, + 'country': session.country, + 'city': session.city, + 'last_activity': session.last_activity.isoformat() if session.last_activity else None, + 'step_up_authenticated': session.step_up_authenticated, + 'step_up_expires_at': session.step_up_expires_at.isoformat() if session.step_up_expires_at else None, + 'created_at': session.created_at.isoformat() if session.created_at else None, + 'expires_at': session.expires_at.isoformat() if session.expires_at else None + }) + + return success_response(data={'sessions': session_list}) + except Exception as e: + logger.error(f'Error fetching sessions: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post('/sessions/{session_id}/revoke') +async def revoke_session( + session_id: int, + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Revoke a specific session.""" + try: + from ..models.accountant_session import AccountantSession + + session = db.query(AccountantSession).filter( + AccountantSession.id == session_id, + AccountantSession.user_id == current_user.id + ).first() + + if not session: + raise HTTPException(status_code=404, detail='Session not found') + + session.is_active = False + db.commit() + + return success_response(message='Session revoked successfully') + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f'Error revoking session: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post('/sessions/revoke-all') +async def revoke_all_sessions( + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Revoke all active sessions for current user.""" + try: + count = accountant_security_service.revoke_all_user_sessions(db, current_user.id) + db.commit() + + return success_response( + data={'revoked_count': count}, + message=f'Successfully revoked {count} active session(s)' + ) + except Exception as e: + db.rollback() + logger.error(f'Error revoking all sessions: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/activity-logs') +async def get_activity_logs( + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), + risk_level: Optional[str] = Query(None), + is_unusual: Optional[bool] = Query(None), + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Get activity logs for current user or all users (admin only).""" + try: + from ..models.accountant_activity_log import AccountantActivityLog + from ...shared.utils.role_helpers import is_admin + + query = db.query(AccountantActivityLog) + + # Non-admins can only see their own logs + if not is_admin(current_user, db): + query = query.filter(AccountantActivityLog.user_id == current_user.id) + + if risk_level: + query = query.filter(AccountantActivityLog.risk_level == risk_level) + if is_unusual is not None: + query = query.filter(AccountantActivityLog.is_unusual == is_unusual) + + total = query.count() + offset = (page - 1) * limit + logs = query.order_by(AccountantActivityLog.created_at.desc()).offset(offset).limit(limit).all() + + log_list = [] + for log in logs: + log_list.append({ + 'id': log.id, + 'user_id': log.user_id, + 'activity_type': log.activity_type, + 'activity_description': log.activity_description, + 'ip_address': log.ip_address, + 'country': log.country, + 'city': log.city, + 'risk_level': log.risk_level, + 'is_unusual': log.is_unusual, + 'metadata': log.activity_metadata, + 'created_at': log.created_at.isoformat() if log.created_at else None + }) + + return success_response(data={ + 'logs': log_list, + 'pagination': { + 'total': total, + 'page': page, + 'limit': limit, + 'total_pages': (total + limit - 1) // limit + } + }) + except Exception as e: + logger.error(f'Error fetching activity logs: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/mfa-status') +async def get_mfa_status( + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Get MFA status and enforcement requirements.""" + try: + requires_mfa = accountant_security_service.requires_mfa(current_user, db) + is_enforced, reason = accountant_security_service.is_mfa_enforced(current_user, db) + + mfa_status = mfa_service.get_mfa_status(db, current_user.id) + + return success_response(data={ + 'requires_mfa': requires_mfa, + 'mfa_enabled': mfa_status['mfa_enabled'], + 'is_enforced': is_enforced, + 'enforcement_reason': reason, + 'backup_codes_count': mfa_status['backup_codes_count'] + }) + except Exception as e: + logger.error(f'Error getting MFA status: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/Backend/src/payments/routes/approval_routes.py b/Backend/src/payments/routes/approval_routes.py new file mode 100644 index 00000000..62740122 --- /dev/null +++ b/Backend/src/payments/routes/approval_routes.py @@ -0,0 +1,433 @@ +""" +Routes for managing financial approval requests. +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.orm import Session +from typing import Optional +from ...shared.config.database import get_db +from ...shared.config.logging_config import get_logger +from ...security.middleware.auth import authorize_roles, get_current_user +from ...auth.models.user import User +from ..models.financial_approval import FinancialApproval, ApprovalStatus, ApprovalActionType +from ..services.approval_service import approval_service +from ...shared.utils.response_helpers import success_response +from ..services.financial_audit_service import financial_audit_service +from ..models.financial_audit_trail import FinancialActionType + +logger = get_logger(__name__) +router = APIRouter(prefix='/financial/approvals', tags=['financial-approvals']) + + +@router.get('') +async def get_approvals( + status: Optional[str] = Query(None), + action_type: Optional[str] = Query(None), + requested_by: Optional[int] = Query(None), + approved_by: Optional[int] = Query(None), + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Get approval requests with optional filters.""" + try: + approval_status = None + if status: + try: + approval_status = ApprovalStatus(status) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + approval_action_type = None + if action_type: + try: + approval_action_type = ApprovalActionType(action_type) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid action type: {action_type}") + + # Non-admins and non-approvers can only see their own requests + from ...shared.utils.role_helpers import is_accountant_approver, is_admin, is_accountant + if not (is_admin(current_user, db) or is_accountant_approver(current_user, db)): + # Regular accountants can only see their own requests + if is_accountant(current_user, db): + requested_by = current_user.id + else: + raise HTTPException(status_code=403, detail='Forbidden: Insufficient permissions') + + approvals = approval_service.get_approvals( + db=db, + status=approval_status, + action_type=approval_action_type, + requested_by=requested_by, + approved_by=approved_by, + page=page, + limit=limit + ) + + approval_list = [] + for approval in approvals: + approval_list.append({ + 'id': approval.id, + 'action_type': approval.action_type.value, + 'action_description': approval.action_description, + 'status': approval.status.value, + 'amount': float(approval.amount) if approval.amount else None, + 'payment_id': approval.payment_id, + 'invoice_id': approval.invoice_id, + 'booking_id': approval.booking_id, + 'requested_by': approval.requested_by, + 'requested_by_email': approval.requested_by_email, + 'approved_by': approval.approved_by, + 'approved_by_email': approval.approved_by_email, + 'request_reason': approval.request_reason, + 'response_notes': approval.approval_notes or approval.rejection_reason, + 'previous_value': approval.previous_value, + 'new_value': approval.new_value, + 'requested_at': approval.created_at.isoformat() if approval.created_at else None, + 'responded_at': (approval.approved_at or approval.rejected_at).isoformat() if (approval.approved_at or approval.rejected_at) else None, + 'metadata': approval.approval_metadata + }) + + return success_response(data=approval_list) + except HTTPException: + raise + except Exception as e: + logger.error(f'Error fetching approvals: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/pending') +async def get_pending_approvals( + action_type: Optional[str] = Query(None), + current_user: User = Depends(authorize_roles('admin', 'accountant_approver')), + db: Session = Depends(get_db) +): + """Get pending approval requests.""" + try: + approval_action_type = None + if action_type: + try: + approval_action_type = ApprovalActionType(action_type) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid action type: {action_type}") + + approvals = approval_service.get_pending_approvals(db, approval_action_type) + + approval_list = [] + for approval in approvals: + approval_list.append({ + 'id': approval.id, + 'action_type': approval.action_type.value, + 'action_description': approval.action_description, + 'status': approval.status.value, + 'amount': float(approval.amount) if approval.amount else None, + 'payment_id': approval.payment_id, + 'invoice_id': approval.invoice_id, + 'booking_id': approval.booking_id, + 'requested_by': approval.requested_by, + 'requested_by_email': approval.requested_by_email, + 'request_reason': approval.request_reason, + 'previous_value': approval.previous_value, + 'new_value': approval.new_value, + 'created_at': approval.created_at.isoformat() if approval.created_at else None, + 'metadata': approval.approval_metadata + }) + + return success_response(data={'approvals': approval_list}) + except HTTPException: + raise + except Exception as e: + logger.error(f'Error fetching pending approvals: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/{approval_id}') +async def get_approval( + approval_id: int, + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Get a specific approval request.""" + try: + approval = approval_service.get_approval_by_id(db, approval_id) + if not approval: + raise HTTPException(status_code=404, detail='Approval request not found') + + # Users can only view their own requests unless they're approvers/admins + from ...shared.utils.role_helpers import is_accountant_approver, is_admin + if not (is_admin(current_user, db) or is_accountant_approver(current_user, db)): + if approval.requested_by != current_user.id: + raise HTTPException(status_code=403, detail='Forbidden: You can only view your own approval requests') + + return success_response(data={ + 'approval': { + 'id': approval.id, + 'action_type': approval.action_type.value, + 'action_description': approval.action_description, + 'status': approval.status.value, + 'amount': float(approval.amount) if approval.amount else None, + 'payment_id': approval.payment_id, + 'invoice_id': approval.invoice_id, + 'booking_id': approval.booking_id, + 'requested_by': approval.requested_by, + 'requested_by_email': approval.requested_by_email, + 'request_reason': approval.request_reason, + 'previous_value': approval.previous_value, + 'new_value': approval.new_value, + 'approved_by': approval.approved_by, + 'approved_by_email': approval.approved_by_email, + 'approval_notes': approval.approval_notes, + 'approved_at': approval.approved_at.isoformat() if approval.approved_at else None, + 'rejected_by': approval.rejected_by, + 'rejection_reason': approval.rejection_reason, + 'rejected_at': approval.rejected_at.isoformat() if approval.rejected_at else None, + 'created_at': approval.created_at.isoformat() if approval.created_at else None, + 'updated_at': approval.updated_at.isoformat() if approval.updated_at else None, + 'metadata': approval.approval_metadata + } + }) + except HTTPException: + raise + except Exception as e: + logger.error(f'Error fetching approval: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post('/{approval_id}/approve') +async def approve_request( + request: Request, + approval_id: int, + approval_data: dict, + current_user: User = Depends(authorize_roles('admin', 'accountant_approver')), + db: Session = Depends(get_db) +): + """Approve an approval request.""" + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + + try: + approval_notes = approval_data.get('notes', '') + + approval = approval_service.approve_request( + db=db, + approval_id=approval_id, + approved_by=current_user.id, + approval_notes=approval_notes + ) + + db.commit() + + # Log to financial audit trail + try: + financial_audit_service.log_financial_action( + db=db, + action_type=FinancialActionType.settings_changed, # Using closest match + performed_by=current_user.id, + action_description=f'Approval request {approval_id} approved for {approval.action_type.value}', + payment_id=approval.payment_id, + invoice_id=approval.invoice_id, + booking_id=approval.booking_id, + amount=float(approval.amount) if approval.amount else None, + metadata={ + 'approval_id': approval_id, + 'action_type': approval.action_type.value, + 'approval_notes': approval_notes, + 'ip_address': client_ip, + 'user_agent': user_agent, + 'request_id': request_id + }, + notes=f'Approval granted by {current_user.email}' + ) + except Exception as e: + logger.warning(f'Failed to log financial audit for approval: {e}') + + return success_response( + data={'approval': { + 'id': approval.id, + 'status': approval.status.value, + 'approved_by': approval.approved_by, + 'approved_at': approval.approved_at.isoformat() if approval.approved_at else None + }}, + message='Approval request approved successfully' + ) + except ValueError as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f'Error approving request: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post('/{approval_id}/reject') +async def reject_request( + request: Request, + approval_id: int, + rejection_data: dict, + current_user: User = Depends(authorize_roles('admin', 'accountant_approver')), + db: Session = Depends(get_db) +): + """Reject an approval request.""" + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + + try: + rejection_reason = rejection_data.get('reason', '') + if not rejection_reason: + raise HTTPException(status_code=400, detail='Rejection reason is required') + + approval = approval_service.reject_request( + db=db, + approval_id=approval_id, + rejected_by=current_user.id, + rejection_reason=rejection_reason + ) + + db.commit() + + # Log to financial audit trail + try: + financial_audit_service.log_financial_action( + db=db, + action_type=FinancialActionType.settings_changed, + performed_by=current_user.id, + action_description=f'Approval request {approval_id} rejected for {approval.action_type.value}', + payment_id=approval.payment_id, + invoice_id=approval.invoice_id, + booking_id=approval.booking_id, + amount=float(approval.amount) if approval.amount else None, + metadata={ + 'approval_id': approval_id, + 'action_type': approval.action_type.value, + 'rejection_reason': rejection_reason, + 'ip_address': client_ip, + 'user_agent': user_agent, + 'request_id': request_id + }, + notes=f'Approval rejected by {current_user.email}: {rejection_reason}' + ) + except Exception as e: + logger.warning(f'Failed to log financial audit for rejection: {e}') + + return success_response( + data={'approval': { + 'id': approval.id, + 'status': approval.status.value, + 'rejected_by': approval.rejected_by, + 'rejected_at': approval.rejected_at.isoformat() if approval.rejected_at else None + }}, + message='Approval request rejected successfully' + ) + except ValueError as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f'Error rejecting request: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post('/{approval_id}/respond') +async def respond_to_approval( + request: Request, + approval_id: int, + response_data: dict, + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Respond to an approval request (approve or reject).""" + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + + try: + status = response_data.get('status') + response_notes = response_data.get('response_notes', '') + + if status not in ['approved', 'rejected']: + raise HTTPException(status_code=400, detail='Status must be either "approved" or "rejected"') + + if status == 'approved': + approval = approval_service.approve_request( + db=db, + approval_id=approval_id, + approved_by=current_user.id, + approval_notes=response_notes + ) + else: + if not response_notes: + raise HTTPException(status_code=400, detail='Rejection reason is required') + approval = approval_service.reject_request( + db=db, + approval_id=approval_id, + rejected_by=current_user.id, + rejection_reason=response_notes + ) + + db.commit() + + # Log to financial audit trail + try: + financial_audit_service.log_financial_action( + db=db, + action_type=FinancialActionType.settings_changed, + performed_by=current_user.id, + action_description=f'Approval request {approval_id} {status} for {approval.action_type.value}', + payment_id=approval.payment_id, + invoice_id=approval.invoice_id, + booking_id=approval.booking_id, + amount=float(approval.amount) if approval.amount else None, + metadata={ + 'approval_id': approval_id, + 'action_type': approval.action_type.value, + 'status': status, + 'response_notes': response_notes, + 'ip_address': client_ip, + 'user_agent': user_agent, + 'request_id': request_id + }, + notes=f'Approval {status} by {current_user.email}: {response_notes}' + ) + except Exception as e: + logger.warning(f'Failed to log financial audit for approval response: {e}') + + return success_response( + data={ + 'id': approval.id, + 'action_type': approval.action_type.value, + 'action_description': approval.action_description, + 'status': approval.status.value, + 'amount': float(approval.amount) if approval.amount else None, + 'payment_id': approval.payment_id, + 'invoice_id': approval.invoice_id, + 'booking_id': approval.booking_id, + 'requested_by': approval.requested_by, + 'requested_by_email': approval.requested_by_email, + 'approved_by': approval.approved_by, + 'approved_by_email': approval.approved_by_email, + 'request_reason': approval.request_reason, + 'response_notes': approval.approval_notes or approval.rejection_reason, + 'previous_value': approval.previous_value, + 'new_value': approval.new_value, + 'requested_at': approval.created_at.isoformat() if approval.created_at else None, + 'responded_at': (approval.approved_at or approval.rejected_at).isoformat() if (approval.approved_at or approval.rejected_at) else None, + 'metadata': approval.approval_metadata + }, + message=f'Approval request {status} successfully' + ) + except ValueError as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f'Error responding to approval: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/Backend/src/payments/routes/audit_trail_routes.py b/Backend/src/payments/routes/audit_trail_routes.py index 66961afa..4361d7c6 100644 --- a/Backend/src/payments/routes/audit_trail_routes.py +++ b/Backend/src/payments/routes/audit_trail_routes.py @@ -1,11 +1,14 @@ """ Routes for financial audit trail access. """ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Request, status from sqlalchemy.orm import Session from sqlalchemy.exc import ProgrammingError, OperationalError from typing import Optional -from datetime import datetime +from datetime import datetime, timedelta +import csv +import io +import json from ...shared.config.database import get_db from ...shared.config.logging_config import get_logger from ...security.middleware.auth import authorize_roles @@ -20,6 +23,7 @@ router = APIRouter(prefix='/financial/audit-trail', tags=['financial-audit']) @router.get('/') async def get_financial_audit_trail( + request: Request, payment_id: Optional[int] = Query(None), invoice_id: Optional[int] = Query(None), booking_id: Optional[int] = Query(None), @@ -32,7 +36,54 @@ async def get_financial_audit_trail( current_user: User = Depends(authorize_roles('admin', 'accountant')), db: Session = Depends(get_db) ): - """Get financial audit trail records with filters.""" + """Get financial audit trail records with filters. Requires step-up authentication.""" + # SECURITY: Enforce MFA for sensitive operations like audit trail access + try: + from ...payments.services.accountant_security_service import accountant_security_service + is_enforced, reason = accountant_security_service.is_mfa_enforced(current_user, db) + if not is_enforced and reason: + logger.warning( + f'User {current_user.id} ({current_user.email}) attempted to access audit trail without MFA enabled. ' + f'Reason: {reason}' + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Multi-factor authentication (MFA) is required for this operation. {reason}' + ) + except HTTPException: + raise + except Exception as e: + logger.error(f'Error checking MFA enforcement for audit trail access: {str(e)}', exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Error verifying authentication requirements' + ) + + # Log activity + try: + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + + # Check for unusual activity + is_unusual = accountant_security_service.detect_unusual_activity( + db=db, + user_id=current_user.id, + ip_address=client_ip + ) + + accountant_security_service.log_activity( + db=db, + user_id=current_user.id, + activity_type='audit_trail_access', + activity_description='Financial audit trail accessed', + ip_address=client_ip, + user_agent=user_agent, + risk_level='medium', + is_unusual=is_unusual + ) + except Exception as e: + logger.warning(f'Error logging audit trail access: {e}') + try: # Parse dates start = None @@ -196,3 +247,287 @@ async def get_audit_record( logger.error(f'Error retrieving audit record: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail='An error occurred while retrieving audit record') + +@router.get('/export') +async def export_audit_trail( + request: Request, + format: str = Query('csv', regex='^(csv|json)$'), + payment_id: Optional[int] = Query(None), + invoice_id: Optional[int] = Query(None), + booking_id: Optional[int] = Query(None), + action_type: Optional[str] = Query(None), + user_id: Optional[int] = Query(None), + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Export financial audit trail to CSV or JSON. Requires step-up authentication.""" + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + + # SECURITY: Enforce MFA for sensitive operations like audit trail export + try: + from ...payments.services.accountant_security_service import accountant_security_service + is_enforced, reason = accountant_security_service.is_mfa_enforced(current_user, db) + if not is_enforced and reason: + logger.warning( + f'User {current_user.id} ({current_user.email}) attempted to export audit trail without MFA enabled. ' + f'Reason: {reason}' + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Multi-factor authentication (MFA) is required for this operation. {reason}' + ) + except HTTPException: + raise + except Exception as e: + logger.error(f'Error checking MFA enforcement for audit trail export: {str(e)}', exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Error verifying authentication requirements' + ) + + try: + # Parse dates + start = None + end = None + if start_date: + start = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + if end_date: + end = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + + # Parse action type + action_type_enum = None + if action_type: + try: + action_type_enum = FinancialActionType(action_type) + except ValueError: + raise HTTPException(status_code=400, detail=f'Invalid action_type: {action_type}') + + # Get all matching records (no pagination for export) + records = financial_audit_service.get_audit_trail( + db=db, + payment_id=payment_id, + invoice_id=invoice_id, + booking_id=booking_id, + action_type=action_type_enum, + user_id=user_id, + start_date=start, + end_date=end, + limit=10000, # Large limit for export + offset=0 + ) + + # Log export action to audit trail + try: + financial_audit_service.log_financial_action( + db=db, + action_type=FinancialActionType.data_exported, + performed_by=current_user.id, + action_description=f'Audit trail exported as {format.upper()}', + metadata={ + 'export_type': 'audit_trail', + 'format': format, + 'filters': { + 'payment_id': payment_id, + 'invoice_id': invoice_id, + 'booking_id': booking_id, + 'action_type': action_type, + 'user_id': user_id, + 'start_date': start_date, + 'end_date': end_date + }, + 'record_count': len(records), + 'ip_address': client_ip, + 'user_agent': user_agent, + 'request_id': request_id + }, + notes=f'Audit trail export by {current_user.email}' + ) + except Exception as e: + logger.warning(f'Failed to log audit trail export: {e}') + + if format == 'csv': + # Generate CSV + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=[ + 'id', 'action_type', 'action_description', 'payment_id', 'invoice_id', + 'booking_id', 'amount', 'previous_amount', 'currency', 'performed_by', + 'performed_by_email', 'notes', 'created_at', 'metadata' + ]) + writer.writeheader() + + for record in records: + writer.writerow({ + 'id': record.id, + 'action_type': record.action_type.value, + 'action_description': record.action_description, + 'payment_id': record.payment_id or '', + 'invoice_id': record.invoice_id or '', + 'booking_id': record.booking_id or '', + 'amount': float(record.amount) if record.amount else '', + 'previous_amount': float(record.previous_amount) if record.previous_amount else '', + 'currency': record.currency or '', + 'performed_by': record.performed_by, + 'performed_by_email': record.performed_by_email or '', + 'notes': record.notes or '', + 'created_at': record.created_at.isoformat() if record.created_at else '', + 'metadata': json.dumps(record.audit_metadata) if record.audit_metadata else '' + }) + + date_str = datetime.utcnow().strftime('%Y%m%d_%H%M%S') + return Response( + content=output.getvalue(), + media_type='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename="audit_trail_{date_str}.csv"' + } + ) + else: + # Generate JSON + export_data = [] + for record in records: + export_data.append({ + 'id': record.id, + 'action_type': record.action_type.value, + 'action_description': record.action_description, + 'payment_id': record.payment_id, + 'invoice_id': record.invoice_id, + 'booking_id': record.booking_id, + 'amount': float(record.amount) if record.amount else None, + 'previous_amount': float(record.previous_amount) if record.previous_amount else None, + 'currency': record.currency, + 'performed_by': record.performed_by, + 'performed_by_email': record.performed_by_email, + 'metadata': record.audit_metadata, + 'notes': record.notes, + 'created_at': record.created_at.isoformat() if record.created_at else None + }) + + date_str = datetime.utcnow().strftime('%Y%m%d_%H%M%S') + return Response( + content=json.dumps(export_data, indent=2), + media_type='application/json', + headers={ + 'Content-Disposition': f'attachment; filename="audit_trail_{date_str}.json"' + } + ) + except HTTPException: + raise + except Exception as e: + logger.error(f'Error exporting audit trail: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post('/retention/cleanup') +async def cleanup_old_audit_records( + request: Request, + retention_days: int = Query(2555, ge=365, le=3650), # Default 7 years, min 1 year, max 10 years + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """ + Clean up audit trail records older than retention period. + WARNING: This is a destructive operation. Only admins can perform this. + """ + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + + try: + cutoff_date = datetime.utcnow() - timedelta(days=retention_days) + + # Count records to be deleted + records_to_delete = db.query(FinancialAuditTrail).filter( + FinancialAuditTrail.created_at < cutoff_date + ).count() + + if records_to_delete == 0: + return success_response( + data={'deleted_count': 0, 'cutoff_date': cutoff_date.isoformat()}, + message='No records found older than retention period' + ) + + # Log cleanup action before deletion + try: + financial_audit_service.log_financial_action( + db=db, + action_type=FinancialActionType.settings_changed, # Using closest match + performed_by=current_user.id, + action_description=f'Audit trail cleanup: {records_to_delete} records older than {retention_days} days', + metadata={ + 'action': 'audit_cleanup', + 'retention_days': retention_days, + 'cutoff_date': cutoff_date.isoformat(), + 'records_deleted': records_to_delete, + 'ip_address': client_ip, + 'user_agent': user_agent, + 'request_id': request_id + }, + notes=f'Audit trail cleanup initiated by {current_user.email}' + ) + except Exception as e: + logger.warning(f'Failed to log audit cleanup action: {e}') + + # Delete old records + deleted_count = db.query(FinancialAuditTrail).filter( + FinancialAuditTrail.created_at < cutoff_date + ).delete(synchronize_session=False) + + db.commit() + + return success_response( + data={ + 'deleted_count': deleted_count, + 'cutoff_date': cutoff_date.isoformat(), + 'retention_days': retention_days + }, + message=f'Successfully deleted {deleted_count} audit trail records older than {retention_days} days' + ) + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f'Error cleaning up audit trail: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/retention/stats') +async def get_retention_stats( + retention_days: int = Query(2555, ge=365, le=3650), + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Get statistics about audit trail retention.""" + try: + cutoff_date = datetime.utcnow() - timedelta(days=retention_days) + + total_records = db.query(FinancialAuditTrail).count() + records_to_delete = db.query(FinancialAuditTrail).filter( + FinancialAuditTrail.created_at < cutoff_date + ).count() + records_to_keep = total_records - records_to_delete + + oldest_record = db.query(FinancialAuditTrail).order_by( + FinancialAuditTrail.created_at.asc() + ).first() + + newest_record = db.query(FinancialAuditTrail).order_by( + FinancialAuditTrail.created_at.desc() + ).first() + + return success_response(data={ + 'retention_days': retention_days, + 'cutoff_date': cutoff_date.isoformat(), + 'total_records': total_records, + 'records_to_keep': records_to_keep, + 'records_to_delete': records_to_delete, + 'oldest_record_date': oldest_record.created_at.isoformat() if oldest_record else None, + 'newest_record_date': newest_record.created_at.isoformat() if newest_record else None + }) + except Exception as e: + logger.error(f'Error getting retention stats: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/Backend/src/payments/routes/financial_routes.py b/Backend/src/payments/routes/financial_routes.py index 7d244e24..6993f966 100644 --- a/Backend/src/payments/routes/financial_routes.py +++ b/Backend/src/payments/routes/financial_routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Request from sqlalchemy.orm import Session from sqlalchemy import func, and_, or_ from typing import Optional @@ -13,6 +13,12 @@ from ..models.payment import Payment, PaymentStatus, PaymentMethod from ..models.invoice import Invoice, InvoiceStatus from ...bookings.models.booking import Booking, BookingStatus from ...shared.utils.response_helpers import success_response +from ..services.financial_audit_service import financial_audit_service +from ..models.financial_audit_trail import FinancialActionType +from ..services.gl_service import gl_service +from ..models.chart_of_accounts import ChartOfAccounts, AccountType +from ..models.journal_entry import JournalEntryStatus +from sqlalchemy import func, and_, or_ logger = get_logger(__name__) router = APIRouter(prefix='/financial', tags=['financial']) @@ -82,27 +88,122 @@ async def get_profit_loss_report( ) ).scalar() or 0.0 - # Expenses (placeholder - would need expense tracking system) - # For now, we'll use refunds as expenses - refunds = db.query(func.sum(Payment.amount)).filter( - and_( - Payment.payment_status == PaymentStatus.refunded, - Payment.payment_date >= start, - Payment.payment_date <= end - ) - ).scalar() or 0.0 - - # Net revenue - net_revenue = total_revenue - discounts - - # Gross profit (revenue - cost of goods sold) - # For hotel, COGS would be room maintenance, cleaning, etc. - # Placeholder: assume 30% COGS - estimated_cogs = net_revenue * 0.30 - gross_profit = net_revenue - estimated_cogs - - # Operating expenses (placeholder) - operating_expenses = refunds # Using refunds as proxy + # Try to get data from GL if available + try: + from ..models.journal_entry import JournalLine + from ..models.fiscal_period import FiscalPeriod + + # Get periods for date range + periods = db.query(FiscalPeriod).filter( + and_( + FiscalPeriod.start_date <= end, + FiscalPeriod.end_date >= start + ) + ).all() + + period_ids = [p.id for p in periods] if periods else [] + + # Get revenue from GL (credit side of revenue accounts) + if period_ids: + gl_revenue = db.query(func.sum(JournalLine.credit_amount)).join( + JournalEntry + ).filter( + and_( + JournalEntry.fiscal_period_id.in_(period_ids), + JournalEntry.status == JournalEntryStatus.posted, + JournalEntry.entry_date >= start, + JournalEntry.entry_date <= end + ) + ).join( + ChartOfAccounts + ).filter( + ChartOfAccounts.account_type == AccountType.revenue + ).scalar() or 0.0 + + # Use GL revenue if available, otherwise fall back to payment data + if gl_revenue > 0: + net_revenue = float(gl_revenue) - discounts + else: + net_revenue = total_revenue - discounts + else: + net_revenue = total_revenue - discounts + + # Get COGS from GL + cogs = 0.0 + if period_ids: + gl_cogs = db.query(func.sum(JournalLine.debit_amount)).join( + JournalEntry + ).filter( + and_( + JournalEntry.fiscal_period_id.in_(period_ids), + JournalEntry.status == JournalEntryStatus.posted, + JournalEntry.entry_date >= start, + JournalEntry.entry_date <= end + ) + ).join( + ChartOfAccounts + ).filter( + ChartOfAccounts.account_type == AccountType.cogs + ).scalar() or 0.0 + cogs = float(gl_cogs) + + # Get operating expenses from GL + operating_expenses_gl = 0.0 + if period_ids: + gl_expenses = db.query(func.sum(JournalLine.debit_amount)).join( + JournalEntry + ).filter( + and_( + JournalEntry.fiscal_period_id.in_(period_ids), + JournalEntry.status == JournalEntryStatus.posted, + JournalEntry.entry_date >= start, + JournalEntry.entry_date <= end + ) + ).join( + ChartOfAccounts + ).filter( + ChartOfAccounts.account_type == AccountType.expense + ).scalar() or 0.0 + operating_expenses_gl = float(gl_expenses) + + # Fallback to refunds if no GL data + refunds = db.query(func.sum(Payment.amount)).filter( + and_( + Payment.payment_status == PaymentStatus.refunded, + Payment.payment_date >= start, + Payment.payment_date <= end + ) + ).scalar() or 0.0 + + # Use GL COGS if available, otherwise estimate + if cogs > 0: + estimated_cogs = cogs + else: + # Fallback: estimate 30% if no GL data + estimated_cogs = net_revenue * 0.30 + + # Use GL expenses if available, otherwise use refunds as proxy + if operating_expenses_gl > 0: + operating_expenses = operating_expenses_gl + else: + operating_expenses = float(refunds) + + gross_profit = net_revenue - estimated_cogs + except Exception as e: + # Fallback to original logic if GL not available + logger.warning(f'Error getting GL data for P&L: {str(e)}, using fallback calculations') + refunds = db.query(func.sum(Payment.amount)).filter( + and_( + Payment.payment_status == PaymentStatus.refunded, + Payment.payment_date >= start, + Payment.payment_date <= end + ) + ).scalar() or 0.0 + + net_revenue = total_revenue - discounts + estimated_cogs = net_revenue * 0.30 + gross_profit = net_revenue - estimated_cogs + operating_expenses = float(refunds) # Net profit net_profit = gross_profit - operating_expenses @@ -178,8 +279,34 @@ async def get_balance_sheet( total_assets = cash + accounts_receivable # Liabilities - # Accounts Payable (placeholder - would need vendor management) + # Try to get Accounts Payable from GL accounts_payable = 0.0 + try: + from ..models.journal_entry import JournalLine + from ..models.chart_of_accounts import ChartOfAccounts + + # Get AP balance from GL (credit balance of AP account) + ap_account = db.query(ChartOfAccounts).filter( + ChartOfAccounts.account_code == gl_service.ACCOUNT_CODES.get('accounts_payable', '2000') + ).first() + + if ap_account: + ap_balance = db.query( + func.sum(JournalLine.credit_amount) - func.sum(JournalLine.debit_amount) + ).join( + JournalEntry + ).filter( + and_( + JournalEntry.status == JournalEntryStatus.posted, + JournalEntry.entry_date <= as_of + ) + ).filter( + JournalLine.account_id == ap_account.id + ).scalar() or 0.0 + accounts_payable = float(ap_balance) if ap_balance > 0 else 0.0 + except Exception as e: + logger.warning(f'Error getting AP from GL: {str(e)}, using placeholder') + accounts_payable = 0.0 # Deferred Revenue (deposits for future bookings) deferred_revenue = db.query(func.sum(Payment.amount)).filter( @@ -193,22 +320,65 @@ async def get_balance_sheet( total_liabilities = accounts_payable + deferred_revenue # Equity - # Retained Earnings (net profit over time) - all_revenue = db.query(func.sum(Payment.amount)).filter( - and_( - Payment.payment_status == PaymentStatus.completed, - Payment.payment_date <= as_of - ) - ).scalar() or 0.0 - - all_refunds = db.query(func.sum(Payment.amount)).filter( - and_( - Payment.payment_status == PaymentStatus.refunded, - Payment.payment_date <= as_of - ) - ).scalar() or 0.0 - - retained_earnings = all_revenue - all_refunds - (all_revenue * 0.30) # Minus estimated COGS + # Retained Earnings - try to get from GL, otherwise calculate + retained_earnings = 0.0 + try: + from ..models.journal_entry import JournalLine + from ..models.chart_of_accounts import ChartOfAccounts + + # Get retained earnings from GL + re_account = db.query(ChartOfAccounts).filter( + ChartOfAccounts.account_code == gl_service.ACCOUNT_CODES.get('retained_earnings', '3000') + ).first() + + if re_account: + re_balance = db.query( + func.sum(JournalLine.credit_amount) - func.sum(JournalLine.debit_amount) + ).join( + JournalEntry + ).filter( + and_( + JournalEntry.status == JournalEntryStatus.posted, + JournalEntry.entry_date <= as_of + ) + ).filter( + JournalLine.account_id == re_account.id + ).scalar() or 0.0 + retained_earnings = float(re_balance) + else: + # Fallback calculation + all_revenue = db.query(func.sum(Payment.amount)).filter( + and_( + Payment.payment_status == PaymentStatus.completed, + Payment.payment_date <= as_of + ) + ).scalar() or 0.0 + + all_refunds = db.query(func.sum(Payment.amount)).filter( + and_( + Payment.payment_status == PaymentStatus.refunded, + Payment.payment_date <= as_of + ) + ).scalar() or 0.0 + + retained_earnings = all_revenue - all_refunds - (all_revenue * 0.30) + except Exception as e: + logger.warning(f'Error getting retained earnings from GL: {str(e)}, using fallback') + all_revenue = db.query(func.sum(Payment.amount)).filter( + and_( + Payment.payment_status == PaymentStatus.completed, + Payment.payment_date <= as_of + ) + ).scalar() or 0.0 + + all_refunds = db.query(func.sum(Payment.amount)).filter( + and_( + Payment.payment_status == PaymentStatus.refunded, + Payment.payment_date <= as_of + ) + ).scalar() or 0.0 + + retained_earnings = all_revenue - all_refunds - (all_revenue * 0.30) total_equity = retained_earnings @@ -239,13 +409,37 @@ async def get_balance_sheet( @router.get('/tax-report') async def get_tax_report( + request: Request, start_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None), format: Optional[str] = Query('json', regex='^(json|csv)$'), current_user: User = Depends(authorize_roles('admin', 'accountant')), db: Session = Depends(get_db) ): - """Generate tax report with export capability.""" + """Generate tax report with export capability. Requires step-up authentication for exports.""" + client_ip = request.client.host if request.client else None + + # Log activity for exports + if format == 'csv': + try: + from ...payments.services.accountant_security_service import accountant_security_service + user_agent = request.headers.get('User-Agent') + + accountant_security_service.log_activity( + db=db, + user_id=current_user.id, + activity_type='data_export', + activity_description='Tax report exported as CSV', + ip_address=client_ip, + user_agent=user_agent, + risk_level='high', + metadata={'export_type': 'tax_report', 'format': 'csv'} + ) + except Exception as e: + logger.warning(f'Error logging export activity: {e}') + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + try: if start_date: start = datetime.fromisoformat(start_date.replace('Z', '+00:00')) @@ -297,6 +491,31 @@ async def get_tax_report( writer.writeheader() writer.writerows(tax_data) + # Log financial audit for data export + try: + financial_audit_service.log_financial_action( + db=db, + action_type=FinancialActionType.data_exported, + performed_by=current_user.id, + action_description=f'Tax report exported as CSV for period {start.strftime("%Y-%m-%d")} to {end.strftime("%Y-%m-%d")}', + amount=total_tax, + metadata={ + 'export_type': 'tax_report', + 'format': 'csv', + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + 'invoice_count': len(tax_data), + 'total_revenue': float(total_revenue), + 'total_tax': float(total_tax), + 'ip_address': client_ip, + 'user_agent': user_agent, + 'request_id': request_id + }, + notes=f'Tax report CSV export by {current_user.email}' + ) + except Exception as e: + logger.warning(f'Failed to log financial audit for tax report export: {e}') + return Response( content=output.getvalue(), media_type='text/csv', @@ -325,10 +544,11 @@ async def get_tax_report( async def get_payment_reconciliation( start_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None), + include_exceptions: bool = Query(True), current_user: User = Depends(authorize_roles('admin', 'accountant')), db: Session = Depends(get_db) ): - """Generate payment reconciliation report.""" + """Generate payment reconciliation report with exception integration.""" try: if start_date: start = datetime.fromisoformat(start_date.replace('Z', '+00:00')) @@ -376,6 +596,27 @@ async def get_payment_reconciliation( 'total_amount': float(total) } + # Get exceptions if requested + if include_exceptions: + try: + from ..services.reconciliation_service import reconciliation_service + from ..models.reconciliation_exception import ExceptionStatus + + exceptions_data = reconciliation_service.get_exceptions( + db=db, + status=ExceptionStatus.open, + page=1, + limit=100 + ) + + reconciliation['exceptions'] = { + 'open_count': exceptions_data['pagination']['total'], + 'recent_exceptions': exceptions_data['exceptions'][:10] # Top 10 + } + except Exception as e: + logger.warning(f'Error fetching exceptions: {str(e)}') + reconciliation['exceptions'] = {'open_count': 0, 'recent_exceptions': []} + # Find discrepancies (payments without matching invoices, etc.) for payment in payments: if payment.payment_status == PaymentStatus.completed: diff --git a/Backend/src/payments/routes/gl_routes.py b/Backend/src/payments/routes/gl_routes.py new file mode 100644 index 00000000..da88abd2 --- /dev/null +++ b/Backend/src/payments/routes/gl_routes.py @@ -0,0 +1,240 @@ +""" +Routes for General Ledger operations. +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.orm import Session +from typing import Optional +from datetime import datetime +from ...shared.config.database import get_db +from ...shared.config.logging_config import get_logger +from ...security.middleware.auth import authorize_roles, get_current_user +from ...auth.models.user import User +from ..services.gl_service import gl_service +from ..models.fiscal_period import FiscalPeriod, PeriodStatus +from ..models.chart_of_accounts import ChartOfAccounts +from ..models.journal_entry import JournalEntry +from ...shared.utils.response_helpers import success_response + +logger = get_logger(__name__) +router = APIRouter(prefix='/financial/gl', tags=['general-ledger']) + + +@router.get('/trial-balance') +async def get_trial_balance( + period_id: Optional[int] = Query(None), + as_of_date: Optional[str] = Query(None), + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Get trial balance for a period or as of a date.""" + try: + as_of = None + if as_of_date: + as_of = datetime.fromisoformat(as_of_date.replace('Z', '+00:00')) + + trial_balance = gl_service.get_trial_balance(db, period_id=period_id, as_of_date=as_of) + + return success_response(data=trial_balance) + except Exception as e: + logger.error(f'Error generating trial balance: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/periods') +async def get_fiscal_periods( + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Get all fiscal periods.""" + try: + periods = db.query(FiscalPeriod).order_by(FiscalPeriod.start_date.desc()).all() + + period_list = [] + for period in periods: + period_list.append({ + 'id': period.id, + 'period_name': period.period_name, + 'period_type': period.period_type, + 'start_date': period.start_date.isoformat() if period.start_date else None, + 'end_date': period.end_date.isoformat() if period.end_date else None, + 'status': period.status.value, + 'is_current': period.is_current, + 'closed_by': period.closed_by, + 'closed_at': period.closed_at.isoformat() if period.closed_at else None, + 'notes': period.notes + }) + + return success_response(data={'periods': period_list}) + except Exception as e: + logger.error(f'Error fetching fiscal periods: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post('/periods') +async def create_fiscal_period( + period_data: dict, + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Create a new fiscal period.""" + try: + period_name = period_data.get('period_name') + start_date_str = period_data.get('start_date') + end_date_str = period_data.get('end_date') + period_type = period_data.get('period_type', 'monthly') + + if not period_name or not start_date_str or not end_date_str: + raise HTTPException(status_code=400, detail='period_name, start_date, and end_date are required') + + start_date = datetime.fromisoformat(start_date_str.replace('Z', '+00:00')) + end_date = datetime.fromisoformat(end_date_str.replace('Z', '+00:00')) + + period = gl_service.create_period( + db=db, + period_name=period_name, + start_date=start_date, + end_date=end_date, + period_type=period_type + ) + + db.commit() + + return success_response( + data={'period': { + 'id': period.id, + 'period_name': period.period_name, + 'start_date': period.start_date.isoformat(), + 'end_date': period.end_date.isoformat(), + 'status': period.status.value + }}, + message='Fiscal period created successfully' + ) + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f'Error creating fiscal period: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post('/periods/{period_id}/close') +async def close_fiscal_period( + period_id: int, + close_data: dict, + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Close a fiscal period.""" + try: + notes = close_data.get('notes', '') + + period = gl_service.close_period( + db=db, + period_id=period_id, + closed_by=current_user.id, + notes=notes + ) + + db.commit() + + return success_response( + data={'period': { + 'id': period.id, + 'status': period.status.value, + 'closed_at': period.closed_at.isoformat() if period.closed_at else None + }}, + message='Fiscal period closed successfully' + ) + except ValueError as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + db.rollback() + logger.error(f'Error closing fiscal period: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/accounts') +async def get_chart_of_accounts( + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Get chart of accounts.""" + try: + accounts = db.query(ChartOfAccounts).filter( + ChartOfAccounts.is_active == 'true' + ).order_by(ChartOfAccounts.account_code).all() + + account_list = [] + for account in accounts: + account_list.append({ + 'id': account.id, + 'account_code': account.account_code, + 'account_name': account.account_name, + 'account_type': account.account_type.value, + 'account_category': account.account_category.value if account.account_category else None, + 'description': account.description + }) + + return success_response(data={'accounts': account_list}) + except Exception as e: + logger.error(f'Error fetching chart of accounts: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/journal-entries') +async def get_journal_entries( + period_id: Optional[int] = Query(None), + status: Optional[str] = Query(None), + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Get journal entries with pagination.""" + try: + query = db.query(JournalEntry) + + if period_id: + query = query.filter(JournalEntry.fiscal_period_id == period_id) + + if status: + try: + from ..models.journal_entry import JournalEntryStatus + status_enum = JournalEntryStatus(status) + query = query.filter(JournalEntry.status == status_enum) + except ValueError: + pass + + total = query.count() + offset = (page - 1) * limit + entries = query.order_by(JournalEntry.entry_date.desc()).offset(offset).limit(limit).all() + + entry_list = [] + for entry in entries: + entry_list.append({ + 'id': entry.id, + 'entry_number': entry.entry_number, + 'entry_date': entry.entry_date.isoformat() if entry.entry_date else None, + 'description': entry.description, + 'status': entry.status.value, + 'reference_type': entry.reference_type, + 'reference_id': entry.reference_id, + 'created_by': entry.created_by, + 'posted_at': entry.posted_at.isoformat() if entry.posted_at else None, + 'line_count': len(entry.journal_lines) if entry.journal_lines else 0 + }) + + return success_response(data={ + 'entries': entry_list, + 'pagination': { + 'total': total, + 'page': page, + 'limit': limit, + 'total_pages': (total + limit - 1) // limit + } + }) + except Exception as e: + logger.error(f'Error fetching journal entries: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/Backend/src/payments/routes/invoice_routes.py b/Backend/src/payments/routes/invoice_routes.py index 711f2ac1..36db03bb 100644 --- a/Backend/src/payments/routes/invoice_routes.py +++ b/Backend/src/payments/routes/invoice_routes.py @@ -12,6 +12,8 @@ from ..services.invoice_service import InvoiceService from ...shared.utils.role_helpers import can_access_all_invoices, can_create_invoices from ...shared.utils.response_helpers import success_response from ...shared.utils.request_helpers import get_request_id +from ..services.financial_audit_service import financial_audit_service +from ..models.financial_audit_trail import FinancialActionType from ..schemas.invoice import ( CreateInvoiceRequest, UpdateInvoiceRequest, @@ -96,6 +98,25 @@ async def create_invoice(request: Request, invoice_data: CreateInvoiceRequest, c request_id=request_id, **invoice_kwargs ) + # Financial audit: invoice created + try: + financial_audit_service.log_financial_action( + db=db, + action_type=FinancialActionType.invoice_created, + performed_by=current_user.id, + action_description=f'Invoice {invoice.get("invoice_number")} created from booking {booking_id}', + invoice_id=invoice.get('id'), + booking_id=booking_id, + amount=invoice.get('total_amount'), + currency=None, + metadata={ + 'request_id': request_id, + 'role': getattr(current_user.role, 'name', None), + }, + ) + except Exception: + # Do not block main flow on audit logging failure + logger.warning('Failed to log financial audit for invoice creation', exc_info=True) return success_response(data={'invoice': invoice}, message='Invoice created successfully') except HTTPException: raise @@ -117,6 +138,26 @@ async def update_invoice(request: Request, id: int, invoice_data: UpdateInvoiceR request_id = get_request_id(request) invoice_dict = invoice_data.model_dump(exclude_unset=True) updated_invoice = InvoiceService.update_invoice(invoice_id=id, db=db, updated_by_id=current_user.id, request_id=request_id, **invoice_dict) + # Financial audit: invoice updated + try: + financial_audit_service.log_financial_action( + db=db, + action_type=FinancialActionType.invoice_updated, + performed_by=current_user.id, + action_description=f'Invoice {updated_invoice.get("invoice_number")} updated', + invoice_id=id, + booking_id=updated_invoice.get('booking_id'), + amount=updated_invoice.get('total_amount'), + previous_amount=float(invoice.total_amount) if getattr(invoice, 'total_amount', None) is not None else None, + currency=None, + metadata={ + 'request_id': request_id, + 'updated_fields': list(invoice_dict.keys()), + 'role': getattr(current_user.role, 'name', None), + }, + ) + except Exception: + logger.warning('Failed to log financial audit for invoice update', exc_info=True) return success_response(data={'invoice': updated_invoice}, message='Invoice updated successfully') except HTTPException: raise @@ -135,6 +176,24 @@ async def mark_invoice_as_paid(request: Request, id: int, payment_data: MarkInvo request_id = get_request_id(request) amount = payment_data.amount updated_invoice = InvoiceService.mark_invoice_as_paid(invoice_id=id, db=db, amount=amount, updated_by_id=current_user.id, request_id=request_id) + # Financial audit: invoice marked as paid + try: + financial_audit_service.log_financial_action( + db=db, + action_type=FinancialActionType.invoice_paid, + performed_by=current_user.id, + action_description=f'Invoice {updated_invoice.get("invoice_number")} marked as paid', + invoice_id=id, + booking_id=updated_invoice.get('booking_id'), + amount=amount if amount is not None else updated_invoice.get('total_amount'), + currency=None, + metadata={ + 'request_id': request_id, + 'role': getattr(current_user.role, 'name', None), + }, + ) + except Exception: + logger.warning('Failed to log financial audit for mark-as-paid', exc_info=True) return success_response(data={'invoice': updated_invoice}, message='Invoice marked as paid successfully') except HTTPException: raise diff --git a/Backend/src/payments/routes/payment_routes.py b/Backend/src/payments/routes/payment_routes.py index f2bd2dfe..38c26b8c 100644 --- a/Backend/src/payments/routes/payment_routes.py +++ b/Backend/src/payments/routes/payment_routes.py @@ -23,6 +23,9 @@ from ...loyalty.services.loyalty_service import LoyaltyService from ...analytics.services.audit_service import audit_service from ..services.financial_audit_service import financial_audit_service from ..models.financial_audit_trail import FinancialActionType +from ..services.approval_service import approval_service +from ..models.financial_approval import ApprovalActionType, ApprovalStatus +from ...shared.utils.role_helpers import is_admin from ..schemas.payment import ( CreatePaymentRequest, UpdatePaymentStatusRequest, @@ -270,6 +273,42 @@ async def create_payment( db.add(payment) db.flush() + + # Financial audit: payment created / possibly completed + try: + financial_audit_service.log_financial_action( + db=db, + action_type=FinancialActionType.payment_created, + performed_by=current_user.id, + action_description=f'Payment {payment.id} created for booking {booking_id}', + payment_id=payment.id, + booking_id=booking_id, + amount=float(payment.amount) if payment.amount else None, + currency=None, + metadata={ + 'request_id': request_id, + 'payment_method': payment.payment_method.value if hasattr(payment.payment_method, 'value') else str(payment.payment_method), + 'payment_type': payment.payment_type.value if hasattr(payment.payment_type, 'value') else str(payment.payment_type), + 'role': getattr(current_user.role, 'name', None), + }, + ) + if payment.payment_status == PaymentStatus.completed: + financial_audit_service.log_financial_action( + db=db, + action_type=FinancialActionType.payment_completed, + performed_by=current_user.id, + action_description=f'Payment {payment.id} completed at creation', + payment_id=payment.id, + booking_id=booking_id, + amount=float(payment.amount) if payment.amount else None, + currency=None, + metadata={ + 'request_id': request_id, + 'role': getattr(current_user.role, 'name', None), + }, + ) + except Exception: + logger.warning('Failed to log financial audit for payment creation', exc_info=True) # Commit transaction transaction.commit() @@ -395,6 +434,110 @@ async def update_payment_status( status_value = status_data.status notes = status_data.notes old_status = payment.payment_status + + # Check if this is a high-risk operation requiring approval + requires_approval = False + approval_action_type = None + + if status_value: + try: + new_status = PaymentStatus(status_value) + # Manual status overrides require approval (unless admin) + if not is_admin(current_user, db) and new_status != old_status: + if new_status in [PaymentStatus.refunded, PaymentStatus.failed]: + # Large refunds require approval + if new_status == PaymentStatus.refunded: + payment_amount = float(payment.amount) if payment.amount else 0.0 + if approval_service.requires_approval( + ApprovalActionType.large_refund, + amount=payment_amount + ): + requires_approval = True + approval_action_type = ApprovalActionType.large_refund + # Payment status overrides require approval + if not requires_approval: + requires_approval = True + approval_action_type = ApprovalActionType.payment_status_override + except ValueError: + raise HTTPException(status_code=400, detail='Invalid payment status') + + # If approval required, check for existing approved request or create one + if requires_approval and approval_action_type: + # Check for existing pending approval + existing_approvals = approval_service.get_approvals_for_action( + db=db, + action_type=approval_action_type, + payment_id=id + ) + pending_approval = next( + (a for a in existing_approvals if a.status == ApprovalStatus.pending), + None + ) + + if pending_approval: + raise HTTPException( + status_code=403, + detail=f'This action requires approval. Approval request #{pending_approval.id} is pending.' + ) + + # Check for existing approved request + approved_request = next( + (a for a in existing_approvals if a.status == ApprovalStatus.approved), + None + ) + + if not approved_request: + # Create approval request + approval = approval_service.create_approval_request( + db=db, + action_type=approval_action_type, + requested_by=current_user.id, + action_description=f'Payment {id} status change from {old_status.value} to {status_value}', + amount=float(payment.amount) if payment.amount else None, + payment_id=id, + booking_id=payment.booking_id, + previous_value={'status': old_status.value if hasattr(old_status, 'value') else str(old_status)}, + new_value={'status': status_value}, + request_reason=notes or 'Payment status override requested', + metadata={ + 'ip_address': client_ip, + 'user_agent': user_agent, + 'request_id': request_id + } + ) + db.commit() + raise HTTPException( + status_code=403, + detail=f'This action requires approval. Approval request #{approval.id} has been created and is pending review.' + ) + + # Log activity for accountant/admin users + try: + from ...payments.services.accountant_security_service import accountant_security_service + from ...shared.utils.role_helpers import is_accountant, is_admin + + if is_accountant(current_user, db) or is_admin(current_user, db): + is_unusual = accountant_security_service.detect_unusual_activity( + db=db, + user_id=current_user.id, + ip_address=client_ip + ) + + accountant_security_service.log_activity( + db=db, + user_id=current_user.id, + activity_type='payment_status_change', + activity_description=f'Payment {id} status update attempted', + ip_address=client_ip, + user_agent=user_agent, + risk_level='high' if status_value in ['refunded', 'failed'] else 'medium', + is_unusual=is_unusual, + metadata={'payment_id': id, 'new_status': status_value} + ) + except Exception as e: + logger.warning(f'Error logging payment status change activity: {e}') + + # Proceed with status update (either no approval needed, or approval already granted) if status_value: try: new_status = PaymentStatus(status_value) @@ -432,6 +575,38 @@ async def update_payment_status( payment.notes = f'{existing_notes}\n{notes}'.strip() if existing_notes else notes db.commit() db.refresh(payment) + + # Financial audit: payment status change / refunds / failures + try: + # Map to financial action type + if payment.payment_status == PaymentStatus.completed: + action_type = FinancialActionType.payment_completed + elif payment.payment_status == PaymentStatus.refunded: + action_type = FinancialActionType.payment_refunded + elif payment.payment_status == PaymentStatus.failed: + action_type = FinancialActionType.payment_failed + else: + action_type = FinancialActionType.payment_updated if hasattr(FinancialActionType, 'payment_updated') else FinancialActionType.payment_created + + financial_audit_service.log_financial_action( + db=db, + action_type=action_type, + performed_by=current_user.id, + action_description=f'Payment {payment.id} status changed from {old_status.value if hasattr(old_status, "value") else str(old_status)} to {payment.payment_status.value if hasattr(payment.payment_status, "value") else str(payment.payment_status)}', + payment_id=payment.id, + booking_id=payment.booking_id, + amount=float(payment.amount) if payment.amount else None, + currency=None, + metadata={ + 'request_id': request_id, + 'old_status': old_status.value if hasattr(old_status, "value") else str(old_status), + 'new_status': payment.payment_status.value if hasattr(payment.payment_status, "value") else str(payment.payment_status), + 'role': getattr(current_user.role, 'name', None), + }, + notes=notes, + ) + except Exception: + logger.warning('Failed to log financial audit for payment status update', exc_info=True) # Log payment status update (admin action) await audit_service.log_action( diff --git a/Backend/src/payments/routes/reconciliation_routes.py b/Backend/src/payments/routes/reconciliation_routes.py new file mode 100644 index 00000000..58f87793 --- /dev/null +++ b/Backend/src/payments/routes/reconciliation_routes.py @@ -0,0 +1,234 @@ +""" +Routes for reconciliation and exception management. +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.orm import Session +from typing import Optional +from datetime import datetime +from ...shared.config.database import get_db +from ...shared.config.logging_config import get_logger +from ...security.middleware.auth import authorize_roles, get_current_user +from ...auth.models.user import User +from ..services.reconciliation_service import reconciliation_service +from ..models.reconciliation_exception import ExceptionStatus, ExceptionType +from ...shared.utils.response_helpers import success_response + +logger = get_logger(__name__) +router = APIRouter(prefix='/financial/reconciliation', tags=['reconciliation']) + + +@router.post('/run') +async def run_reconciliation( + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Run reconciliation and detect exceptions.""" + try: + start = None + end = None + + if start_date: + start = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + if end_date: + end = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + + result = reconciliation_service.run_reconciliation(db, start, end) + db.commit() + + return success_response( + data=result, + message=f'Reconciliation completed. {result["exceptions_created"]} new exceptions created.' + ) + except Exception as e: + db.rollback() + logger.error(f'Error running reconciliation: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/exceptions') +async def get_reconciliation_exceptions( + status: Optional[str] = Query(None), + exception_type: Optional[str] = Query(None), + assigned_to: Optional[int] = Query(None), + severity: Optional[str] = Query(None), + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Get reconciliation exceptions with filters.""" + try: + status_enum = None + if status: + try: + status_enum = ExceptionStatus(status) + except ValueError: + raise HTTPException(status_code=400, detail=f'Invalid status: {status}') + + type_enum = None + if exception_type: + try: + type_enum = ExceptionType(exception_type) + except ValueError: + raise HTTPException(status_code=400, detail=f'Invalid exception_type: {exception_type}') + + result = reconciliation_service.get_exceptions( + db=db, + status=status_enum, + exception_type=type_enum, + assigned_to=assigned_to, + severity=severity, + page=page, + limit=limit + ) + + return success_response(data=result) + except HTTPException: + raise + except Exception as e: + logger.error(f'Error fetching exceptions: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post('/exceptions/{exception_id}/assign') +async def assign_exception( + exception_id: int, + assign_data: dict, + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Assign an exception to a user.""" + try: + assigned_to = assign_data.get('assigned_to') + if not assigned_to: + raise HTTPException(status_code=400, detail='assigned_to is required') + + exception = reconciliation_service.assign_exception( + db=db, + exception_id=exception_id, + assigned_to=assigned_to + ) + + db.commit() + + return success_response( + data={'exception_id': exception.id, 'assigned_to': exception.assigned_to}, + message='Exception assigned successfully' + ) + except ValueError as e: + db.rollback() + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + db.rollback() + logger.error(f'Error assigning exception: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post('/exceptions/{exception_id}/resolve') +async def resolve_exception( + exception_id: int, + resolve_data: dict, + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Resolve an exception.""" + try: + resolution_notes = resolve_data.get('notes', '') + if not resolution_notes: + raise HTTPException(status_code=400, detail='Resolution notes are required') + + exception = reconciliation_service.resolve_exception( + db=db, + exception_id=exception_id, + resolved_by=current_user.id, + resolution_notes=resolution_notes + ) + + db.commit() + + return success_response( + data={'exception_id': exception.id, 'status': exception.status.value}, + message='Exception resolved successfully' + ) + except ValueError as e: + db.rollback() + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + db.rollback() + logger.error(f'Error resolving exception: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post('/exceptions/{exception_id}/comment') +async def add_exception_comment( + exception_id: int, + comment_data: dict, + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Add a comment to an exception.""" + try: + comment = comment_data.get('comment', '') + if not comment: + raise HTTPException(status_code=400, detail='Comment is required') + + exception = reconciliation_service.add_comment( + db=db, + exception_id=exception_id, + user_id=current_user.id, + comment=comment + ) + + db.commit() + + return success_response( + data={'exception_id': exception.id, 'comments': exception.comments}, + message='Comment added successfully' + ) + except ValueError as e: + db.rollback() + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + db.rollback() + logger.error(f'Error adding comment: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/exceptions/stats') +async def get_exception_stats( + current_user: User = Depends(authorize_roles('admin', 'accountant')), + db: Session = Depends(get_db) +): + """Get statistics about reconciliation exceptions.""" + try: + from ..models.reconciliation_exception import ReconciliationException + from sqlalchemy import func + + total = db.query(ReconciliationException).count() + by_status = db.query( + ReconciliationException.status, + func.count(ReconciliationException.id).label('count') + ).group_by(ReconciliationException.status).all() + + by_type = db.query( + ReconciliationException.exception_type, + func.count(ReconciliationException.id).label('count') + ).group_by(ReconciliationException.exception_type).all() + + by_severity = db.query( + ReconciliationException.severity, + func.count(ReconciliationException.id).label('count') + ).group_by(ReconciliationException.severity).all() + + return success_response(data={ + 'total': total, + 'by_status': {status.value: count for status, count in by_status}, + 'by_type': {exc_type.value: count for exc_type, count in by_type}, + 'by_severity': {severity: count for severity, count in by_severity} + }) + except Exception as e: + logger.error(f'Error getting exception stats: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/Backend/src/payments/services/__pycache__/accountant_security_service.cpython-312.pyc b/Backend/src/payments/services/__pycache__/accountant_security_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..02916289ea5a6a8f2a929784225128e8de51194a GIT binary patch literal 10638 zcmc&aYiv|kdiUOW&)j)<#_t!`j{#4>V<045h#`T114*zcHpzB2$=zY@wLSJcoOAD( zcy{Qb6+es?DNv;g>2?(&wF(HYN~NmeM^&Z&kRo+G*s5o5i9*$tTKJ^-sd~tIp=%d`ONS4GLT+7y(@hu$}s!A$`wXp5{$%3T!zhZ z3690OEn~~t6ZWhl;mA4@&a5lpqW$)aJL^ukvz~+p+78K?@n(GqAJkovJHu!F34b<_ z2+%Q4CYTK+LfM8y18sXV;cO%kfjTSsGL4BwS~ew`I7W`h%{N&ct3(UOyuwKQc}DWz zJC9s<+;JaoRBQ2)6kL?C7sU+*<>y`MQ6ml4{+NU ztGUMY!I)ihkIkttEt%2mhtnxlvmMMWXpV`(Tt<#@np;Y$vYO7yng?@9&Zx-;HXVrl zlp>P{cr2LMaL_{9fUPyvx(=q)^gLkydVUJ#`qPS7JEP{trj$+R05R{BtcscZ)Ras{ zQ;4jYx{S)BQP}VbBQpsW-kOuxgiYcScFC4-NcMzNc1aG|EjeY6V}27o6ynmZ*=-Rz+3@-XH)Pwir@y*TceV~=R6ltLXriZXv~$55+orYE zNOjy`G=m|FvCTR2n+zF$fU@X8H)cV|G)n(vOwf_3uz)NC?Mh! zBQcJI002RpZg8Xp>#dL}h>ak8{npTQJ}VDR<=z`QEGx5Wes1V!UX?S0hw}N^bZ%RgWYLsSP@#kIxdn|ku@SSA$>P>0-gCTuUI(Iw z9Dk1a(#`OVmkwX;UG;VSS=V*-*2&eL?^Pl#m0;r|n80j)r?0~Z+$m0=X~w!mv*+=9 zCV>(&gD97yuDxh-zt{a5|sQ z?0{!VD*4<#klI8j38VR(9JAKNxRK&M1H5rDI{4ac4^fdxUtAGR7gQmaR{@pe>5MGJ zxQWpl+{kFmMj~(`f>){e-~j*$I)Ts|)(L=g;%E>s)F)7dA{Ap#eyR;q`(VAY6*71& zp5dcwzFlSCu3P=LxmDi_@KkG&zPpjWN=u>=>8b?V33AA&=E9p2(~{;*&w==tfM6BP zOFKbb1>W=pK8 zqR+ZihY4cFY1V3`DY8|M*{4!t1Y0w_*;gx3V+fJ}*@oEwWSR%nI_iB{ z^JkKZsyE=-v?erE!~#4!7%K48=@cLWgdo2+hjL3ysxhzT0%-zbQ_=i%AF)6(N^(*r zZiMYDsHt5348cdCB(4buUw(P))ab;)(Ft+<$oTlNu~Bj2*zqG{rzXUsW2a7z6C@DL z29%~bCNl+PI_9NfkRIxZryx6Uhn=_-5TXW^g70t20zuH(Cjhv@AwcrcImLk|bI;>5 zhX9yz4l-b#R;Eo@YaJ}N4qiXK(zk7FU|KF14-hJ-N15DeqNQV`{lc zUG)AY+*fJrsI+gZwDjQLmR;CvXsR@~-)ryvXz0VCmGY2`AzzsZxrOrCGr(6@klHNGWlmuj z)#5pmE@vz*+iccmoL6FJfsSgBX7J@VB#F*3wdIV#CH0d`x<6|x*+7~D|82&#H7=b& zUAZBdFlOOYq*Ve)qTQVGUEH?7JsoVd$%Lpjo3$BK(;L!~-MrBv(BWA-oTq)m6lj+0 z|22}?-kBtfS!Q`&$4(mjOu#OTNka8SS< zD5$a$3uuma3o=>I+1ELl&ZsicxpELT2VPDS(4fenR5Xxhv4%EPop9JvCmfC)e*K6g z8IB(tJvDJ;oWx;%%%RHz%JF*T6j-6yyqU@wZ?BR^V_eVE%U>)wXMu=yS}H) z4?S|&BHrIR8DHo^^rzdZF2)mD_cEatP`6bl!$;P9U1czs+@AzL4z38h?)r9Bgy`Zc zt9)0554?Z$!qJPb!c>qit-_TbU;gn*%fQm!)%|a*?Vm62pI_a6rrdI7k+1l|Yd)du z6Rr*4^$l$XU>)}AxYxhqld+G-R{EdA4SK8%YGOqTk70ZXWW zyNyDHL&NJk%{NZsmTWf-;RLK;hgFboX2^jsf!74H*#YC5sCjY(`Zp0UWbfwvswHYR z6DO9yI866+N?ge_#%z=<$b=?dQxdxJeEe$5Z@~*Hkx_=~?gU8yXaS zeXvGNb8! zq3d_a>RV+%!Ilskyn2c-o6$dE{BJ2pZv6I z`G@jycz7i|x#;?Ju(=X>?!GVl{)-o0ygGH)7p+8g8r(c^*Eewe&9%W-?hd|E2{x|< zW949M>F8gK-56WhvHxyxf2F1O{?6wvxmH7il~DMK`?C83(Cd-qmKTTV;UXV0g3ZbPG`RVSB+lcHfVjz+6D}bWN3YZp=K;SWR5e8$xNo!msrqO zmbai<7W7QGw#KZPYoA&p+boR?AU5blGi+r7hth=4G7BvDm9gVVGW%HQu8zZ!3dH-e z5Q+o)7+iVs0<4b($$luPLL>*UMv0e_F{(uJB4)@0bp&5Clv~Fqj+_uroeR57`COGWKgU)glcS5MQ-m5e(kLn6DP$XpYxwM)G*M2H)xR>O0 z*pj@8*=v~fV;05C@+`@5Y>q;vHJD&U^NJz7Y@bd~O>1s2G|;om6bFt%dl27dQ-ma7 zt`zXsKB4FlPA}-12$TaQe2R#*AH@b;V);FlBw-G|A$blnEQj8@zh!GBy1UZ5L;qX%Ia|V22jlPflJWQhRnUu(MX#=y?0}t_ zEUWO3o+6}U(sV7XBC&PB7*Hc%Au=Lb7t^x)OupI7k*v+y%;GbgRSVJ#QpSb^ zPT5-^{tef7voMZ%hO>^618iB~I1*K@V2)%z&y*a8Ed^p?d)5iO=$vOsu%@y~PIFXp z%pL~%WEY$z+hfpNPk`ov5S42KG}kwwK~U6P96n5$2jkE1d5VDMvK%?IMGXNcoK7mT zB!GQNgVbOs0?#kR3A((%O?D}{pp2yI!w7gs=*T@nE`N<-X`tb4P^M?u5<3HZ8IC+NGPQmv2e;-Uweij{s?9xIZ!Wo6STR1F$%D{Q4#X`ek6~x%2F=4t#dt>dPOE{$jM; zk@)Hn63$N=qU;CopWx{y{;%$X08PRx=;HNlsMp^G`5|=t@7%&y2)cF)J&9X=cT&Ha z{cQGH-zVGue0#Yk@fAtJ9MA)J8M+@&%@%6yP)SRz9hA$OSC67e5G#ZS%w#m1B10`B z=fEGput{B54;6y_ye~mKDu23o*r95vwa!E3&O`V3o;5yJ=4032Eb|a8fYy#OzvEW0%)bcnfi4;!2wdp6N|?x!M1B{<=`_*%B{UO|L{}x?$E(XO9#%4-0SXv z*IC~C>S|A7r8}`W1`f#8d^tGySADngE4xSTG~C_&Donl~Y}94AA>rUWb)UKZ78<|% zaG@!uHnU(X>B{Nx1#k*D(Xx&}H=wHBkjyn;tmEglu9NaSM7<$^Rc}fdCSZJIBIerE zHm4T(Ab{4*Yg?UpO)YCQ;()kxx(X1whZRx!mQeEJGyemiC?_EUqniKDc+w*q7xDg! zH%Q}04m6wHhS}WNgC=pCrQ=P5__0rImTvwB*zkA%uK&|8u*6BWbnT-L_g$}D!pMug zxYc~`Oh#91vKhQk^j~UFjTWCE__!nAe0^#|;uw6fpfUBw2!Kj52bD3(V~C`>8tezI(N(wYz`R^=U&EE*yh8(3|7W+k+JEwsBF+D-kbCwW&6TLTp%4Hvdv z+J3dL+z?&yZbfktsCw%>rp85{vVP1liYdlU#JJI72n})5@W%LG%uR>Uuss7`Cpnne zrMX2hm(0qdsCh*(o0kd@ZWT3N6yF8k$ry2oqLfdGBH07yATMEd6f%uh)Fc@BVEj+# zCE~+=yhzQCpIGT4tv*5F&B?mvGIM-O2(g4r?5khBT<2Eb+5v+n71{60%h zfE9WKtN^4|;hOF8*{X{+-AtEIrE}o~-r%Bo>HAkxm)~9v_Le<;4}Cbt*n&a8aKlYi7uEp7z(WtzjD^@2$924M$#Bo~k}K_7Djh;)aMyaA zO-jL*+r$Ou;yni*`k-cj#J)omBo{C)7D{@tPy#5J?t+>z9s30O3_fFHDCx#f;%xsz z7u4wNbq;De-yyff+Q~c=T0nU>lgy;1K#yf^r2l4U6%2H{!IMnUje^e`rI0 za-($`li)Xdlj*5AMh5^ZQEw8I7i1R#b4@3e=}h`GQLyV2W_U!c<1rNU9{>U_^v5;i zM>wq;vrfpqb`R^a^8h&yO?0Ldj5AbiEX%HQ9BcnKC&TuA!3?f2gI_QMpELbmFekoX uUizGQ{vVkD{QZ+Ba_Q)br|X>ax6aq>Y|}bJ^LKz?zqiu8x6E|V-To6H^NQC1 literal 0 HcmV?d00001 diff --git a/Backend/src/payments/services/__pycache__/approval_service.cpython-312.pyc b/Backend/src/payments/services/__pycache__/approval_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c31056689b742c5a55ae6795922886f06676b50d GIT binary patch literal 11657 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 PP`-^twqK^aq`m(Edd38h literal 0 HcmV?d00001 diff --git a/Backend/src/payments/services/__pycache__/financial_audit_service.cpython-312.pyc b/Backend/src/payments/services/__pycache__/financial_audit_service.cpython-312.pyc index 95c366108cc4cd9e8fab0083c732a0471e997402..012dfe6ca6140caaace051ee021b7dd1794ddbf0 100644 GIT binary patch delta 1190 zcmaJ;O-vI(6rSyNxBt6K+ojl2Sx8aJpQ0ofS`ZDQ#Gm*BCSqdJI9n)LMW%~NEy0Ti zO-v+{Xo)fQsG>o_*#ilB&}{_t6A;vI4ZuSxOwWW(C}4^+!;h?k`yY zj0Q|rj*u%QijTn)%N$esz<@07;biRML_98p;tbf*BwQ{w8RWCf=uT6W<6{7Ac$^C) z6w}k|OqR4n0J<h-3R70vsnBxuh{*Fo9}9WBu9mHysNB~#OfH*9`9YWo!`qMW5dHLD%fr$&{fi?z$PFpa7k?0tt7YE45{Dme=66j~|lArSQ-FUhZpbyMdcg&qq3 z%2Ba#C-q_k(jEfJ)`Q}>ONue47bhi`g=xokyfxu2n(iP_uxjKH&{0C4gH3{fq%V0p zr(>M=i`!?gB(BR121la7gFw)*QDz5W#c)vO1adfcZsw%nqRMS54^@JxM5-iEu_GtX zh(-lfUJ$Iy*U#1%KC1k{7s~CQX)$D~DuJ&flxxqk`Ez;uZ1<|S(Fjn#3P@Es_U@Fi zgQ_5KhJS?!+hhUnH7wN2;0523aF(u7LEu!h5}m-e{R@0QwOVn9To*h;X)z^E*dKfR i${3u`)NV9{({emC$}rvyP{bh?g!X<6wylCdvEVP?%>*(4 delta 920 zcmb7?&1(}u7{+IIvzzbTO}3lngVi*qO=Br-Qj6HsqSTgB>4y}Af*y*SwkfgIO=`3x zTD*CZI3g(`oKW7+qVLrQ>Tda%Om4$`0 zNyhEE&03flkr=;otm{IO z-<6`kq>ClHL(!24|286w#A;)%AG|BdE?~AZti?vlR3p=h=8Qq+1ay)cO0w%5$ny+! zelD9eEno#e63_$aV~D#=FRSex&#-{DTq=fD0mr{yW6fK958%>CUj7<+%QpOkTuN1@)2!NeYFZ9MQ7 DS0K(Y diff --git a/Backend/src/payments/services/__pycache__/gl_service.cpython-312.pyc b/Backend/src/payments/services/__pycache__/gl_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a2ad71638edff5dee6e68b7a5240ee49051df0c GIT binary patch literal 14600 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 NZT@#(`EBVo{vGe;El~gf literal 0 HcmV?d00001 diff --git a/Backend/src/payments/services/__pycache__/invoice_service.cpython-312.pyc b/Backend/src/payments/services/__pycache__/invoice_service.cpython-312.pyc index 92d4e2ba345470ca83e8fbb779f9a118fb101a70..4b948d440a19fa170abb5e2d443f7f45d6edb9a0 100644 GIT binary patch delta 3974 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 L%_xg>87%$_)&-<2 diff --git a/Backend/src/payments/services/__pycache__/reconciliation_service.cpython-312.pyc b/Backend/src/payments/services/__pycache__/reconciliation_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7a30bd925331f69935b83c484c0c7630805949bd GIT binary patch literal 14333 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 bool: + """Check if user role requires MFA.""" + # Admin and all accountant roles require MFA + if is_admin(user, db) or is_accountant(user, db): + return True + return False + + @staticmethod + def is_mfa_enforced(user: User, db: Session) -> Tuple[bool, Optional[str]]: + """ + Check if MFA is enforced for user. + Returns (is_enforced: bool, reason: str | None) + """ + if AccountantSecurityService.requires_mfa(user, db): + if not user.mfa_enabled: + return False, "MFA is required for accountant/admin roles but not enabled" + return True, None + return False, None + + @staticmethod + def create_session( + db: Session, + user_id: int, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + device_fingerprint: Optional[str] = None, + country: Optional[str] = None, + city: Optional[str] = None + ) -> AccountantSession: + """Create a new accountant session.""" + # Generate session token + session_token = secrets.token_urlsafe(32) + + # Calculate expiration (shorter for accountants) + expires_at = datetime.utcnow() + timedelta(hours=AccountantSecurityService.ACCOUNTANT_SESSION_TIMEOUT_HOURS) + + session = AccountantSession( + user_id=user_id, + session_token=session_token, + ip_address=ip_address, + user_agent=user_agent, + device_fingerprint=device_fingerprint, + country=country, + city=city, + is_active=True, + last_activity=datetime.utcnow(), + step_up_authenticated=False, + expires_at=expires_at + ) + + db.add(session) + db.flush() + + return session + + @staticmethod + def validate_session( + db: Session, + session_token: str, + update_activity: bool = True + ) -> Optional[AccountantSession]: + """Validate and update session activity.""" + session = db.query(AccountantSession).filter( + AccountantSession.session_token == session_token, + AccountantSession.is_active == True + ).first() + + if not session: + return None + + # Check if session expired + if session.expires_at < datetime.utcnow(): + session.is_active = False + db.flush() + return None + + # Check idle timeout + idle_timeout = datetime.utcnow() - timedelta(minutes=AccountantSecurityService.ACCOUNTANT_IDLE_TIMEOUT_MINUTES) + if session.last_activity < idle_timeout: + session.is_active = False + db.flush() + return None + + # Update last activity + if update_activity: + session.last_activity = datetime.utcnow() + db.flush() + + return session + + @staticmethod + def require_step_up( + db: Session, + user_id: int, + session_token: Optional[str] = None, + action_description: str = "high-risk action" + ) -> Tuple[bool, Optional[str]]: + """ + Check if step-up authentication is required. + Returns (requires_step_up: bool, reason: str | None) + """ + if not session_token: + return True, "Step-up authentication required for this action" + + session = AccountantSecurityService.validate_session(db, session_token, update_activity=False) + if not session: + return True, "Invalid or expired session" + + if session.user_id != user_id: + return True, "Session user mismatch" + + # Check if step-up is still valid + if session.step_up_authenticated and session.step_up_expires_at: + if session.step_up_expires_at > datetime.utcnow(): + return False, None # Step-up still valid + else: + session.step_up_authenticated = False + db.flush() + + return True, f"Step-up authentication required for {action_description}" + + @staticmethod + def complete_step_up( + db: Session, + session_token: str, + user_id: int + ) -> bool: + """Mark step-up authentication as completed.""" + session = db.query(AccountantSession).filter( + AccountantSession.session_token == session_token, + AccountantSession.user_id == user_id, + AccountantSession.is_active == True + ).first() + + if not session: + return False + + session.step_up_authenticated = True + session.step_up_expires_at = datetime.utcnow() + timedelta( + minutes=AccountantSecurityService.STEP_UP_VALIDITY_MINUTES + ) + + db.flush() + return True + + @staticmethod + def log_activity( + db: Session, + user_id: int, + activity_type: str, + activity_description: str, + session_id: Optional[int] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + country: Optional[str] = None, + city: Optional[str] = None, + risk_level: str = 'low', + is_unusual: bool = False, + metadata: Optional[Dict[str, Any]] = None + ) -> AccountantActivityLog: + """Log accountant activity for security monitoring.""" + log = AccountantActivityLog( + user_id=user_id, + session_id=session_id, + activity_type=activity_type, + activity_description=activity_description, + ip_address=ip_address, + user_agent=user_agent, + country=country, + city=city, + risk_level=risk_level, + is_unusual=is_unusual, + activity_metadata=metadata or {} + ) + + db.add(log) + db.flush() + + # Alert on high-risk or unusual activity + if risk_level in ['high', 'critical'] or is_unusual: + logger.warning( + f"High-risk accountant activity detected: {activity_type} by user {user_id}", + extra={ + 'user_id': user_id, + 'activity_type': activity_type, + 'risk_level': risk_level, + 'is_unusual': is_unusual, + 'ip_address': ip_address + } + ) + + return log + + @staticmethod + def detect_unusual_activity( + db: Session, + user_id: int, + ip_address: Optional[str] = None, + country: Optional[str] = None + ) -> bool: + """Detect if current activity is unusual based on user history.""" + # Get user's recent activity (last 30 days) + thirty_days_ago = datetime.utcnow() - timedelta(days=30) + + recent_activities = db.query(AccountantActivityLog).filter( + AccountantActivityLog.user_id == user_id, + AccountantActivityLog.created_at >= thirty_days_ago + ).all() + + if not recent_activities: + # First activity - not unusual + return False + + # Check for new IP address + if ip_address: + known_ips = set(act.ip_address for act in recent_activities if act.ip_address) + if ip_address not in known_ips and len(known_ips) > 0: + return True + + # Check for new country + if country: + known_countries = set(act.country for act in recent_activities if act.country) + if country not in known_countries and len(known_countries) > 0: + return True + + return False + + @staticmethod + def revoke_session( + db: Session, + session_token: str + ) -> bool: + """Revoke an accountant session.""" + session = db.query(AccountantSession).filter( + AccountantSession.session_token == session_token + ).first() + + if not session: + return False + + session.is_active = False + db.flush() + return True + + @staticmethod + def revoke_all_user_sessions( + db: Session, + user_id: int + ) -> int: + """Revoke all active sessions for a user.""" + count = db.query(AccountantSession).filter( + AccountantSession.user_id == user_id, + AccountantSession.is_active == True + ).update({'is_active': False}) + + db.flush() + return count + + +# Singleton instance +accountant_security_service = AccountantSecurityService() + diff --git a/Backend/src/payments/services/approval_service.py b/Backend/src/payments/services/approval_service.py new file mode 100644 index 00000000..e59b447e --- /dev/null +++ b/Backend/src/payments/services/approval_service.py @@ -0,0 +1,257 @@ +""" +Service for handling financial approval workflows with segregation of duties. +""" +from sqlalchemy.orm import Session +from typing import Optional, Dict, Any, List +from datetime import datetime +from ..models.financial_approval import FinancialApproval, ApprovalStatus, ApprovalActionType +from ...auth.models.user import User +from ...shared.utils.role_helpers import is_accountant_approver, is_accountant_readonly, is_admin +from ...shared.config.logging_config import get_logger + +logger = get_logger(__name__) + + +class ApprovalService: + """Service for managing financial approvals.""" + + # Configurable thresholds (can be moved to system settings) + LARGE_REFUND_THRESHOLD = 1000.0 # $1000 + LARGE_DISCOUNT_THRESHOLD = 500.0 # $500 or 20% of invoice + LARGE_DISCOUNT_PERCENTAGE_THRESHOLD = 20.0 # 20% + + @staticmethod + def requires_approval( + action_type: ApprovalActionType, + amount: Optional[float] = None, + invoice_total: Optional[float] = None, + db: Session = None + ) -> bool: + """Check if an action requires approval based on thresholds.""" + # Admin actions don't require approval + # (This check should be done at the route level, but we include it here for safety) + + if action_type == ApprovalActionType.large_refund: + return amount is not None and amount >= ApprovalService.LARGE_REFUND_THRESHOLD + + if action_type == ApprovalActionType.large_discount: + if amount is not None and amount >= ApprovalService.LARGE_DISCOUNT_THRESHOLD: + return True + if invoice_total and amount: + discount_percentage = (amount / invoice_total) * 100 + return discount_percentage >= ApprovalService.LARGE_DISCOUNT_PERCENTAGE_THRESHOLD + return False + + if action_type == ApprovalActionType.invoice_write_off: + return True # All write-offs require approval + + if action_type == ApprovalActionType.payment_status_override: + return True # All manual status overrides require approval + + if action_type == ApprovalActionType.tax_rate_change: + return True # All tax rate changes require approval + + if action_type == ApprovalActionType.manual_payment_adjustment: + return amount is not None and amount >= ApprovalService.LARGE_REFUND_THRESHOLD + + return False + + @staticmethod + def can_approve(user: User, approval: FinancialApproval, db: Session) -> tuple[bool, str]: + """ + Check if a user can approve a specific approval request. + Returns (can_approve: bool, reason: str) + Implements segregation of duties - users cannot approve their own actions. + """ + # Admin can always approve + if is_admin(user, db): + return True, "Admin approval" + + # Only approvers can approve + if not is_accountant_approver(user, db): + return False, "User does not have approval permissions" + + # Segregation of duties: users cannot approve their own requests + if approval.requested_by == user.id: + return False, "Users cannot approve their own requests (segregation of duties)" + + # Check if already approved/rejected + if approval.status != ApprovalStatus.pending: + return False, f"Approval request is already {approval.status.value}" + + return True, "Approval allowed" + + @staticmethod + def create_approval_request( + db: Session, + action_type: ApprovalActionType, + requested_by: int, + action_description: str, + amount: Optional[float] = None, + payment_id: Optional[int] = None, + invoice_id: Optional[int] = None, + booking_id: Optional[int] = None, + previous_value: Optional[Dict[str, Any]] = None, + new_value: Optional[Dict[str, Any]] = None, + request_reason: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> FinancialApproval: + """Create a new approval request.""" + user = db.query(User).filter(User.id == requested_by).first() + if not user: + raise ValueError("Requesting user not found") + + approval = FinancialApproval( + action_type=action_type, + action_description=action_description, + status=ApprovalStatus.pending, + requested_by=requested_by, + requested_by_email=user.email, + amount=amount, + payment_id=payment_id, + invoice_id=invoice_id, + booking_id=booking_id, + previous_value=previous_value, + new_value=new_value, + request_reason=request_reason, + approval_metadata=metadata or {} + ) + + db.add(approval) + db.flush() + return approval + + @staticmethod + def approve_request( + db: Session, + approval_id: int, + approved_by: int, + approval_notes: Optional[str] = None + ) -> FinancialApproval: + """Approve an approval request.""" + approval = db.query(FinancialApproval).filter(FinancialApproval.id == approval_id).first() + if not approval: + raise ValueError("Approval request not found") + + approver = db.query(User).filter(User.id == approved_by).first() + if not approver: + raise ValueError("Approver user not found") + + can_approve, reason = ApprovalService.can_approve(approver, approval, db) + if not can_approve: + raise ValueError(f"Cannot approve: {reason}") + + approval.status = ApprovalStatus.approved + approval.approved_by = approved_by + approval.approved_by_email = approver.email + approval.approval_notes = approval_notes + approval.approved_at = datetime.utcnow() + + db.flush() + return approval + + @staticmethod + def reject_request( + db: Session, + approval_id: int, + rejected_by: int, + rejection_reason: str + ) -> FinancialApproval: + """Reject an approval request.""" + approval = db.query(FinancialApproval).filter(FinancialApproval.id == approval_id).first() + if not approval: + raise ValueError("Approval request not found") + + rejector = db.query(User).filter(User.id == rejected_by).first() + if not rejector: + raise ValueError("Rejector user not found") + + # Check permissions (same as approval) + can_approve, reason = ApprovalService.can_approve(rejector, approval, db) + if not can_approve: + raise ValueError(f"Cannot reject: {reason}") + + if approval.status != ApprovalStatus.pending: + raise ValueError(f"Approval request is already {approval.status.value}") + + approval.status = ApprovalStatus.rejected + approval.rejected_by = rejected_by + approval.rejection_reason = rejection_reason + approval.rejected_at = datetime.utcnow() + + db.flush() + return approval + + @staticmethod + def get_pending_approvals( + db: Session, + action_type: Optional[ApprovalActionType] = None, + limit: int = 50 + ) -> List[FinancialApproval]: + """Get pending approval requests.""" + query = db.query(FinancialApproval).filter( + FinancialApproval.status == ApprovalStatus.pending + ) + + if action_type: + query = query.filter(FinancialApproval.action_type == action_type) + + return query.order_by(FinancialApproval.created_at.desc()).limit(limit).all() + + @staticmethod + def get_approval_by_id(db: Session, approval_id: int) -> Optional[FinancialApproval]: + """Get an approval request by ID.""" + return db.query(FinancialApproval).filter(FinancialApproval.id == approval_id).first() + + @staticmethod + def get_approvals_for_action( + db: Session, + action_type: ApprovalActionType, + payment_id: Optional[int] = None, + invoice_id: Optional[int] = None, + booking_id: Optional[int] = None + ) -> List[FinancialApproval]: + """Get approval requests for a specific action/entity.""" + query = db.query(FinancialApproval).filter( + FinancialApproval.action_type == action_type + ) + + if payment_id: + query = query.filter(FinancialApproval.payment_id == payment_id) + if invoice_id: + query = query.filter(FinancialApproval.invoice_id == invoice_id) + if booking_id: + query = query.filter(FinancialApproval.booking_id == booking_id) + + return query.order_by(FinancialApproval.created_at.desc()).all() + + @staticmethod + def get_approvals( + db: Session, + status: Optional[ApprovalStatus] = None, + action_type: Optional[ApprovalActionType] = None, + requested_by: Optional[int] = None, + approved_by: Optional[int] = None, + page: int = 1, + limit: int = 50 + ) -> List[FinancialApproval]: + """Get approval requests with optional filters.""" + query = db.query(FinancialApproval) + + if status: + query = query.filter(FinancialApproval.status == status) + if action_type: + query = query.filter(FinancialApproval.action_type == action_type) + if requested_by: + query = query.filter(FinancialApproval.requested_by == requested_by) + if approved_by: + query = query.filter(FinancialApproval.approved_by == approved_by) + + # Pagination + offset = (page - 1) * limit + return query.order_by(FinancialApproval.created_at.desc()).offset(offset).limit(limit).all() + + +# Singleton instance +approval_service = ApprovalService() + diff --git a/Backend/src/payments/services/audit_retention_service.py b/Backend/src/payments/services/audit_retention_service.py new file mode 100644 index 00000000..bdef26c0 --- /dev/null +++ b/Backend/src/payments/services/audit_retention_service.py @@ -0,0 +1,93 @@ +""" +Service for managing audit trail retention policies. +""" +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from ..models.financial_audit_trail import FinancialAuditTrail +from ...shared.config.logging_config import get_logger + +logger = get_logger(__name__) + + +class AuditRetentionService: + """Service for managing audit trail retention.""" + + # Default retention period: 7 years (2555 days) - common for financial records + DEFAULT_RETENTION_DAYS = 2555 + + # Minimum retention: 1 year (365 days) - legal minimum in many jurisdictions + MIN_RETENTION_DAYS = 365 + + # Maximum retention: 10 years (3650 days) - reasonable maximum + MAX_RETENTION_DAYS = 3650 + + @staticmethod + def get_retention_policy() -> Dict[str, Any]: + """Get current retention policy settings.""" + return { + 'default_retention_days': AuditRetentionService.DEFAULT_RETENTION_DAYS, + 'min_retention_days': AuditRetentionService.MIN_RETENTION_DAYS, + 'max_retention_days': AuditRetentionService.MAX_RETENTION_DAYS, + 'description': 'Financial audit trail retention policy' + } + + @staticmethod + def get_records_eligible_for_deletion( + db: Session, + retention_days: int = DEFAULT_RETENTION_DAYS + ) -> int: + """Get count of records eligible for deletion based on retention policy.""" + if retention_days < AuditRetentionService.MIN_RETENTION_DAYS: + retention_days = AuditRetentionService.MIN_RETENTION_DAYS + if retention_days > AuditRetentionService.MAX_RETENTION_DAYS: + retention_days = AuditRetentionService.MAX_RETENTION_DAYS + + cutoff_date = datetime.utcnow() - timedelta(days=retention_days) + + return db.query(FinancialAuditTrail).filter( + FinancialAuditTrail.created_at < cutoff_date + ).count() + + @staticmethod + def get_retention_statistics( + db: Session, + retention_days: int = DEFAULT_RETENTION_DAYS + ) -> Dict[str, Any]: + """Get statistics about audit trail retention.""" + if retention_days < AuditRetentionService.MIN_RETENTION_DAYS: + retention_days = AuditRetentionService.MIN_RETENTION_DAYS + if retention_days > AuditRetentionService.MAX_RETENTION_DAYS: + retention_days = AuditRetentionService.MAX_RETENTION_DAYS + + cutoff_date = datetime.utcnow() - timedelta(days=retention_days) + + total_records = db.query(FinancialAuditTrail).count() + records_to_delete = db.query(FinancialAuditTrail).filter( + FinancialAuditTrail.created_at < cutoff_date + ).count() + records_to_keep = total_records - records_to_delete + + oldest_record = db.query(FinancialAuditTrail).order_by( + FinancialAuditTrail.created_at.asc() + ).first() + + newest_record = db.query(FinancialAuditTrail).order_by( + FinancialAuditTrail.created_at.desc() + ).first() + + return { + 'retention_days': retention_days, + 'cutoff_date': cutoff_date.isoformat(), + 'total_records': total_records, + 'records_to_keep': records_to_keep, + 'records_to_delete': records_to_delete, + 'oldest_record_date': oldest_record.created_at.isoformat() if oldest_record else None, + 'newest_record_date': newest_record.created_at.isoformat() if newest_record else None, + 'retention_policy': AuditRetentionService.get_retention_policy() + } + + +# Singleton instance +audit_retention_service = AuditRetentionService() + diff --git a/Backend/src/payments/services/financial_audit_service.py b/Backend/src/payments/services/financial_audit_service.py index 50194c10..b8645c15 100644 --- a/Backend/src/payments/services/financial_audit_service.py +++ b/Backend/src/payments/services/financial_audit_service.py @@ -52,6 +52,12 @@ class FinancialAuditService: created_at=datetime.utcnow() ) + # SECURITY: Ensure append-only behavior - prevent any updates to existing records + # This is enforced at the database level, but we also check here + if hasattr(audit_record, 'id') and audit_record.id: + # If somehow an ID exists, this shouldn't happen for new records + logger.warning(f"Attempted to create audit record with existing ID: {audit_record.id}") + db.add(audit_record) db.flush() # Flush to get ID without committing diff --git a/Backend/src/payments/services/gl_posting_service.py b/Backend/src/payments/services/gl_posting_service.py new file mode 100644 index 00000000..8a495e89 --- /dev/null +++ b/Backend/src/payments/services/gl_posting_service.py @@ -0,0 +1,270 @@ +""" +Service for automatically posting transactions to General Ledger. +""" +from sqlalchemy.orm import Session +from typing import Optional, Dict, Any +from datetime import datetime +from ..services.gl_service import gl_service +from ..models.chart_of_accounts import AccountType, AccountCategory +from ...shared.config.logging_config import get_logger + +logger = get_logger(__name__) + + +class GLPostingService: + """Service for posting business transactions to GL.""" + + @staticmethod + def post_payment_received( + db: Session, + payment_id: int, + booking_id: int, + amount: float, + payment_date: datetime, + created_by: int, + payment_method: str = 'cash' + ) -> Optional[int]: + """Post a payment received to GL.""" + try: + # Ensure accounts exist + cash_account = gl_service.get_or_create_account( + db=db, + account_code=gl_service.ACCOUNT_CODES['cash'], + account_name='Cash', + account_type=AccountType.asset, + account_category=AccountCategory.current_assets + ) + + revenue_account = gl_service.get_or_create_account( + db=db, + account_code=gl_service.ACCOUNT_CODES['revenue'], + account_name='Room Revenue', + account_type=AccountType.revenue, + account_category=AccountCategory.operating_revenue + ) + + # Create journal entry + entry = gl_service.create_journal_entry( + db=db, + entry_date=payment_date, + description=f'Payment received for booking {booking_id}', + lines=[ + { + 'account_code': cash_account.account_code, + 'debit': amount, + 'credit': 0.0, + 'description': f'Payment {payment_id} received' + }, + { + 'account_code': revenue_account.account_code, + 'debit': 0.0, + 'credit': amount, + 'description': f'Revenue from booking {booking_id}' + } + ], + reference_type='payment', + reference_id=payment_id, + created_by=created_by, + auto_post=True + ) + + return entry.id + except Exception as e: + logger.error(f'Error posting payment to GL: {str(e)}', exc_info=True) + # Don't fail the main transaction if GL posting fails + return None + + @staticmethod + def post_invoice_created( + db: Session, + invoice_id: int, + booking_id: int, + subtotal: float, + tax_amount: float, + total_amount: float, + invoice_date: datetime, + created_by: int + ) -> Optional[int]: + """Post an invoice creation to GL.""" + try: + # Ensure accounts exist + ar_account = gl_service.get_or_create_account( + db=db, + account_code=gl_service.ACCOUNT_CODES['accounts_receivable'], + account_name='Accounts Receivable', + account_type=AccountType.asset, + account_category=AccountCategory.current_assets + ) + + revenue_account = gl_service.get_or_create_account( + db=db, + account_code=gl_service.ACCOUNT_CODES['revenue'], + account_name='Room Revenue', + account_type=AccountType.revenue, + account_category=AccountCategory.operating_revenue + ) + + tax_payable_account = gl_service.get_or_create_account( + db=db, + account_code=gl_service.ACCOUNT_CODES['tax_payable'], + account_name='Tax Payable', + account_type=AccountType.liability, + account_category=AccountCategory.current_liabilities + ) + + # Create journal entry + lines = [ + { + 'account_code': ar_account.account_code, + 'debit': total_amount, + 'credit': 0.0, + 'description': f'Invoice {invoice_id} receivable' + }, + { + 'account_code': revenue_account.account_code, + 'debit': 0.0, + 'credit': subtotal, + 'description': f'Revenue from invoice {invoice_id}' + } + ] + + if tax_amount > 0: + lines.append({ + 'account_code': tax_payable_account.account_code, + 'debit': 0.0, + 'credit': tax_amount, + 'description': f'Tax on invoice {invoice_id}' + }) + + entry = gl_service.create_journal_entry( + db=db, + entry_date=invoice_date, + description=f'Invoice {invoice_id} created for booking {booking_id}', + lines=lines, + reference_type='invoice', + reference_id=invoice_id, + created_by=created_by, + auto_post=True + ) + + return entry.id + except Exception as e: + logger.error(f'Error posting invoice to GL: {str(e)}', exc_info=True) + return None + + @staticmethod + def post_invoice_paid( + db: Session, + invoice_id: int, + payment_id: int, + amount: float, + payment_date: datetime, + created_by: int + ) -> Optional[int]: + """Post invoice payment to GL (reduce AR, increase cash).""" + try: + cash_account = gl_service.get_or_create_account( + db=db, + account_code=gl_service.ACCOUNT_CODES['cash'], + account_name='Cash', + account_type=AccountType.asset, + account_category=AccountCategory.current_assets + ) + + ar_account = gl_service.get_or_create_account( + db=db, + account_code=gl_service.ACCOUNT_CODES['accounts_receivable'], + account_name='Accounts Receivable', + account_type=AccountType.asset, + account_category=AccountCategory.current_assets + ) + + entry = gl_service.create_journal_entry( + db=db, + entry_date=payment_date, + description=f'Payment received for invoice {invoice_id}', + lines=[ + { + 'account_code': cash_account.account_code, + 'debit': amount, + 'credit': 0.0, + 'description': f'Payment {payment_id} received' + }, + { + 'account_code': ar_account.account_code, + 'debit': 0.0, + 'credit': amount, + 'description': f'Invoice {invoice_id} payment applied' + } + ], + reference_type='payment', + reference_id=payment_id, + created_by=created_by, + auto_post=True + ) + + return entry.id + except Exception as e: + logger.error(f'Error posting invoice payment to GL: {str(e)}', exc_info=True) + return None + + @staticmethod + def post_refund( + db: Session, + payment_id: int, + booking_id: int, + amount: float, + refund_date: datetime, + created_by: int + ) -> Optional[int]: + """Post a refund to GL.""" + try: + cash_account = gl_service.get_or_create_account( + db=db, + account_code=gl_service.ACCOUNT_CODES['cash'], + account_name='Cash', + account_type=AccountType.asset, + account_category=AccountCategory.current_assets + ) + + revenue_account = gl_service.get_or_create_account( + db=db, + account_code=gl_service.ACCOUNT_CODES['revenue'], + account_name='Room Revenue', + account_type=AccountType.revenue, + account_category=AccountCategory.operating_revenue + ) + + entry = gl_service.create_journal_entry( + db=db, + entry_date=refund_date, + description=f'Refund for booking {booking_id}', + lines=[ + { + 'account_code': revenue_account.account_code, + 'debit': amount, + 'credit': 0.0, + 'description': f'Refund for payment {payment_id}' + }, + { + 'account_code': cash_account.account_code, + 'debit': 0.0, + 'credit': amount, + 'description': f'Refund payment {payment_id}' + } + ], + reference_type='payment', + reference_id=payment_id, + created_by=created_by, + auto_post=True + ) + + return entry.id + except Exception as e: + logger.error(f'Error posting refund to GL: {str(e)}', exc_info=True) + return None + + +# Singleton instance +gl_posting_service = GLPostingService() + diff --git a/Backend/src/payments/services/gl_service.py b/Backend/src/payments/services/gl_service.py new file mode 100644 index 00000000..b2c826d7 --- /dev/null +++ b/Backend/src/payments/services/gl_service.py @@ -0,0 +1,321 @@ +""" +General Ledger service for posting transactions and managing GL operations. +""" +from sqlalchemy.orm import Session +from sqlalchemy import func, and_, or_ +from typing import Optional, Dict, Any, List +from datetime import datetime, timedelta +from ..models.chart_of_accounts import ChartOfAccounts, AccountType, AccountCategory +from ..models.fiscal_period import FiscalPeriod, PeriodStatus +from ..models.journal_entry import JournalEntry, JournalLine, JournalEntryStatus +from ...shared.config.logging_config import get_logger + +logger = get_logger(__name__) + + +class GLService: + """Service for General Ledger operations.""" + + # Standard account codes (can be seeded) + ACCOUNT_CODES = { + 'cash': '1000', + 'accounts_receivable': '1100', + 'accounts_payable': '2000', + 'deferred_revenue': '2100', + 'revenue': '4000', + 'cogs': '5000', + 'operating_expenses': '6000', + 'tax_payable': '2200', + 'retained_earnings': '3000', + } + + @staticmethod + def get_or_create_account( + db: Session, + account_code: str, + account_name: str, + account_type: AccountType, + account_category: Optional[AccountCategory] = None, + description: Optional[str] = None + ) -> ChartOfAccounts: + """Get or create a chart of accounts entry.""" + account = db.query(ChartOfAccounts).filter( + ChartOfAccounts.account_code == account_code + ).first() + + if not account: + account = ChartOfAccounts( + account_code=account_code, + account_name=account_name, + account_type=account_type, + account_category=account_category, + description=description, + is_active='true' + ) + db.add(account) + db.flush() + + return account + + @staticmethod + def get_current_period(db: Session) -> Optional[FiscalPeriod]: + """Get the current fiscal period.""" + return db.query(FiscalPeriod).filter( + FiscalPeriod.is_current == True, + FiscalPeriod.status == PeriodStatus.open + ).first() + + @staticmethod + def get_period_for_date(db: Session, date: datetime) -> Optional[FiscalPeriod]: + """Get the fiscal period for a given date.""" + return db.query(FiscalPeriod).filter( + FiscalPeriod.start_date <= date, + FiscalPeriod.end_date >= date + ).first() + + @staticmethod + def create_period( + db: Session, + period_name: str, + start_date: datetime, + end_date: datetime, + period_type: str = 'monthly' + ) -> FiscalPeriod: + """Create a new fiscal period.""" + # Ensure only one current period + if period_type == 'monthly': + db.query(FiscalPeriod).filter(FiscalPeriod.is_current == True).update({'is_current': False}) + + period = FiscalPeriod( + period_name=period_name, + period_type=period_type, + start_date=start_date, + end_date=end_date, + status=PeriodStatus.open, + is_current=True + ) + db.add(period) + db.flush() + return period + + @staticmethod + def close_period( + db: Session, + period_id: int, + closed_by: int, + notes: Optional[str] = None + ) -> FiscalPeriod: + """Close a fiscal period.""" + period = db.query(FiscalPeriod).filter(FiscalPeriod.id == period_id).first() + if not period: + raise ValueError("Period not found") + + if period.status != PeriodStatus.open: + raise ValueError(f"Period is already {period.status.value}") + + period.status = PeriodStatus.closed + period.closed_by = closed_by + period.closed_at = datetime.utcnow() + period.notes = notes + period.is_current = False + + db.flush() + return period + + @staticmethod + def generate_entry_number(db: Session, entry_date: datetime) -> str: + """Generate a unique journal entry number.""" + date_str = entry_date.strftime('%Y%m%d') + last_entry = db.query(JournalEntry).filter( + JournalEntry.entry_number.like(f'JE-{date_str}-%') + ).order_by(JournalEntry.entry_number.desc()).first() + + if last_entry: + try: + sequence = int(last_entry.entry_number.split('-')[-1]) + sequence += 1 + except (ValueError, IndexError): + sequence = 1 + else: + sequence = 1 + + return f'JE-{date_str}-{sequence:04d}' + + @staticmethod + def create_journal_entry( + db: Session, + entry_date: datetime, + description: str, + lines: List[Dict[str, Any]], + reference_type: Optional[str] = None, + reference_id: Optional[int] = None, + created_by: int = None, + notes: Optional[str] = None, + auto_post: bool = False + ) -> JournalEntry: + """ + Create a journal entry with lines. + Lines format: [{'account_code': '1000', 'debit': 100.0, 'credit': 0.0, 'description': '...'}, ...] + """ + # Validate that debits equal credits + total_debits = sum(line.get('debit', 0) or 0 for line in lines) + total_credits = sum(line.get('credit', 0) or 0 for line in lines) + + if abs(total_debits - total_credits) > 0.01: + raise ValueError(f"Journal entry is not balanced. Debits: {total_debits}, Credits: {total_credits}") + + # Get or create fiscal period + period = GLService.get_period_for_date(db, entry_date) + if not period: + # Create a monthly period if none exists + month_start = entry_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + if month_start.month == 12: + month_end = month_start.replace(year=month_start.year + 1, month=1) - timedelta(days=1) + else: + month_end = (month_start.replace(month=month_start.month + 1) - timedelta(days=1)) + month_end = month_end.replace(hour=23, minute=59, second=59) + + period_name = entry_date.strftime('%Y-%m') + period = GLService.create_period(db, period_name, month_start, month_end, 'monthly') + + # Check if period is closed + if period.status == PeriodStatus.closed: + raise ValueError(f"Cannot post to closed period: {period.period_name}") + + # Generate entry number + entry_number = GLService.generate_entry_number(db, entry_date) + + # Create journal entry + entry = JournalEntry( + entry_number=entry_number, + entry_date=entry_date, + description=description, + fiscal_period_id=period.id, + reference_type=reference_type, + reference_id=reference_id, + created_by=created_by, + notes=notes, + status=JournalEntryStatus.draft + ) + db.add(entry) + db.flush() + + # Create journal lines + for idx, line_data in enumerate(lines, start=1): + account_code = line_data.get('account_code') + if not account_code: + raise ValueError(f"Line {idx} missing account_code") + + # Get or create account + account = db.query(ChartOfAccounts).filter( + ChartOfAccounts.account_code == account_code + ).first() + + if not account: + raise ValueError(f"Account {account_code} not found in chart of accounts") + + journal_line = JournalLine( + journal_entry_id=entry.id, + line_number=idx, + account_id=account.id, + debit_amount=line_data.get('debit', 0) or 0, + credit_amount=line_data.get('credit', 0) or 0, + description=line_data.get('description') + ) + db.add(journal_line) + + # Auto-post if requested + if auto_post: + GLService.post_entry(db, entry.id, created_by) + + db.flush() + return entry + + @staticmethod + def post_entry(db: Session, entry_id: int, posted_by: int) -> JournalEntry: + """Post a journal entry (change status from draft to posted).""" + entry = db.query(JournalEntry).filter(JournalEntry.id == entry_id).first() + if not entry: + raise ValueError("Journal entry not found") + + if entry.status != JournalEntryStatus.draft: + raise ValueError(f"Entry is already {entry.status.value}") + + # Check period status + period = db.query(FiscalPeriod).filter(FiscalPeriod.id == entry.fiscal_period_id).first() + if period and period.status == PeriodStatus.closed: + raise ValueError(f"Cannot post to closed period: {period.period_name}") + + entry.status = JournalEntryStatus.posted + entry.posted_by = posted_by + entry.posted_at = datetime.utcnow() + + db.flush() + return entry + + @staticmethod + def get_trial_balance( + db: Session, + period_id: Optional[int] = None, + as_of_date: Optional[datetime] = None + ) -> Dict[str, Any]: + """Generate trial balance from journal entries.""" + query = db.query( + ChartOfAccounts.id, + ChartOfAccounts.account_code, + ChartOfAccounts.account_name, + ChartOfAccounts.account_type, + func.sum(JournalLine.debit_amount).label('total_debits'), + func.sum(JournalLine.credit_amount).label('total_credits') + ).join( + JournalLine, ChartOfAccounts.id == JournalLine.account_id + ).join( + JournalEntry, JournalLine.journal_entry_id == JournalEntry.id + ).filter( + JournalEntry.status == JournalEntryStatus.posted + ) + + if period_id: + query = query.filter(JournalEntry.fiscal_period_id == period_id) + elif as_of_date: + query = query.filter(JournalEntry.entry_date <= as_of_date) + + results = query.group_by( + ChartOfAccounts.id, + ChartOfAccounts.account_code, + ChartOfAccounts.account_name, + ChartOfAccounts.account_type + ).all() + + trial_balance = [] + total_debits = 0.0 + total_credits = 0.0 + + for result in results: + debits = float(result.total_debits or 0) + credits = float(result.total_credits or 0) + balance = debits - credits + + trial_balance.append({ + 'account_code': result.account_code, + 'account_name': result.account_name, + 'account_type': result.account_type.value, + 'debits': debits, + 'credits': credits, + 'balance': balance + }) + + total_debits += debits + total_credits += credits + + return { + 'trial_balance': trial_balance, + 'total_debits': total_debits, + 'total_credits': total_credits, + 'is_balanced': abs(total_debits - total_credits) < 0.01 + } + + +# Singleton instance +gl_service = GLService() + diff --git a/Backend/src/payments/services/invoice_service.py b/Backend/src/payments/services/invoice_service.py index bacd51f6..a66d7724 100644 --- a/Backend/src/payments/services/invoice_service.py +++ b/Backend/src/payments/services/invoice_service.py @@ -35,25 +35,43 @@ class InvoiceService: from ...rooms.models.room_type import RoomType from ...hotel_services.models.service import Service from sqlalchemy.exc import IntegrityError - - logger.info(f'Creating invoice from booking {booking_id}', extra={'booking_id': booking_id, 'request_id': request_id}) - - # Start transaction - transaction = db.begin() + + logger.info( + f'Creating invoice from booking {booking_id}', + extra={'booking_id': booking_id, 'request_id': request_id}, + ) + + # NOTE: + # FastAPI's `get_db` dependency already provides a Session with an + # active transaction. Calling `Session.begin()` again on that same + # Session results in `InvalidRequestError: A transaction is already begun`. + # Instead of managing a separate Transaction object, we operate on the + # Session directly and use `db.commit()` / `db.rollback()`. try: # Lock booking row to prevent race conditions - booking = db.query(Booking).options( - selectinload(Booking.service_usages).selectinload(ServiceUsage.service), - selectinload(Booking.room).selectinload(Room.room_type), - selectinload(Booking.payments) - ).filter(Booking.id == booking_id).with_for_update().first() + booking = ( + db.query(Booking) + .options( + selectinload(Booking.service_usages).selectinload( + ServiceUsage.service + ), + selectinload(Booking.room).selectinload(Room.room_type), + selectinload(Booking.payments), + ) + .filter(Booking.id == booking_id) + .with_for_update() + .first() + ) if not booking: - transaction.rollback() - logger.error(f'Booking {booking_id} not found', extra={'booking_id': booking_id, 'request_id': request_id}) + db.rollback() + logger.error( + f'Booking {booking_id} not found', + extra={'booking_id': booking_id, 'request_id': request_id}, + ) raise ValueError('Booking not found') user = db.query(User).filter(User.id == booking.user_id).first() if not user: - transaction.rollback() + db.rollback() raise ValueError('User not found') # Get tax_rate from system settings if not provided or is 0 @@ -149,18 +167,25 @@ class InvoiceService: invoice.tax_amount = tax_amount invoice.total_amount = total_amount invoice.balance_due = balance_due - - # Commit transaction - transaction.commit() + + # Commit all invoice changes in the current Session transaction + db.commit() db.refresh(invoice) return InvoiceService.invoice_to_dict(invoice) except IntegrityError as e: - transaction.rollback() - logger.error(f'Database integrity error during invoice creation: {str(e)}', extra={'booking_id': booking_id, 'request_id': request_id}) + db.rollback() + logger.error( + f'Database integrity error during invoice creation: {str(e)}', + extra={'booking_id': booking_id, 'request_id': request_id}, + ) raise ValueError(f'Invoice creation failed due to database conflict: {str(e)}') except Exception as e: - transaction.rollback() - logger.error(f'Error creating invoice: {str(e)}', extra={'booking_id': booking_id, 'request_id': request_id}, exc_info=True) + db.rollback() + logger.error( + f'Error creating invoice: {str(e)}', + extra={'booking_id': booking_id, 'request_id': request_id}, + exc_info=True, + ) raise @staticmethod diff --git a/Backend/src/payments/services/reconciliation_service.py b/Backend/src/payments/services/reconciliation_service.py new file mode 100644 index 00000000..90534f3a --- /dev/null +++ b/Backend/src/payments/services/reconciliation_service.py @@ -0,0 +1,307 @@ +""" +Service for payment/invoice reconciliation and exception management. +""" +from sqlalchemy.orm import Session +from sqlalchemy import func, and_, or_ +from typing import Optional, Dict, Any, List +from datetime import datetime, timedelta +from ..models.payment import Payment, PaymentStatus +from ..models.invoice import Invoice, InvoiceStatus +from ..models.reconciliation_exception import ReconciliationException, ExceptionType, ExceptionStatus +from ...shared.config.logging_config import get_logger + +logger = get_logger(__name__) + + +class ReconciliationService: + """Service for reconciliation operations.""" + + @staticmethod + def run_reconciliation( + db: Session, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None + ) -> Dict[str, Any]: + """Run reconciliation and detect exceptions.""" + if not start_date: + start_date = datetime.utcnow() - timedelta(days=30) + if not end_date: + end_date = datetime.utcnow() + + exceptions = [] + + # Get all completed payments in period + payments = db.query(Payment).filter( + and_( + Payment.payment_status == PaymentStatus.completed, + Payment.payment_date >= start_date, + Payment.payment_date <= end_date + ) + ).all() + + # Check for payments without invoices + for payment in payments: + invoice = db.query(Invoice).filter( + Invoice.booking_id == payment.booking_id + ).first() + + if not invoice: + # Check if exception already exists + existing = db.query(ReconciliationException).filter( + and_( + ReconciliationException.payment_id == payment.id, + ReconciliationException.exception_type == ExceptionType.missing_invoice, + ReconciliationException.status != ExceptionStatus.resolved, + ReconciliationException.status != ExceptionStatus.closed + ) + ).first() + + if not existing: + exception = ReconciliationException( + exception_type=ExceptionType.missing_invoice, + status=ExceptionStatus.open, + severity='high', + payment_id=payment.id, + booking_id=payment.booking_id, + description=f'Payment {payment.id} has no associated invoice', + actual_amount=float(payment.amount) if payment.amount else None, + exception_metadata={ + 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None, + 'payment_method': payment.payment_method.value if hasattr(payment.payment_method, 'value') else str(payment.payment_method) + } + ) + db.add(exception) + exceptions.append(exception) + else: + # Check for amount mismatches + payment_amount = float(payment.amount) if payment.amount else 0.0 + invoice_total = float(invoice.total_amount) if invoice.total_amount else 0.0 + invoice_paid = float(invoice.amount_paid) if invoice.amount_paid else 0.0 + + # Check if payment amount matches invoice expectations + if abs(payment_amount - (invoice_total - invoice_paid + payment_amount)) > 0.01: + # Potential mismatch - check if exception exists + existing = db.query(ReconciliationException).filter( + and_( + ReconciliationException.payment_id == payment.id, + ReconciliationException.invoice_id == invoice.id, + ReconciliationException.exception_type == ExceptionType.amount_mismatch, + ReconciliationException.status != ExceptionStatus.resolved, + ReconciliationException.status != ExceptionStatus.closed + ) + ).first() + + if not existing: + difference = payment_amount - (invoice_total - invoice_paid) + exception = ReconciliationException( + exception_type=ExceptionType.amount_mismatch, + status=ExceptionStatus.open, + severity='high' if abs(difference) > 100 else 'medium', + payment_id=payment.id, + invoice_id=invoice.id, + booking_id=payment.booking_id, + description=f'Payment amount mismatch: Payment {payment.id} amount {payment_amount} vs Invoice {invoice.id}', + expected_amount=invoice_total - invoice_paid, + actual_amount=payment_amount, + difference=difference, + exception_metadata={ + 'invoice_total': invoice_total, + 'invoice_paid': invoice_paid, + 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None + } + ) + db.add(exception) + exceptions.append(exception) + + # Check for invoices without payments + invoices = db.query(Invoice).filter( + and_( + Invoice.status == InvoiceStatus.paid, + Invoice.paid_date >= start_date, + Invoice.paid_date <= end_date + ) + ).all() + + for invoice in invoices: + payments_for_invoice = db.query(Payment).filter( + Payment.booking_id == invoice.booking_id, + Payment.payment_status == PaymentStatus.completed + ).all() + + if not payments_for_invoice: + # Check if exception exists + existing = db.query(ReconciliationException).filter( + and_( + ReconciliationException.invoice_id == invoice.id, + ReconciliationException.exception_type == ExceptionType.missing_payment, + ReconciliationException.status != ExceptionStatus.resolved, + ReconciliationException.status != ExceptionStatus.closed + ) + ).first() + + if not existing: + exception = ReconciliationException( + exception_type=ExceptionType.missing_payment, + status=ExceptionStatus.open, + severity='critical', + invoice_id=invoice.id, + booking_id=invoice.booking_id, + description=f'Invoice {invoice.invoice_number} marked as paid but no payments found', + expected_amount=float(invoice.total_amount) if invoice.total_amount else None, + exception_metadata={ + 'invoice_date': invoice.paid_date.isoformat() if invoice.paid_date else None, + 'invoice_status': invoice.status.value if hasattr(invoice.status, 'value') else str(invoice.status) + } + ) + db.add(exception) + exceptions.append(exception) + + db.flush() + + return { + 'exceptions_created': len(exceptions), + 'exceptions': [{ + 'id': exc.id, + 'type': exc.exception_type.value, + 'status': exc.status.value, + 'severity': exc.severity, + 'description': exc.description + } for exc in exceptions] + } + + @staticmethod + def get_exceptions( + db: Session, + status: Optional[ExceptionStatus] = None, + exception_type: Optional[ExceptionType] = None, + assigned_to: Optional[int] = None, + severity: Optional[str] = None, + page: int = 1, + limit: int = 50 + ) -> Dict[str, Any]: + """Get reconciliation exceptions with filters.""" + query = db.query(ReconciliationException) + + if status: + query = query.filter(ReconciliationException.status == status) + if exception_type: + query = query.filter(ReconciliationException.exception_type == exception_type) + if assigned_to: + query = query.filter(ReconciliationException.assigned_to == assigned_to) + if severity: + query = query.filter(ReconciliationException.severity == severity) + + total = query.count() + offset = (page - 1) * limit + exceptions = query.order_by( + ReconciliationException.created_at.desc() + ).offset(offset).limit(limit).all() + + exception_list = [] + for exc in exceptions: + exception_list.append({ + 'id': exc.id, + 'exception_type': exc.exception_type.value, + 'status': exc.status.value, + 'severity': exc.severity, + 'payment_id': exc.payment_id, + 'invoice_id': exc.invoice_id, + 'booking_id': exc.booking_id, + 'description': exc.description, + 'expected_amount': float(exc.expected_amount) if exc.expected_amount else None, + 'actual_amount': float(exc.actual_amount) if exc.actual_amount else None, + 'difference': float(exc.difference) if exc.difference else None, + 'assigned_to': exc.assigned_to, + 'assigned_at': exc.assigned_at.isoformat() if exc.assigned_at else None, + 'resolved_by': exc.resolved_by, + 'resolved_at': exc.resolved_at.isoformat() if exc.resolved_at else None, + 'resolution_notes': exc.resolution_notes, + 'comments': exc.comments or [], + 'created_at': exc.created_at.isoformat() if exc.created_at else None, + 'updated_at': exc.updated_at.isoformat() if exc.updated_at else None + }) + + return { + 'exceptions': exception_list, + 'pagination': { + 'total': total, + 'page': page, + 'limit': limit, + 'total_pages': (total + limit - 1) // limit + } + } + + @staticmethod + def assign_exception( + db: Session, + exception_id: int, + assigned_to: int + ) -> ReconciliationException: + """Assign an exception to a user.""" + exception = db.query(ReconciliationException).filter( + ReconciliationException.id == exception_id + ).first() + + if not exception: + raise ValueError("Exception not found") + + exception.assigned_to = assigned_to + exception.assigned_at = datetime.utcnow() + exception.status = ExceptionStatus.assigned + + db.flush() + return exception + + @staticmethod + def resolve_exception( + db: Session, + exception_id: int, + resolved_by: int, + resolution_notes: str + ) -> ReconciliationException: + """Resolve an exception.""" + exception = db.query(ReconciliationException).filter( + ReconciliationException.id == exception_id + ).first() + + if not exception: + raise ValueError("Exception not found") + + exception.status = ExceptionStatus.resolved + exception.resolved_by = resolved_by + exception.resolved_at = datetime.utcnow() + exception.resolution_notes = resolution_notes + + db.flush() + return exception + + @staticmethod + def add_comment( + db: Session, + exception_id: int, + user_id: int, + comment: str + ) -> ReconciliationException: + """Add a comment to an exception.""" + exception = db.query(ReconciliationException).filter( + ReconciliationException.id == exception_id + ).first() + + if not exception: + raise ValueError("Exception not found") + + comments = exception.comments or [] + comments.append({ + 'user_id': user_id, + 'comment': comment, + 'created_at': datetime.utcnow().isoformat() + }) + exception.comments = comments + + db.flush() + return exception + + +# Singleton instance +reconciliation_service = ReconciliationService() + diff --git a/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc b/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc index 5e9d9ded490a160a1b42667ca8178cd1fc9d6677..9e1f3ae9b880fa4ecc291c2c932c4eba239ce95f 100644 GIT binary patch delta 2397 zcmZuzTWl0n7(QobXS?@nFT33>J87YIgsz1aq)3q#kd{(KP!OZ8>(0>bvX^>hmeS3v ziwT4 zW%m5jGyR|Mc7N}3*%3Ui3u{vw+dp-musf*Cz~9ZIta^X2YvzXiS5w-C1drg&G-tyl zC#Kw&iDX+!uI!4ETbKQrXqGE^Fft*2n6x){+(P;BsA!gQY>S ztKWp`&7`qL2GhH4_se0S5qz5z{|WSt)u;ff3@Y|`yWy|+LAuM3)E`{qTicMlSR6tt z5iwzO9LakO7tr*Spy=`)+hdf~ACmVOI~1D{91<}Z9HWCsz$M#rHpRYgkxk$Ubd))W zCNSSTv)z3Smx∈jtD@B$N35T?H~kPl4D}>a4I*t zcpB_VidiuyZ{qm7)m7e~%Ub7nVv=g4*kT=0Y^j9XiX>a15n4zZOF$B_f}F}oF&a8P zCT7M(B9VTulL6`!S#(O~bB9u+FLTWh9yK0oCIZH|a?)Wmb#5ng($| zXd|ptbz=}MPZp;$RfM}&SNi(DK6v)vsh*E}PxpSj<-;xK zhGu*2-Ve3|I9iFWni{LJriP9M#_aP~ooG$htLAxcM}@zKuI*JXa(a&q|G97&tDS*$ z<-Zml!^j!>9bwj8vAV7}>VHQJ)Us4U#p-^={jz&D)ca-IXNfP`KMT$_4P3Tvo3(EH z$>BLR{8!aMH{6oCpdn{B4)&q%JOgWny3x75z)%-+-WwiT&75D&Qr_*~)yrI9`CUEC zg&r2Tsst{UFTf0Y1HTDZO)zCp7MI{+%PgFxKME55ooS>^L1~L&Sz4cOy0boQUGgzl ziOC&NaN1$WNZrhYO=*wn3k9%K&9f0R9OhS@4N=^n{_Sk`m&}S;3d>E#4mqek8;YzG z%pbAFusx0y^PPh!%**T%Ox7t(x`mF_Hj6Q5h()La8_Y2U?L)U8Z~f2l;KJJ;G^UQ9 z&W-|H&$xco=#3!5>h*?}kmgu+S2g>Rp#I&kzU(3Up$d`((CWq#QbLvq8K$boDGX7l zr?8vC2!K|nM@R|eL8>xSpznt4p+>tTCx|T30mWO$VXE(;pa*dOKSAUIQwr=7eTSU- ze&hWO?^5w1z@Nv^9~NZWj&C^p>Oy0oT(u)-^K{FcBSvpU^s<+q^YW)U=e-*%_1yHx zT>Vnlye3oH|GR}tfKSFOk!nYD&V30eF+17LkEV^b_+mN7VC8jeP~+Z&T$40Lbu0$3631TQ7C=U!0gf@Z|U9-%tx1 zdB3Mhmv1NZWj;Cj2NkQEXClGpnDPU#lkB8GKm4SPg5GC6I{iEnXbY;ng@4;#>-Iy` z@Gu1rg}ng0jjW}79ffWRdc$FABEnNhPbVLH*0jiWO~m2YCuO>7<}HjCO+1b2PJ%O3H+~o<@3O$r|9dtedlWp^y=` zkQYIu|CUHA)fc0QhYw)vt+?u|3?)DLLbJ^q=6v^7St`OC41}k;U+b*a>7o_cyj7b{ b?Z{%k>Ch=LaX2`=?Iwcdw?V9~<%<6RwWVFc delta 1104 zcma)5T}V?=96#sYT{rhV=bXA-Z7#tZQ~c@!3dwYvsg)$AL}9~?a(>0R(?BLpdkT6e z4pEQ~LLVUd5`&;#f{?vLpcEt{P^gCQD+01Hbo9n2fmys4W<<}UY6jF-ly01Jv=jz4zMr5|9I(D`j=u3CKcUN|^vbU!~ zc9gL@!#tnydqPcorb!6%^h)u^&;us$2tP&%W^*HnqIi+ZapF&+_#W5tU}xNi`6Ga zqC}y_;zb(Uw^441*KRS3lYCsapVQbBm*np0CNTLQRj;}#syif$x{)MYBq8eXh&1du zK5fqGZ$;!tB0)%qEy(IdBO@`=$|!;3k}yCUWb|ZgIMzQHCFdaory~MD2LnkUSAjP& z$Xdtc80lB5RWZ;deGFu%Kf*?e1fbAN#%PhZ&M^zQ1qM4Qw4H~{PI}Ed-m_sUnHN`0 zHD3$LmqK3x!PP)eLFf{0;kFTSwsOp|v~p`U&x*lAYS?Oa2zZ+W-WqWLK)8d>**@8(gIfnTuC+ZdG_dM>i`9)63#@zOxx5 z&jG-{LHqz}x759Y;93CGOaE(Lu4nC#=mf9E00@z#P$$=Gg#$PVPSonSVh2M{ZSzcxzvKlnhF2GF*=wx-#!GQ#U(gmbm zO8o@8hBG5$(edF}6Oma8vnh+zQW User: # PERFORMANCE: Use eager-loaded relationship if available, otherwise query # This reduces database queries since get_current_user now eager loads the role + from ...shared.utils.role_helpers import get_user_role_name + if hasattr(current_user, 'role') and current_user.role is not None: user_role_name = current_user.role.name else: @@ -139,8 +169,21 @@ def authorize_roles(*allowed_roles: str): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='User role not found') user_role_name = role.name + # Backwards‑compatible support for accountant sub‑roles: + # any role starting with "accountant_" is treated as "accountant" + # when a route allows plain "accountant". if user_role_name not in allowed_roles: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='You do not have permission to access this resource') + # Normalise accountant_* → accountant for role checks + if ( + user_role_name.startswith("accountant_") + and "accountant" in allowed_roles + ): + # treated as allowed + return current_user + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='You do not have permission to access this resource' + ) return current_user return role_checker diff --git a/Backend/src/security/middleware/step_up_auth.py b/Backend/src/security/middleware/step_up_auth.py new file mode 100644 index 00000000..2cf315cc --- /dev/null +++ b/Backend/src/security/middleware/step_up_auth.py @@ -0,0 +1,87 @@ +""" +Step-up authentication middleware for high-risk operations. +""" +from fastapi import Depends, HTTPException, status, Request +from sqlalchemy.orm import Session +from typing import Optional +from ...shared.config.database import get_db +from ...security.middleware.auth import get_current_user +from ...auth.models.user import User +from ...payments.services.accountant_security_service import accountant_security_service +from ...shared.utils.role_helpers import is_accountant, is_admin +from ...shared.config.logging_config import get_logger + +logger = get_logger(__name__) + + +def require_step_up_auth( + action_description: str = "this high-risk action" +): + """ + Dependency to require step-up authentication for high-risk operations. + """ + async def step_up_checker( + request: Request, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) + ) -> User: + # Only enforce for accountant/admin roles + if not (is_accountant(current_user, db) or is_admin(current_user, db)): + return current_user # Regular users don't need step-up + + # Get session token from request + session_token = None + # Try to get from custom header + session_token = request.headers.get('X-Session-Token') + # Or from cookie + if not session_token: + session_token = request.cookies.get('session_token') + + # Check if step-up is required + requires_step_up, reason = accountant_security_service.require_step_up( + db=db, + user_id=current_user.id, + session_token=session_token, + action_description=action_description + ) + + if requires_step_up: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + 'error': 'step_up_required', + 'message': reason or f'Step-up authentication required for {action_description}', + 'action': action_description + } + ) + + return current_user + + return step_up_checker + + +def enforce_mfa_for_accountants(): + """ + Dependency to enforce MFA for accountant/admin roles. + """ + async def mfa_enforcer( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) + ) -> User: + # Check if MFA is required + is_enforced, reason = accountant_security_service.is_mfa_enforced(current_user, db) + + if not is_enforced and reason: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + 'error': 'mfa_required', + 'message': reason, + 'requires_mfa_setup': True + } + ) + + return current_user + + return mfa_enforcer + diff --git a/Backend/src/shared/config/__pycache__/settings.cpython-312.pyc b/Backend/src/shared/config/__pycache__/settings.cpython-312.pyc index 4abc62ba6324433ae7dad55535bbd3f5a90a54f8..1d4c8e0cb8a434f43e9c447fc6247c0a4ae01158 100644 GIT binary patch delta 2055 zcmd5-O>7fK6rS<=Cr+H$$;Rv00WwZV>`nYboDgUsRDlpuMFNVdwn=}|Wp{xM&f48w zNbK6U6@JLPC{#VF{8`18Po{ih3}`hl*Z08?!i6RO+FX zI)^tiZ{GL4@4cC|e~vFr*ZojiTMh6iu-(GF?N{nv^PBq2YRd+n`ksHTiZgLUyeex} zlYYuHL8=Q;UoLCOm?zvi=E=cXzJg^>)|#E6D-_wPb;MOhI?jH48vs(%Iw{pp zi#rdvnhS)1@|=b#q75i!Bhh9coz2=a>bu?U;1gsHWup<3u^VtqR(pX^FZ=t|xBPCS zr&4FeG|^&2S7xs6Z#bM4K@f1R3>be58U-AYwV$%5h{~$$nSu&3!XBvx6DI6Y&vZNg z_u8kxG_Gh0()H;x)#%Z9iCLU&dk5yy)Elo3gvXe0dQ143uITHX{cmpx#j=~zC-&~& zb7=J7i_ag}KR&W|6tM|WVFXb|f_RdN3mjsPF%rWnyd+1^Zbs(&dXcP1f_PL$EF+?X z7*81>gfoN?&JjX5O9*v^P!#V)4mI_o;E{xck_^XTC>{Ale;yBEziMwEf}QHYc6TER z^3zEkXW~##3>{7>yiBWndmFr{&bEiuFJqBh2pwKiumdHr0wq}9MGp7%@*UcHpy zPOv&Ev_7)jXB?$ojrm9ZjT$BWYp3hgrS_hjN#Y9@{l=078=II+3UR!jWEA#Tfr#^B z!H&CE?vA8jRKYBWM-m15-f5O!E0C_1PGG+@tD6sW^O0^o#-?DyqQ^0Jp{{%r<4H!r zFGVakCk0u?EgVlamv6jaE9c+}keBrzRQ-Lb-5n#QL6dr_V>CAi?cv)F?`?;BmGb2& zTBGPZ71gNdGSxk^uUG{(p^IbZ#%=@$mN)L6*}p>i^JG9H1D9;K$Srp$R5U^NoA3ca zcp8d;sBb7aKuhaF;C%ej3)-g0%z+=Bn^s(&`JM&;LPBfZsx|j%uD&^2(F!~p^HfJp zqdFF*mZ|8>zB|r_xou}N^U_VHzf9~wcTn5HzAqf}r*4E_E)vi^x-L_Z0It^GOVz;T z`wcYO!>dj*@AQ|!E7aC+*(Lwtk#Alp0@JQx6SnSQ(*we+%QV+FJaB;emK86`<-MJn zxAXG$WpD4Cb(Qqw$qtR|$de(B4Ba9lD~-NSM?M@`@vY+{c1=TEt9Kff2#cF4)`70@ z++e;jpfv_c^`J5ON2vk0y#Gj4?y0K&6H!GkZqh?JfFHqkuHF7UF>ozr-!R-nT-!0Q za|jaGp#yKO*ADC)CW-G!%W%`;Qs5?ppR4yta`7{I-eMkumhj@U(R*f#^a-X>yLva& zsCXg0ubXq~q258u6};3I&-ac%>({#Xiu&u;*63Bd)c=L$L2U3%LiiKdbr<;V0?%z= WFPVo37@oDcv?wb0kAKqbAqpWvac5lTWF;F^WwV)DWF)rJ>BIJ2_56mr0XnGP9Pk8)uT4 zNlI#ReoE>s=A6{LTb!;H$*Bb;nfZA|B|x`nvK5sAsiIC0(F-Cb0f}4e5G^UU7*mVc zfC7^*Xn8Y9F;14%j+7SU^k%%xBY8ti=DL{LMKQGnCD+xhE~;By7PFrGS6flp`2#bf zs4L@VW@d4r4-CwLLLZnJ1eAV!;bIVw{KUWs)O};JgN}q4qn#7u2L?t_CnTkxxh8vQ zOIck|bT}e>An>r*0gemup4SDuE(&=4{vZs}`>R+A=$?ZDj;u~%42Q%xU6`2<$va9p z+c6)uV|8KP+^2hqk#WZ6ME%vQEb6S@jGF~aU$L;?Vl6Hy$}E`t-%^HqBFL9LAY#g7 zH7gg^6+q^^&G}Z|jBJZRqAMmJuvRi$3uJ=QMzI@^0Q!iL@jipyT?VPU4C1#LIKQw2 WGckHIcGP@f05d*ju}l`UQ3n94#Gb(b diff --git a/Backend/src/shared/config/settings.py b/Backend/src/shared/config/settings.py index 38ebbc88..3b2b739a 100644 --- a/Backend/src/shared/config/settings.py +++ b/Backend/src/shared/config/settings.py @@ -135,22 +135,73 @@ class Settings(BaseSettings): # Validate base64 encoding and key length (32 bytes = 44 base64 chars) try: import base64 - decoded = base64.b64decode(self.ENCRYPTION_KEY) + import re + # Try to decode with automatic padding if needed + key_str = self.ENCRYPTION_KEY.strip() + + # Check if string contains only valid base64 characters + # Base64 characters: A-Z, a-z, 0-9, +, /, and = for padding + base64_pattern = re.compile(r'^[A-Za-z0-9+/]*={0,2}$') + if not base64_pattern.match(key_str): + raise ValueError( + 'ENCRYPTION_KEY contains invalid characters. ' + 'Base64 strings can only contain A-Z, a-z, 0-9, +, /, and = (for padding).' + ) + + # Add padding if needed (base64 strings must be multiple of 4) + missing_padding = len(key_str) % 4 + if missing_padding: + key_str += '=' * (4 - missing_padding) + + # Decode without strict validation since we already checked the pattern + decoded = base64.b64decode(key_str) if len(decoded) != 32: raise ValueError( f'ENCRYPTION_KEY must be a base64-encoded 32-byte key. ' - f'Received {len(decoded)} bytes after decoding.' + f'Received {len(decoded)} bytes after decoding (expected 32 bytes).' ) - except Exception as e: + except ValueError as e: + # Re-raise ValueError as-is (these are our validation errors) if self.is_production: raise ValueError( f'Invalid ENCRYPTION_KEY format: {str(e)}. ' - 'Must be a valid base64-encoded 32-byte key.' + 'Must be a valid base64-encoded 32-byte key. ' + 'Generate one using: python -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"' ) else: import logging logger = logging.getLogger(__name__) - logger.warning(f'Invalid ENCRYPTION_KEY format: {str(e)}') + # In development, clear invalid key so encryption service can generate a temporary one + if self.ENCRYPTION_KEY.strip(): + logger.info( + f'ENCRYPTION_KEY is invalid ({str(e)}). ' + 'Clearing it in development mode - encryption service will generate a temporary key. ' + 'For production, generate a valid key using: ' + 'python -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"' + ) + # Clear the invalid key so encryption service can generate a temporary one + self.ENCRYPTION_KEY = '' + except Exception as e: + # Handle other exceptions (like binascii.Error from base64) + if self.is_production: + raise ValueError( + f'Invalid ENCRYPTION_KEY format: {str(e)}. ' + 'Must be a valid base64-encoded 32-byte key. ' + 'Generate one using: python -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"' + ) + else: + import logging + logger = logging.getLogger(__name__) + # In development, clear invalid key so encryption service can generate a temporary one + if self.ENCRYPTION_KEY.strip(): + logger.info( + f'ENCRYPTION_KEY is invalid ({str(e)}). ' + 'Clearing it in development mode - encryption service will generate a temporary key. ' + 'For production, generate a valid key using: ' + 'python -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"' + ) + # Clear the invalid key so encryption service can generate a temporary one + self.ENCRYPTION_KEY = '' settings = Settings() diff --git a/Backend/src/shared/utils/__pycache__/role_helpers.cpython-312.pyc b/Backend/src/shared/utils/__pycache__/role_helpers.cpython-312.pyc index 6cc9c814fc0399e2e6f76e8ac2370f4d788cdbd6..0b1e07aac3db0a348fa6016a94cfa1bbb34a4195 100644 GIT binary patch literal 7561 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 str: - """Get the role name for a user""" - if not user or not user.role_id: - return 'customer' + """Get the role name for a user (falls back to 'customer').""" + if not user or not getattr(user, "role_id", None): + return "customer" try: # PERFORMANCE: Use eager-loaded relationship if available to avoid query - if hasattr(user, 'role') and user.role is not None: + if hasattr(user, "role") and user.role is not None: return user.role.name # Fallback: query if relationship not loaded role = db.query(Role).filter(Role.id == user.role_id).first() - return role.name if role else 'customer' + return role.name if role else "customer" except Exception: - return 'customer' + # Never break business logic because of RBAC helper failures + return "customer" + + +def _is_role(user: User, db: Session, *role_names: str) -> bool: + """Internal helper to check if user has any of the given roles.""" + role = get_user_role_name(user, db) + return role in role_names + def is_admin(user: User, db: Session) -> bool: - """Check if user is admin""" - return get_user_role_name(user, db) == 'admin' + """Check if user is platform admin.""" + return _is_role(user, db, "admin") + def is_staff(user: User, db: Session) -> bool: - """Check if user is staff""" - return get_user_role_name(user, db) == 'staff' + """Check if user is staff.""" + return _is_role(user, db, "staff") -def is_accountant(user: User, db: Session) -> bool: - """Check if user is accountant""" - return get_user_role_name(user, db) == 'accountant' def is_customer(user: User, db: Session) -> bool: - """Check if user is customer""" - return get_user_role_name(user, db) == 'customer' + """Check if user is customer.""" + return _is_role(user, db, "customer") + def is_housekeeping(user: User, db: Session) -> bool: - """Check if user is housekeeping""" - return get_user_role_name(user, db) == 'housekeeping' + """Check if user is housekeeping.""" + return _is_role(user, db, "housekeeping") + + +# ---- Accountant role family ---------------------------------------------- + +ACCOUNTANT_BASE_ROLE = "accountant" + +# Sub‑roles for finer-grained permissions. These are logical names only; +# they must also exist in the `roles` table to be usable. +ACCOUNTANT_SUB_ROLES = { + "accountant_readonly", # View‑only, no mutations + "accountant_operator", # Day‑to‑day ops within limits + "accountant_approver", # High‑risk approver (no self‑approval logic yet) +} + + +def is_accountant(user: User, db: Session) -> bool: + """ + Check if user belongs to the accountant family. + + This returns True for: + - 'accountant' + - any of the accountant_* sub‑roles + """ + role = get_user_role_name(user, db) + return role == ACCOUNTANT_BASE_ROLE or role in ACCOUNTANT_SUB_ROLES + + +def is_readonly_accountant(user: User, db: Session) -> bool: + """Check if user is an accountant with read‑only permissions.""" + return get_user_role_name(user, db) == "accountant_readonly" + + +def is_operator_accountant(user: User, db: Session) -> bool: + """Check if user is an accountant operator.""" + return get_user_role_name(user, db) in {"accountant", "accountant_operator"} + + +def is_approver_accountant(user: User, db: Session) -> bool: + """ + Check if user can act as an accountant approver for high‑risk actions. + Plain 'accountant' is treated as having full approval powers. + """ + return get_user_role_name(user, db) in {"accountant", "accountant_approver"} + + +# Aliases for backward compatibility and consistency with naming conventions +def is_accountant_readonly(user: User, db: Session) -> bool: + """Alias for is_readonly_accountant.""" + return is_readonly_accountant(user, db) + + +def is_accountant_operator(user: User, db: Session) -> bool: + """Alias for is_operator_accountant.""" + return is_operator_accountant(user, db) + + +def is_accountant_approver(user: User, db: Session) -> bool: + """Alias for is_approver_accountant.""" + return is_approver_accountant(user, db) + + +# ---- Permission mapping (role -> capability set) ------------------------- + +# NOTE: +# This is an in‑code permission matrix. If you later introduce a full +# permission table, this mapping can become a cache over that table. +ROLE_PERMISSIONS = { + # Admin: full access to everything + "admin": { + "financial.view_reports", + "financial.manage_invoices", + "financial.manage_payments", + "financial.manage_settings", + "financial.high_risk_approve", + "users.manage", + }, + # Existing generic accountant – treated as full‑power accountant + "accountant": { + "financial.view_reports", + "financial.manage_invoices", + "financial.manage_payments", + "financial.manage_settings", + "financial.high_risk_approve", + }, + # New sub‑roles + "accountant_readonly": { + "financial.view_reports", + "financial.view_invoices", + "financial.view_payments", + "financial.view_audit_trail", + }, + "accountant_operator": { + "financial.view_reports", + "financial.view_invoices", + "financial.view_payments", + "financial.manage_invoices", # normal invoice ops + "financial.manage_payments", # small/manual adjustments + }, + "accountant_approver": { + "financial.view_reports", + "financial.view_invoices", + "financial.view_payments", + "financial.high_risk_approve", # approvals only + }, + # Staff: keep existing behaviour for now + "staff": { + "financial.view_invoices", + "financial.manage_invoices", + "financial.view_payments", + }, +} + + +def get_user_permissions(user: User, db: Session) -> set[str]: + """Return a set of logical permissions granted to the user.""" + role_name = get_user_role_name(user, db) + return set(ROLE_PERMISSIONS.get(role_name, set())) + + +def user_has_permission(user: User, db: Session, permission: str) -> bool: + """Check if user has a single permission.""" + return permission in get_user_permissions(user, db) + + +def user_has_permissions(user: User, db: Session, permissions: list[str]) -> bool: + """Check if user has all listed permissions.""" + if not permissions: + return True + perms = get_user_permissions(user, db) + return all(p in perms for p in permissions) + + +# ---- Higher‑level helpers used by routes/services ------------------------ def can_access_all_payments(user: User, db: Session) -> bool: - """Check if user can see all payments (admin or accountant)""" + """ + Check if user can see all payments. + - Admins and any accountant family role may view all. + """ role_name = get_user_role_name(user, db) - return role_name in ['admin', 'accountant'] + if role_name == "admin": + return True + if is_accountant(user, db): + return True + return False + def can_access_all_invoices(user: User, db: Session) -> bool: - """Check if user can see all invoices (admin or accountant)""" + """ + Check if user can see all invoices. + - Admin, staff, and all accountant family roles may view all. + """ role_name = get_user_role_name(user, db) - return role_name in ['admin', 'accountant'] + if role_name in {"admin", "staff"}: + return True + if is_accountant(user, db): + return True + return False + def can_create_invoices(user: User, db: Session) -> bool: - """Check if user can create invoices (admin, staff, or accountant)""" + """ + Check if user can create invoices. + - Admin, staff, full accountant, and accountant_operator. + - Readonly accountants are explicitly excluded. + """ role_name = get_user_role_name(user, db) - return role_name in ['admin', 'staff', 'accountant'] + return role_name in {"admin", "staff", "accountant", "accountant_operator"} + def can_manage_users(user: User, db: Session) -> bool: - """Check if user can manage users (admin only)""" + """Check if user can manage users (admin only).""" return is_admin(user, db) + diff --git a/Backend/src/system/routes/__pycache__/system_settings_routes.cpython-312.pyc b/Backend/src/system/routes/__pycache__/system_settings_routes.cpython-312.pyc index d91735abcc603be2538ce4f0b458e5b9494c55e7..9837915f1d58f354a9b4d759de145bc7b3fa98a9 100644 GIT binary patch delta 12634 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 NsUU9>bpiDF{{ZgTSyKQ2 delta 10756 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 LJHgCV)SdqY<=h;T diff --git a/Backend/src/system/routes/system_settings_routes.py b/Backend/src/system/routes/system_settings_routes.py index 76cb4bcb..a7c22c81 100644 --- a/Backend/src/system/routes/system_settings_routes.py +++ b/Backend/src/system/routes/system_settings_routes.py @@ -16,6 +16,8 @@ from ..models.system_settings import SystemSettings from ...shared.utils.mailer import send_email from ...rooms.services.room_service import get_base_url from ...analytics.services.audit_service import audit_service +from ...payments.services.financial_audit_service import financial_audit_service +from ...payments.models.financial_audit_trail import FinancialActionType def normalize_image_url(image_url: str, base_url: str) -> str: if not image_url: @@ -122,6 +124,26 @@ async def update_platform_currency( }, status='success' ) + # Also log to financial audit trail (currency affects financial reporting) + try: + financial_audit_service.log_financial_action( + db=db, + action_type=FinancialActionType.settings_changed, + performed_by=current_user.id, + action_description=f'Platform currency changed from {old_value} to {currency}', + currency=currency, + metadata={ + 'setting_key': 'platform_currency', + 'old_value': old_value, + 'new_value': currency, + 'ip_address': client_ip, + 'user_agent': user_agent, + 'request_id': request_id + }, + notes=f'Currency setting updated by {current_user.email}' + ) + except Exception as e: + logger.warning(f'Failed to log financial audit for currency change: {e}') except Exception as e: logger.warning(f'Failed to log system setting change audit: {e}') @@ -1476,11 +1498,24 @@ async def get_company_settings( @router.put("/company") async def update_company_settings( request_data: UpdateCompanySettingsRequest, + request: Request, current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + try: db_settings = {} + old_values = {} # Track old values for financial audit + + # Get old values for financial-relevant settings before updating + if request_data.tax_rate is not None: + old_tax_setting = db.query(SystemSettings).filter( + SystemSettings.key == "tax_rate" + ).first() + old_values["tax_rate"] = old_tax_setting.value if old_tax_setting else None if request_data.company_name is not None: db_settings["company_name"] = request_data.company_name @@ -1521,6 +1556,29 @@ async def update_company_settings( db.commit() + # Log financial audit for tax_rate changes (affects all future invoices) + if request_data.tax_rate is not None: + try: + old_tax = float(old_values.get("tax_rate", 0)) if old_values.get("tax_rate") else 0.0 + new_tax = float(request_data.tax_rate) + financial_audit_service.log_financial_action( + db=db, + action_type=FinancialActionType.settings_changed, + performed_by=current_user.id, + action_description=f'Tax rate changed from {old_tax}% to {new_tax}%', + metadata={ + 'setting_key': 'tax_rate', + 'old_value': str(old_tax), + 'new_value': str(new_tax), + 'ip_address': client_ip, + 'user_agent': user_agent, + 'request_id': request_id + }, + notes=f'Tax rate setting updated by {current_user.email} - affects all future invoices' + ) + except Exception as e: + logger.warning(f'Failed to log financial audit for tax rate change: {e}') + updated_settings = {} for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address", "tax_rate", "chat_working_hours_start", "chat_working_hours_end"]: diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index 46d5b982..e1261397 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -129,6 +129,12 @@ const AccountantDashboardPage = lazy(() => import('./pages/accountant/DashboardP const AccountantPaymentManagementPage = lazy(() => import('./pages/accountant/PaymentManagementPage')); const AccountantInvoiceManagementPage = lazy(() => import('./pages/accountant/InvoiceManagementPage')); const AccountantAnalyticsDashboardPage = lazy(() => import('./pages/accountant/AnalyticsDashboardPage')); +const GLManagementPage = lazy(() => import('./pages/accountant/GLManagementPage')); +const AccountantApprovalManagementPage = lazy(() => import('./pages/accountant/ApprovalManagementPage')); +const FinancialReportsPage = lazy(() => import('./pages/accountant/FinancialReportsPage')); +const ReconciliationPage = lazy(() => import('./pages/accountant/ReconciliationPage')); +const AuditTrailPage = lazy(() => import('./pages/accountant/AuditTrailPage')); +const AccountantSecurityManagementPage = lazy(() => import('./pages/accountant/SecurityManagementPage')); const AccountantLayout = lazy(() => import('./pages/AccountantLayout')); const HousekeepingDashboardPage = lazy(() => import('./pages/housekeeping/DashboardPage')); @@ -745,6 +751,10 @@ function App() { path="upsells" element={} /> + } + /> } @@ -775,6 +785,34 @@ function App() { path="invoices" element={} /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> } @@ -783,14 +821,6 @@ function App() { path="invoices/:id" element={} /> - } - /> - } - /> {/* Housekeeping Routes */} diff --git a/Frontend/src/features/payments/services/approvalService.ts b/Frontend/src/features/payments/services/approvalService.ts new file mode 100644 index 00000000..e201dcda --- /dev/null +++ b/Frontend/src/features/payments/services/approvalService.ts @@ -0,0 +1,69 @@ +import apiClient from '../../../shared/services/apiClient'; + +export type ApprovalActionType = + | 'large_refund' + | 'manual_payment_status_override' + | 'invoice_write_off' + | 'significant_discount' + | 'tax_rate_change' + | 'fiscal_period_close' + | 'gl_manual_entry'; + +export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'cancelled'; + +export interface FinancialApproval { + id: number; + action_type: ApprovalActionType; + action_description: string; + status: ApprovalStatus; + requested_by: number; + requested_by_email?: string; + approved_by?: number; + approved_by_email?: string; + requested_at: string; + responded_at?: string; + payment_id?: number; + invoice_id?: number; + booking_id?: number; + gl_entry_id?: number; + amount?: number; + previous_value?: any; + new_value?: any; + currency?: string; + request_reason?: string; + response_notes?: string; + metadata?: any; +} + +export interface RespondToApprovalRequest { + status: 'approved' | 'rejected'; + response_notes?: string; +} + +class ApprovalService { + async getApprovals(params?: { + status?: ApprovalStatus; + action_type?: ApprovalActionType; + requested_by?: number; + approved_by?: number; + page?: number; + limit?: number; + }): Promise<{ status: string; data: FinancialApproval[] }> { + const response = await apiClient.get('/financial/approvals', { params }); + return response.data; + } + + async getApprovalById(approvalId: number): Promise<{ status: string; data: FinancialApproval }> { + const response = await apiClient.get(`/financial/approvals/${approvalId}`); + return response.data; + } + + async respondToApproval(approvalId: number, data: RespondToApprovalRequest): Promise<{ status: string; data: FinancialApproval }> { + const response = await apiClient.post(`/financial/approvals/${approvalId}/respond`, data); + return response.data; + } +} + +const approvalService = new ApprovalService(); +export default approvalService; + diff --git a/Frontend/src/features/payments/services/financialAuditService.ts b/Frontend/src/features/payments/services/financialAuditService.ts index 1a294d87..88358e54 100644 --- a/Frontend/src/features/payments/services/financialAuditService.ts +++ b/Frontend/src/features/payments/services/financialAuditService.ts @@ -59,6 +59,26 @@ const financialAuditService = { const response = await apiClient.get(`/financial/audit-trail/${recordId}`); return response.data; }, + + /** + * Export audit trail to CSV or JSON + */ + async exportAuditTrail(filters: FinancialAuditFilters = {}, format: 'csv' | 'json' = 'csv'): Promise { + const params = new URLSearchParams(); + if (filters.payment_id) params.append('payment_id', filters.payment_id.toString()); + if (filters.invoice_id) params.append('invoice_id', filters.invoice_id.toString()); + if (filters.booking_id) params.append('booking_id', filters.booking_id.toString()); + if (filters.action_type) params.append('action_type', filters.action_type); + if (filters.user_id) params.append('user_id', filters.user_id.toString()); + if (filters.start_date) params.append('start_date', filters.start_date); + if (filters.end_date) params.append('end_date', filters.end_date); + params.append('format', format); + + const response = await apiClient.get(`/financial/audit-trail/export?${params.toString()}`, { + responseType: 'blob' + }); + return response.data; + }, }; export default financialAuditService; diff --git a/Frontend/src/features/payments/services/financialReportService.ts b/Frontend/src/features/payments/services/financialReportService.ts new file mode 100644 index 00000000..d38b033b --- /dev/null +++ b/Frontend/src/features/payments/services/financialReportService.ts @@ -0,0 +1,98 @@ +import apiClient from '../../../shared/services/apiClient'; + +export interface ProfitLossReport { + period: { + start_date: string; + end_date: string; + fiscal_period_id?: number; + }; + revenue: { + total_revenue: number; + revenue_by_account: Record; + }; + costs: { + total_cogs: number; + gross_profit: number; + }; + expenses: { + total_operating_expenses: number; + expenses_by_account: Record; + }; + profit: { + net_profit: number; + profit_margin: number; + }; +} + +export interface BalanceSheetReport { + as_of_date: string; + fiscal_period_id?: number; + assets: { + breakdown: Record; + total_assets: number; + }; + liabilities: { + breakdown: Record; + total_liabilities: number; + }; + equity: { + breakdown: Record; + total_equity: number; + }; + balance: { + total_liabilities_and_equity: number; + is_balanced: boolean; + }; +} + +export interface TaxReport { + period: { + start_date: string; + end_date: string; + }; + total_tax_collected: number; + total_taxable_amount: number; + transactions: Array<{ + invoice_number: string; + customer_name: string; + taxable_amount: number; + tax_amount: number; + tax_rate: number; + transaction_date: string; + }>; +} + +class FinancialReportService { + async getProfitLoss(params?: { + start_date?: string; + end_date?: string; + fiscal_period_id?: number; + }): Promise<{ status: string; data: ProfitLossReport }> { + const response = await apiClient.get('/financial/profit-loss', { params }); + return response.data; + } + + async getBalanceSheet(params?: { + as_of_date?: string; + fiscal_period_id?: number; + }): Promise<{ status: string; data: BalanceSheetReport }> { + const response = await apiClient.get('/financial/balance-sheet', { params }); + return response.data; + } + + async getTaxReport(params?: { + start_date?: string; + end_date?: string; + format?: 'json' | 'csv'; + }): Promise<{ status: string; data: TaxReport } | Blob> { + const response = await apiClient.get('/financial/tax-report', { + params, + responseType: params?.format === 'csv' ? 'blob' : 'json' + }); + return response.data; + } +} + +const financialReportService = new FinancialReportService(); +export default financialReportService; + diff --git a/Frontend/src/features/payments/services/glService.ts b/Frontend/src/features/payments/services/glService.ts new file mode 100644 index 00000000..a062be46 --- /dev/null +++ b/Frontend/src/features/payments/services/glService.ts @@ -0,0 +1,155 @@ +import apiClient from '../../../shared/services/apiClient'; + +// Chart of Accounts +export interface Account { + id: number; + account_number: string; + account_name: string; + account_type: string; + account_category: string; + normal_balance: string; + description?: string; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface CreateAccountRequest { + account_number: string; + account_name: string; + account_type: string; + account_category: string; + normal_balance: string; + description?: string; + is_active?: boolean; +} + +// Fiscal Periods +export interface FiscalPeriod { + id: number; + name: string; + start_date: string; + end_date: string; + status: 'Open' | 'Closed' | 'Locked'; + is_current: boolean; + created_at: string; + updated_at: string; +} + +export interface CreateFiscalPeriodRequest { + name: string; + start_date: string; + end_date: string; + set_as_current?: boolean; +} + +// Journal Entries +export interface JournalLine { + account_id: number; + debit: number; + credit: number; + description?: string; +} + +export interface JournalEntry { + id: number; + entry_date: string; + description: string; + entry_type: string; + reference_id?: string; + fiscal_period_id: number; + created_at: string; + updated_at: string; + journal_lines: Array; +} + +export interface CreateJournalEntryRequest { + entry_date: string; + description: string; + entry_type: string; + fiscal_period_id: number; + lines: JournalLine[]; + reference_id?: string; + requires_approval?: boolean; + request_reason?: string; +} + +// Trial Balance +export interface TrialBalance { + accounts: Array<{ + account_number: string; + account_name: string; + account_type: string; + normal_balance: string; + debit: number; + credit: number; + }>; + total_debits: number; + total_credits: number; + is_balanced: boolean; +} + +class GLService { + // Chart of Accounts + async getAccounts(): Promise<{ status: string; data: { accounts: Account[] } }> { + const response = await apiClient.get('/financial/gl/accounts'); + return response.data; + } + + async createAccount(data: CreateAccountRequest): Promise<{ status: string; data: Account }> { + const response = await apiClient.post('/financial/gl/accounts', data); + return response.data; + } + + // Fiscal Periods + async getFiscalPeriods(): Promise<{ status: string; data: { fiscal_periods: FiscalPeriod[] } }> { + const response = await apiClient.get('/financial/gl/fiscal-periods'); + return response.data; + } + + async createFiscalPeriod(data: CreateFiscalPeriodRequest): Promise<{ status: string; data: FiscalPeriod }> { + const response = await apiClient.post('/financial/gl/fiscal-periods', data); + return response.data; + } + + async closeFiscalPeriod(periodId: number, lockPeriod: boolean = false, reason?: string): Promise<{ status: string; data: FiscalPeriod }> { + const response = await apiClient.put(`/financial/gl/fiscal-periods/${periodId}/close`, { + lock_period: lockPeriod, + request_reason: reason + }); + return response.data; + } + + // Journal Entries + async getJournalEntries(params?: { + fiscal_period_id?: number; + start_date?: string; + end_date?: string; + entry_type?: string; + account_id?: number; + page?: number; + limit?: number; + }): Promise<{ status: string; data: { journal_entries: JournalEntry[] } }> { + const response = await apiClient.get('/financial/gl/journal-entries', { params }); + return response.data; + } + + async createJournalEntry(data: CreateJournalEntryRequest): Promise<{ status: string; data: JournalEntry }> { + const response = await apiClient.post('/financial/gl/journal-entries', data); + return response.data; + } + + // Trial Balance + async getTrialBalance(fiscalPeriodId?: number, asOfDate?: string): Promise<{ status: string; data: TrialBalance }> { + const params: any = {}; + if (fiscalPeriodId) params.fiscal_period_id = fiscalPeriodId; + if (asOfDate) params.as_of_date = asOfDate; + + const response = await apiClient.get('/financial/gl/trial-balance', { params }); + return response.data; + } +} + +const glService = new GLService(); +export default glService; + diff --git a/Frontend/src/features/payments/services/reconciliationService.ts b/Frontend/src/features/payments/services/reconciliationService.ts new file mode 100644 index 00000000..41f32b57 --- /dev/null +++ b/Frontend/src/features/payments/services/reconciliationService.ts @@ -0,0 +1,96 @@ +import apiClient from '../../../shared/services/apiClient'; + +export type ExceptionType = + | 'missing_invoice' + | 'missing_payment' + | 'amount_mismatch' + | 'duplicate_payment' + | 'orphaned_payment' + | 'date_mismatch'; + +export type ExceptionStatus = 'open' | 'assigned' | 'in_review' | 'resolved' | 'closed'; + +export interface ReconciliationException { + id: number; + exception_type: ExceptionType; + status: ExceptionStatus; + severity: 'low' | 'medium' | 'high' | 'critical'; + payment_id?: number; + invoice_id?: number; + booking_id?: number; + description: string; + expected_amount?: number; + actual_amount?: number; + difference?: number; + assigned_to?: number; + assigned_at?: string; + resolved_by?: number; + resolved_at?: string; + resolution_notes?: string; + comments?: Array<{ + user_id: number; + comment: string; + created_at: string; + }>; + created_at: string; + updated_at: string; +} + +export interface ExceptionStats { + total: number; + by_status: Record; + by_type: Record; + by_severity: Record; +} + +class ReconciliationService { + async runReconciliation(params?: { + start_date?: string; + end_date?: string; + }): Promise<{ status: string; data: { exceptions_created: number; exceptions: any[] } }> { + const response = await apiClient.post('/financial/reconciliation/run', null, { params }); + return response.data; + } + + async getExceptions(params?: { + status?: ExceptionStatus; + exception_type?: ExceptionType; + assigned_to?: number; + severity?: string; + page?: number; + limit?: number; + }): Promise<{ status: string; data: { exceptions: ReconciliationException[]; pagination: any } }> { + const response = await apiClient.get('/financial/reconciliation/exceptions', { params }); + return response.data; + } + + async assignException(exceptionId: number, assignedTo: number): Promise<{ status: string; data: any }> { + const response = await apiClient.post(`/financial/reconciliation/exceptions/${exceptionId}/assign`, { + assigned_to: assignedTo + }); + return response.data; + } + + async resolveException(exceptionId: number, notes: string): Promise<{ status: string; data: any }> { + const response = await apiClient.post(`/financial/reconciliation/exceptions/${exceptionId}/resolve`, { + notes + }); + return response.data; + } + + async addComment(exceptionId: number, comment: string): Promise<{ status: string; data: any }> { + const response = await apiClient.post(`/financial/reconciliation/exceptions/${exceptionId}/comment`, { + comment + }); + return response.data; + } + + async getExceptionStats(): Promise<{ status: string; data: ExceptionStats }> { + const response = await apiClient.get('/financial/reconciliation/exceptions/stats'); + return response.data; + } +} + +const reconciliationService = new ReconciliationService(); +export default reconciliationService; + diff --git a/Frontend/src/features/security/services/accountantSecurityService.ts b/Frontend/src/features/security/services/accountantSecurityService.ts new file mode 100644 index 00000000..06756052 --- /dev/null +++ b/Frontend/src/features/security/services/accountantSecurityService.ts @@ -0,0 +1,81 @@ +import apiClient from '../../../shared/services/apiClient'; + +export interface AccountantSession { + id: number; + ip_address?: string; + user_agent?: string; + country?: string; + city?: string; + last_activity: string; + step_up_authenticated: boolean; + step_up_expires_at?: string; + created_at: string; + expires_at: string; +} + +export interface AccountantActivityLog { + id: number; + user_id: number; + activity_type: string; + activity_description: string; + ip_address?: string; + country?: string; + city?: string; + risk_level: 'low' | 'medium' | 'high' | 'critical'; + is_unusual: boolean; + metadata?: any; + created_at: string; +} + +export interface MFAStatus { + requires_mfa: boolean; + mfa_enabled: boolean; + is_enforced: boolean; + enforcement_reason?: string; + backup_codes_count: number; +} + +class AccountantSecurityService { + async verifyStepUp(data: { + mfa_token?: string; + password?: string; + session_token?: string; + }): Promise<{ status: string; data: { step_up_completed: boolean } }> { + const response = await apiClient.post('/accountant/security/step-up/verify', data); + return response.data; + } + + async getSessions(): Promise<{ status: string; data: { sessions: AccountantSession[] } }> { + const response = await apiClient.get('/accountant/security/sessions'); + return response.data; + } + + async revokeSession(sessionId: number): Promise<{ status: string; message: string }> { + const response = await apiClient.post(`/accountant/security/sessions/${sessionId}/revoke`); + return response.data; + } + + async revokeAllSessions(): Promise<{ status: string; data: { revoked_count: number } }> { + const response = await apiClient.post('/accountant/security/sessions/revoke-all'); + return response.data; + } + + async getActivityLogs(params?: { + page?: number; + limit?: number; + risk_level?: string; + is_unusual?: boolean; + }): Promise<{ status: string; data: { logs: AccountantActivityLog[]; pagination: any } }> { + const response = await apiClient.get('/accountant/security/activity-logs', { params }); + return response.data; + } + + async getMFAStatus(): Promise<{ status: string; data: MFAStatus }> { + const response = await apiClient.get('/accountant/security/mfa-status'); + return response.data; + } +} + +const accountantSecurityService = new AccountantSecurityService(); +export default accountantSecurityService; + diff --git a/Frontend/src/pages/accountant/ApprovalManagementPage.tsx b/Frontend/src/pages/accountant/ApprovalManagementPage.tsx new file mode 100644 index 00000000..58745e07 --- /dev/null +++ b/Frontend/src/pages/accountant/ApprovalManagementPage.tsx @@ -0,0 +1,280 @@ +import React, { useState, useEffect } from 'react'; +import { CheckCircle2, XCircle, Clock, AlertCircle, Eye } from 'lucide-react'; +import { toast } from 'react-toastify'; +import approvalService, { FinancialApproval, ApprovalStatus } from '../../features/payments/services/approvalService'; +import Loading from '../../shared/components/Loading'; +import EmptyState from '../../shared/components/EmptyState'; +import { formatDate } from '../../shared/utils/format'; +import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency'; + +const ApprovalManagementPage: React.FC = () => { + const { formatCurrency } = useFormatCurrency(); + const [loading, setLoading] = useState(true); + const [approvals, setApprovals] = useState([]); + const [selectedApproval, setSelectedApproval] = useState(null); + const [filter, setFilter] = useState('all'); + const [responseNotes, setResponseNotes] = useState(''); + + useEffect(() => { + fetchApprovals(); + }, [filter]); + + const fetchApprovals = async () => { + try { + setLoading(true); + const params: any = {}; + if (filter !== 'all') params.status = filter; + const response = await approvalService.getApprovals(params); + setApprovals(response.data || []); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to load approvals'); + } finally { + setLoading(false); + } + }; + + const handleRespond = async (approvalId: number, status: 'approved' | 'rejected') => { + if (!responseNotes.trim() && status === 'rejected') { + toast.error('Please provide notes for rejection'); + return; + } + try { + await approvalService.respondToApproval(approvalId, { + status, + response_notes: responseNotes || undefined + }); + toast.success(`Approval ${status} successfully`); + setSelectedApproval(null); + setResponseNotes(''); + fetchApprovals(); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to respond to approval'); + } + }; + + const getStatusColor = (status: ApprovalStatus) => { + switch (status) { + case 'approved': + return 'bg-green-100 text-green-800 border-green-200'; + case 'rejected': + return 'bg-red-100 text-red-800 border-red-200'; + case 'pending': + return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + default: + return 'bg-gray-100 text-gray-800 border-gray-200'; + } + }; + + const getActionTypeLabel = (type: string) => { + return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); + }; + + if (loading && approvals.length === 0) { + return ; + } + + return ( +

+ {/* Header */} +
+
+
+

+ Approval Management +

+
+

+ Review and respond to financial approval requests +

+
+ + {/* Filters */} +
+ + + + +
+ + {/* Approvals List */} +
+ {approvals.length === 0 ? ( + + ) : ( +
+ {approvals.map((approval) => ( +
setSelectedApproval(approval)} + > +
+
+
+

{getActionTypeLabel(approval.action_type)}

+ + {approval.status.charAt(0).toUpperCase() + approval.status.slice(1)} + +
+

{approval.action_description}

+
+ Requested by: {approval.requested_by_email || `User #${approval.requested_by}`} + + {formatDate(approval.requested_at)} + {approval.amount && ( + <> + + {formatCurrency(approval.amount)} + + )} +
+ {approval.request_reason && ( +

+ Reason: {approval.request_reason} +

+ )} +
+ {approval.status === 'pending' && ( + + )} +
+
+ ))} +
+ )} +
+ + {/* Approval Detail Modal */} + {selectedApproval && ( +
+
+
+
+
+

{getActionTypeLabel(selectedApproval.action_type)}

+ + {selectedApproval.status.charAt(0).toUpperCase() + selectedApproval.status.slice(1)} + +
+ +
+
+
+
+

Description

+

{selectedApproval.action_description}

+
+ {selectedApproval.request_reason && ( +
+

Request Reason

+

{selectedApproval.request_reason}

+
+ )} + {selectedApproval.amount && ( +
+

Amount

+

{formatCurrency(selectedApproval.amount)}

+
+ )} + {selectedApproval.previous_value && ( +
+

Previous Value

+
+                    {JSON.stringify(selectedApproval.previous_value, null, 2)}
+                  
+
+ )} + {selectedApproval.new_value && ( +
+

New Value

+
+                    {JSON.stringify(selectedApproval.new_value, null, 2)}
+                  
+
+ )} + {selectedApproval.status === 'pending' && ( +
+

Response Notes

+