This commit is contained in:
Iliyan Angelov
2025-12-04 15:10:07 +02:00
parent 3d634b4fce
commit 9f1aeb32da
78 changed files with 7204 additions and 114 deletions

5
.cursor/worktrees.json Normal file
View File

@@ -0,0 +1,5 @@
{
"setup-worktree": [
"npm install"
]
}

View File

@@ -12,12 +12,14 @@ from ..schemas.auth import RegisterRequest, LoginRequest, RefreshTokenRequest, F
from ...security.middleware.auth import get_current_user
from ..models.user import User
from ...analytics.services.audit_service import audit_service
from ...shared.config.logging_config import get_logger
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from functools import wraps
router = APIRouter(prefix='/auth', tags=['auth'])
logger = get_logger(__name__)
# Stricter rate limits for authentication endpoints
AUTH_RATE_LIMIT = "5/minute" # 5 attempts per minute per IP
@@ -102,8 +104,6 @@ async def register(
)
except Exception as e:
# Log error but don't fail registration if session creation fails
from ...shared.config.logging_config import get_logger
logger = get_logger(__name__)
logger.warning(f'Failed to create session during registration: {str(e)}')
# Log successful registration
@@ -157,6 +157,66 @@ async def login(
mfa_token=login_request.mfaToken,
expected_role=login_request.expectedRole
)
# After successful login, check if user is accountant/admin and enforce MFA
requires_mfa_setup = False
if result.get('user') and not result.get('requires_mfa'):
user = db.query(User).filter(User.id == result['user']['id']).first()
if user:
try:
from ...payments.services.accountant_security_service import accountant_security_service
from ...shared.utils.role_helpers import is_accountant, is_admin
if is_accountant(user, db) or is_admin(user, db):
# Check if MFA is required but not enabled
is_enforced, reason = accountant_security_service.is_mfa_enforced(user, db)
if not is_enforced:
# MFA required but not enabled - allow login but flag for setup
requires_mfa_setup = True
await audit_service.log_action(
db=db,
action='login_mfa_setup_required',
resource_type='authentication',
user_id=user.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': login_request.email, 'reason': reason},
status='success'
)
logger.info(f'User {user.id} logged in but MFA setup required: {reason}')
else:
# MFA is enabled and enforced - create accountant session for tracking
try:
accountant_security_service.create_session(
db=db,
user_id=user.id,
ip_address=client_ip,
user_agent=user_agent,
device_fingerprint=None # Can be enhanced with device fingerprinting
)
# Log login activity
is_unusual = accountant_security_service.detect_unusual_activity(
db=db,
user_id=user.id,
ip_address=client_ip
)
accountant_security_service.log_activity(
db=db,
user_id=user.id,
activity_type='login',
activity_description='Accountant/admin login successful',
ip_address=client_ip,
user_agent=user_agent,
risk_level='low',
is_unusual=is_unusual
)
except Exception as e:
logger.warning(f'Error creating accountant session: {e}')
except Exception as e:
logger.warning(f'Error enforcing MFA for accountant: {e}')
if result.get('requires_mfa'):
# Log MFA required
user = db.query(User).filter(User.email == login_request.email.lower().strip()).first()
@@ -194,6 +254,66 @@ async def login(
status='success'
)
return {'status': 'success', 'requires_mfa': True, 'user_id': result['user_id']}
# After successful login (MFA passed if required), check MFA for accountant/admin roles
if not requires_mfa_setup:
user = db.query(User).filter(User.id == result['user']['id']).first()
if user:
try:
from ...payments.services.accountant_security_service import accountant_security_service
from ...shared.utils.role_helpers import is_accountant, is_admin
if is_accountant(user, db) or is_admin(user, db):
# Check if MFA is required but not enabled
is_enforced, reason = accountant_security_service.is_mfa_enforced(user, db)
if not is_enforced:
# MFA required but not enabled - allow login but flag for setup
requires_mfa_setup = True
await audit_service.log_action(
db=db,
action='login_mfa_setup_required',
resource_type='authentication',
user_id=user.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': login_request.email, 'reason': reason},
status='success'
)
logger.info(f'User {user.id} logged in but MFA setup required: {reason}')
else:
# MFA is enabled and enforced - create accountant session for tracking
try:
accountant_session = accountant_security_service.create_session(
db=db,
user_id=user.id,
ip_address=client_ip,
user_agent=user_agent,
device_fingerprint=None # Can be enhanced with device fingerprinting
)
# Log login activity
is_unusual = accountant_security_service.detect_unusual_activity(
db=db,
user_id=user.id,
ip_address=client_ip
)
accountant_security_service.log_activity(
db=db,
user_id=user.id,
activity_type='login',
activity_description='Accountant/admin login successful',
ip_address=client_ip,
user_agent=user_agent,
risk_level='low',
is_unusual=is_unusual
)
except Exception as e:
logger.warning(f'Error creating accountant session: {e}')
except Exception as e:
logger.warning(f'Error enforcing MFA for accountant: {e}')
from ...shared.config.settings import settings
max_age = 7 * 24 * 60 * 60 if login_request.rememberMe else 1 * 24 * 60 * 60
# Use secure cookies in production (HTTPS required)
@@ -236,8 +356,6 @@ async def login(
)
except Exception as e:
# Log error but don't fail login if session creation fails
from ...shared.config.logging_config import get_logger
logger = get_logger(__name__)
logger.warning(f'Failed to create session during login: {str(e)}')
# Validate role if expected_role is provided (for role-specific login endpoints)
@@ -279,7 +397,11 @@ async def login(
)
# Return user data but NOT the token (it's in httpOnly cookie now)
return {'status': 'success', 'data': {'user': result['user']}}
response_data = {'status': 'success', 'data': {'user': result['user']}}
if requires_mfa_setup:
response_data['requires_mfa_setup'] = True
response_data['message'] = 'MFA setup is required for your role. Please enable MFA in your profile settings.'
return response_data
except ValueError as e:
error_message = str(e)
# SECURITY: Sanitize error messages to prevent information disclosure

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel, EmailStr, Field, validator
from pydantic import BaseModel, EmailStr, Field, validator, ConfigDict
from typing import Optional
class RegisterRequest(BaseModel):
@@ -132,5 +132,4 @@ class UpdateProfileRequest(BaseModel):
raise ValueError('Currency must be a 3-letter ISO 4217 code (e.g., USD, EUR, VND)')
return v.upper() if v else v
class Config:
allow_population_by_field_name = True
model_config = ConfigDict(populate_by_name=True) # Allows population by field name (alias)

View File

@@ -291,6 +291,10 @@ from .rooms.routes import room_routes, advanced_room_routes, rate_plan_routes
from .bookings.routes import booking_routes, group_booking_routes
from .bookings.routes.upsell_routes import router as upsell_routes
from .payments.routes import payment_routes, invoice_routes, financial_routes, audit_trail_routes
from .payments.routes.approval_routes import router as financial_approval_routes
from .payments.routes.gl_routes import router as gl_routes
from .payments.routes.reconciliation_routes import router as reconciliation_routes
from .payments.routes.accountant_security_routes import router as accountant_security_routes
from .hotel_services.routes import service_routes, service_booking_routes, inventory_routes, guest_request_routes, staff_shift_routes
from .content.routes import (
banner_routes, page_content_routes, home_routes, about_routes,
@@ -324,6 +328,10 @@ app.include_router(payment_routes.router, prefix=api_prefix)
app.include_router(invoice_routes.router, prefix=api_prefix)
app.include_router(financial_routes.router, prefix=api_prefix)
app.include_router(audit_trail_routes.router, prefix=api_prefix)
app.include_router(financial_approval_routes, prefix=api_prefix)
app.include_router(gl_routes, prefix=api_prefix)
app.include_router(reconciliation_routes, prefix=api_prefix)
app.include_router(accountant_security_routes, prefix=api_prefix)
app.include_router(banner_routes.router, prefix=api_prefix)
app.include_router(favorite_routes.router, prefix=api_prefix)
app.include_router(service_routes.router, prefix=api_prefix)

View File

@@ -31,10 +31,17 @@ from ..bookings.models.group_booking import GroupBooking, GroupBookingMember, Gr
# Payment models
from ..payments.models.payment import Payment
from ..payments.models.invoice import Invoice, InvoiceItem
from ..payments.models.financial_audit_trail import FinancialAuditTrail, FinancialActionType
from ..payments.models.financial_approval import FinancialApproval, ApprovalStatus, ApprovalActionType
from ..payments.models.reconciliation_exception import ReconciliationException, ExceptionStatus, ExceptionType
from ..payments.models.accountant_session import AccountantSession, AccountantActivityLog
from ..payments.models.chart_of_accounts import ChartOfAccounts, AccountType, AccountCategory
from ..payments.models.fiscal_period import FiscalPeriod, PeriodStatus
from ..payments.models.journal_entry import JournalEntry, JournalLine, JournalEntryStatus
# Guest management models
from ..guest_management.models.guest_complaint import GuestComplaint, ComplaintUpdate, ComplaintStatus, ComplaintPriority, ComplaintCategory
from ..payments.models.invoice import Invoice, InvoiceItem
# Hotel Services models
from ..hotel_services.models.service import Service
@@ -108,6 +115,13 @@ __all__ = [
'Booking', 'CheckInCheckOut', 'GroupBooking', 'GroupBookingMember', 'GroupRoomBlock', 'GroupPayment', 'GroupBookingStatus', 'PaymentOption',
# Payments
'Payment', 'Invoice', 'InvoiceItem',
'FinancialAuditTrail', 'FinancialActionType',
'FinancialApproval', 'ApprovalStatus', 'ApprovalActionType',
'ReconciliationException', 'ExceptionStatus', 'ExceptionType',
'AccountantSession', 'AccountantActivityLog',
'ChartOfAccounts', 'AccountType', 'AccountCategory',
'FiscalPeriod', 'PeriodStatus',
'JournalEntry', 'JournalLine', 'JournalEntryStatus',
# Hotel Services
'Service', 'ServiceUsage', 'ServiceBooking', 'ServiceBookingItem', 'ServicePayment', 'ServiceBookingStatus', 'ServicePaymentStatus', 'ServicePaymentMethod',
'HousekeepingTask', 'HousekeepingStatus', 'HousekeepingType',

View 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'),
)

View 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'),
)

View 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'),
)

View File

@@ -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):

View 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'),
)

View 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'),
)

View 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'),
)

View 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))

View 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))

View File

@@ -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))

View File

@@ -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
# Try to get data from GL if available
try:
from ..models.journal_entry import JournalLine
from ..models.fiscal_period import FiscalPeriod
# Net revenue
net_revenue = total_revenue - discounts
# Get periods for date range
periods = db.query(FiscalPeriod).filter(
and_(
FiscalPeriod.start_date <= end,
FiscalPeriod.end_date >= start
)
).all()
# 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
period_ids = [p.id for p in periods] if periods else []
# Operating expenses (placeholder)
operating_expenses = refunds # Using refunds as proxy
# 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
# 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
all_refunds = db.query(func.sum(Payment.amount)).filter(
and_(
Payment.payment_status == PaymentStatus.refunded,
Payment.payment_date <= as_of
)
).scalar() or 0.0
# Get retained earnings from GL
re_account = db.query(ChartOfAccounts).filter(
ChartOfAccounts.account_code == gl_service.ACCOUNT_CODES.get('retained_earnings', '3000')
).first()
retained_earnings = all_revenue - all_refunds - (all_revenue * 0.30) # Minus estimated COGS
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:

View 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))

View File

@@ -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

View File

@@ -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,
@@ -271,6 +274,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()
db.refresh(payment)
@@ -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)
@@ -433,6 +576,38 @@ async def update_payment_status(
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(
db=db,

View 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))

View 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()

View 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()

View 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()

View File

@@ -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

View 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()

View 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()

View File

