This commit is contained in:
Iliyan Angelov
2025-11-30 23:29:01 +02:00
parent 39fcfff811
commit 0fa2adeb19
1058 changed files with 4630 additions and 296 deletions

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

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

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

View File

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

View File

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

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

View File

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