update
This commit is contained in:
BIN
Backend/src/payments/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/payments/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/payments/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/payments/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/payments/models/__pycache__/invoice.cpython-312.pyc
Normal file
BIN
Backend/src/payments/models/__pycache__/invoice.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/payments/models/__pycache__/payment.cpython-312.pyc
Normal file
BIN
Backend/src/payments/models/__pycache__/payment.cpython-312.pyc
Normal file
Binary file not shown.
71
Backend/src/payments/models/financial_audit_trail.py
Normal file
71
Backend/src/payments/models/financial_audit_trail.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Financial audit trail model for tracking all financial transactions.
|
||||
This ensures compliance and provides a complete audit log for financial operations.
|
||||
"""
|
||||
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 FinancialActionType(str, enum.Enum):
|
||||
"""Types of financial actions that can be audited."""
|
||||
payment_created = 'payment_created'
|
||||
payment_completed = 'payment_completed'
|
||||
payment_refunded = 'payment_refunded'
|
||||
payment_failed = 'payment_failed'
|
||||
invoice_created = 'invoice_created'
|
||||
invoice_updated = 'invoice_updated'
|
||||
invoice_paid = 'invoice_paid'
|
||||
refund_processed = 'refund_processed'
|
||||
price_modified = 'price_modified'
|
||||
discount_applied = 'discount_applied'
|
||||
promotion_applied = 'promotion_applied'
|
||||
|
||||
|
||||
class FinancialAuditTrail(Base):
|
||||
"""Audit trail for all financial transactions and modifications."""
|
||||
__tablename__ = 'financial_audit_trail'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
|
||||
# Action details
|
||||
action_type = Column(Enum(FinancialActionType), nullable=False, index=True)
|
||||
action_description = Column(Text, nullable=False)
|
||||
|
||||
# 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_amount = Column(Numeric(10, 2), nullable=True)
|
||||
currency = Column(String(3), nullable=True, default='USD')
|
||||
|
||||
# User information
|
||||
performed_by = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
performed_by_email = Column(String(255), nullable=True) # Store email for audit even if user deleted
|
||||
|
||||
# Additional context
|
||||
audit_metadata = Column(JSON, nullable=True) # Store additional context (IP, user agent, etc.)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
# Timestamp
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
payment = relationship('Payment', foreign_keys=[payment_id])
|
||||
invoice = relationship('Invoice', foreign_keys=[invoice_id])
|
||||
booking = relationship('Booking', foreign_keys=[booking_id])
|
||||
user = relationship('User', foreign_keys=[performed_by])
|
||||
|
||||
# Indexes for common queries
|
||||
__table_args__ = (
|
||||
Index('idx_financial_audit_created', 'created_at'),
|
||||
Index('idx_financial_audit_action', 'action_type', 'created_at'),
|
||||
Index('idx_financial_audit_user', 'performed_by', 'created_at'),
|
||||
Index('idx_financial_audit_booking', 'booking_id', 'created_at'),
|
||||
)
|
||||
|
||||
BIN
Backend/src/payments/routes/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/payments/routes/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
164
Backend/src/payments/routes/audit_trail_routes.py
Normal file
164
Backend/src/payments/routes/audit_trail_routes.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Routes for financial audit trail access.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
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
|
||||
from ...auth.models.user import User
|
||||
from ..models.financial_audit_trail import FinancialAuditTrail, FinancialActionType
|
||||
from ..services.financial_audit_service import financial_audit_service
|
||||
from ...shared.utils.response_helpers import success_response
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix='/financial/audit-trail', tags=['financial-audit'])
|
||||
|
||||
|
||||
@router.get('/')
|
||||
async def get_financial_audit_trail(
|
||||
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),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
current_user: User = Depends(authorize_roles('admin', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get financial audit trail records with filters."""
|
||||
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}')
|
||||
|
||||
# Calculate offset
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Get audit trail
|
||||
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=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
# Format response
|
||||
audit_data = []
|
||||
for record in records:
|
||||
audit_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
|
||||
})
|
||||
|
||||
# Get total count for pagination
|
||||
total_query = db.query(FinancialAuditTrail)
|
||||
if payment_id:
|
||||
total_query = total_query.filter(FinancialAuditTrail.payment_id == payment_id)
|
||||
if invoice_id:
|
||||
total_query = total_query.filter(FinancialAuditTrail.invoice_id == invoice_id)
|
||||
if booking_id:
|
||||
total_query = total_query.filter(FinancialAuditTrail.booking_id == booking_id)
|
||||
if action_type_enum:
|
||||
total_query = total_query.filter(FinancialAuditTrail.action_type == action_type_enum)
|
||||
if user_id:
|
||||
total_query = total_query.filter(FinancialAuditTrail.performed_by == user_id)
|
||||
if start:
|
||||
total_query = total_query.filter(FinancialAuditTrail.created_at >= start)
|
||||
if end:
|
||||
total_query = total_query.filter(FinancialAuditTrail.created_at <= end)
|
||||
|
||||
total_count = total_query.count()
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'audit_trail': audit_data,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total': total_count,
|
||||
'total_pages': total_pages
|
||||
}
|
||||
},
|
||||
message='Financial audit trail retrieved successfully'
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error retrieving financial audit trail: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='An error occurred while retrieving audit trail')
|
||||
|
||||
|
||||
@router.get('/{record_id}')
|
||||
async def get_audit_record(
|
||||
record_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a specific audit trail record."""
|
||||
try:
|
||||
record = db.query(FinancialAuditTrail).filter(FinancialAuditTrail.id == record_id).first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail='Audit record not found')
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'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
|
||||
},
|
||||
message='Audit record retrieved successfully'
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
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')
|
||||
|
||||
446
Backend/src/payments/routes/financial_routes.py
Normal file
446
Backend/src/payments/routes/financial_routes.py
Normal file
@@ -0,0 +1,446 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
import csv
|
||||
import io
|
||||
from ...shared.config.database import get_db
|
||||
from ...shared.config.logging_config import get_logger
|
||||
from ...security.middleware.auth import authorize_roles
|
||||
from ...auth.models.user import User
|
||||
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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix='/financial', tags=['financial'])
|
||||
|
||||
@router.get('/profit-loss')
|
||||
async def get_profit_loss_report(
|
||||
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)
|
||||
):
|
||||
"""Generate Profit & Loss statement."""
|
||||
try:
|
||||
# Parse dates
|
||||
if start_date:
|
||||
start = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
start = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0)
|
||||
|
||||
if end_date:
|
||||
end = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
end = datetime.utcnow()
|
||||
|
||||
# Revenue (completed payments)
|
||||
revenue_query = db.query(func.sum(Payment.amount)).filter(
|
||||
and_(
|
||||
Payment.payment_status == PaymentStatus.completed,
|
||||
Payment.payment_date >= start,
|
||||
Payment.payment_date <= end
|
||||
)
|
||||
)
|
||||
total_revenue = revenue_query.scalar() or 0.0
|
||||
|
||||
# Revenue by source
|
||||
revenue_by_method = db.query(
|
||||
Payment.payment_method,
|
||||
func.sum(Payment.amount).label('total')
|
||||
).filter(
|
||||
and_(
|
||||
Payment.payment_status == PaymentStatus.completed,
|
||||
Payment.payment_date >= start,
|
||||
Payment.payment_date <= end
|
||||
)
|
||||
).group_by(Payment.payment_method).all()
|
||||
|
||||
revenue_breakdown = {}
|
||||
for method, total in revenue_by_method:
|
||||
method_name = method.value if hasattr(method, 'value') else str(method)
|
||||
revenue_breakdown[method_name] = float(total or 0)
|
||||
|
||||
# Tax collected (from invoices)
|
||||
tax_collected = db.query(func.sum(Invoice.tax_amount)).filter(
|
||||
and_(
|
||||
Invoice.status == InvoiceStatus.paid,
|
||||
Invoice.paid_date >= start,
|
||||
Invoice.paid_date <= end
|
||||
)
|
||||
).scalar() or 0.0
|
||||
|
||||
# Discounts given
|
||||
discounts = db.query(func.sum(Invoice.discount_amount)).filter(
|
||||
and_(
|
||||
Invoice.status == InvoiceStatus.paid,
|
||||
Invoice.paid_date >= start,
|
||||
Invoice.paid_date <= end
|
||||
)
|
||||
).scalar() or 0.0
|
||||
|
||||
# Expenses (placeholder - would need expense tracking system)
|
||||
# For now, we'll use refunds as expenses
|
||||
refunds = db.query(func.sum(Payment.amount)).filter(
|
||||
and_(
|
||||
Payment.payment_status == PaymentStatus.refunded,
|
||||
Payment.payment_date >= start,
|
||||
Payment.payment_date <= end
|
||||
)
|
||||
).scalar() or 0.0
|
||||
|
||||
# Net revenue
|
||||
net_revenue = total_revenue - discounts
|
||||
|
||||
# Gross profit (revenue - cost of goods sold)
|
||||
# For hotel, COGS would be room maintenance, cleaning, etc.
|
||||
# Placeholder: assume 30% COGS
|
||||
estimated_cogs = net_revenue * 0.30
|
||||
gross_profit = net_revenue - estimated_cogs
|
||||
|
||||
# Operating expenses (placeholder)
|
||||
operating_expenses = refunds # Using refunds as proxy
|
||||
|
||||
# Net profit
|
||||
net_profit = gross_profit - operating_expenses
|
||||
|
||||
return success_response(data={
|
||||
'period': {
|
||||
'start_date': start.isoformat(),
|
||||
'end_date': end.isoformat()
|
||||
},
|
||||
'revenue': {
|
||||
'total_revenue': float(total_revenue),
|
||||
'revenue_by_method': revenue_breakdown,
|
||||
'tax_collected': float(tax_collected),
|
||||
'discounts': float(discounts),
|
||||
'net_revenue': float(net_revenue)
|
||||
},
|
||||
'costs': {
|
||||
'estimated_cogs': float(estimated_cogs),
|
||||
'gross_profit': float(gross_profit)
|
||||
},
|
||||
'expenses': {
|
||||
'refunds': float(refunds),
|
||||
'operating_expenses': float(operating_expenses)
|
||||
},
|
||||
'profit': {
|
||||
'net_profit': float(net_profit),
|
||||
'profit_margin': float((net_profit / net_revenue * 100) if net_revenue > 0 else 0)
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating P&L report: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/balance-sheet')
|
||||
async def get_balance_sheet(
|
||||
as_of_date: Optional[str] = Query(None),
|
||||
current_user: User = Depends(authorize_roles('admin', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Generate Balance Sheet statement."""
|
||||
try:
|
||||
if as_of_date:
|
||||
as_of = datetime.fromisoformat(as_of_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
as_of = datetime.utcnow()
|
||||
|
||||
# Assets
|
||||
# Cash (completed payments - refunds)
|
||||
total_cash = db.query(func.sum(Payment.amount)).filter(
|
||||
and_(
|
||||
Payment.payment_status == PaymentStatus.completed,
|
||||
Payment.payment_date <= as_of
|
||||
)
|
||||
).scalar() or 0.0
|
||||
|
||||
refunds_total = db.query(func.sum(Payment.amount)).filter(
|
||||
and_(
|
||||
Payment.payment_status == PaymentStatus.refunded,
|
||||
Payment.payment_date <= as_of
|
||||
)
|
||||
).scalar() or 0.0
|
||||
|
||||
cash = total_cash - refunds_total
|
||||
|
||||
# Accounts Receivable (unpaid invoices)
|
||||
accounts_receivable = db.query(func.sum(Invoice.balance_due)).filter(
|
||||
and_(
|
||||
Invoice.status != InvoiceStatus.paid,
|
||||
Invoice.issue_date <= as_of
|
||||
)
|
||||
).scalar() or 0.0
|
||||
|
||||
total_assets = cash + accounts_receivable
|
||||
|
||||
# Liabilities
|
||||
# Accounts Payable (placeholder - would need vendor management)
|
||||
accounts_payable = 0.0
|
||||
|
||||
# Deferred Revenue (deposits for future bookings)
|
||||
deferred_revenue = db.query(func.sum(Payment.amount)).filter(
|
||||
and_(
|
||||
Payment.payment_type == 'deposit',
|
||||
Payment.payment_status == PaymentStatus.completed,
|
||||
Payment.payment_date <= as_of
|
||||
)
|
||||
).scalar() or 0.0
|
||||
|
||||
total_liabilities = accounts_payable + deferred_revenue
|
||||
|
||||
# Equity
|
||||
# Retained Earnings (net profit over time)
|
||||
all_revenue = db.query(func.sum(Payment.amount)).filter(
|
||||
and_(
|
||||
Payment.payment_status == PaymentStatus.completed,
|
||||
Payment.payment_date <= as_of
|
||||
)
|
||||
).scalar() or 0.0
|
||||
|
||||
all_refunds = db.query(func.sum(Payment.amount)).filter(
|
||||
and_(
|
||||
Payment.payment_status == PaymentStatus.refunded,
|
||||
Payment.payment_date <= as_of
|
||||
)
|
||||
).scalar() or 0.0
|
||||
|
||||
retained_earnings = all_revenue - all_refunds - (all_revenue * 0.30) # Minus estimated COGS
|
||||
|
||||
total_equity = retained_earnings
|
||||
|
||||
return success_response(data={
|
||||
'as_of_date': as_of.isoformat(),
|
||||
'assets': {
|
||||
'cash': float(cash),
|
||||
'accounts_receivable': float(accounts_receivable),
|
||||
'total_assets': float(total_assets)
|
||||
},
|
||||
'liabilities': {
|
||||
'accounts_payable': float(accounts_payable),
|
||||
'deferred_revenue': float(deferred_revenue),
|
||||
'total_liabilities': float(total_liabilities)
|
||||
},
|
||||
'equity': {
|
||||
'retained_earnings': float(retained_earnings),
|
||||
'total_equity': float(total_equity)
|
||||
},
|
||||
'balance': {
|
||||
'total_liabilities_and_equity': float(total_liabilities + total_equity),
|
||||
'is_balanced': abs(total_assets - (total_liabilities + total_equity)) < 0.01
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating balance sheet: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/tax-report')
|
||||
async def get_tax_report(
|
||||
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."""
|
||||
try:
|
||||
if start_date:
|
||||
start = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
start = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0)
|
||||
|
||||
if end_date:
|
||||
end = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
end = datetime.utcnow()
|
||||
|
||||
# Get all paid invoices in period
|
||||
invoices = db.query(Invoice).filter(
|
||||
and_(
|
||||
Invoice.status == InvoiceStatus.paid,
|
||||
Invoice.paid_date >= start,
|
||||
Invoice.paid_date <= end
|
||||
)
|
||||
).all()
|
||||
|
||||
tax_data = []
|
||||
total_tax = 0.0
|
||||
total_revenue = 0.0
|
||||
|
||||
for invoice in invoices:
|
||||
tax_amount = float(invoice.tax_amount) if invoice.tax_amount else 0.0
|
||||
revenue = float(invoice.total_amount) if invoice.total_amount else 0.0
|
||||
total_tax += tax_amount
|
||||
total_revenue += revenue
|
||||
|
||||
tax_data.append({
|
||||
'invoice_number': invoice.invoice_number,
|
||||
'date': invoice.paid_date.isoformat() if invoice.paid_date else None,
|
||||
'customer_name': invoice.customer_name,
|
||||
'customer_tax_id': invoice.customer_tax_id,
|
||||
'subtotal': float(invoice.subtotal) if invoice.subtotal else 0.0,
|
||||
'tax_rate': float(invoice.tax_rate) if invoice.tax_rate else 0.0,
|
||||
'tax_amount': tax_amount,
|
||||
'total_amount': revenue
|
||||
})
|
||||
|
||||
if format == 'csv':
|
||||
# Generate CSV
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=[
|
||||
'invoice_number', 'date', 'customer_name', 'customer_tax_id',
|
||||
'subtotal', 'tax_rate', 'tax_amount', 'total_amount'
|
||||
])
|
||||
writer.writeheader()
|
||||
writer.writerows(tax_data)
|
||||
|
||||
return Response(
|
||||
content=output.getvalue(),
|
||||
media_type='text/csv',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename="tax_report_{start.strftime("%Y%m%d")}_{end.strftime("%Y%m%d")}.csv"'
|
||||
}
|
||||
)
|
||||
|
||||
return success_response(data={
|
||||
'period': {
|
||||
'start_date': start.isoformat(),
|
||||
'end_date': end.isoformat()
|
||||
},
|
||||
'summary': {
|
||||
'total_revenue': float(total_revenue),
|
||||
'total_tax_collected': float(total_tax),
|
||||
'invoice_count': len(tax_data)
|
||||
},
|
||||
'transactions': tax_data
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating tax report: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/payment-reconciliation')
|
||||
async def get_payment_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)
|
||||
):
|
||||
"""Generate payment reconciliation report."""
|
||||
try:
|
||||
if start_date:
|
||||
start = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
start = datetime.utcnow() - timedelta(days=30)
|
||||
|
||||
if end_date:
|
||||
end = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
end = datetime.utcnow()
|
||||
|
||||
# Get all payments in period
|
||||
payments = db.query(Payment).filter(
|
||||
and_(
|
||||
Payment.created_at >= start,
|
||||
Payment.created_at <= end
|
||||
)
|
||||
).all()
|
||||
|
||||
reconciliation = {
|
||||
'period': {
|
||||
'start_date': start.isoformat(),
|
||||
'end_date': end.isoformat()
|
||||
},
|
||||
'by_status': {},
|
||||
'by_method': {},
|
||||
'discrepancies': []
|
||||
}
|
||||
|
||||
# Group by status
|
||||
for status in PaymentStatus:
|
||||
status_payments = [p for p in payments if p.payment_status == status]
|
||||
total = sum(float(p.amount) for p in status_payments)
|
||||
reconciliation['by_status'][status.value] = {
|
||||
'count': len(status_payments),
|
||||
'total_amount': float(total)
|
||||
}
|
||||
|
||||
# Group by payment method
|
||||
for method in PaymentMethod:
|
||||
method_payments = [p for p in payments if p.payment_method == method]
|
||||
total = sum(float(p.amount) for p in method_payments)
|
||||
reconciliation['by_method'][method.value] = {
|
||||
'count': len(method_payments),
|
||||
'total_amount': float(total)
|
||||
}
|
||||
|
||||
# Find discrepancies (payments without matching invoices, etc.)
|
||||
for payment in payments:
|
||||
if payment.payment_status == PaymentStatus.completed:
|
||||
# Check if invoice exists
|
||||
invoice = db.query(Invoice).filter(
|
||||
Invoice.booking_id == payment.booking_id
|
||||
).first()
|
||||
|
||||
if not invoice:
|
||||
reconciliation['discrepancies'].append({
|
||||
'type': 'missing_invoice',
|
||||
'payment_id': payment.id,
|
||||
'booking_id': payment.booking_id,
|
||||
'amount': float(payment.amount),
|
||||
'date': payment.payment_date.isoformat() if payment.payment_date else None
|
||||
})
|
||||
|
||||
return success_response(data=reconciliation)
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating payment reconciliation: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/refunds')
|
||||
async def get_refund_history(
|
||||
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)
|
||||
):
|
||||
"""Get refund history and statistics."""
|
||||
try:
|
||||
query = db.query(Payment).filter(
|
||||
Payment.payment_status == PaymentStatus.refunded
|
||||
)
|
||||
|
||||
if start_date:
|
||||
start = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||
query = query.filter(Payment.payment_date >= start)
|
||||
|
||||
if end_date:
|
||||
end = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
query = query.filter(Payment.payment_date <= end)
|
||||
|
||||
refunds = query.order_by(Payment.payment_date.desc()).all()
|
||||
|
||||
total_refunded = sum(float(p.amount) for p in refunds)
|
||||
|
||||
refund_list = []
|
||||
for refund in refunds:
|
||||
refund_list.append({
|
||||
'id': refund.id,
|
||||
'booking_id': refund.booking_id,
|
||||
'amount': float(refund.amount),
|
||||
'payment_method': refund.payment_method.value if hasattr(refund.payment_method, 'value') else str(refund.payment_method),
|
||||
'refund_date': refund.payment_date.isoformat() if refund.payment_date else None,
|
||||
'transaction_id': refund.transaction_id,
|
||||
'notes': refund.notes
|
||||
})
|
||||
|
||||
return success_response(data={
|
||||
'total_refunds': len(refunds),
|
||||
'total_refunded_amount': float(total_refunded),
|
||||
'refunds': refund_list
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching refund history: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, Header
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload, load_only
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import os
|
||||
@@ -20,7 +21,19 @@ from ..services.paypal_service import PayPalService
|
||||
from ..services.borica_service import BoricaService
|
||||
from ...loyalty.services.loyalty_service import LoyaltyService
|
||||
from ...analytics.services.audit_service import audit_service
|
||||
from ..schemas.payment import CreatePaymentRequest, UpdatePaymentStatusRequest, CreateStripePaymentIntentRequest
|
||||
from ..services.financial_audit_service import financial_audit_service
|
||||
from ..models.financial_audit_trail import FinancialActionType
|
||||
from ..schemas.payment import (
|
||||
CreatePaymentRequest,
|
||||
UpdatePaymentStatusRequest,
|
||||
CreateStripePaymentIntentRequest,
|
||||
ConfirmStripePaymentRequest,
|
||||
CreatePayPalOrderRequest,
|
||||
CancelPayPalPaymentRequest,
|
||||
CapturePayPalPaymentRequest,
|
||||
CreateBoricaPaymentRequest,
|
||||
ConfirmBoricaPaymentRequest
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix='/payments', tags=['payments'])
|
||||
@@ -186,6 +199,11 @@ async def create_payment(
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Start transaction
|
||||
transaction = db.begin()
|
||||
try:
|
||||
booking_id = payment_data.booking_id
|
||||
amount = payment_data.amount
|
||||
@@ -193,19 +211,56 @@ async def create_payment(
|
||||
payment_type = payment_data.payment_type
|
||||
mark_as_paid = payment_data.mark_as_paid
|
||||
notes = payment_data.notes
|
||||
idempotency_key = payment_data.idempotency_key
|
||||
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
# Idempotency check: if idempotency_key provided, check for existing payment
|
||||
if idempotency_key:
|
||||
existing_payment = db.query(Payment).filter(
|
||||
Payment.transaction_id == idempotency_key,
|
||||
Payment.booking_id == booking_id,
|
||||
Payment.amount == amount
|
||||
).first()
|
||||
if existing_payment:
|
||||
transaction.rollback()
|
||||
logger.info(f'Duplicate payment request detected with idempotency_key: {idempotency_key}')
|
||||
return success_response(
|
||||
data={'payment': existing_payment},
|
||||
message='Payment already exists (idempotency check)'
|
||||
)
|
||||
|
||||
# Lock booking row to prevent race conditions
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).with_for_update().first()
|
||||
if not booking:
|
||||
transaction.rollback()
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
|
||||
from ...shared.utils.role_helpers import is_admin
|
||||
if not is_admin(current_user, db) and booking.user_id != current_user.id:
|
||||
transaction.rollback()
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod(payment_method), payment_type=PaymentType(payment_type), payment_status=PaymentStatus.pending, payment_date=datetime.utcnow() if mark_as_paid else None, notes=notes)
|
||||
|
||||
# Use idempotency_key as transaction_id if provided
|
||||
transaction_id = idempotency_key if idempotency_key else None
|
||||
|
||||
payment = Payment(
|
||||
booking_id=booking_id,
|
||||
amount=amount,
|
||||
payment_method=PaymentMethod(payment_method),
|
||||
payment_type=PaymentType(payment_type),
|
||||
payment_status=PaymentStatus.pending,
|
||||
payment_date=datetime.utcnow() if mark_as_paid else None,
|
||||
notes=notes,
|
||||
transaction_id=transaction_id
|
||||
)
|
||||
if mark_as_paid:
|
||||
payment.payment_status = PaymentStatus.completed
|
||||
payment.payment_date = datetime.utcnow()
|
||||
|
||||
db.add(payment)
|
||||
db.commit()
|
||||
db.flush()
|
||||
|
||||
# Commit transaction
|
||||
transaction.commit()
|
||||
db.refresh(payment)
|
||||
|
||||
# Send payment receipt notification
|
||||
@@ -278,9 +333,21 @@ async def create_payment(
|
||||
|
||||
return success_response(data={'payment': payment}, message='Payment created successfully')
|
||||
except HTTPException:
|
||||
if 'transaction' in locals():
|
||||
transaction.rollback()
|
||||
raise
|
||||
except IntegrityError as e:
|
||||
if 'transaction' in locals():
|
||||
transaction.rollback()
|
||||
logger.error(f'Database integrity error during payment creation: {str(e)}')
|
||||
# Check if it's a duplicate payment error
|
||||
if 'duplicate' in str(e).lower() or 'unique' in str(e).lower():
|
||||
raise HTTPException(status_code=409, detail='Duplicate payment detected. Please check if payment already exists.')
|
||||
raise HTTPException(status_code=409, detail='Payment conflict detected. Please try again.')
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
if 'transaction' in locals():
|
||||
transaction.rollback()
|
||||
logger.error(f'Error creating payment: {str(e)}', exc_info=True)
|
||||
# Log failed payment creation
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
@@ -482,12 +549,11 @@ async def create_stripe_payment_intent(intent_data: CreateStripePaymentIntentReq
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/stripe/confirm')
|
||||
async def confirm_stripe_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
async def confirm_stripe_payment(payment_data: ConfirmStripePaymentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
"""Confirm a Stripe payment with validated input using Pydantic schema."""
|
||||
try:
|
||||
payment_intent_id = payment_data.get('payment_intent_id')
|
||||
booking_id = payment_data.get('booking_id')
|
||||
if not payment_intent_id:
|
||||
raise HTTPException(status_code=400, detail='payment_intent_id is required')
|
||||
payment_intent_id = payment_data.payment_intent_id
|
||||
booking_id = payment_data.booking_id
|
||||
payment = await StripeService.confirm_payment(payment_intent_id=payment_intent_id, db=db, booking_id=booking_id)
|
||||
try:
|
||||
db.commit()
|
||||
@@ -549,7 +615,7 @@ async def stripe_webhook(request: Request, db: Session=Depends(get_db)):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/paypal/create-order')
|
||||
async def create_paypal_order(order_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
async def create_paypal_order(order_data: CreatePayPalOrderRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
from ..services.paypal_service import get_paypal_client_id, get_paypal_client_secret
|
||||
client_id = get_paypal_client_id(db)
|
||||
@@ -560,11 +626,9 @@ async def create_paypal_order(order_data: dict, current_user: User=Depends(get_c
|
||||
client_secret = settings.PAYPAL_CLIENT_SECRET
|
||||
if not client_id or not client_secret:
|
||||
raise HTTPException(status_code=500, detail='PayPal is not configured. Please configure PayPal settings in Admin Panel or set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET environment variables.')
|
||||
booking_id = order_data.get('booking_id')
|
||||
amount = float(order_data.get('amount', 0))
|
||||
currency = order_data.get('currency', 'USD')
|
||||
if not booking_id or amount <= 0:
|
||||
raise HTTPException(status_code=400, detail='booking_id and amount are required')
|
||||
booking_id = order_data.booking_id
|
||||
amount = float(order_data.amount)
|
||||
currency = order_data.currency or 'USD'
|
||||
if amount > 100000:
|
||||
raise HTTPException(status_code=400, detail=f"Amount ${amount:,.2f} exceeds PayPal's maximum of $100,000. Please contact support for large payments.")
|
||||
from ...shared.utils.role_helpers import is_admin
|
||||
@@ -603,11 +667,9 @@ async def create_paypal_order(order_data: dict, current_user: User=Depends(get_c
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/paypal/cancel')
|
||||
async def cancel_paypal_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
async def cancel_paypal_payment(payment_data: CancelPayPalPaymentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
booking_id = payment_data.get('booking_id')
|
||||
if not booking_id:
|
||||
raise HTTPException(status_code=400, detail='booking_id is required')
|
||||
booking_id = payment_data.booking_id
|
||||
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_method == PaymentMethod.paypal, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
|
||||
if not payment:
|
||||
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
|
||||
@@ -627,12 +689,10 @@ async def cancel_paypal_payment(payment_data: dict, current_user: User=Depends(g
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/paypal/capture')
|
||||
async def capture_paypal_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
async def capture_paypal_payment(payment_data: CapturePayPalPaymentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
order_id = payment_data.get('order_id')
|
||||
booking_id = payment_data.get('booking_id')
|
||||
if not order_id:
|
||||
raise HTTPException(status_code=400, detail='order_id is required')
|
||||
order_id = payment_data.order_id
|
||||
booking_id = payment_data.booking_id
|
||||
payment = await PayPalService.confirm_payment(order_id=order_id, db=db, booking_id=booking_id)
|
||||
try:
|
||||
db.commit()
|
||||
@@ -673,7 +733,7 @@ async def capture_paypal_payment(payment_data: dict, current_user: User=Depends(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/borica/create-payment')
|
||||
async def create_borica_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
async def create_borica_payment(payment_data: CreateBoricaPaymentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
from ..services.borica_service import get_borica_terminal_id, get_borica_merchant_id
|
||||
terminal_id = get_borica_terminal_id(db)
|
||||
@@ -681,11 +741,9 @@ async def create_borica_payment(payment_data: dict, current_user: User=Depends(g
|
||||
if not terminal_id or not merchant_id:
|
||||
if not settings.BORICA_TERMINAL_ID or not settings.BORICA_MERCHANT_ID:
|
||||
raise HTTPException(status_code=500, detail='Borica is not configured. Please configure Borica settings in Admin Panel or set BORICA_TERMINAL_ID and BORICA_MERCHANT_ID environment variables.')
|
||||
booking_id = payment_data.get('booking_id')
|
||||
amount = float(payment_data.get('amount', 0))
|
||||
currency = payment_data.get('currency', 'BGN')
|
||||
if not booking_id or amount <= 0:
|
||||
raise HTTPException(status_code=400, detail='booking_id and amount are required')
|
||||
booking_id = payment_data.booking_id
|
||||
amount = float(payment_data.amount)
|
||||
currency = payment_data.currency or 'BGN'
|
||||
if amount > 100000:
|
||||
raise HTTPException(status_code=400, detail=f"Amount {amount:,.2f} exceeds maximum of 100,000. Please contact support for large payments.")
|
||||
from ...shared.utils.role_helpers import is_admin
|
||||
@@ -705,7 +763,7 @@ async def create_borica_payment(payment_data: dict, current_user: User=Depends(g
|
||||
raise HTTPException(status_code=400, detail=f'For pay-on-arrival bookings, only the deposit amount ({expected_deposit_amount:,.2f}) should be charged, not the full booking amount ({float(booking.total_price):,.2f}).')
|
||||
transaction_id = BoricaService.generate_transaction_id(booking_id)
|
||||
client_url = settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
|
||||
return_url = payment_data.get('return_url', f'{client_url}/payment/borica/return')
|
||||
return_url = f'{client_url}/payment/borica/return'
|
||||
description = f'Hotel Booking Payment - {booking.booking_number}'
|
||||
payment_request = BoricaService.create_payment_request(amount=amount, currency=currency, order_id=transaction_id, description=description, return_url=return_url, db=db)
|
||||
payment_type = PaymentType.full
|
||||
|
||||
Binary file not shown.
BIN
Backend/src/payments/schemas/__pycache__/invoice.cpython-312.pyc
Normal file
BIN
Backend/src/payments/schemas/__pycache__/invoice.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/payments/schemas/__pycache__/payment.cpython-312.pyc
Normal file
BIN
Backend/src/payments/schemas/__pycache__/payment.cpython-312.pyc
Normal file
Binary file not shown.
@@ -13,6 +13,7 @@ class CreatePaymentRequest(BaseModel):
|
||||
payment_type: str = Field("full", description="Payment type (full, deposit)")
|
||||
mark_as_paid: Optional[bool] = Field(False, description="Mark payment as completed immediately")
|
||||
notes: Optional[str] = Field(None, max_length=1000, description="Payment notes")
|
||||
idempotency_key: Optional[str] = Field(None, max_length=255, description="Idempotency key to prevent duplicate payments")
|
||||
|
||||
@field_validator('payment_method')
|
||||
@classmethod
|
||||
@@ -53,3 +54,67 @@ class CreateStripePaymentIntentRequest(BaseModel):
|
||||
raise ValueError(f"Amount ${v:,.2f} exceeds Stripe's maximum of $999,999.99")
|
||||
return v
|
||||
|
||||
@field_validator('currency')
|
||||
@classmethod
|
||||
def validate_currency(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Validate currency code."""
|
||||
if v:
|
||||
# Basic currency code validation (3 uppercase letters)
|
||||
if not v.isupper() or len(v) != 3 or not v.isalpha():
|
||||
raise ValueError('Currency must be a valid 3-letter uppercase code (e.g., USD, EUR)')
|
||||
return v
|
||||
|
||||
|
||||
class ConfirmStripePaymentRequest(BaseModel):
|
||||
"""Schema for confirming a Stripe payment."""
|
||||
payment_intent_id: str = Field(..., min_length=1, max_length=255, description="Stripe payment intent ID")
|
||||
booking_id: Optional[int] = Field(None, gt=0, description="Optional booking ID for validation")
|
||||
|
||||
|
||||
class CreatePayPalOrderRequest(BaseModel):
|
||||
"""Schema for creating a PayPal order."""
|
||||
booking_id: int = Field(..., gt=0, description="Booking ID")
|
||||
amount: float = Field(..., gt=0, le=999999.99, description="Payment amount")
|
||||
currency: Optional[str] = Field("USD", description="Currency code")
|
||||
|
||||
@field_validator('currency')
|
||||
@classmethod
|
||||
def validate_currency(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Validate currency code."""
|
||||
if v:
|
||||
if not v.isupper() or len(v) != 3 or not v.isalpha():
|
||||
raise ValueError('Currency must be a valid 3-letter uppercase code (e.g., USD, EUR)')
|
||||
return v
|
||||
|
||||
|
||||
class CancelPayPalPaymentRequest(BaseModel):
|
||||
"""Schema for canceling a PayPal payment."""
|
||||
order_id: str = Field(..., min_length=1, max_length=255, description="PayPal order ID")
|
||||
booking_id: Optional[int] = Field(None, gt=0, description="Optional booking ID")
|
||||
|
||||
|
||||
class CapturePayPalPaymentRequest(BaseModel):
|
||||
"""Schema for capturing a PayPal payment."""
|
||||
order_id: str = Field(..., min_length=1, max_length=255, description="PayPal order ID")
|
||||
booking_id: Optional[int] = Field(None, gt=0, description="Optional booking ID")
|
||||
|
||||
|
||||
class CreateBoricaPaymentRequest(BaseModel):
|
||||
"""Schema for creating a Borica payment."""
|
||||
booking_id: int = Field(..., gt=0, description="Booking ID")
|
||||
amount: float = Field(..., gt=0, le=999999.99, description="Payment amount")
|
||||
currency: Optional[str] = Field("BGN", description="Currency code (default: BGN)")
|
||||
|
||||
@field_validator('currency')
|
||||
@classmethod
|
||||
def validate_currency(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Validate currency code."""
|
||||
if v:
|
||||
if not v.isupper() or len(v) != 3 or not v.isalpha():
|
||||
raise ValueError('Currency must be a valid 3-letter uppercase code (e.g., BGN, USD)')
|
||||
return v
|
||||
|
||||
|
||||
class ConfirmBoricaPaymentRequest(BaseModel):
|
||||
"""Schema for confirming a Borica payment (webhook)."""
|
||||
response_data: dict = Field(..., description="Borica payment response data")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
102
Backend/src/payments/services/financial_audit_service.py
Normal file
102
Backend/src/payments/services/financial_audit_service.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Service for creating and managing financial audit trail records.
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from ..models.financial_audit_trail import FinancialAuditTrail, FinancialActionType
|
||||
from ...auth.models.user import User
|
||||
from ...shared.config.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class FinancialAuditService:
|
||||
"""Service for financial audit trail operations."""
|
||||
|
||||
@staticmethod
|
||||
def log_financial_action(
|
||||
db: Session,
|
||||
action_type: FinancialActionType,
|
||||
performed_by: int,
|
||||
action_description: str,
|
||||
payment_id: Optional[int] = None,
|
||||
invoice_id: Optional[int] = None,
|
||||
booking_id: Optional[int] = None,
|
||||
amount: Optional[float] = None,
|
||||
previous_amount: Optional[float] = None,
|
||||
currency: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
notes: Optional[str] = None
|
||||
) -> FinancialAuditTrail:
|
||||
"""Log a financial action to the audit trail."""
|
||||
try:
|
||||
# Get user email for audit trail
|
||||
user = db.query(User).filter(User.id == performed_by).first()
|
||||
user_email = user.email if user else None
|
||||
|
||||
audit_record = FinancialAuditTrail(
|
||||
action_type=action_type,
|
||||
action_description=action_description,
|
||||
payment_id=payment_id,
|
||||
invoice_id=invoice_id,
|
||||
booking_id=booking_id,
|
||||
amount=amount,
|
||||
previous_amount=previous_amount,
|
||||
currency=currency or 'USD',
|
||||
performed_by=performed_by,
|
||||
performed_by_email=user_email,
|
||||
audit_metadata=metadata or {},
|
||||
notes=notes,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(audit_record)
|
||||
db.flush() # Flush to get ID without committing
|
||||
|
||||
logger.info(f"Financial audit trail created: {action_type.value} by user {performed_by}")
|
||||
return audit_record
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating financial audit trail: {str(e)}", exc_info=True)
|
||||
# Don't fail the main operation if audit logging fails
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_audit_trail(
|
||||
db: Session,
|
||||
payment_id: Optional[int] = None,
|
||||
invoice_id: Optional[int] = None,
|
||||
booking_id: Optional[int] = None,
|
||||
action_type: Optional[FinancialActionType] = None,
|
||||
user_id: Optional[int] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> list[FinancialAuditTrail]:
|
||||
"""Get audit trail records with filters."""
|
||||
query = db.query(FinancialAuditTrail)
|
||||
|
||||
if payment_id:
|
||||
query = query.filter(FinancialAuditTrail.payment_id == payment_id)
|
||||
if invoice_id:
|
||||
query = query.filter(FinancialAuditTrail.invoice_id == invoice_id)
|
||||
if booking_id:
|
||||
query = query.filter(FinancialAuditTrail.booking_id == booking_id)
|
||||
if action_type:
|
||||
query = query.filter(FinancialAuditTrail.action_type == action_type)
|
||||
if user_id:
|
||||
query = query.filter(FinancialAuditTrail.performed_by == user_id)
|
||||
if start_date:
|
||||
query = query.filter(FinancialAuditTrail.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(FinancialAuditTrail.created_at <= end_date)
|
||||
|
||||
query = query.order_by(FinancialAuditTrail.created_at.desc())
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
return query.all()
|
||||
|
||||
|
||||
financial_audit_service = FinancialAuditService()
|
||||
|
||||
@@ -34,116 +34,134 @@ class InvoiceService:
|
||||
from ...rooms.models.room import Room
|
||||
from ...rooms.models.room_type import RoomType
|
||||
from ...hotel_services.models.service import Service
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
logger.info(f'Creating invoice from booking {booking_id}', extra={'booking_id': booking_id, 'request_id': request_id})
|
||||
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).first()
|
||||
if not booking:
|
||||
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:
|
||||
raise ValueError('User not found')
|
||||
|
||||
# Get tax_rate from system settings if not provided or is 0
|
||||
if tax_rate == 0.0:
|
||||
tax_rate_setting = db.query(SystemSettings).filter(SystemSettings.key == 'tax_rate').first()
|
||||
if tax_rate_setting and tax_rate_setting.value:
|
||||
try:
|
||||
tax_rate = float(tax_rate_setting.value)
|
||||
except (ValueError, TypeError):
|
||||
tax_rate = 0.0
|
||||
# Start transaction
|
||||
transaction = db.begin()
|
||||
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()
|
||||
if not booking:
|
||||
transaction.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()
|
||||
raise ValueError('User not found')
|
||||
|
||||
invoice_number = generate_invoice_number(db, is_proforma=is_proforma)
|
||||
booking_total = float(booking.total_price)
|
||||
if invoice_amount is not None:
|
||||
subtotal = float(invoice_amount)
|
||||
if invoice_amount < booking_total and discount_amount > 0:
|
||||
if discount_amount > subtotal * 0.5:
|
||||
proportion = float(invoice_amount) / booking_total
|
||||
original_discount = float(booking.discount_amount) if booking.discount_amount else discount_amount
|
||||
discount_amount = original_discount * proportion
|
||||
else:
|
||||
subtotal = booking_total
|
||||
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
|
||||
total_amount = subtotal + tax_amount - discount_amount
|
||||
amount_paid = sum((float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed))
|
||||
balance_due = total_amount - amount_paid
|
||||
if balance_due <= 0:
|
||||
status = InvoiceStatus.paid
|
||||
paid_date = datetime.utcnow()
|
||||
elif amount_paid > 0:
|
||||
status = InvoiceStatus.sent
|
||||
paid_date = None
|
||||
else:
|
||||
status = InvoiceStatus.draft
|
||||
paid_date = None
|
||||
# Get company information from system settings if not provided
|
||||
company_name = kwargs.get('company_name')
|
||||
company_address = kwargs.get('company_address')
|
||||
company_phone = kwargs.get('company_phone')
|
||||
company_email = kwargs.get('company_email')
|
||||
company_tax_id = kwargs.get('company_tax_id')
|
||||
company_logo_url = kwargs.get('company_logo_url')
|
||||
|
||||
# If company info not provided, fetch from system settings
|
||||
if not company_name or not company_address or not company_phone or not company_email:
|
||||
company_settings = db.query(SystemSettings).filter(
|
||||
SystemSettings.key.in_(['company_name', 'company_address', 'company_phone', 'company_email', 'company_logo_url'])
|
||||
).all()
|
||||
# Get tax_rate from system settings if not provided or is 0
|
||||
if tax_rate == 0.0:
|
||||
tax_rate_setting = db.query(SystemSettings).filter(SystemSettings.key == 'tax_rate').first()
|
||||
if tax_rate_setting and tax_rate_setting.value:
|
||||
try:
|
||||
tax_rate = float(tax_rate_setting.value)
|
||||
except (ValueError, TypeError):
|
||||
tax_rate = 0.0
|
||||
|
||||
settings_dict = {setting.key: setting.value for setting in company_settings if setting.value}
|
||||
invoice_number = generate_invoice_number(db, is_proforma=is_proforma)
|
||||
booking_total = float(booking.total_price)
|
||||
if invoice_amount is not None:
|
||||
subtotal = float(invoice_amount)
|
||||
if invoice_amount < booking_total and discount_amount > 0:
|
||||
if discount_amount > subtotal * 0.5:
|
||||
proportion = float(invoice_amount) / booking_total
|
||||
original_discount = float(booking.discount_amount) if booking.discount_amount else discount_amount
|
||||
discount_amount = original_discount * proportion
|
||||
else:
|
||||
subtotal = booking_total
|
||||
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
|
||||
total_amount = subtotal + tax_amount - discount_amount
|
||||
amount_paid = sum((float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed))
|
||||
balance_due = total_amount - amount_paid
|
||||
if balance_due <= 0:
|
||||
status = InvoiceStatus.paid
|
||||
paid_date = datetime.utcnow()
|
||||
elif amount_paid > 0:
|
||||
status = InvoiceStatus.sent
|
||||
paid_date = None
|
||||
else:
|
||||
status = InvoiceStatus.draft
|
||||
paid_date = None
|
||||
# Get company information from system settings if not provided
|
||||
company_name = kwargs.get('company_name')
|
||||
company_address = kwargs.get('company_address')
|
||||
company_phone = kwargs.get('company_phone')
|
||||
company_email = kwargs.get('company_email')
|
||||
company_tax_id = kwargs.get('company_tax_id')
|
||||
company_logo_url = kwargs.get('company_logo_url')
|
||||
|
||||
if not company_name and settings_dict.get('company_name'):
|
||||
company_name = settings_dict['company_name']
|
||||
if not company_address and settings_dict.get('company_address'):
|
||||
company_address = settings_dict['company_address']
|
||||
if not company_phone and settings_dict.get('company_phone'):
|
||||
company_phone = settings_dict['company_phone']
|
||||
if not company_email and settings_dict.get('company_email'):
|
||||
company_email = settings_dict['company_email']
|
||||
if not company_logo_url and settings_dict.get('company_logo_url'):
|
||||
company_logo_url = settings_dict['company_logo_url']
|
||||
|
||||
invoice = Invoice(invoice_number=invoice_number, booking_id=booking_id, user_id=booking.user_id, issue_date=datetime.utcnow(), due_date=datetime.utcnow() + timedelta(days=due_days), paid_date=paid_date, subtotal=subtotal, tax_rate=tax_rate, tax_amount=tax_amount, discount_amount=discount_amount, total_amount=total_amount, amount_paid=amount_paid, balance_due=balance_due, status=status, is_proforma=is_proforma, company_name=company_name, company_address=company_address, company_phone=company_phone, company_email=company_email, company_tax_id=company_tax_id, company_logo_url=company_logo_url, customer_name=user.full_name or f'{user.email}', customer_email=user.email, customer_address=user.address, customer_phone=user.phone, customer_tax_id=kwargs.get('customer_tax_id'), notes=kwargs.get('notes'), terms_and_conditions=kwargs.get('terms_and_conditions'), payment_instructions=kwargs.get('payment_instructions'), created_by_id=created_by_id)
|
||||
db.add(invoice)
|
||||
db.flush()
|
||||
services_total = sum((float(su.total_price) for su in booking.service_usages))
|
||||
booking_total = float(booking.total_price)
|
||||
room_price = booking_total - services_total
|
||||
nights = (booking.check_out_date - booking.check_in_date).days
|
||||
if nights <= 0:
|
||||
nights = 1
|
||||
if invoice_amount is not None and invoice_amount < booking_total:
|
||||
proportion = float(invoice_amount) / booking_total
|
||||
room_price = room_price * proportion
|
||||
services_total = services_total * proportion
|
||||
item_description_suffix = f' (Partial: {proportion * 100:.0f}%)'
|
||||
else:
|
||||
item_description_suffix = ''
|
||||
room_item = InvoiceItem(invoice_id=invoice.id, description=f'Room: {booking.room.room_number} - {(booking.room.room_type.name if booking.room.room_type else 'N/A')} ({nights} night{('s' if nights > 1 else '')}){item_description_suffix}', quantity=nights, unit_price=room_price / nights if nights > 0 else room_price, tax_rate=tax_rate, discount_amount=0.0, line_total=room_price, room_id=booking.room_id)
|
||||
db.add(room_item)
|
||||
for service_usage in booking.service_usages:
|
||||
service_item_price = float(service_usage.total_price)
|
||||
# If company info not provided, fetch from system settings
|
||||
if not company_name or not company_address or not company_phone or not company_email:
|
||||
company_settings = db.query(SystemSettings).filter(
|
||||
SystemSettings.key.in_(['company_name', 'company_address', 'company_phone', 'company_email', 'company_logo_url'])
|
||||
).all()
|
||||
|
||||
settings_dict = {setting.key: setting.value for setting in company_settings if setting.value}
|
||||
|
||||
if not company_name and settings_dict.get('company_name'):
|
||||
company_name = settings_dict['company_name']
|
||||
if not company_address and settings_dict.get('company_address'):
|
||||
company_address = settings_dict['company_address']
|
||||
if not company_phone and settings_dict.get('company_phone'):
|
||||
company_phone = settings_dict['company_phone']
|
||||
if not company_email and settings_dict.get('company_email'):
|
||||
company_email = settings_dict['company_email']
|
||||
if not company_logo_url and settings_dict.get('company_logo_url'):
|
||||
company_logo_url = settings_dict['company_logo_url']
|
||||
|
||||
invoice = Invoice(invoice_number=invoice_number, booking_id=booking_id, user_id=booking.user_id, issue_date=datetime.utcnow(), due_date=datetime.utcnow() + timedelta(days=due_days), paid_date=paid_date, subtotal=subtotal, tax_rate=tax_rate, tax_amount=tax_amount, discount_amount=discount_amount, total_amount=total_amount, amount_paid=amount_paid, balance_due=balance_due, status=status, is_proforma=is_proforma, company_name=company_name, company_address=company_address, company_phone=company_phone, company_email=company_email, company_tax_id=company_tax_id, company_logo_url=company_logo_url, customer_name=user.full_name or f'{user.email}', customer_email=user.email, customer_address=user.address, customer_phone=user.phone, customer_tax_id=kwargs.get('customer_tax_id'), notes=kwargs.get('notes'), terms_and_conditions=kwargs.get('terms_and_conditions'), payment_instructions=kwargs.get('payment_instructions'), created_by_id=created_by_id)
|
||||
db.add(invoice)
|
||||
db.flush()
|
||||
services_total = sum((float(su.total_price) for su in booking.service_usages))
|
||||
booking_total = float(booking.total_price)
|
||||
room_price = booking_total - services_total
|
||||
nights = (booking.check_out_date - booking.check_in_date).days
|
||||
if nights <= 0:
|
||||
nights = 1
|
||||
if invoice_amount is not None and invoice_amount < booking_total:
|
||||
proportion = float(invoice_amount) / booking_total
|
||||
service_item_price = service_item_price * proportion
|
||||
service_item = InvoiceItem(invoice_id=invoice.id, description=f'Service: {service_usage.service.name}{item_description_suffix}', quantity=float(service_usage.quantity), unit_price=service_item_price / float(service_usage.quantity) if service_usage.quantity > 0 else service_item_price, tax_rate=tax_rate, discount_amount=0.0, line_total=service_item_price, service_id=service_usage.service_id)
|
||||
db.add(service_item)
|
||||
subtotal = room_price + services_total
|
||||
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
|
||||
total_amount = subtotal + tax_amount - discount_amount
|
||||
balance_due = total_amount - amount_paid
|
||||
invoice.subtotal = subtotal
|
||||
invoice.tax_amount = tax_amount
|
||||
invoice.total_amount = total_amount
|
||||
invoice.balance_due = balance_due
|
||||
db.commit()
|
||||
db.refresh(invoice)
|
||||
return InvoiceService.invoice_to_dict(invoice)
|
||||
room_price = room_price * proportion
|
||||
services_total = services_total * proportion
|
||||
item_description_suffix = f' (Partial: {proportion * 100:.0f}%)'
|
||||
else:
|
||||
item_description_suffix = ''
|
||||
room_item = InvoiceItem(invoice_id=invoice.id, description=f'Room: {booking.room.room_number} - {(booking.room.room_type.name if booking.room.room_type else 'N/A')} ({nights} night{('s' if nights > 1 else '')}){item_description_suffix}', quantity=nights, unit_price=room_price / nights if nights > 0 else room_price, tax_rate=tax_rate, discount_amount=0.0, line_total=room_price, room_id=booking.room_id)
|
||||
db.add(room_item)
|
||||
for service_usage in booking.service_usages:
|
||||
service_item_price = float(service_usage.total_price)
|
||||
if invoice_amount is not None and invoice_amount < booking_total:
|
||||
proportion = float(invoice_amount) / booking_total
|
||||
service_item_price = service_item_price * proportion
|
||||
service_item = InvoiceItem(invoice_id=invoice.id, description=f'Service: {service_usage.service.name}{item_description_suffix}', quantity=float(service_usage.quantity), unit_price=service_item_price / float(service_usage.quantity) if service_usage.quantity > 0 else service_item_price, tax_rate=tax_rate, discount_amount=0.0, line_total=service_item_price, service_id=service_usage.service_id)
|
||||
db.add(service_item)
|
||||
subtotal = room_price + services_total
|
||||
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
|
||||
total_amount = subtotal + tax_amount - discount_amount
|
||||
balance_due = total_amount - amount_paid
|
||||
invoice.subtotal = subtotal
|
||||
invoice.tax_amount = tax_amount
|
||||
invoice.total_amount = total_amount
|
||||
invoice.balance_due = balance_due
|
||||
|
||||
# Commit transaction
|
||||
transaction.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})
|
||||
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)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def update_invoice(invoice_id: int, db: Session, updated_by_id: Optional[int]=None, request_id: Optional[str]=None, **kwargs) -> Dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user