@@ -36,24 +36,42 @@ class InvoiceService:
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})
logger.info(
f'Creating invoice from booking {booking_id}',
extra={'booking_id': booking_id, 'request_id': request_id},
)
# Start transaction
transaction = db.begin()
# 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
@@ -150,17 +168,24 @@ class InvoiceService:
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

View 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()

View File

@@ -123,6 +123,34 @@ def get_current_user(
detail=f'Account is temporarily locked due to multiple failed login attempts. Please try again in {remaining_minutes} minute(s).'
)
# SECURITY: Check MFA for accountant/admin roles (warn but allow access for MFA setup)
try:
from ...payments.services.accountant_security_service import accountant_security_service
from ...shared.utils.role_helpers import is_accountant, is_admin
from ...shared.config.logging_config import get_logger
logger = get_logger(__name__)
if is_accountant(user, db) or is_admin(user, db):
is_enforced, reason = accountant_security_service.is_mfa_enforced(user, db)
if not is_enforced and reason:
# Log warning but allow access so user can set up MFA
# Individual routes can enforce MFA for sensitive operations
logger.warning(
f'User {user.id} ({user.email}) accessed system without MFA enabled. '
f'MFA is required for {user.role.name if user.role else "unknown"} role. '
f'Reason: {reason}'
)
# Store MFA requirement in user object for route-level checks
user._mfa_setup_required = True
user._mfa_setup_reason = reason
except HTTPException:
raise
except Exception as e:
# Don't block authentication if MFA check fails (log and continue)
logger = get_logger(__name__)
logger.warning(f'Error checking MFA enforcement: {str(e)}')
return user
def authorize_roles(*allowed_roles: str):
@@ -130,6 +158,8 @@ def authorize_roles(*allowed_roles: str):
def role_checker(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)) -> User:
# PERFORMANCE: Use eager-loaded relationship if available, otherwise query
# This reduces database queries since get_current_user now eager loads the role
from ...shared.utils.role_helpers import get_user_role_name
if hasattr(current_user, 'role') and current_user.role is not None:
user_role_name = current_user.role.name
else:
@@ -139,8 +169,21 @@ def authorize_roles(*allowed_roles: str):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='User role not found')
user_role_name = role.name
# Backwardscompatible support for accountant subroles:
# any role starting with "accountant_" is treated as "accountant"
# when a route allows plain "accountant".
if user_role_name not in allowed_roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='You do not have permission to access this resource')
# Normalise accountant_* → accountant for role checks
if (
user_role_name.startswith("accountant_")
and "accountant" in allowed_roles
):
# treated as allowed
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='You do not have permission to access this resource'
)
return current_user
return role_checker

View File

@@ -0,0 +1,87 @@
"""
Step-up authentication middleware for high-risk operations.
"""
from fastapi import Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from typing import Optional
from ...shared.config.database import get_db
from ...security.middleware.auth import get_current_user
from ...auth.models.user import User
from ...payments.services.accountant_security_service import accountant_security_service
from ...shared.utils.role_helpers import is_accountant, is_admin
from ...shared.config.logging_config import get_logger
logger = get_logger(__name__)
def require_step_up_auth(
action_description: str = "this high-risk action"
):
"""
Dependency to require step-up authentication for high-risk operations.
"""
async def step_up_checker(
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> User:
# Only enforce for accountant/admin roles
if not (is_accountant(current_user, db) or is_admin(current_user, db)):
return current_user # Regular users don't need step-up
# Get session token from request
session_token = None
# Try to get from custom header
session_token = request.headers.get('X-Session-Token')
# Or from cookie
if not session_token:
session_token = request.cookies.get('session_token')
# Check if step-up is required
requires_step_up, reason = accountant_security_service.require_step_up(
db=db,
user_id=current_user.id,
session_token=session_token,
action_description=action_description
)
if requires_step_up:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'error': 'step_up_required',
'message': reason or f'Step-up authentication required for {action_description}',
'action': action_description
}
)
return current_user
return step_up_checker
def enforce_mfa_for_accountants():
"""
Dependency to enforce MFA for accountant/admin roles.
"""
async def mfa_enforcer(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> User:
# Check if MFA is required
is_enforced, reason = accountant_security_service.is_mfa_enforced(current_user, db)
if not is_enforced and reason:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'error': 'mfa_required',
'message': reason,
'requires_mfa_setup': True
}
)
return current_user
return mfa_enforcer

View File

@@ -135,22 +135,73 @@ class Settings(BaseSettings):
# Validate base64 encoding and key length (32 bytes = 44 base64 chars)
try:
import base64
decoded = base64.b64decode(self.ENCRYPTION_KEY)
import re
# Try to decode with automatic padding if needed
key_str = self.ENCRYPTION_KEY.strip()
# Check if string contains only valid base64 characters
# Base64 characters: A-Z, a-z, 0-9, +, /, and = for padding
base64_pattern = re.compile(r'^[A-Za-z0-9+/]*={0,2}$')
if not base64_pattern.match(key_str):
raise ValueError(
'ENCRYPTION_KEY contains invalid characters. '
'Base64 strings can only contain A-Z, a-z, 0-9, +, /, and = (for padding).'
)
# Add padding if needed (base64 strings must be multiple of 4)
missing_padding = len(key_str) % 4
if missing_padding:
key_str += '=' * (4 - missing_padding)
# Decode without strict validation since we already checked the pattern
decoded = base64.b64decode(key_str)
if len(decoded) != 32:
raise ValueError(
f'ENCRYPTION_KEY must be a base64-encoded 32-byte key. '
f'Received {len(decoded)} bytes after decoding.'
f'Received {len(decoded)} bytes after decoding (expected 32 bytes).'
)
except Exception as e:
except ValueError as e:
# Re-raise ValueError as-is (these are our validation errors)
if self.is_production:
raise ValueError(
f'Invalid ENCRYPTION_KEY format: {str(e)}. '
'Must be a valid base64-encoded 32-byte key.'
'Must be a valid base64-encoded 32-byte key. '
'Generate one using: python -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"'
)
else:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Invalid ENCRYPTION_KEY format: {str(e)}')
# In development, clear invalid key so encryption service can generate a temporary one
if self.ENCRYPTION_KEY.strip():
logger.info(
f'ENCRYPTION_KEY is invalid ({str(e)}). '
'Clearing it in development mode - encryption service will generate a temporary key. '
'For production, generate a valid key using: '
'python -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"'
)
# Clear the invalid key so encryption service can generate a temporary one
self.ENCRYPTION_KEY = ''
except Exception as e:
# Handle other exceptions (like binascii.Error from base64)
if self.is_production:
raise ValueError(
f'Invalid ENCRYPTION_KEY format: {str(e)}. '
'Must be a valid base64-encoded 32-byte key. '
'Generate one using: python -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"'
)
else:
import logging
logger = logging.getLogger(__name__)
# In development, clear invalid key so encryption service can generate a temporary one
if self.ENCRYPTION_KEY.strip():
logger.info(
f'ENCRYPTION_KEY is invalid ({str(e)}). '
'Clearing it in development mode - encryption service will generate a temporary key. '
'For production, generate a valid key using: '
'python -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"'
)
# Clear the invalid key so encryption service can generate a temporary one
self.ENCRYPTION_KEY = ''
settings = Settings()

View File

@@ -5,56 +5,232 @@ from sqlalchemy.orm import Session
from ...auth.models.user import User
from ...auth.models.role import Role
"""
Centralised helpers for role & permission checks.
This module is deliberately kept framework-agnostic so it can be used
from FastAPI dependencies, services and background jobs.
It also introduces finer-grained accountant roles while keeping full
backwardscompatibility with existing code that checks for plain
`accountant`.
"""
# ---- Core helpers --------------------------------------------------------
def get_user_role_name(user: User, db: Session) -> str:
"""Get the role name for a user"""
if not user or not user.role_id:
return 'customer'
"""Get the role name for a user (falls back to 'customer')."""
if not user or not getattr(user, "role_id", None):
return "customer"
try:
# PERFORMANCE: Use eager-loaded relationship if available to avoid query
if hasattr(user, 'role') and user.role is not None:
if hasattr(user, "role") and user.role is not None:
return user.role.name
# Fallback: query if relationship not loaded
role = db.query(Role).filter(Role.id == user.role_id).first()
return role.name if role else 'customer'
return role.name if role else "customer"
except Exception:
return 'customer'
# Never break business logic because of RBAC helper failures
return "customer"
def _is_role(user: User, db: Session, *role_names: str) -> bool:
"""Internal helper to check if user has any of the given roles."""
role = get_user_role_name(user, db)
return role in role_names
def is_admin(user: User, db: Session) -> bool:
"""Check if user is admin"""
return get_user_role_name(user, db) == 'admin'
"""Check if user is platform admin."""
return _is_role(user, db, "admin")
def is_staff(user: User, db: Session) -> bool:
"""Check if user is staff"""
return get_user_role_name(user, db) == 'staff'
"""Check if user is staff."""
return _is_role(user, db, "staff")
def is_accountant(user: User, db: Session) -> bool:
"""Check if user is accountant"""
return get_user_role_name(user, db) == 'accountant'
def is_customer(user: User, db: Session) -> bool:
"""Check if user is customer"""
return get_user_role_name(user, db) == 'customer'
"""Check if user is customer."""
return _is_role(user, db, "customer")
def is_housekeeping(user: User, db: Session) -> bool:
"""Check if user is housekeeping"""
return get_user_role_name(user, db) == 'housekeeping'
"""Check if user is housekeeping."""
return _is_role(user, db, "housekeeping")
# ---- Accountant role family ----------------------------------------------
ACCOUNTANT_BASE_ROLE = "accountant"
# Subroles for finer-grained permissions. These are logical names only;
# they must also exist in the `roles` table to be usable.
ACCOUNTANT_SUB_ROLES = {
"accountant_readonly", # Viewonly, no mutations
"accountant_operator", # Daytoday ops within limits
"accountant_approver", # Highrisk approver (no selfapproval logic yet)
}
def is_accountant(user: User, db: Session) -> bool:
"""
Check if user belongs to the accountant family.
This returns True for:
- 'accountant'
- any of the accountant_* subroles
"""
role = get_user_role_name(user, db)
return role == ACCOUNTANT_BASE_ROLE or role in ACCOUNTANT_SUB_ROLES
def is_readonly_accountant(user: User, db: Session) -> bool:
"""Check if user is an accountant with readonly permissions."""
return get_user_role_name(user, db) == "accountant_readonly"
def is_operator_accountant(user: User, db: Session) -> bool:
"""Check if user is an accountant operator."""
return get_user_role_name(user, db) in {"accountant", "accountant_operator"}
def is_approver_accountant(user: User, db: Session) -> bool:
"""
Check if user can act as an accountant approver for highrisk actions.
Plain 'accountant' is treated as having full approval powers.
"""
return get_user_role_name(user, db) in {"accountant", "accountant_approver"}
# Aliases for backward compatibility and consistency with naming conventions
def is_accountant_readonly(user: User, db: Session) -> bool:
"""Alias for is_readonly_accountant."""
return is_readonly_accountant(user, db)
def is_accountant_operator(user: User, db: Session) -> bool:
"""Alias for is_operator_accountant."""
return is_operator_accountant(user, db)
def is_accountant_approver(user: User, db: Session) -> bool:
"""Alias for is_approver_accountant."""
return is_approver_accountant(user, db)
# ---- Permission mapping (role -> capability set) -------------------------
# NOTE:
# This is an incode permission matrix. If you later introduce a full
# permission table, this mapping can become a cache over that table.
ROLE_PERMISSIONS = {
# Admin: full access to everything
"admin": {
"financial.view_reports",
"financial.manage_invoices",
"financial.manage_payments",
"financial.manage_settings",
"financial.high_risk_approve",
"users.manage",
},
# Existing generic accountant treated as fullpower accountant
"accountant": {
"financial.view_reports",
"financial.manage_invoices",
"financial.manage_payments",
"financial.manage_settings",
"financial.high_risk_approve",
},
# New subroles
"accountant_readonly": {
"financial.view_reports",
"financial.view_invoices",
"financial.view_payments",
"financial.view_audit_trail",
},
"accountant_operator": {
"financial.view_reports",
"financial.view_invoices",
"financial.view_payments",
"financial.manage_invoices", # normal invoice ops
"financial.manage_payments", # small/manual adjustments
},
"accountant_approver": {
"financial.view_reports",
"financial.view_invoices",
"financial.view_payments",
"financial.high_risk_approve", # approvals only
},
# Staff: keep existing behaviour for now
"staff": {
"financial.view_invoices",
"financial.manage_invoices",
"financial.view_payments",
},
}
def get_user_permissions(user: User, db: Session) -> set[str]:
"""Return a set of logical permissions granted to the user."""
role_name = get_user_role_name(user, db)
return set(ROLE_PERMISSIONS.get(role_name, set()))
def user_has_permission(user: User, db: Session, permission: str) -> bool:
"""Check if user has a single permission."""
return permission in get_user_permissions(user, db)
def user_has_permissions(user: User, db: Session, permissions: list[str]) -> bool:
"""Check if user has all listed permissions."""
if not permissions:
return True
perms = get_user_permissions(user, db)
return all(p in perms for p in permissions)
# ---- Higherlevel helpers used by routes/services ------------------------
def can_access_all_payments(user: User, db: Session) -> bool:
"""Check if user can see all payments (admin or accountant)"""
"""
Check if user can see all payments.
- Admins and any accountant family role may view all.
"""
role_name = get_user_role_name(user, db)
return role_name in ['admin', 'accountant']
if role_name == "admin":
return True
if is_accountant(user, db):
return True
return False
def can_access_all_invoices(user: User, db: Session) -> bool:
"""Check if user can see all invoices (admin or accountant)"""
"""
Check if user can see all invoices.
- Admin, staff, and all accountant family roles may view all.
"""
role_name = get_user_role_name(user, db)
return role_name in ['admin', 'accountant']
if role_name in {"admin", "staff"}:
return True
if is_accountant(user, db):
return True
return False
def can_create_invoices(user: User, db: Session) -> bool:
"""Check if user can create invoices (admin, staff, or accountant)"""
"""
Check if user can create invoices.
- Admin, staff, full accountant, and accountant_operator.
- Readonly accountants are explicitly excluded.
"""
role_name = get_user_role_name(user, db)
return role_name in ['admin', 'staff', 'accountant']
return role_name in {"admin", "staff", "accountant", "accountant_operator"}
def can_manage_users(user: User, db: Session) -> bool:
"""Check if user can manage users (admin only)"""
"""Check if user can manage users (admin only)."""
return is_admin(user, db)

