updates
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
model_config = ConfigDict(populate_by_name=True) # Allows population by field name (alias)
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
94
Backend/src/payments/models/accountant_session.py
Normal file
94
Backend/src/payments/models/accountant_session.py
Normal file
@@ -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'),
|
||||
)
|
||||
|
||||
71
Backend/src/payments/models/chart_of_accounts.py
Normal file
71
Backend/src/payments/models/chart_of_accounts.py
Normal file
@@ -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'),
|
||||
)
|
||||
|
||||
89
Backend/src/payments/models/financial_approval.py
Normal file
89
Backend/src/payments/models/financial_approval.py
Normal file
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
53
Backend/src/payments/models/fiscal_period.py
Normal file
53
Backend/src/payments/models/fiscal_period.py
Normal file
@@ -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'),
|
||||
)
|
||||
|
||||
104
Backend/src/payments/models/journal_entry.py
Normal file
104
Backend/src/payments/models/journal_entry.py
Normal file
@@ -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'),
|
||||
)
|
||||
|
||||
82
Backend/src/payments/models/reconciliation_exception.py
Normal file
82
Backend/src/payments/models/reconciliation_exception.py
Normal file
@@ -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'),
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
262
Backend/src/payments/routes/accountant_security_routes.py
Normal file
262
Backend/src/payments/routes/accountant_security_routes.py
Normal file
@@ -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))
|
||||
|
||||
433
Backend/src/payments/routes/approval_routes.py
Normal file
433
Backend/src/payments/routes/approval_routes.py
Normal file
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
240
Backend/src/payments/routes/gl_routes.py
Normal file
240
Backend/src/payments/routes/gl_routes.py
Normal file
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
234
Backend/src/payments/routes/reconciliation_routes.py
Normal file
234
Backend/src/payments/routes/reconciliation_routes.py
Normal file
@@ -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))
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
289
Backend/src/payments/services/accountant_security_service.py
Normal file
289
Backend/src/payments/services/accountant_security_service.py
Normal file
@@ -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()
|
||||
|
||||
257
Backend/src/payments/services/approval_service.py
Normal file
257
Backend/src/payments/services/approval_service.py
Normal file
@@ -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()
|
||||
|
||||
93
Backend/src/payments/services/audit_retention_service.py
Normal file
93
Backend/src/payments/services/audit_retention_service.py
Normal file
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
270
Backend/src/payments/services/gl_posting_service.py
Normal file
270
Backend/src/payments/services/gl_posting_service.py
Normal file
@@ -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()
|
||||
|
||||
321
Backend/src/payments/services/gl_service.py
Normal file
321
Backend/src/payments/services/gl_service.py
Normal file
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
307
Backend/src/payments/services/reconciliation_service.py
Normal file
307
Backend/src/payments/services/reconciliation_service.py
Normal file
@@ -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()
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
87
Backend/src/security/middleware/step_up_auth.py
Normal file
87
Backend/src/security/middleware/step_up_auth.py
Normal file
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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"]:
|
||||
|
||||
Reference in New Issue
Block a user