updates
This commit is contained in:
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()
|
||||
|
||||
Reference in New Issue
Block a user