View File

@@ -16,6 +16,8 @@ from ..models.system_settings import SystemSettings
from ...shared.utils.mailer import send_email
from ...rooms.services.room_service import get_base_url
from ...analytics.services.audit_service import audit_service
from ...payments.services.financial_audit_service import financial_audit_service
from ...payments.models.financial_audit_trail import FinancialActionType
def normalize_image_url(image_url: str, base_url: str) -> str:
if not image_url:
@@ -122,6 +124,26 @@ async def update_platform_currency(
},
status='success'
)
# Also log to financial audit trail (currency affects financial reporting)
try:
financial_audit_service.log_financial_action(
db=db,
action_type=FinancialActionType.settings_changed,
performed_by=current_user.id,
action_description=f'Platform currency changed from {old_value} to {currency}',
currency=currency,
metadata={
'setting_key': 'platform_currency',
'old_value': old_value,
'new_value': currency,
'ip_address': client_ip,
'user_agent': user_agent,
'request_id': request_id
},
notes=f'Currency setting updated by {current_user.email}'
)
except Exception as e:
logger.warning(f'Failed to log financial audit for currency change: {e}')
except Exception as e:
logger.warning(f'Failed to log system setting change audit: {e}')
@@ -1476,11 +1498,24 @@ async def get_company_settings(
@router.put("/company")
async def update_company_settings(
request_data: UpdateCompanySettingsRequest,
request: Request,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
try:
db_settings = {}
old_values = {} # Track old values for financial audit
# Get old values for financial-relevant settings before updating
if request_data.tax_rate is not None:
old_tax_setting = db.query(SystemSettings).filter(
SystemSettings.key == "tax_rate"
).first()
old_values["tax_rate"] = old_tax_setting.value if old_tax_setting else None
if request_data.company_name is not None:
db_settings["company_name"] = request_data.company_name
@@ -1521,6 +1556,29 @@ async def update_company_settings(
db.commit()
# Log financial audit for tax_rate changes (affects all future invoices)
if request_data.tax_rate is not None:
try:
old_tax = float(old_values.get("tax_rate", 0)) if old_values.get("tax_rate") else 0.0
new_tax = float(request_data.tax_rate)
financial_audit_service.log_financial_action(
db=db,
action_type=FinancialActionType.settings_changed,
performed_by=current_user.id,
action_description=f'Tax rate changed from {old_tax}% to {new_tax}%',
metadata={
'setting_key': 'tax_rate',
'old_value': str(old_tax),
'new_value': str(new_tax),
'ip_address': client_ip,
'user_agent': user_agent,
'request_id': request_id
},
notes=f'Tax rate setting updated by {current_user.email} - affects all future invoices'
)
except Exception as e:
logger.warning(f'Failed to log financial audit for tax rate change: {e}')
updated_settings = {}
for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address", "tax_rate", "chat_working_hours_start", "chat_working_hours_end"]:

View File

@@ -129,6 +129,12 @@ const AccountantDashboardPage = lazy(() => import('./pages/accountant/DashboardP
const AccountantPaymentManagementPage = lazy(() => import('./pages/accountant/PaymentManagementPage'));
const AccountantInvoiceManagementPage = lazy(() => import('./pages/accountant/InvoiceManagementPage'));
const AccountantAnalyticsDashboardPage = lazy(() => import('./pages/accountant/AnalyticsDashboardPage'));
const GLManagementPage = lazy(() => import('./pages/accountant/GLManagementPage'));
const AccountantApprovalManagementPage = lazy(() => import('./pages/accountant/ApprovalManagementPage'));
const FinancialReportsPage = lazy(() => import('./pages/accountant/FinancialReportsPage'));
const ReconciliationPage = lazy(() => import('./pages/accountant/ReconciliationPage'));
const AuditTrailPage = lazy(() => import('./pages/accountant/AuditTrailPage'));
const AccountantSecurityManagementPage = lazy(() => import('./pages/accountant/SecurityManagementPage'));
const AccountantLayout = lazy(() => import('./pages/AccountantLayout'));
const HousekeepingDashboardPage = lazy(() => import('./pages/housekeeping/DashboardPage'));
@@ -745,6 +751,10 @@ function App() {
path="upsells"
element={<UpsellManagementPage />}
/>
<Route
path="invoices/:id"
element={<InvoicePage />}
/>
<Route
path="profile"
element={<StaffProfilePage />}
@@ -775,6 +785,34 @@ function App() {
path="invoices"
element={<AccountantInvoiceManagementPage />}
/>
<Route
path="gl"
element={<GLManagementPage />}
/>
<Route
path="approvals"
element={<AccountantApprovalManagementPage />}
/>
<Route
path="reports"
element={<FinancialReportsPage />}
/>
<Route
path="reconciliation"
element={<ReconciliationPage />}
/>
<Route
path="audit-trail"
element={<AuditTrailPage />}
/>
<Route
path="security"
element={<AccountantSecurityManagementPage />}
/>
<Route
path="profile"
element={<AccountantProfilePage />}
/>
<Route
path="invoices/:id/edit"
element={<InvoiceEditPage />}
@@ -783,14 +821,6 @@ function App() {
path="invoices/:id"
element={<InvoicePage />}
/>
<Route
path="reports"
element={<AccountantAnalyticsDashboardPage />}
/>
<Route
path="profile"
element={<AccountantProfilePage />}
/>
</Route>
{/* Housekeeping Routes */}

View File

@@ -0,0 +1,69 @@
import apiClient from '../../../shared/services/apiClient';
export type ApprovalActionType =
| 'large_refund'
| 'manual_payment_status_override'
| 'invoice_write_off'
| 'significant_discount'
| 'tax_rate_change'
| 'fiscal_period_close'
| 'gl_manual_entry';
export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'cancelled';
export interface FinancialApproval {
id: number;
action_type: ApprovalActionType;
action_description: string;
status: ApprovalStatus;
requested_by: number;
requested_by_email?: string;
approved_by?: number;
approved_by_email?: string;
requested_at: string;
responded_at?: string;
payment_id?: number;
invoice_id?: number;
booking_id?: number;
gl_entry_id?: number;
amount?: number;
previous_value?: any;
new_value?: any;
currency?: string;
request_reason?: string;
response_notes?: string;
metadata?: any;
}
export interface RespondToApprovalRequest {
status: 'approved' | 'rejected';
response_notes?: string;
}
class ApprovalService {
async getApprovals(params?: {
status?: ApprovalStatus;
action_type?: ApprovalActionType;
requested_by?: number;
approved_by?: number;
page?: number;
limit?: number;
}): Promise<{ status: string; data: FinancialApproval[] }> {
const response = await apiClient.get('/financial/approvals', { params });
return response.data;
}
async getApprovalById(approvalId: number): Promise<{ status: string; data: FinancialApproval }> {
const response = await apiClient.get(`/financial/approvals/${approvalId}`);
return response.data;
}
async respondToApproval(approvalId: number, data: RespondToApprovalRequest): Promise<{ status: string; data: FinancialApproval }> {
const response = await apiClient.post(`/financial/approvals/${approvalId}/respond`, data);
return response.data;
}
}
const approvalService = new ApprovalService();
export default approvalService;

View File

@@ -59,6 +59,26 @@ const financialAuditService = {
const response = await apiClient.get(`/financial/audit-trail/${recordId}`);
return response.data;
},
/**
* Export audit trail to CSV or JSON
*/
async exportAuditTrail(filters: FinancialAuditFilters = {}, format: 'csv' | 'json' = 'csv'): Promise<Blob> {
const params = new URLSearchParams();
if (filters.payment_id) params.append('payment_id', filters.payment_id.toString());
if (filters.invoice_id) params.append('invoice_id', filters.invoice_id.toString());
if (filters.booking_id) params.append('booking_id', filters.booking_id.toString());
if (filters.action_type) params.append('action_type', filters.action_type);
if (filters.user_id) params.append('user_id', filters.user_id.toString());
if (filters.start_date) params.append('start_date', filters.start_date);
if (filters.end_date) params.append('end_date', filters.end_date);
params.append('format', format);
const response = await apiClient.get(`/financial/audit-trail/export?${params.toString()}`, {
responseType: 'blob'
});
return response.data;
},
};
export default financialAuditService;

View File

@@ -0,0 +1,98 @@
import apiClient from '../../../shared/services/apiClient';
export interface ProfitLossReport {
period: {
start_date: string;
end_date: string;
fiscal_period_id?: number;
};
revenue: {
total_revenue: number;
revenue_by_account: Record<string, number>;
};
costs: {
total_cogs: number;
gross_profit: number;
};
expenses: {
total_operating_expenses: number;
expenses_by_account: Record<string, number>;
};
profit: {
net_profit: number;
profit_margin: number;
};
}
export interface BalanceSheetReport {
as_of_date: string;
fiscal_period_id?: number;
assets: {
breakdown: Record<string, number>;
total_assets: number;
};
liabilities: {
breakdown: Record<string, number>;
total_liabilities: number;
};
equity: {
breakdown: Record<string, number>;
total_equity: number;
};
balance: {
total_liabilities_and_equity: number;
is_balanced: boolean;
};
}
export interface TaxReport {
period: {
start_date: string;
end_date: string;
};
total_tax_collected: number;
total_taxable_amount: number;
transactions: Array<{
invoice_number: string;
customer_name: string;
taxable_amount: number;
tax_amount: number;
tax_rate: number;
transaction_date: string;
}>;
}
class FinancialReportService {
async getProfitLoss(params?: {
start_date?: string;
end_date?: string;
fiscal_period_id?: number;
}): Promise<{ status: string; data: ProfitLossReport }> {
const response = await apiClient.get('/financial/profit-loss', { params });
return response.data;
}
async getBalanceSheet(params?: {
as_of_date?: string;
fiscal_period_id?: number;
}): Promise<{ status: string; data: BalanceSheetReport }> {
const response = await apiClient.get('/financial/balance-sheet', { params });
return response.data;
}
async getTaxReport(params?: {
start_date?: string;
end_date?: string;
format?: 'json' | 'csv';
}): Promise<{ status: string; data: TaxReport } | Blob> {
const response = await apiClient.get('/financial/tax-report', {
params,
responseType: params?.format === 'csv' ? 'blob' : 'json'
});
return response.data;
}
}
const financialReportService = new FinancialReportService();
export default financialReportService;

View File

@@ -0,0 +1,155 @@
import apiClient from '../../../shared/services/apiClient';
// Chart of Accounts
export interface Account {
id: number;
account_number: string;
account_name: string;
account_type: string;
account_category: string;
normal_balance: string;
description?: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateAccountRequest {
account_number: string;
account_name: string;
account_type: string;
account_category: string;
normal_balance: string;
description?: string;
is_active?: boolean;
}
// Fiscal Periods
export interface FiscalPeriod {
id: number;
name: string;
start_date: string;
end_date: string;
status: 'Open' | 'Closed' | 'Locked';
is_current: boolean;
created_at: string;
updated_at: string;
}
export interface CreateFiscalPeriodRequest {
name: string;
start_date: string;
end_date: string;
set_as_current?: boolean;
}
// Journal Entries
export interface JournalLine {
account_id: number;
debit: number;
credit: number;
description?: string;
}
export interface JournalEntry {
id: number;
entry_date: string;
description: string;
entry_type: string;
reference_id?: string;
fiscal_period_id: number;
created_at: string;
updated_at: string;
journal_lines: Array<JournalLine & { id: number; account: Account }>;
}
export interface CreateJournalEntryRequest {
entry_date: string;
description: string;
entry_type: string;
fiscal_period_id: number;
lines: JournalLine[];
reference_id?: string;
requires_approval?: boolean;
request_reason?: string;
}
// Trial Balance
export interface TrialBalance {
accounts: Array<{
account_number: string;
account_name: string;
account_type: string;
normal_balance: string;
debit: number;
credit: number;
}>;
total_debits: number;
total_credits: number;
is_balanced: boolean;
}
class GLService {
// Chart of Accounts
async getAccounts(): Promise<{ status: string; data: { accounts: Account[] } }> {
const response = await apiClient.get('/financial/gl/accounts');
return response.data;
}
async createAccount(data: CreateAccountRequest): Promise<{ status: string; data: Account }> {
const response = await apiClient.post('/financial/gl/accounts', data);
return response.data;
}
// Fiscal Periods
async getFiscalPeriods(): Promise<{ status: string; data: { fiscal_periods: FiscalPeriod[] } }> {
const response = await apiClient.get('/financial/gl/fiscal-periods');
return response.data;
}
async createFiscalPeriod(data: CreateFiscalPeriodRequest): Promise<{ status: string; data: FiscalPeriod }> {
const response = await apiClient.post('/financial/gl/fiscal-periods', data);
return response.data;
}
async closeFiscalPeriod(periodId: number, lockPeriod: boolean = false, reason?: string): Promise<{ status: string; data: FiscalPeriod }> {
const response = await apiClient.put(`/financial/gl/fiscal-periods/${periodId}/close`, {
lock_period: lockPeriod,
request_reason: reason
});
return response.data;
}
// Journal Entries
async getJournalEntries(params?: {
fiscal_period_id?: number;
start_date?: string;
end_date?: string;
entry_type?: string;
account_id?: number;
page?: number;
limit?: number;
}): Promise<{ status: string; data: { journal_entries: JournalEntry[] } }> {
const response = await apiClient.get('/financial/gl/journal-entries', { params });
return response.data;
}
async createJournalEntry(data: CreateJournalEntryRequest): Promise<{ status: string; data: JournalEntry }> {
const response = await apiClient.post('/financial/gl/journal-entries', data);
return response.data;
}
// Trial Balance
async getTrialBalance(fiscalPeriodId?: number, asOfDate?: string): Promise<{ status: string; data: TrialBalance }> {
const params: any = {};
if (fiscalPeriodId) params.fiscal_period_id = fiscalPeriodId;
if (asOfDate) params.as_of_date = asOfDate;
const response = await apiClient.get('/financial/gl/trial-balance', { params });
return response.data;
}
}
const glService = new GLService();
export default glService;

View File

@@ -0,0 +1,96 @@
import apiClient from '../../../shared/services/apiClient';
export type ExceptionType =
| 'missing_invoice'
| 'missing_payment'
| 'amount_mismatch'
| 'duplicate_payment'
| 'orphaned_payment'
| 'date_mismatch';
export type ExceptionStatus = 'open' | 'assigned' | 'in_review' | 'resolved' | 'closed';
export interface ReconciliationException {
id: number;
exception_type: ExceptionType;
status: ExceptionStatus;
severity: 'low' | 'medium' | 'high' | 'critical';
payment_id?: number;
invoice_id?: number;
booking_id?: number;
description: string;
expected_amount?: number;
actual_amount?: number;
difference?: number;
assigned_to?: number;
assigned_at?: string;
resolved_by?: number;
resolved_at?: string;
resolution_notes?: string;
comments?: Array<{
user_id: number;
comment: string;
created_at: string;
}>;
created_at: string;
updated_at: string;
}
export interface ExceptionStats {
total: number;
by_status: Record<ExceptionStatus, number>;
by_type: Record<ExceptionType, number>;
by_severity: Record<string, number>;
}
class ReconciliationService {
async runReconciliation(params?: {
start_date?: string;
end_date?: string;
}): Promise<{ status: string; data: { exceptions_created: number; exceptions: any[] } }> {
const response = await apiClient.post('/financial/reconciliation/run', null, { params });
return response.data;
}
async getExceptions(params?: {
status?: ExceptionStatus;
exception_type?: ExceptionType;
assigned_to?: number;
severity?: string;
page?: number;
limit?: number;
}): Promise<{ status: string; data: { exceptions: ReconciliationException[]; pagination: any } }> {
const response = await apiClient.get('/financial/reconciliation/exceptions', { params });
return response.data;
}
async assignException(exceptionId: number, assignedTo: number): Promise<{ status: string; data: any }> {
const response = await apiClient.post(`/financial/reconciliation/exceptions/${exceptionId}/assign`, {
assigned_to: assignedTo
});
return response.data;
}
async resolveException(exceptionId: number, notes: string): Promise<{ status: string; data: any }> {
const response = await apiClient.post(`/financial/reconciliation/exceptions/${exceptionId}/resolve`, {
notes
});
return response.data;
}
async addComment(exceptionId: number, comment: string): Promise<{ status: string; data: any }> {
const response = await apiClient.post(`/financial/reconciliation/exceptions/${exceptionId}/comment`, {
comment
});
return response.data;
}
async getExceptionStats(): Promise<{ status: string; data: ExceptionStats }> {
const response = await apiClient.get('/financial/reconciliation/exceptions/stats');
return response.data;
}
}
const reconciliationService = new ReconciliationService();
export default reconciliationService;

View File

@@ -0,0 +1,81 @@
import apiClient from '../../../shared/services/apiClient';
export interface AccountantSession {
id: number;
ip_address?: string;
user_agent?: string;
country?: string;
city?: string;
last_activity: string;
step_up_authenticated: boolean;
step_up_expires_at?: string;
created_at: string;
expires_at: string;
}
export interface AccountantActivityLog {
id: number;
user_id: number;
activity_type: string;
activity_description: string;
ip_address?: string;
country?: string;
city?: string;
risk_level: 'low' | 'medium' | 'high' | 'critical';
is_unusual: boolean;
metadata?: any;
created_at: string;
}
export interface MFAStatus {
requires_mfa: boolean;
mfa_enabled: boolean;
is_enforced: boolean;
enforcement_reason?: string;
backup_codes_count: number;
}
class AccountantSecurityService {
async verifyStepUp(data: {
mfa_token?: string;
password?: string;
session_token?: string;
}): Promise<{ status: string; data: { step_up_completed: boolean } }> {
const response = await apiClient.post('/accountant/security/step-up/verify', data);
return response.data;
}
async getSessions(): Promise<{ status: string; data: { sessions: AccountantSession[] } }> {
const response = await apiClient.get('/accountant/security/sessions');
return response.data;
}
async revokeSession(sessionId: number): Promise<{ status: string; message: string }> {
const response = await apiClient.post(`/accountant/security/sessions/${sessionId}/revoke`);
return response.data;
}
async revokeAllSessions(): Promise<{ status: string; data: { revoked_count: number } }> {
const response = await apiClient.post('/accountant/security/sessions/revoke-all');
return response.data;
}
async getActivityLogs(params?: {
page?: number;
limit?: number;
risk_level?: string;
is_unusual?: boolean;
}): Promise<{ status: string; data: { logs: AccountantActivityLog[]; pagination: any } }> {
const response = await apiClient.get('/accountant/security/activity-logs', { params });
return response.data;
}
async getMFAStatus(): Promise<{ status: string; data: MFAStatus }> {
const response = await apiClient.get('/accountant/security/mfa-status');
return response.data;
}
}
const accountantSecurityService = new AccountantSecurityService();
export default accountantSecurityService;

View File

@@ -0,0 +1,280 @@
import React, { useState, useEffect } from 'react';
import { CheckCircle2, XCircle, Clock, AlertCircle, Eye } from 'lucide-react';
import { toast } from 'react-toastify';
import approvalService, { FinancialApproval, ApprovalStatus } from '../../features/payments/services/approvalService';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
const ApprovalManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [loading, setLoading] = useState(true);
const [approvals, setApprovals] = useState<FinancialApproval[]>([]);
const [selectedApproval, setSelectedApproval] = useState<FinancialApproval | null>(null);
const [filter, setFilter] = useState<ApprovalStatus | 'all'>('all');
const [responseNotes, setResponseNotes] = useState('');
useEffect(() => {
fetchApprovals();
}, [filter]);
const fetchApprovals = async () => {
try {
setLoading(true);
const params: any = {};
if (filter !== 'all') params.status = filter;
const response = await approvalService.getApprovals(params);
setApprovals(response.data || []);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load approvals');
} finally {
setLoading(false);
}
};
const handleRespond = async (approvalId: number, status: 'approved' | 'rejected') => {
if (!responseNotes.trim() && status === 'rejected') {
toast.error('Please provide notes for rejection');
return;
}
try {
await approvalService.respondToApproval(approvalId, {
status,
response_notes: responseNotes || undefined
});
toast.success(`Approval ${status} successfully`);
setSelectedApproval(null);
setResponseNotes('');
fetchApprovals();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to respond to approval');
}
};
const getStatusColor = (status: ApprovalStatus) => {
switch (status) {
case 'approved':
return 'bg-green-100 text-green-800 border-green-200';
case 'rejected':
return 'bg-red-100 text-red-800 border-red-200';
case 'pending':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getActionTypeLabel = (type: string) => {
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
};
if (loading && approvals.length === 0) {
return <Loading fullScreen text="Loading approvals..." />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8 md:mb-10">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent">
Approval Management
</h1>
</div>
<p className="text-slate-600 text-sm sm:text-base md:text-lg font-light">
Review and respond to financial approval requests
</p>
</div>
{/* Filters */}
<div className="flex gap-2 mb-6 flex-wrap">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'all' ? 'bg-emerald-500 text-white' : 'bg-white text-slate-700 hover:bg-slate-100'
}`}
>
All
</button>
<button
onClick={() => setFilter('pending')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'pending' ? 'bg-emerald-500 text-white' : 'bg-white text-slate-700 hover:bg-slate-100'
}`}
>
<Clock className="inline w-4 h-4 mr-2" />
Pending
</button>
<button
onClick={() => setFilter('approved')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'approved' ? 'bg-emerald-500 text-white' : 'bg-white text-slate-700 hover:bg-slate-100'
}`}
>
<CheckCircle2 className="inline w-4 h-4 mr-2" />
Approved
</button>
<button
onClick={() => setFilter('rejected')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'rejected' ? 'bg-emerald-500 text-white' : 'bg-white text-slate-700 hover:bg-slate-100'
}`}
>
<XCircle className="inline w-4 h-4 mr-2" />
Rejected
</button>
</div>
{/* Approvals List */}
<div className="bg-white rounded-xl shadow-lg p-6">
{approvals.length === 0 ? (
<EmptyState title="No Approvals" description="Approval requests will appear here" />
) : (
<div className="space-y-4">
{approvals.map((approval) => (
<div
key={approval.id}
className="border border-slate-200 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedApproval(approval)}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-bold text-lg">{getActionTypeLabel(approval.action_type)}</h3>
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(approval.status)}`}>
{approval.status.charAt(0).toUpperCase() + approval.status.slice(1)}
</span>
</div>
<p className="text-slate-600 mb-2">{approval.action_description}</p>
<div className="flex flex-wrap gap-4 text-sm text-slate-500">
<span>Requested by: {approval.requested_by_email || `User #${approval.requested_by}`}</span>
<span></span>
<span>{formatDate(approval.requested_at)}</span>
{approval.amount && (
<>
<span></span>
<span className="font-semibold text-slate-700">{formatCurrency(approval.amount)}</span>
</>
)}
</div>
{approval.request_reason && (
<p className="mt-2 text-sm text-slate-600 bg-slate-50 p-2 rounded">
<strong>Reason:</strong> {approval.request_reason}
</p>
)}
</div>
{approval.status === 'pending' && (
<button
onClick={(e) => {
e.stopPropagation();
setSelectedApproval(approval);
}}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2"
>
<Eye className="w-4 h-4" />
Review
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Approval Detail Modal */}
{selectedApproval && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<div className="flex justify-between items-start">
<div>
<h2 className="text-2xl font-bold mb-2">{getActionTypeLabel(selectedApproval.action_type)}</h2>
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(selectedApproval.status)}`}>
{selectedApproval.status.charAt(0).toUpperCase() + selectedApproval.status.slice(1)}
</span>
</div>
<button
onClick={() => {
setSelectedApproval(null);
setResponseNotes('');
}}
className="text-slate-500 hover:text-slate-700"
>
<XCircle className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-4">
<div>
<h3 className="font-semibold mb-2">Description</h3>
<p className="text-slate-600">{selectedApproval.action_description}</p>
</div>
{selectedApproval.request_reason && (
<div>
<h3 className="font-semibold mb-2">Request Reason</h3>
<p className="text-slate-600">{selectedApproval.request_reason}</p>
</div>
)}
{selectedApproval.amount && (
<div>
<h3 className="font-semibold mb-2">Amount</h3>
<p className="text-lg font-bold text-emerald-600">{formatCurrency(selectedApproval.amount)}</p>
</div>
)}
{selectedApproval.previous_value && (
<div>
<h3 className="font-semibold mb-2">Previous Value</h3>
<pre className="bg-slate-50 p-3 rounded text-sm overflow-x-auto">
{JSON.stringify(selectedApproval.previous_value, null, 2)}
</pre>
</div>
)}
{selectedApproval.new_value && (
<div>
<h3 className="font-semibold mb-2">New Value</h3>
<pre className="bg-slate-50 p-3 rounded text-sm overflow-x-auto">
{JSON.stringify(selectedApproval.new_value, null, 2)}
</pre>
</div>
)}
{selectedApproval.status === 'pending' && (
<div>
<h3 className="font-semibold mb-2">Response Notes</h3>
<textarea
value={responseNotes}
onChange={(e) => setResponseNotes(e.target.value)}
className="w-full p-3 border border-slate-300 rounded-lg"
rows={4}
placeholder="Add notes for your response..."
/>
<div className="flex gap-3 mt-4">
<button
onClick={() => handleRespond(selectedApproval.id, 'approved')}
className="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 flex items-center justify-center gap-2"
>
<CheckCircle2 className="w-4 h-4" />
Approve
</button>
<button
onClick={() => handleRespond(selectedApproval.id, 'rejected')}
className="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 flex items-center justify-center gap-2"
>
<XCircle className="w-4 h-4" />
Reject
</button>
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default ApprovalManagementPage;

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect } from 'react';
import { FileText, Download, Filter, Search, Calendar } from 'lucide-react';
import { toast } from 'react-toastify';
import financialAuditService, { FinancialAuditRecord } from '../../features/payments/services/financialAuditService';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
const AuditTrailPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [loading, setLoading] = useState(true);
const [records, setRecords] = useState<FinancialAuditRecord[]>([]);
const [pagination, setPagination] = useState<any>(null);
const [filters, setFilters] = useState({
action_type: '',
user_id: '',
start_date: '',
end_date: '',
payment_id: '',
invoice_id: '',
booking_id: '',
page: 1,
limit: 50,
});
const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
fetchAuditTrail();
}, [filters.page, filters.action_type, filters.user_id, filters.start_date, filters.end_date]);
const fetchAuditTrail = async () => {
try {
setLoading(true);
const response = await financialAuditService.getAuditTrail(filters);
if (response.status === 'success' && response.data) {
setRecords(response.data.audit_trail || []);
setPagination(response.data.pagination);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load audit trail');
} finally {
setLoading(false);
}
};
const handleExport = async (format: 'csv' | 'json') => {
try {
const blob = await financialAuditService.exportAuditTrail(filters, format);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-trail-${new Date().toISOString().split('T')[0]}.${format}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Audit trail exported successfully');
} catch (error: any) {
toast.error('Failed to export audit trail');
}
};
const getActionTypeColor = (type: string) => {
if (type.includes('create') || type.includes('approve')) return 'bg-green-100 text-green-800';
if (type.includes('update') || type.includes('modify')) return 'bg-blue-100 text-blue-800';
if (type.includes('delete') || type.includes('reject')) return 'bg-red-100 text-red-800';
if (type.includes('export')) return 'bg-purple-100 text-purple-800';
return 'bg-gray-100 text-gray-800';
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8 md:mb-10">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent">
Financial Audit Trail
</h1>
</div>
<p className="text-slate-600 text-sm sm:text-base md:text-lg font-light">
Complete audit log of all financial actions
</p>
</div>
{/* Actions */}
<div className="bg-white rounded-xl shadow-lg p-4 mb-6 flex justify-between items-center flex-wrap gap-4">
<div className="flex gap-2">
<button
onClick={() => setShowFilters(!showFilters)}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 flex items-center gap-2"
>
<Filter className="w-4 h-4" />
Filters
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => handleExport('csv')}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2"
>
<Download className="w-4 h-4" />
Export CSV
</button>
<button
onClick={() => handleExport('json')}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 flex items-center gap-2"
>
<Download className="w-4 h-4" />
Export JSON
</button>
</div>
</div>
{/* Filters */}
{showFilters && (
<div className="bg-white rounded-xl shadow-lg p-4 mb-6">
<div className="grid md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-semibold mb-2">Action Type</label>
<input
type="text"
value={filters.action_type}
onChange={(e) => setFilters({ ...filters, action_type: e.target.value, page: 1 })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
placeholder="Filter by action type"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2">Start Date</label>
<input
type="date"
value={filters.start_date}
onChange={(e) => setFilters({ ...filters, start_date: e.target.value, page: 1 })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2">End Date</label>
<input
type="date"
value={filters.end_date}
onChange={(e) => setFilters({ ...filters, end_date: e.target.value, page: 1 })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2">Payment ID</label>
<input
type="number"
value={filters.payment_id}
onChange={(e) => setFilters({ ...filters, payment_id: e.target.value, page: 1 })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
placeholder="Filter by payment ID"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2">Invoice ID</label>
<input
type="number"
value={filters.invoice_id}
onChange={(e) => setFilters({ ...filters, invoice_id: e.target.value, page: 1 })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
placeholder="Filter by invoice ID"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2">User ID</label>
<input
type="number"
value={filters.user_id}
onChange={(e) => setFilters({ ...filters, user_id: e.target.value, page: 1 })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
placeholder="Filter by user ID"
/>
</div>
</div>
</div>
)}
{/* Audit Trail */}
<div className="bg-white rounded-xl shadow-lg p-6">
{loading && records.length === 0 ? (
<Loading text="Loading audit trail..." />
) : records.length === 0 ? (
<EmptyState title="No Audit Records" description="Audit trail records will appear here" />
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left p-3">Date</th>
<th className="text-left p-3">Action Type</th>
<th className="text-left p-3">Description</th>
<th className="text-left p-3">Performed By</th>
<th className="text-right p-3">Amount</th>
<th className="text-left p-3">Related IDs</th>
</tr>
</thead>
<tbody>
{records.map((record) => (
<tr key={record.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="p-3 text-sm">{formatDate(record.created_at)}</td>
<td className="p-3">
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${getActionTypeColor(record.action_type)}`}>
{record.action_type.replace(/_/g, ' ')}
</span>
</td>
<td className="p-3">{record.action_description}</td>
<td className="p-3 text-sm">{record.performed_by_email || `User #${record.performed_by}`}</td>
<td className="p-3 text-right">
{record.amount ? formatCurrency(record.amount) : '-'}
</td>
<td className="p-3 text-sm text-slate-500">
{record.payment_id && `Payment: ${record.payment_id} `}
{record.invoice_id && `Invoice: ${record.invoice_id} `}
{record.booking_id && `Booking: ${record.booking_id}`}
</td>
</tr>
))}
</tbody>
</table>
</div>
{pagination && (
<div className="flex justify-between items-center mt-4 pt-4 border-t border-slate-200">
<div className="text-sm text-slate-600">
Showing {((pagination.page - 1) * pagination.limit) + 1} to {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} records
</div>
<div className="flex gap-2">
<button
onClick={() => setFilters({ ...filters, page: filters.page - 1 })}
disabled={pagination.page === 1}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => setFilters({ ...filters, page: filters.page + 1 })}
disabled={pagination.page >= pagination.total_pages}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 disabled:opacity-50"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
};
export default AuditTrailPage;

View File

@@ -0,0 +1,415 @@
import React, { useState, useEffect } from 'react';
import { TrendingUp, FileText, Download } from 'lucide-react';
import { toast } from 'react-toastify';
import financialReportService, { ProfitLossReport, BalanceSheetReport, TaxReport } from '../../features/payments/services/financialReportService';
import glService from '../../features/payments/services/glService';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
const FinancialReportsPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [activeTab, setActiveTab] = useState<'pl' | 'balance' | 'tax'>('pl');
const [loading, setLoading] = useState(false);
const [plReport, setPlReport] = useState<ProfitLossReport | null>(null);
const [balanceReport, setBalanceReport] = useState<BalanceSheetReport | null>(null);
const [taxReport, setTaxReport] = useState<TaxReport | null>(null);
const [periods, setPeriods] = useState<any[]>([]);
const [selectedPeriod, setSelectedPeriod] = useState<number | null>(null);
const [dateRange, setDateRange] = useState({
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0],
});
useEffect(() => {
fetchPeriods();
}, []);
useEffect(() => {
if (activeTab === 'pl') fetchPLReport();
else if (activeTab === 'balance') fetchBalanceSheet();
else if (activeTab === 'tax') fetchTaxReport();
}, [activeTab, selectedPeriod, dateRange]);
const fetchPeriods = async () => {
try {
const response = await glService.getFiscalPeriods();
setPeriods(response.data.fiscal_periods || []);
} catch (error) {
// Silently fail
}
};
const fetchPLReport = async () => {
try {
setLoading(true);
const params: any = {};
if (selectedPeriod) {
params.fiscal_period_id = selectedPeriod;
} else {
params.start_date = dateRange.start;
params.end_date = dateRange.end;
}
const response = await financialReportService.getProfitLoss(params);
setPlReport(response.data);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load P&L report');
} finally {
setLoading(false);
}
};
const fetchBalanceSheet = async () => {
try {
setLoading(true);
const params: any = {};
if (selectedPeriod) {
params.fiscal_period_id = selectedPeriod;
} else {
params.as_of_date = dateRange.end;
}
const response = await financialReportService.getBalanceSheet(params);
setBalanceReport(response.data);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load balance sheet');
} finally {
setLoading(false);
}
};
const fetchTaxReport = async () => {
try {
setLoading(true);
const params: any = {
start_date: dateRange.start,
end_date: dateRange.end,
};
const response = await financialReportService.getTaxReport(params);
if (response && 'data' in response && !(response instanceof Blob)) {
setTaxReport(response.data);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load tax report');
} finally {
setLoading(false);
}
};
const handleExport = async (format: 'csv' | 'json') => {
try {
const response = await financialReportService.getTaxReport({
start_date: dateRange.start,
end_date: dateRange.end,
format
});
if (response instanceof Blob) {
const url = window.URL.createObjectURL(response);
const a = document.createElement('a');
a.href = url;
a.download = `tax-report-${dateRange.start}-${dateRange.end}.${format}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Report exported successfully');
}
} catch (error: any) {
toast.error('Failed to export report');
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8 md:mb-10">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent">
Financial Reports
</h1>
</div>
<p className="text-slate-600 text-sm sm:text-base md:text-lg font-light">
Profit & Loss, Balance Sheet, and Tax Reports
</p>
</div>
{/* Filters */}
<div className="bg-white rounded-xl shadow-lg p-4 mb-6">
<div className="flex flex-wrap gap-4 items-end">
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-semibold mb-2">Fiscal Period</label>
<select
value={selectedPeriod || ''}
onChange={(e) => setSelectedPeriod(e.target.value ? parseInt(e.target.value) : null)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
>
<option value="">Custom Date Range</option>
{periods.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{!selectedPeriod && (
<>
<div className="flex-1 min-w-[150px]">
<label className="block text-sm font-semibold mb-2">Start Date</label>
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
/>
</div>
<div className="flex-1 min-w-[150px]">
<label className="block text-sm font-semibold mb-2">End Date</label>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
/>
</div>
</>
)}
</div>
</div>
{/* Tabs */}
<div className="flex flex-wrap gap-2 mb-6 border-b border-slate-200">
<button
onClick={() => setActiveTab('pl')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'pl'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<TrendingUp className="inline w-4 h-4 mr-2" />
Profit & Loss
</button>
<button
onClick={() => setActiveTab('balance')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'balance'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<FileText className="inline w-4 h-4 mr-2" />
Balance Sheet
</button>
<button
onClick={() => setActiveTab('tax')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'tax'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<FileText className="inline w-4 h-4 mr-2" />
Tax Report
</button>
</div>
{/* Content */}
{loading ? (
<Loading text="Loading report..." />
) : (
<>
{activeTab === 'pl' && plReport && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Profit & Loss Statement</h2>
<button
onClick={() => handleExport('csv')}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2"
>
<Download className="w-4 h-4" />
Export CSV
</button>
</div>
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">Revenue</h3>
<div className="space-y-2">
{plReport.revenue?.revenue_by_account && Object.entries(plReport.revenue.revenue_by_account).map(([account, amount]) => (
<div key={account} className="flex justify-between p-2 bg-slate-50 rounded">
<span>{account}</span>
<span className="font-semibold">{formatCurrency(amount)}</span>
</div>
))}
<div className="flex justify-between p-3 bg-emerald-50 rounded border-t-2 border-emerald-500 font-bold">
<span>Total Revenue</span>
<span>{formatCurrency(plReport.revenue?.total_revenue || 0)}</span>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Cost of Goods Sold</h3>
<div className="flex justify-between p-3 bg-slate-50 rounded">
<span>Total COGS</span>
<span className="font-semibold">{formatCurrency(plReport.costs?.total_cogs || 0)}</span>
</div>
<div className="flex justify-between p-3 bg-blue-50 rounded border-t-2 border-blue-500 font-bold mt-2">
<span>Gross Profit</span>
<span>{formatCurrency(plReport.costs?.gross_profit || 0)}</span>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Operating Expenses</h3>
<div className="space-y-2">
{plReport.expenses?.expenses_by_account && Object.entries(plReport.expenses.expenses_by_account).map(([account, amount]) => (
<div key={account} className="flex justify-between p-2 bg-slate-50 rounded">
<span>{account}</span>
<span className="font-semibold">{formatCurrency(amount)}</span>
</div>
))}
<div className="flex justify-between p-3 bg-slate-100 rounded border-t-2 border-slate-400 font-semibold">
<span>Total Operating Expenses</span>
<span>{formatCurrency(plReport.expenses?.total_operating_expenses || 0)}</span>
</div>
</div>
</div>
<div className="flex justify-between p-4 bg-gradient-to-r from-emerald-100 to-green-100 rounded border-t-4 border-emerald-500">
<span className="text-xl font-bold">Net Profit</span>
<span className="text-xl font-bold">{formatCurrency(plReport.profit?.net_profit || 0)}</span>
</div>
<div className="text-sm text-slate-600">
Profit Margin: {plReport.profit?.profit_margin ? plReport.profit.profit_margin.toFixed(2) : '0.00'}%
</div>
</div>
</div>
)}
{activeTab === 'balance' && balanceReport && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Balance Sheet</h2>
<div className="text-sm text-slate-600">
As of: {balanceReport.as_of_date ? formatDate(balanceReport.as_of_date) : 'N/A'}
</div>
</div>
<div className="grid md:grid-cols-2 gap-6">
<div>
<h3 className="text-lg font-semibold mb-3">Assets</h3>
<div className="space-y-2">
{balanceReport.assets?.breakdown && Object.entries(balanceReport.assets.breakdown).map(([account, amount]) => (
<div key={account} className="flex justify-between p-2 bg-slate-50 rounded">
<span>{account}</span>
<span className="font-semibold">{formatCurrency(amount)}</span>
</div>
))}
<div className="flex justify-between p-3 bg-emerald-50 rounded border-t-2 border-emerald-500 font-bold">
<span>Total Assets</span>
<span>{formatCurrency(balanceReport.assets?.total_assets || 0)}</span>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Liabilities & Equity</h3>
<div className="space-y-2">
{balanceReport.liabilities?.breakdown && Object.entries(balanceReport.liabilities.breakdown).map(([account, amount]) => (
<div key={account} className="flex justify-between p-2 bg-slate-50 rounded">
<span>{account}</span>
<span className="font-semibold">{formatCurrency(amount)}</span>
</div>
))}
<div className="flex justify-between p-3 bg-slate-100 rounded border-t-2 border-slate-400 font-semibold">
<span>Total Liabilities</span>
<span>{formatCurrency(balanceReport.liabilities?.total_liabilities || 0)}</span>
</div>
{balanceReport.equity?.breakdown && Object.entries(balanceReport.equity.breakdown).map(([account, amount]) => (
<div key={account} className="flex justify-between p-2 bg-slate-50 rounded">
<span>{account}</span>
<span className="font-semibold">{formatCurrency(amount)}</span>
</div>
))}
<div className="flex justify-between p-3 bg-blue-50 rounded border-t-2 border-blue-500 font-semibold">
<span>Total Equity</span>
<span>{formatCurrency(balanceReport.equity?.total_equity || 0)}</span>
</div>
<div className={`flex justify-between p-3 rounded border-t-2 font-bold ${
balanceReport.balance?.is_balanced
? 'bg-green-50 border-green-500'
: 'bg-red-50 border-red-500'
}`}>
<span>Total Liabilities & Equity</span>
<span>{formatCurrency(balanceReport.balance?.total_liabilities_and_equity || 0)}</span>
</div>
</div>
</div>
</div>
<div className={`mt-4 p-3 rounded ${
balanceReport.balance?.is_balanced ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{balanceReport.balance?.is_balanced ? '✓ Balance Sheet is balanced' : '✗ Balance Sheet is not balanced'}
</div>
</div>
)}
{activeTab === 'tax' && taxReport && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Tax Report</h2>
<div className="flex gap-2">
<button
onClick={() => handleExport('csv')}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2"
>
<Download className="w-4 h-4" />
Export CSV
</button>
</div>
</div>
<div className="mb-6 p-4 bg-slate-50 rounded-lg">
<div className="grid md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-slate-600">Total Tax Collected</p>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(taxReport.total_tax_collected || 0)}</p>
</div>
<div>
<p className="text-sm text-slate-600">Total Taxable Amount</p>
<p className="text-2xl font-bold">{formatCurrency(taxReport.total_taxable_amount || 0)}</p>
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left p-3">Invoice #</th>
<th className="text-left p-3">Customer</th>
<th className="text-right p-3">Taxable Amount</th>
<th className="text-right p-3">Tax Amount</th>
<th className="text-right p-3">Tax Rate</th>
<th className="text-left p-3">Date</th>
</tr>
</thead>
<tbody>
{taxReport.transactions?.map((tx, idx) => (
<tr key={idx} className="border-b border-slate-100 hover:bg-slate-50">
<td className="p-3">{tx.invoice_number}</td>
<td className="p-3">{tx.customer_name}</td>
<td className="p-3 text-right">{formatCurrency(tx.taxable_amount)}</td>
<td className="p-3 text-right font-semibold">{formatCurrency(tx.tax_amount)}</td>
<td className="p-3 text-right">{(tx.tax_rate * 100).toFixed(2)}%</td>
<td className="p-3">{formatDate(tx.transaction_date)}</td>
</tr>
)) || (
<tr>
<td colSpan={6} className="p-3 text-center text-slate-500">No transactions found</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
);
};
export default FinancialReportsPage;

View File

@@ -0,0 +1,281 @@
import React, { useState, useEffect } from 'react';
import { BookOpen, Plus, Calendar, FileText, TrendingUp, Lock, Unlock } from 'lucide-react';
import { toast } from 'react-toastify';
import glService, { Account, FiscalPeriod, JournalEntry } from '../../features/payments/services/glService';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
const GLManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [activeTab, setActiveTab] = useState<'accounts' | 'periods' | 'entries'>('accounts');
const [loading, setLoading] = useState(true);
const [accounts, setAccounts] = useState<Account[]>([]);
const [periods, setPeriods] = useState<FiscalPeriod[]>([]);
const [entries, setEntries] = useState<JournalEntry[]>([]);
const [selectedPeriod, setSelectedPeriod] = useState<number | null>(null);
useEffect(() => {
fetchData();
}, [activeTab, selectedPeriod]);
const fetchData = async () => {
try {
setLoading(true);
if (activeTab === 'accounts') {
const response = await glService.getAccounts();
setAccounts(response.data.accounts || []);
} else if (activeTab === 'periods') {
const response = await glService.getFiscalPeriods();
setPeriods(response.data.fiscal_periods || []);
} else if (activeTab === 'entries') {
const params: any = { limit: 50 };
if (selectedPeriod) params.fiscal_period_id = selectedPeriod;
const response = await glService.getJournalEntries(params);
setEntries(response.data.journal_entries || []);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load data');
} finally {
setLoading(false);
}
};
const handleClosePeriod = async (periodId: number) => {
if (!confirm('Are you sure you want to close this fiscal period? This action may require approval.')) {
return;
}
try {
await glService.closeFiscalPeriod(periodId, false);
toast.success('Fiscal period closed successfully');
fetchData();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to close period');
}
};
if (loading && accounts.length === 0 && periods.length === 0 && entries.length === 0) {
return <Loading fullScreen text="Loading General Ledger..." />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8 md:mb-10">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent">
General Ledger
</h1>
</div>
<p className="text-slate-600 text-sm sm:text-base md:text-lg font-light">
Manage chart of accounts, fiscal periods, and journal entries
</p>
</div>
{/* Tabs */}
<div className="flex flex-wrap gap-2 mb-6 border-b border-slate-200">
<button
onClick={() => setActiveTab('accounts')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'accounts'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<BookOpen className="inline w-4 h-4 mr-2" />
Chart of Accounts
</button>
<button
onClick={() => setActiveTab('periods')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'periods'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<Calendar className="inline w-4 h-4 mr-2" />
Fiscal Periods
</button>
<button
onClick={() => setActiveTab('entries')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'entries'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<FileText className="inline w-4 h-4 mr-2" />
Journal Entries
</button>
</div>
{/* Content */}
{activeTab === 'accounts' && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Chart of Accounts</h2>
<button className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2">
<Plus className="w-4 h-4" />
Add Account
</button>
</div>
{accounts.length === 0 ? (
<EmptyState title="No Accounts" description="Chart of accounts will appear here" />
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left p-3">Account #</th>
<th className="text-left p-3">Account Name</th>
<th className="text-left p-3">Type</th>
<th className="text-left p-3">Category</th>
<th className="text-left p-3">Normal Balance</th>
<th className="text-left p-3">Status</th>
</tr>
</thead>
<tbody>
{accounts.map((account) => (
<tr key={account.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="p-3 font-mono">{account.account_number}</td>
<td className="p-3 font-semibold">{account.account_name}</td>
<td className="p-3">{account.account_type}</td>
<td className="p-3">{account.account_category}</td>
<td className="p-3">{account.normal_balance}</td>
<td className="p-3">
<span className={`px-2 py-1 rounded-full text-xs ${
account.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}>
{account.is_active ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === 'periods' && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Fiscal Periods</h2>
<button className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2">
<Plus className="w-4 h-4" />
Create Period
</button>
</div>
{periods.length === 0 ? (
<EmptyState title="No Fiscal Periods" description="Fiscal periods will appear here" />
) : (
<div className="space-y-4">
{periods.map((period) => (
<div key={period.id} className="border border-slate-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start">
<div>
<h3 className="font-bold text-lg">{period.name}</h3>
<p className="text-sm text-slate-600">
{formatDate(period.start_date)} - {formatDate(period.end_date)}
</p>
<div className="flex gap-2 mt-2">
<span className={`px-2 py-1 rounded-full text-xs ${
period.status === 'Open' ? 'bg-green-100 text-green-800' :
period.status === 'Closed' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{period.status}
</span>
{period.is_current && (
<span className="px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800">
Current
</span>
)}
</div>
</div>
{period.status === 'Open' && (
<button
onClick={() => handleClosePeriod(period.id)}
className="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 flex items-center gap-2"
>
<Lock className="w-4 h-4" />
Close Period
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'entries' && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Journal Entries</h2>
<div className="flex gap-2">
<select
value={selectedPeriod || ''}
onChange={(e) => setSelectedPeriod(e.target.value ? parseInt(e.target.value) : null)}
className="px-4 py-2 border border-slate-300 rounded-lg"
>
<option value="">All Periods</option>
{periods.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<button className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2">
<Plus className="w-4 h-4" />
New Entry
</button>
</div>
</div>
{entries.length === 0 ? (
<EmptyState title="No Journal Entries" description="Journal entries will appear here" />
) : (
<div className="space-y-4">
{entries.map((entry) => (
<div key={entry.id} className="border border-slate-200 rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="font-bold">{entry.description}</h3>
<p className="text-sm text-slate-600">{formatDate(entry.entry_date)}</p>
<p className="text-xs text-slate-500">{entry.entry_type}</p>
</div>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left p-2">Account</th>
<th className="text-right p-2">Debit</th>
<th className="text-right p-2">Credit</th>
</tr>
</thead>
<tbody>
{entry.journal_lines?.map((line) => (
<tr key={line.id} className="border-b border-slate-100">
<td className="p-2">{line.account?.account_name}</td>
<td className="p-2 text-right">{line.debit > 0 ? formatCurrency(line.debit) : '-'}</td>
<td className="p-2 text-right">{line.credit > 0 ? formatCurrency(line.credit) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
};
export default GLManagementPage;

View File

@@ -0,0 +1,372 @@
import React, { useState, useEffect } from 'react';
import { AlertTriangle, CheckCircle2, Clock, User, MessageSquare, Play, Filter } from 'lucide-react';
import { toast } from 'react-toastify';
import reconciliationService, { ReconciliationException, ExceptionStatus } from '../../features/payments/services/reconciliationService';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
const ReconciliationPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [loading, setLoading] = useState(false);
const [exceptions, setExceptions] = useState<ReconciliationException[]>([]);
const [stats, setStats] = useState<any>(null);
const [selectedException, setSelectedException] = useState<ReconciliationException | null>(null);
const [filter, setFilter] = useState<ExceptionStatus | 'all'>('all');
const [comment, setComment] = useState('');
const [resolutionNotes, setResolutionNotes] = useState('');
useEffect(() => {
fetchExceptions();
fetchStats();
}, [filter]);
const fetchExceptions = async () => {
try {
setLoading(true);
const params: any = { limit: 50 };
if (filter !== 'all') params.status = filter;
const response = await reconciliationService.getExceptions(params);
setExceptions(response.data.exceptions || []);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load exceptions');
} finally {
setLoading(false);
}
};
const fetchStats = async () => {
try {
const response = await reconciliationService.getExceptionStats();
setStats(response.data);
} catch (error) {
// Silently fail
}
};
const handleRunReconciliation = async () => {
try {
setLoading(true);
const response = await reconciliationService.runReconciliation();
toast.success(`Reconciliation completed. ${response.data.exceptions_created} new exceptions created.`);
fetchExceptions();
fetchStats();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to run reconciliation');
} finally {
setLoading(false);
}
};
const handleResolve = async (exceptionId: number) => {
if (!resolutionNotes.trim()) {
toast.error('Please provide resolution notes');
return;
}
try {
await reconciliationService.resolveException(exceptionId, resolutionNotes);
toast.success('Exception resolved successfully');
setSelectedException(null);
setResolutionNotes('');
fetchExceptions();
fetchStats();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to resolve exception');
}
};
const handleAddComment = async (exceptionId: number) => {
if (!comment.trim()) {
toast.error('Please enter a comment');
return;
}
try {
await reconciliationService.addComment(exceptionId, comment);
toast.success('Comment added successfully');
setComment('');
fetchExceptions();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to add comment');
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'critical':
return 'bg-red-100 text-red-800 border-red-200';
case 'high':
return 'bg-orange-100 text-orange-800 border-orange-200';
case 'medium':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
default:
return 'bg-blue-100 text-blue-800 border-blue-200';
}
};
const getStatusColor = (status: ExceptionStatus) => {
switch (status) {
case 'resolved':
return 'bg-green-100 text-green-800 border-green-200';
case 'closed':
return 'bg-gray-100 text-gray-800 border-gray-200';
case 'in_review':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'assigned':
return 'bg-purple-100 text-purple-800 border-purple-200';
default:
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8 md:mb-10">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent">
Reconciliation
</h1>
</div>
<p className="text-slate-600 text-sm sm:text-base md:text-lg font-light">
Manage payment and invoice reconciliation exceptions
</p>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl shadow-lg p-4 border-l-4 border-blue-500">
<p className="text-sm text-slate-600 mb-1">Total Exceptions</p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<div className="bg-white rounded-xl shadow-lg p-4 border-l-4 border-yellow-500">
<p className="text-sm text-slate-600 mb-1">Open</p>
<p className="text-2xl font-bold">{stats.by_status?.open || 0}</p>
</div>
<div className="bg-white rounded-xl shadow-lg p-4 border-l-4 border-green-500">
<p className="text-sm text-slate-600 mb-1">Resolved</p>
<p className="text-2xl font-bold">{stats.by_status?.resolved || 0}</p>
</div>
<div className="bg-white rounded-xl shadow-lg p-4 border-l-4 border-red-500">
<p className="text-sm text-slate-600 mb-1">Critical</p>
<p className="text-2xl font-bold">{stats.by_severity?.critical || 0}</p>
</div>
</div>
)}
{/* Actions */}
<div className="bg-white rounded-xl shadow-lg p-4 mb-6 flex justify-between items-center">
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'all' ? 'bg-emerald-500 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
All
</button>
<button
onClick={() => setFilter('open')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'open' ? 'bg-emerald-500 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Open
</button>
<button
onClick={() => setFilter('resolved')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'resolved' ? 'bg-emerald-500 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Resolved
</button>
</div>
<button
onClick={handleRunReconciliation}
disabled={loading}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2 disabled:opacity-50"
>
<Play className="w-4 h-4" />
Run Reconciliation
</button>
</div>
{/* Exceptions List */}
<div className="bg-white rounded-xl shadow-lg p-6">
{loading && exceptions.length === 0 ? (
<Loading text="Loading exceptions..." />
) : exceptions.length === 0 ? (
<EmptyState title="No Exceptions" description="Reconciliation exceptions will appear here" />
) : (
<div className="space-y-4">
{exceptions.map((exception) => (
<div
key={exception.id}
className="border border-slate-200 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedException(exception)}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<AlertTriangle className={`w-5 h-5 ${
exception.severity === 'critical' ? 'text-red-500' :
exception.severity === 'high' ? 'text-orange-500' :
'text-yellow-500'
}`} />
<h3 className="font-bold text-lg">{exception.exception_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</h3>
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getSeverityColor(exception.severity)}`}>
{exception.severity}
</span>
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(exception.status)}`}>
{exception.status}
</span>
</div>
<p className="text-slate-600 mb-2">{exception.description}</p>
<div className="flex flex-wrap gap-4 text-sm text-slate-500">
{exception.expected_amount && (
<span>Expected: {formatCurrency(exception.expected_amount)}</span>
)}
{exception.actual_amount && (
<span>Actual: {formatCurrency(exception.actual_amount)}</span>
)}
{exception.difference && (
<span className="font-semibold text-red-600">
Difference: {formatCurrency(Math.abs(exception.difference))}
</span>
)}
<span></span>
<span>{formatDate(exception.created_at)}</span>
</div>
</div>
{exception.status === 'open' && (
<button
onClick={(e) => {
e.stopPropagation();
setSelectedException(exception);
}}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600"
>
Review
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Exception Detail Modal */}
{selectedException && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<div className="flex justify-between items-start">
<div>
<h2 className="text-2xl font-bold mb-2">
{selectedException.exception_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</h2>
<div className="flex gap-2">
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getSeverityColor(selectedException.severity)}`}>
{selectedException.severity}
</span>
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(selectedException.status)}`}>
{selectedException.status}
</span>
</div>
</div>
<button
onClick={() => {
setSelectedException(null);
setComment('');
setResolutionNotes('');
}}
className="text-slate-500 hover:text-slate-700"
>
<AlertTriangle className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-4">
<div>
<h3 className="font-semibold mb-2">Description</h3>
<p className="text-slate-600">{selectedException.description}</p>
</div>
{selectedException.expected_amount && (
<div>
<h3 className="font-semibold mb-2">Expected Amount</h3>
<p className="text-lg font-bold">{formatCurrency(selectedException.expected_amount)}</p>
</div>
)}
{selectedException.actual_amount && (
<div>
<h3 className="font-semibold mb-2">Actual Amount</h3>
<p className="text-lg font-bold">{formatCurrency(selectedException.actual_amount)}</p>
</div>
)}
{selectedException.difference && (
<div>
<h3 className="font-semibold mb-2">Difference</h3>
<p className="text-lg font-bold text-red-600">{formatCurrency(Math.abs(selectedException.difference))}</p>
</div>
)}
{selectedException.comments && selectedException.comments.length > 0 && (
<div>
<h3 className="font-semibold mb-2">Comments</h3>
<div className="space-y-2">
{selectedException.comments.map((c, idx) => (
<div key={idx} className="bg-slate-50 p-3 rounded">
<p className="text-sm text-slate-600">{c.comment}</p>
<p className="text-xs text-slate-400 mt-1">{formatDate(c.created_at)}</p>
</div>
))}
</div>
</div>
)}
<div>
<h3 className="font-semibold mb-2">Add Comment</h3>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full p-3 border border-slate-300 rounded-lg"
rows={3}
placeholder="Add a comment..."
/>
<button
onClick={() => handleAddComment(selectedException.id)}
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Add Comment
</button>
</div>
{selectedException.status === 'open' && (
<div>
<h3 className="font-semibold mb-2">Resolution Notes</h3>
<textarea
value={resolutionNotes}
onChange={(e) => setResolutionNotes(e.target.value)}
className="w-full p-3 border border-slate-300 rounded-lg"
rows={4}
placeholder="Enter resolution notes..."
/>
<button
onClick={() => handleResolve(selectedException.id)}
className="mt-2 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600"
>
Resolve Exception
</button>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default ReconciliationPage;

View File

@@ -0,0 +1,281 @@
import React, { useState, useEffect } from 'react';
import { Shield, Activity, LogOut, AlertTriangle, CheckCircle2, Clock } from 'lucide-react';
import { toast } from 'react-toastify';
import accountantSecurityService, { AccountantSession, AccountantActivityLog, MFAStatus } from '../../features/security/services/accountantSecurityService';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate } from '../../shared/utils/format';
const SecurityManagementPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'sessions' | 'activity' | 'mfa'>('sessions');
const [loading, setLoading] = useState(true);
const [sessions, setSessions] = useState<AccountantSession[]>([]);
const [activityLogs, setActivityLogs] = useState<AccountantActivityLog[]>([]);
const [mfaStatus, setMfaStatus] = useState<MFAStatus | null>(null);
const [pagination, setPagination] = useState<any>(null);
useEffect(() => {
if (activeTab === 'sessions') fetchSessions();
else if (activeTab === 'activity') fetchActivityLogs();
else if (activeTab === 'mfa') fetchMFAStatus();
}, [activeTab]);
const fetchSessions = async () => {
try {
setLoading(true);
const response = await accountantSecurityService.getSessions();
setSessions(response.data.sessions || []);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load sessions');
} finally {
setLoading(false);
}
};
const fetchActivityLogs = async () => {
try {
setLoading(true);
const response = await accountantSecurityService.getActivityLogs({ limit: 50 });
setActivityLogs(response.data.logs || []);
setPagination(response.data.pagination);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load activity logs');
} finally {
setLoading(false);
}
};
const fetchMFAStatus = async () => {
try {
setLoading(true);
const response = await accountantSecurityService.getMFAStatus();
setMfaStatus(response.data);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load MFA status');
} finally {
setLoading(false);
}
};
const handleRevokeSession = async (sessionId: number) => {
if (!confirm('Are you sure you want to revoke this session?')) return;
try {
await accountantSecurityService.revokeSession(sessionId);
toast.success('Session revoked successfully');
fetchSessions();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to revoke session');
}
};
const handleRevokeAllSessions = async () => {
if (!confirm('Are you sure you want to revoke all sessions? You will be logged out.')) return;
try {
await accountantSecurityService.revokeAllSessions();
toast.success('All sessions revoked successfully');
fetchSessions();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to revoke sessions');
}
};
const getRiskColor = (risk: string) => {
switch (risk) {
case 'critical':
return 'bg-red-100 text-red-800 border-red-200';
case 'high':
return 'bg-orange-100 text-orange-800 border-orange-200';
case 'medium':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
default:
return 'bg-blue-100 text-blue-800 border-blue-200';
}
};
if (loading && sessions.length === 0 && activityLogs.length === 0 && !mfaStatus) {
return <Loading fullScreen text="Loading security information..." />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8 md:mb-10">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent">
Security Management
</h1>
</div>
<p className="text-slate-600 text-sm sm:text-base md:text-lg font-light">
Manage sessions, view activity logs, and MFA settings
</p>
</div>
{/* Tabs */}
<div className="flex flex-wrap gap-2 mb-6 border-b border-slate-200">
<button
onClick={() => setActiveTab('sessions')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'sessions'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<LogOut className="inline w-4 h-4 mr-2" />
Active Sessions
</button>
<button
onClick={() => setActiveTab('activity')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'activity'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<Activity className="inline w-4 h-4 mr-2" />
Activity Logs
</button>
<button
onClick={() => setActiveTab('mfa')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'mfa'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<Shield className="inline w-4 h-4 mr-2" />
MFA Status
</button>
</div>
{/* Content */}
{activeTab === 'sessions' && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Active Sessions</h2>
<button
onClick={handleRevokeAllSessions}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Revoke All Sessions
</button>
</div>
{sessions.length === 0 ? (
<EmptyState title="No Active Sessions" description="Active sessions will appear here" />
) : (
<div className="space-y-4">
{sessions.map((session) => (
<div key={session.id} className="border border-slate-200 rounded-lg p-4">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-bold">Session #{session.id}</h3>
{session.step_up_authenticated && (
<span className="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs">
Step-up Authenticated
</span>
)}
</div>
<div className="space-y-1 text-sm text-slate-600">
<p>IP Address: {session.ip_address || 'N/A'}</p>
<p>Location: {session.city && session.country ? `${session.city}, ${session.country}` : 'N/A'}</p>
<p>Last Activity: {formatDate(session.last_activity)}</p>
<p>Expires: {formatDate(session.expires_at)}</p>
</div>
</div>
<button
onClick={() => handleRevokeSession(session.id)}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Revoke
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'activity' && (
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-xl font-bold mb-4">Activity Logs</h2>
{activityLogs.length === 0 ? (
<EmptyState title="No Activity Logs" description="Activity logs will appear here" />
) : (
<div className="space-y-4">
{activityLogs.map((log) => (
<div key={log.id} className="border border-slate-200 rounded-lg p-4">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-bold">{log.activity_type.replace(/_/g, ' ')}</h3>
<span className={`px-2 py-1 rounded-full text-xs font-semibold border ${getRiskColor(log.risk_level)}`}>
{log.risk_level}
</span>
{log.is_unusual && (
<span className="px-2 py-1 bg-orange-100 text-orange-800 rounded-full text-xs">
Unusual
</span>
)}
</div>
<p className="text-slate-600 mb-2">{log.activity_description}</p>
<div className="flex flex-wrap gap-4 text-sm text-slate-500">
<span>IP: {log.ip_address || 'N/A'}</span>
{log.country && <span>Location: {log.country}</span>}
<span></span>
<span>{formatDate(log.created_at)}</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'mfa' && mfaStatus && (
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-xl font-bold mb-4">MFA Status</h2>
<div className="space-y-4">
<div className="p-4 bg-slate-50 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold">MFA Required</span>
<span className={mfaStatus.requires_mfa ? 'text-green-600' : 'text-gray-600'}>
{mfaStatus.requires_mfa ? 'Yes' : 'No'}
</span>
</div>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold">MFA Enabled</span>
<span className={mfaStatus.mfa_enabled ? 'text-green-600' : 'text-red-600'}>
{mfaStatus.mfa_enabled ? 'Yes' : 'No'}
</span>
</div>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold">MFA Enforced</span>
<span className={mfaStatus.is_enforced ? 'text-green-600' : 'text-red-600'}>
{mfaStatus.is_enforced ? 'Yes' : 'No'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="font-semibold">Backup Codes</span>
<span>{mfaStatus.backup_codes_count} remaining</span>
</div>
</div>
{mfaStatus.enforcement_reason && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-yellow-800">
<strong>Note:</strong> {mfaStatus.enforcement_reason}
</p>
</div>
)}
</div>
</div>
)}
</div>
);
};
export default SecurityManagementPage;

View File

@@ -25,8 +25,12 @@ const InvoicePage: React.FC = () => {
if (!invoiceId) {
setLoading(false);
// Redirect based on user role
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
if (userInfo?.role === 'admin') {
navigate('/admin/bookings?createInvoice=true');
} else if (userInfo?.role === 'staff') {
navigate('/staff/bookings?createInvoice=true');
} else if (userInfo?.role === 'accountant') {
navigate('/accountant/invoices');
} else {
navigate('/bookings');
}

View File

@@ -11,6 +11,13 @@ const AccountantDashboardPage = lazy(() => import('../pages/accountant/Dashboard
const PaymentManagementPage = lazy(() => import('../pages/accountant/PaymentManagementPage'));
const InvoiceManagementPage = lazy(() => import('../pages/accountant/InvoiceManagementPage'));
const AnalyticsDashboardPage = lazy(() => import('../pages/accountant/AnalyticsDashboardPage'));
const GLManagementPage = lazy(() => import('../pages/accountant/GLManagementPage'));
const ApprovalManagementPage = lazy(() => import('../pages/accountant/ApprovalManagementPage'));
const FinancialReportsPage = lazy(() => import('../pages/accountant/FinancialReportsPage'));
const ReconciliationPage = lazy(() => import('../pages/accountant/ReconciliationPage'));
const AuditTrailPage = lazy(() => import('../pages/accountant/AuditTrailPage'));
const SecurityManagementPage = lazy(() => import('../pages/accountant/SecurityManagementPage'));
const ProfilePage = lazy(() => import('../pages/accountant/ProfilePage'));
const accountantRoutes: RouteObject[] = [
{
@@ -20,7 +27,13 @@ const accountantRoutes: RouteObject[] = [
{ path: 'dashboard', element: <AccountantDashboardPage /> },
{ path: 'payments', element: <PaymentManagementPage /> },
{ path: 'invoices', element: <InvoiceManagementPage /> },
{ path: 'reports', element: <AnalyticsDashboardPage /> },
{ path: 'gl', element: <GLManagementPage /> },
{ path: 'approvals', element: <ApprovalManagementPage /> },
{ path: 'reports', element: <FinancialReportsPage /> },
{ path: 'reconciliation', element: <ReconciliationPage /> },
{ path: 'audit-trail', element: <AuditTrailPage /> },
{ path: 'security', element: <SecurityManagementPage /> },
{ path: 'profile', element: <ProfilePage /> },
],
},
];

View File

@@ -10,7 +10,15 @@ import {
X,
CreditCard,
Receipt,
User
User,
BookOpen,
CheckCircle2,
FileText,
AlertTriangle,
Shield,
Activity,
TrendingUp,
FileCheck
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useResponsive } from '../../hooks';
@@ -91,11 +99,36 @@ const SidebarAccountant: React.FC<SidebarAccountantProps> = ({
icon: Receipt,
label: 'Invoices'
},
{
path: '/accountant/gl',
icon: BookOpen,
label: 'General Ledger'
},
{
path: '/accountant/approvals',
icon: CheckCircle2,
label: 'Approvals'
},
{
path: '/accountant/reports',
icon: BarChart3,
label: 'Financial Reports'
},
{
path: '/accountant/reconciliation',
icon: FileCheck,
label: 'Reconciliation'
},
{
path: '/accountant/audit-trail',
icon: FileText,
label: 'Audit Trail'
},
{
path: '/accountant/security',
icon: Shield,
label: 'Security'
},
{
path: '/accountant/profile',
icon: User,