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 fabf3a55..faa19b45 100644 Binary files a/Backend/src/__pycache__/main.cpython-312.pyc and b/Backend/src/__pycache__/main.cpython-312.pyc differ diff --git a/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc b/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc index 4fb2e107..1e301a81 100644 Binary files a/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc and b/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc differ 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 548ca9ea..90d58157 100644 Binary files a/Backend/src/auth/schemas/__pycache__/auth.cpython-312.pyc and b/Backend/src/auth/schemas/__pycache__/auth.cpython-312.pyc differ 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 210ca95b..7555c912 100644 Binary files a/Backend/src/models/__pycache__/__init__.cpython-312.pyc and b/Backend/src/models/__pycache__/__init__.cpython-312.pyc differ 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 00000000..4cacae50 Binary files /dev/null and b/Backend/src/payments/models/__pycache__/accountant_session.cpython-312.pyc differ 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 00000000..8badf297 Binary files /dev/null and b/Backend/src/payments/models/__pycache__/chart_of_accounts.cpython-312.pyc differ 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 00000000..72164690 Binary files /dev/null and b/Backend/src/payments/models/__pycache__/financial_approval.cpython-312.pyc differ diff --git a/Backend/src/payments/models/__pycache__/financial_audit_trail.cpython-312.pyc b/Backend/src/payments/models/__pycache__/financial_audit_trail.cpython-312.pyc index c6116dd6..da17d16a 100644 Binary files a/Backend/src/payments/models/__pycache__/financial_audit_trail.cpython-312.pyc and b/Backend/src/payments/models/__pycache__/financial_audit_trail.cpython-312.pyc differ 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 00000000..f1363ced Binary files /dev/null and b/Backend/src/payments/models/__pycache__/fiscal_period.cpython-312.pyc differ diff --git a/Backend/src/payments/models/__pycache__/journal_entry.cpython-312.pyc b/Backend/src/payments/models/__pycache__/journal_entry.cpython-312.pyc new file mode 100644 index 00000000..c37bb68c Binary files /dev/null and b/Backend/src/payments/models/__pycache__/journal_entry.cpython-312.pyc differ 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 00000000..4b7440f5 Binary files /dev/null and b/Backend/src/payments/models/__pycache__/reconciliation_exception.cpython-312.pyc differ diff --git a/Backend/src/payments/models/accountant_session.py b/Backend/src/payments/models/accountant_session.py new file mode 100644 index 00000000..d1d6dd33 --- /dev/null +++ b/Backend/src/payments/models/accountant_session.py @@ -0,0 +1,94 @@ +""" +Accountant session tracking model for security monitoring. +""" +from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, JSON, Index, Text +from sqlalchemy.orm import relationship +from datetime import datetime, timedelta +from ...shared.config.database import Base + + +class AccountantSession(Base): + """Track accountant sessions for security monitoring.""" + __tablename__ = 'accountant_sessions' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + + # User reference + user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True) + + # Session details + session_token = Column(String(255), unique=True, nullable=False, index=True) + ip_address = Column(String(45), nullable=True) # IPv6 compatible + user_agent = Column(Text, nullable=True) + device_fingerprint = Column(String(255), nullable=True) + + # Location (if available) + country = Column(String(2), nullable=True) # ISO country code + city = Column(String(100), nullable=True) + + # Session status + is_active = Column(Boolean, default=True, nullable=False, index=True) + last_activity = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + # Step-up authentication + step_up_authenticated = Column(Boolean, default=False, nullable=False) + step_up_expires_at = Column(DateTime, nullable=True) + + # Session metadata + session_metadata = Column(JSON, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + expires_at = Column(DateTime, nullable=False, index=True) + + # Relationships + user = relationship('User', foreign_keys=[user_id]) + + # Indexes + __table_args__ = ( + Index('idx_accountant_session_user_active', 'user_id', 'is_active', 'last_activity'), + Index('idx_accountant_session_expires', 'expires_at', 'is_active'), + ) + + +class AccountantActivityLog(Base): + """Log unusual accountant activity for security monitoring.""" + __tablename__ = 'accountant_activity_logs' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + + # User reference + user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True) + session_id = Column(Integer, ForeignKey('accountant_sessions.id'), nullable=True, index=True) + + # Activity details + activity_type = Column(String(50), nullable=False, index=True) # 'login', 'high_risk_action', 'export', 'unusual_location', etc. + activity_description = Column(Text, nullable=False) + + # Location and device + ip_address = Column(String(45), nullable=True) + user_agent = Column(Text, nullable=True) + country = Column(String(2), nullable=True) + city = Column(String(100), nullable=True) + + # Risk indicators + risk_level = Column(String(20), default='low', nullable=False) # 'low', 'medium', 'high', 'critical' + is_unusual = Column(Boolean, default=False, nullable=False, index=True) + + # Additional context + activity_metadata = Column(JSON, nullable=True) + + # Timestamp + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + # Relationships + user = relationship('User', foreign_keys=[user_id]) + session = relationship('AccountantSession', foreign_keys=[session_id]) + + # Indexes + __table_args__ = ( + Index('idx_accountant_activity_user_date', 'user_id', 'created_at'), + Index('idx_accountant_activity_unusual', 'is_unusual', 'risk_level', 'created_at'), + Index('idx_accountant_activity_type', 'activity_type', 'created_at'), + ) + diff --git a/Backend/src/payments/models/chart_of_accounts.py b/Backend/src/payments/models/chart_of_accounts.py new file mode 100644 index 00000000..ccf3dc3f --- /dev/null +++ b/Backend/src/payments/models/chart_of_accounts.py @@ -0,0 +1,71 @@ +""" +Chart of Accounts model for General Ledger. +""" +from sqlalchemy import Column, Integer, String, DateTime, Enum, ForeignKey, Text, Index +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from ...shared.config.database import Base + + +class AccountType(str, enum.Enum): + """Types of accounts in the chart of accounts.""" + asset = 'asset' + liability = 'liability' + equity = 'equity' + revenue = 'revenue' + expense = 'expense' + cogs = 'cogs' # Cost of Goods Sold + + +class AccountCategory(str, enum.Enum): + """Categories for organizing accounts.""" + # Assets + current_assets = 'current_assets' + fixed_assets = 'fixed_assets' + # Liabilities + current_liabilities = 'current_liabilities' + long_term_liabilities = 'long_term_liabilities' + # Equity + equity = 'equity' + retained_earnings = 'retained_earnings' + # Revenue + operating_revenue = 'operating_revenue' + other_revenue = 'other_revenue' + # Expenses + operating_expenses = 'operating_expenses' + cogs = 'cogs' + other_expenses = 'other_expenses' + + +class ChartOfAccounts(Base): + """Chart of Accounts for General Ledger.""" + __tablename__ = 'chart_of_accounts' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + + # Account identification + account_code = Column(String(20), unique=True, nullable=False, index=True) + account_name = Column(String(255), nullable=False) + account_type = Column(Enum(AccountType), nullable=False, index=True) + account_category = Column(Enum(AccountCategory), nullable=True, index=True) + + # Account details + description = Column(Text, nullable=True) + parent_account_id = Column(Integer, ForeignKey('chart_of_accounts.id'), nullable=True) + is_active = Column(String(10), default='true', nullable=False) # Using string for compatibility + + # Metadata + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + parent_account = relationship('ChartOfAccounts', remote_side=[id], backref='child_accounts') + journal_lines = relationship('JournalLine', back_populates='account') + + # Indexes + __table_args__ = ( + Index('idx_chart_of_accounts_type', 'account_type', 'is_active'), + Index('idx_chart_of_accounts_category', 'account_category', 'is_active'), + ) + diff --git a/Backend/src/payments/models/financial_approval.py b/Backend/src/payments/models/financial_approval.py new file mode 100644 index 00000000..9d5c9170 --- /dev/null +++ b/Backend/src/payments/models/financial_approval.py @@ -0,0 +1,89 @@ +""" +Financial approval model for tracking high-risk financial operations that require approval. +Implements segregation of duties - users cannot approve their own actions. +""" +from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey, JSON, Index +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from ...shared.config.database import Base + + +class ApprovalStatus(str, enum.Enum): + """Status of an approval request.""" + pending = 'pending' + approved = 'approved' + rejected = 'rejected' + cancelled = 'cancelled' + + +class ApprovalActionType(str, enum.Enum): + """Types of actions that require approval.""" + large_refund = 'large_refund' + payment_status_override = 'payment_status_override' + invoice_write_off = 'invoice_write_off' + large_discount = 'large_discount' + tax_rate_change = 'tax_rate_change' + manual_payment_adjustment = 'manual_payment_adjustment' + + +class FinancialApproval(Base): + """Approval request for high-risk financial operations.""" + __tablename__ = 'financial_approvals' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + + # Action details + action_type = Column(Enum(ApprovalActionType), nullable=False, index=True) + action_description = Column(Text, nullable=False) + status = Column(Enum(ApprovalStatus), default=ApprovalStatus.pending, nullable=False, index=True) + + # Related entities + payment_id = Column(Integer, ForeignKey('payments.id'), nullable=True, index=True) + invoice_id = Column(Integer, ForeignKey('invoices.id'), nullable=True, index=True) + booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True, index=True) + + # Financial details + amount = Column(Numeric(10, 2), nullable=True) + previous_value = Column(JSON, nullable=True) # Store previous state (e.g., old status, old amount) + new_value = Column(JSON, nullable=True) # Store new state + + # Request details + requested_by = Column(Integer, ForeignKey('users.id'), nullable=False, index=True) + requested_by_email = Column(String(255), nullable=True) # Store email for audit even if user deleted + request_reason = Column(Text, nullable=True) + + # Approval details + approved_by = Column(Integer, ForeignKey('users.id'), nullable=True, index=True) + approved_by_email = Column(String(255), nullable=True) + approval_notes = Column(Text, nullable=True) + approved_at = Column(DateTime, nullable=True) + + # Rejection details + rejected_by = Column(Integer, ForeignKey('users.id'), nullable=True) + rejection_reason = Column(Text, nullable=True) + rejected_at = Column(DateTime, nullable=True) + + # Additional context + approval_metadata = Column(JSON, nullable=True) # Store additional context (IP, user agent, thresholds, etc.) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + payment = relationship('Payment', foreign_keys=[payment_id]) + invoice = relationship('Invoice', foreign_keys=[invoice_id]) + booking = relationship('Booking', foreign_keys=[booking_id]) + requester = relationship('User', foreign_keys=[requested_by]) + approver = relationship('User', foreign_keys=[approved_by]) + rejector = relationship('User', foreign_keys=[rejected_by]) + + # Indexes for common queries + __table_args__ = ( + Index('idx_financial_approval_status', 'status', 'created_at'), + Index('idx_financial_approval_action', 'action_type', 'status'), + Index('idx_financial_approval_requester', 'requested_by', 'status'), + Index('idx_financial_approval_approver', 'approved_by', 'created_at'), + ) + diff --git a/Backend/src/payments/models/financial_audit_trail.py b/Backend/src/payments/models/financial_audit_trail.py index 937f3753..8148a050 100644 --- a/Backend/src/payments/models/financial_audit_trail.py +++ b/Backend/src/payments/models/financial_audit_trail.py @@ -22,6 +22,8 @@ class FinancialActionType(str, enum.Enum): price_modified = 'price_modified' discount_applied = 'discount_applied' promotion_applied = 'promotion_applied' + settings_changed = 'settings_changed' # Financial settings (tax_rate, currency, etc.) + data_exported = 'data_exported' # Financial data exports (CSV, reports) class FinancialAuditTrail(Base): diff --git a/Backend/src/payments/models/fiscal_period.py b/Backend/src/payments/models/fiscal_period.py new file mode 100644 index 00000000..38ea7da1 --- /dev/null +++ b/Backend/src/payments/models/fiscal_period.py @@ -0,0 +1,53 @@ +""" +Fiscal Period model for General Ledger period management. +""" +from sqlalchemy import Column, Integer, String, DateTime, Enum, Boolean, Index +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from ...shared.config.database import Base + + +class PeriodStatus(str, enum.Enum): + """Status of a fiscal period.""" + open = 'open' + closed = 'closed' + locked = 'locked' # Locked periods cannot be modified + + +class FiscalPeriod(Base): + """Fiscal Period for organizing financial transactions.""" + __tablename__ = 'fiscal_periods' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + + # Period identification + period_name = Column(String(50), unique=True, nullable=False, index=True) # e.g., "2024-Q1", "2024-01" + period_type = Column(String(20), nullable=False) # 'monthly', 'quarterly', 'yearly' + + # Period dates + start_date = Column(DateTime, nullable=False, index=True) + end_date = Column(DateTime, nullable=False, index=True) + + # Period status + status = Column(Enum(PeriodStatus), default=PeriodStatus.open, nullable=False, index=True) + is_current = Column(Boolean, default=False, nullable=False, index=True) # Only one current period + + # Period metadata + closed_by = Column(Integer, nullable=True) # User ID who closed the period + closed_at = Column(DateTime, nullable=True) + notes = Column(String(500), nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + journal_entries = relationship('JournalEntry', back_populates='fiscal_period') + + # Indexes + __table_args__ = ( + Index('idx_fiscal_period_dates', 'start_date', 'end_date'), + Index('idx_fiscal_period_status', 'status', 'is_current'), + ) + diff --git a/Backend/src/payments/models/journal_entry.py b/Backend/src/payments/models/journal_entry.py new file mode 100644 index 00000000..9fe2487e --- /dev/null +++ b/Backend/src/payments/models/journal_entry.py @@ -0,0 +1,104 @@ +""" +Journal Entry and Journal Line models for General Ledger. +""" +from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey, JSON, Index +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from ...shared.config.database import Base + + +class JournalEntryStatus(str, enum.Enum): + """Status of a journal entry.""" + draft = 'draft' + posted = 'posted' + reversed = 'reversed' + + +class JournalEntry(Base): + """Journal Entry for recording financial transactions.""" + __tablename__ = 'journal_entries' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + + # Entry identification + entry_number = Column(String(50), unique=True, nullable=False, index=True) + entry_date = Column(DateTime, nullable=False, index=True) + status = Column(Enum(JournalEntryStatus), default=JournalEntryStatus.draft, nullable=False, index=True) + + # Period and reference + fiscal_period_id = Column(Integer, ForeignKey('fiscal_periods.id'), nullable=False, index=True) + reference_type = Column(String(50), nullable=True) # 'booking', 'invoice', 'payment', 'manual' + reference_id = Column(Integer, nullable=True, index=True) # ID of the referenced entity + + # Entry details + description = Column(Text, nullable=False) + notes = Column(Text, nullable=True) + + # User tracking + created_by = Column(Integer, ForeignKey('users.id'), nullable=False) + posted_by = Column(Integer, ForeignKey('users.id'), nullable=True) + posted_at = Column(DateTime, nullable=True) + + # Reversal tracking + reversed_by = Column(Integer, ForeignKey('users.id'), nullable=True) + reversed_at = Column(DateTime, nullable=True) + reversal_reason = Column(Text, nullable=True) + original_entry_id = Column(Integer, ForeignKey('journal_entries.id'), nullable=True) + + # Additional metadata + entry_metadata = Column(JSON, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + fiscal_period = relationship('FiscalPeriod', back_populates='journal_entries') + journal_lines = relationship('JournalLine', back_populates='journal_entry', cascade='all, delete-orphan') + creator = relationship('User', foreign_keys=[created_by]) + poster = relationship('User', foreign_keys=[posted_by]) + reverser = relationship('User', foreign_keys=[reversed_by]) + original_entry = relationship('JournalEntry', remote_side=[id], backref='reversals') + + # Indexes + __table_args__ = ( + Index('idx_journal_entry_date', 'entry_date', 'status'), + Index('idx_journal_entry_reference', 'reference_type', 'reference_id'), + Index('idx_journal_entry_period', 'fiscal_period_id', 'status'), + ) + + +class JournalLine(Base): + """Journal Line for individual account postings within a journal entry.""" + __tablename__ = 'journal_lines' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + + # Entry reference + journal_entry_id = Column(Integer, ForeignKey('journal_entries.id'), nullable=False, index=True) + line_number = Column(Integer, nullable=False) # Line sequence within entry + + # Account reference + account_id = Column(Integer, ForeignKey('chart_of_accounts.id'), nullable=False, index=True) + + # Amounts + debit_amount = Column(Numeric(10, 2), nullable=True, default=0.0) + credit_amount = Column(Numeric(10, 2), nullable=True, default=0.0) + + # Line details + description = Column(Text, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + journal_entry = relationship('JournalEntry', back_populates='journal_lines') + account = relationship('ChartOfAccounts', back_populates='journal_lines') + + # Indexes + __table_args__ = ( + Index('idx_journal_line_account', 'account_id', 'created_at'), + Index('idx_journal_line_entry', 'journal_entry_id', 'line_number'), + ) + diff --git a/Backend/src/payments/models/reconciliation_exception.py b/Backend/src/payments/models/reconciliation_exception.py new file mode 100644 index 00000000..f1c15235 --- /dev/null +++ b/Backend/src/payments/models/reconciliation_exception.py @@ -0,0 +1,82 @@ +""" +Reconciliation exception model for tracking payment/invoice mismatches. +""" +from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey, JSON, Index +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from ...shared.config.database import Base + + +class ExceptionStatus(str, enum.Enum): + """Status of a reconciliation exception.""" + open = 'open' + assigned = 'assigned' + in_review = 'in_review' + resolved = 'resolved' + closed = 'closed' + + +class ExceptionType(str, enum.Enum): + """Types of reconciliation exceptions.""" + missing_invoice = 'missing_invoice' + missing_payment = 'missing_payment' + amount_mismatch = 'amount_mismatch' + duplicate_payment = 'duplicate_payment' + orphaned_payment = 'orphaned_payment' + date_mismatch = 'date_mismatch' + + +class ReconciliationException(Base): + """Exception record for payment/invoice reconciliation mismatches.""" + __tablename__ = 'reconciliation_exceptions' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + + # Exception details + exception_type = Column(Enum(ExceptionType), nullable=False, index=True) + status = Column(Enum(ExceptionStatus), default=ExceptionStatus.open, nullable=False, index=True) + severity = Column(String(20), default='medium', nullable=False) # 'low', 'medium', 'high', 'critical' + + # Related entities + payment_id = Column(Integer, ForeignKey('payments.id'), nullable=True, index=True) + invoice_id = Column(Integer, ForeignKey('invoices.id'), nullable=True, index=True) + booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True, index=True) + + # Exception details + description = Column(Text, nullable=False) + expected_amount = Column(Numeric(10, 2), nullable=True) + actual_amount = Column(Numeric(10, 2), nullable=True) + difference = Column(Numeric(10, 2), nullable=True) + + # Assignment and resolution + assigned_to = Column(Integer, ForeignKey('users.id'), nullable=True, index=True) + assigned_at = Column(DateTime, nullable=True) + resolved_by = Column(Integer, ForeignKey('users.id'), nullable=True) + resolved_at = Column(DateTime, nullable=True) + resolution_notes = Column(Text, nullable=True) + + # Comments/notes + comments = Column(JSON, nullable=True) # Array of comment objects + + # Additional metadata + exception_metadata = Column(JSON, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + payment = relationship('Payment', foreign_keys=[payment_id]) + invoice = relationship('Invoice', foreign_keys=[invoice_id]) + booking = relationship('Booking', foreign_keys=[booking_id]) + assignee = relationship('User', foreign_keys=[assigned_to]) + resolver = relationship('User', foreign_keys=[resolved_by]) + + # Indexes + __table_args__ = ( + Index('idx_reconciliation_exception_status', 'status', 'created_at'), + Index('idx_reconciliation_exception_type', 'exception_type', 'status'), + Index('idx_reconciliation_exception_assigned', 'assigned_to', 'status'), + ) + diff --git a/Backend/src/payments/routes/__pycache__/accountant_security_routes.cpython-312.pyc b/Backend/src/payments/routes/__pycache__/accountant_security_routes.cpython-312.pyc new file mode 100644 index 00000000..df825680 Binary files /dev/null and b/Backend/src/payments/routes/__pycache__/accountant_security_routes.cpython-312.pyc differ 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 00000000..4ea69c23 Binary files /dev/null and b/Backend/src/payments/routes/__pycache__/approval_routes.cpython-312.pyc differ 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 685956e7..ca714718 100644 Binary files a/Backend/src/payments/routes/__pycache__/audit_trail_routes.cpython-312.pyc and b/Backend/src/payments/routes/__pycache__/audit_trail_routes.cpython-312.pyc differ diff --git a/Backend/src/payments/routes/__pycache__/financial_routes.cpython-312.pyc b/Backend/src/payments/routes/__pycache__/financial_routes.cpython-312.pyc index 648f7273..220385d7 100644 Binary files a/Backend/src/payments/routes/__pycache__/financial_routes.cpython-312.pyc and b/Backend/src/payments/routes/__pycache__/financial_routes.cpython-312.pyc differ 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 00000000..fd94796f Binary files /dev/null and b/Backend/src/payments/routes/__pycache__/gl_routes.cpython-312.pyc differ 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 acd4bb43..06922522 100644 Binary files a/Backend/src/payments/routes/__pycache__/invoice_routes.cpython-312.pyc and b/Backend/src/payments/routes/__pycache__/invoice_routes.cpython-312.pyc differ 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 039143d3..2010991c 100644 Binary files a/Backend/src/payments/routes/__pycache__/payment_routes.cpython-312.pyc and b/Backend/src/payments/routes/__pycache__/payment_routes.cpython-312.pyc differ diff --git a/Backend/src/payments/routes/__pycache__/reconciliation_routes.cpython-312.pyc b/Backend/src/payments/routes/__pycache__/reconciliation_routes.cpython-312.pyc new file mode 100644 index 00000000..63f816e8 Binary files /dev/null and b/Backend/src/payments/routes/__pycache__/reconciliation_routes.cpython-312.pyc differ 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 00000000..02916289 Binary files /dev/null and b/Backend/src/payments/services/__pycache__/accountant_security_service.cpython-312.pyc differ 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 00000000..c3105668 Binary files /dev/null and b/Backend/src/payments/services/__pycache__/approval_service.cpython-312.pyc differ 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 95c36610..012dfe6c 100644 Binary files a/Backend/src/payments/services/__pycache__/financial_audit_service.cpython-312.pyc and b/Backend/src/payments/services/__pycache__/financial_audit_service.cpython-312.pyc differ 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 00000000..0a2ad716 Binary files /dev/null and b/Backend/src/payments/services/__pycache__/gl_service.cpython-312.pyc differ 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 92d4e2ba..4b948d44 100644 Binary files a/Backend/src/payments/services/__pycache__/invoice_service.cpython-312.pyc and b/Backend/src/payments/services/__pycache__/invoice_service.cpython-312.pyc differ 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 00000000..7a30bd92 Binary files /dev/null and b/Backend/src/payments/services/__pycache__/reconciliation_service.cpython-312.pyc differ diff --git a/Backend/src/payments/services/accountant_security_service.py b/Backend/src/payments/services/accountant_security_service.py new file mode 100644 index 00000000..1f50764b --- /dev/null +++ b/Backend/src/payments/services/accountant_security_service.py @@ -0,0 +1,289 @@ +""" +Service for accountant-specific security controls: MFA enforcement, step-up auth, session management. +""" +from sqlalchemy.orm import Session +from typing import Optional, Dict, Any, Tuple +from datetime import datetime, timedelta +from ...auth.models.user import User +from ..models.accountant_session import AccountantSession, AccountantActivityLog +from ...shared.utils.role_helpers import is_accountant, is_admin +from ...shared.config.logging_config import get_logger +import secrets +import hashlib + +logger = get_logger(__name__) + + +class AccountantSecurityService: + """Service for accountant security controls.""" + + # Session timeout for accountants (shorter than default) + ACCOUNTANT_SESSION_TIMEOUT_HOURS = 4 # 4 hours instead of default 14 days + ACCOUNTANT_IDLE_TIMEOUT_MINUTES = 30 # 30 minutes idle timeout + + # Step-up authentication validity + STEP_UP_VALIDITY_MINUTES = 15 # Step-up auth valid for 15 minutes + + @staticmethod + def requires_mfa(user: User, db: Session) -> 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 5e9d9ded..9e1f3ae9 100644 Binary files a/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc and b/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc differ diff --git a/Backend/src/security/middleware/auth.py b/Backend/src/security/middleware/auth.py index b85703c0..5a8785a8 100644 --- a/Backend/src/security/middleware/auth.py +++ b/Backend/src/security/middleware/auth.py @@ -123,6 +123,34 @@ def get_current_user( detail=f'Account is temporarily locked due to multiple failed login attempts. Please try again in {remaining_minutes} minute(s).' ) + # SECURITY: Check MFA for accountant/admin roles (warn but allow access for MFA setup) + try: + 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__) + + if is_accountant(user, db) or is_admin(user, db): + is_enforced, reason = accountant_security_service.is_mfa_enforced(user, db) + if not is_enforced and reason: + # Log warning but allow access so user can set up MFA + # Individual routes can enforce MFA for sensitive operations + logger.warning( + f'User {user.id} ({user.email}) accessed system without MFA enabled. ' + f'MFA is required for {user.role.name if user.role else "unknown"} role. ' + f'Reason: {reason}' + ) + # Store MFA requirement in user object for route-level checks + user._mfa_setup_required = True + user._mfa_setup_reason = reason + except HTTPException: + raise + except Exception as e: + # Don't block authentication if MFA check fails (log and continue) + logger = get_logger(__name__) + logger.warning(f'Error checking MFA enforcement: {str(e)}') + return user def authorize_roles(*allowed_roles: str): @@ -130,6 +158,8 @@ def authorize_roles(*allowed_roles: str): def role_checker(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)) -> 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 4abc62ba..1d4c8e0c 100644 Binary files a/Backend/src/shared/config/__pycache__/settings.cpython-312.pyc and b/Backend/src/shared/config/__pycache__/settings.cpython-312.pyc differ 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 6cc9c814..0b1e07aa 100644 Binary files a/Backend/src/shared/utils/__pycache__/role_helpers.cpython-312.pyc and b/Backend/src/shared/utils/__pycache__/role_helpers.cpython-312.pyc differ diff --git a/Backend/src/shared/utils/role_helpers.py b/Backend/src/shared/utils/role_helpers.py index d33fc45d..6db5d920 100644 --- a/Backend/src/shared/utils/role_helpers.py +++ b/Backend/src/shared/utils/role_helpers.py @@ -5,56 +5,232 @@ from sqlalchemy.orm import Session from ...auth.models.user import User from ...auth.models.role import Role +""" +Centralised helpers for role & permission checks. + +This module is deliberately kept framework-agnostic so it can be used +from FastAPI dependencies, services and background jobs. + +It also introduces finer-grained accountant roles while keeping full +backwards‑compatibility with existing code that checks for plain +`accountant`. +""" + + +# ---- Core helpers -------------------------------------------------------- + def get_user_role_name(user: User, db: Session) -> 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 d91735ab..9837915f 100644 Binary files a/Backend/src/system/routes/__pycache__/system_settings_routes.cpython-312.pyc and b/Backend/src/system/routes/__pycache__/system_settings_routes.cpython-312.pyc differ 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

+