Files
Hotel-Booking/Backend/src/bookings/routes/booking_routes.py
Iliyan Angelov 1a103a769f updates
2025-12-01 01:08:39 +02:00

1362 lines
87 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session, joinedload, selectinload, load_only
from sqlalchemy import and_, or_, func
from sqlalchemy.exc import IntegrityError
from typing import Optional
from datetime import datetime
import random
import os
from ...shared.config.database import get_db
from ...shared.config.settings import settings
from ...security.middleware.auth import get_current_user, authorize_roles
from ...auth.models.user import User
from ...auth.models.role import Role
from ..models.booking import Booking, BookingStatus
from ...rooms.models.room import Room, RoomStatus
from ...rooms.models.room_type import RoomType
from ...payments.models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ...hotel_services.models.service_usage import ServiceUsage
from ...loyalty.models.user_loyalty import UserLoyalty
from ...loyalty.models.referral import Referral, ReferralStatus
from ...rooms.services.room_service import normalize_images, get_base_url
from fastapi import Request
from ...shared.utils.mailer import send_email
from ...shared.utils.email_templates import booking_confirmation_email_template, booking_status_changed_email_template
from ...loyalty.services.loyalty_service import LoyaltyService
from ...shared.utils.currency_helpers import get_currency_symbol
from ...shared.utils.response_helpers import success_response
from ..schemas.booking import CreateBookingRequest, UpdateBookingRequest
from ..schemas.admin_booking import AdminCreateBookingRequest
router = APIRouter(prefix='/bookings', tags=['bookings'])
def _generate_invoice_email_html(invoice: dict, is_proforma: bool=False) -> str:
invoice_type = 'Proforma Invoice' if is_proforma else 'Invoice'
items_html = ''.join([f for item in invoice.get('items', [])])
return f
def generate_booking_number() -> str:
prefix = 'BK'
ts = int(datetime.utcnow().timestamp() * 1000)
rand = random.randint(1000, 9999)
return f'{prefix}-{ts}-{rand}'
def calculate_booking_payment_balance(booking: Booking) -> dict:
total_paid = 0.0
if booking.payments:
total_paid = sum((float(payment.amount) if payment.amount else 0.0 for payment in booking.payments if payment.payment_status == PaymentStatus.completed))
total_price = float(booking.total_price) if booking.total_price else 0.0
remaining_balance = total_price - total_paid
return {'total_paid': total_paid, 'total_price': total_price, 'remaining_balance': remaining_balance, 'is_fully_paid': remaining_balance <= 0.01, 'payment_percentage': total_paid / total_price * 100 if total_price > 0 else 0}
@router.get('/')
async def get_all_bookings(search: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), startDate: Optional[str]=Query(None), endDate: Optional[str]=Query(None), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)):
try:
# Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
query = db.query(Booking).options(
load_only(
Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id,
Booking.check_in_date, Booking.check_out_date, Booking.num_guests,
Booking.total_price, Booking.original_price, Booking.discount_amount,
Booking.promotion_code, Booking.status, Booking.deposit_paid,
Booking.requires_deposit, Booking.special_requests,
Booking.created_at, Booking.updated_at
),
selectinload(Booking.payments),
joinedload(Booking.user),
joinedload(Booking.room).joinedload(Room.room_type)
)
if search:
query = query.filter(Booking.booking_number.like(f'%{search}%'))
if status_filter:
try:
query = query.filter(Booking.status == BookingStatus(status_filter))
except ValueError:
pass
if startDate:
start = datetime.fromisoformat(startDate.replace('Z', '+00:00'))
query = query.filter(Booking.check_in_date >= start)
if endDate:
end = datetime.fromisoformat(endDate.replace('Z', '+00:00'))
query = query.filter(Booking.check_in_date <= end)
# Use func.count() to avoid loading all columns (including non-existent rate_plan_id)
total = query.with_entities(func.count(Booking.id)).scalar()
offset = (page - 1) * limit
bookings = query.order_by(Booking.created_at.desc()).offset(offset).limit(limit).all()
result = []
for booking in bookings:
payment_method_from_payments = None
payment_status_from_payments = 'unpaid'
if booking.payments:
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
if isinstance(latest_payment.payment_method, PaymentMethod):
payment_method_from_payments = latest_payment.payment_method.value
elif hasattr(latest_payment.payment_method, 'value'):
payment_method_from_payments = latest_payment.payment_method.value
else:
payment_method_from_payments = str(latest_payment.payment_method)
if latest_payment.payment_status == PaymentStatus.completed:
payment_status_from_payments = 'paid'
elif latest_payment.payment_status == PaymentStatus.refunded:
payment_status_from_payments = 'refunded'
booking_dict = {'id': booking.id, 'booking_number': booking.booking_number, 'user_id': booking.user_id, 'room_id': booking.room_id, 'check_in_date': booking.check_in_date.strftime('%Y-%m-%d') if booking.check_in_date else None, 'check_out_date': booking.check_out_date.strftime('%Y-%m-%d') if booking.check_out_date else None, 'num_guests': booking.num_guests, 'guest_count': booking.num_guests, 'total_price': float(booking.total_price) if booking.total_price else 0.0, 'original_price': float(booking.original_price) if booking.original_price else None, 'discount_amount': float(booking.discount_amount) if booking.discount_amount else None, 'promotion_code': booking.promotion_code, 'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, 'payment_method': payment_method_from_payments if payment_method_from_payments else 'cash', 'payment_status': payment_status_from_payments, 'deposit_paid': booking.deposit_paid, 'requires_deposit': booking.requires_deposit, 'special_requests': booking.special_requests, 'notes': booking.special_requests, 'created_at': booking.created_at.isoformat() if booking.created_at else None, 'createdAt': booking.created_at.isoformat() if booking.created_at else None, 'updated_at': booking.updated_at.isoformat() if booking.updated_at else None, 'updatedAt': booking.updated_at.isoformat() if booking.updated_at else None}
if booking.user:
booking_dict['user'] = {'id': booking.user.id, 'name': booking.user.full_name, 'full_name': booking.user.full_name, 'email': booking.user.email, 'phone': booking.user.phone, 'phone_number': booking.user.phone}
if booking.room:
booking_dict['room'] = {'id': booking.room.id, 'room_number': booking.room.room_number, 'floor': booking.room.floor}
try:
if hasattr(booking.room, 'room_type') and booking.room.room_type:
booking_dict['room']['room_type'] = {'id': booking.room.room_type.id, 'name': booking.room.room_type.name, 'base_price': float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0, 'capacity': booking.room.room_type.capacity}
except Exception as room_type_error:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Could not load room_type for booking {booking.id}: {room_type_error}')
if booking.payments:
booking_dict['payments'] = [{'id': p.id, 'amount': float(p.amount) if p.amount else 0.0, 'payment_method': p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method), 'payment_type': p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type), 'payment_status': p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, 'transaction_id': p.transaction_id, 'payment_date': p.payment_date.isoformat() if p.payment_date else None, 'created_at': p.created_at.isoformat() if p.created_at else None} for p in booking.payments]
else:
booking_dict['payments'] = []
result.append(booking_dict)
return success_response(
data={'bookings': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}
)
except Exception as e:
import logging
import traceback
logger = logging.getLogger(__name__)
logger.error(f'Error in get_all_bookings: {str(e)}')
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@router.get('/me')
async def get_my_bookings(request: Request, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
bookings = db.query(Booking).options(selectinload(Booking.payments), joinedload(Booking.room).joinedload(Room.room_type)).filter(Booking.user_id == current_user.id).order_by(Booking.created_at.desc()).all()
base_url = get_base_url(request)
result = []
for booking in bookings:
payment_method_from_payments = None
payment_status_from_payments = 'unpaid'
if booking.payments:
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
if isinstance(latest_payment.payment_method, PaymentMethod):
payment_method_from_payments = latest_payment.payment_method.value
elif hasattr(latest_payment.payment_method, 'value'):
payment_method_from_payments = latest_payment.payment_method.value
else:
payment_method_from_payments = str(latest_payment.payment_method)
if latest_payment.payment_status == PaymentStatus.completed:
payment_status_from_payments = 'paid'
elif latest_payment.payment_status == PaymentStatus.refunded:
payment_status_from_payments = 'refunded'
booking_dict = {'id': booking.id, 'booking_number': booking.booking_number, 'room_id': booking.room_id, 'check_in_date': booking.check_in_date.strftime('%Y-%m-%d') if booking.check_in_date else None, 'check_out_date': booking.check_out_date.strftime('%Y-%m-%d') if booking.check_out_date else None, 'num_guests': booking.num_guests, 'guest_count': booking.num_guests, 'total_price': float(booking.total_price) if booking.total_price else 0.0, 'original_price': float(booking.original_price) if booking.original_price else None, 'discount_amount': float(booking.discount_amount) if booking.discount_amount else None, 'promotion_code': booking.promotion_code, 'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, 'payment_method': payment_method_from_payments if payment_method_from_payments else 'cash', 'payment_status': payment_status_from_payments, 'deposit_paid': booking.deposit_paid, 'requires_deposit': booking.requires_deposit, 'special_requests': booking.special_requests, 'notes': booking.special_requests, 'created_at': booking.created_at.isoformat() if booking.created_at else None, 'createdAt': booking.created_at.isoformat() if booking.created_at else None, 'updated_at': booking.updated_at.isoformat() if booking.updated_at else None, 'updatedAt': booking.updated_at.isoformat() if booking.updated_at else None}
if booking.room and booking.room.room_type:
room_images = []
if booking.room.images:
try:
room_images = normalize_images(booking.room.images, base_url)
except:
room_images = []
booking_dict['room'] = {'id': booking.room.id, 'room_number': booking.room.room_number, 'floor': booking.room.floor, 'images': room_images, 'room_type': {'id': booking.room.room_type.id, 'name': booking.room.room_type.name, 'base_price': float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0, 'capacity': booking.room.room_type.capacity, 'images': room_images}}
if booking.payments:
booking_dict['payments'] = [{'id': p.id, 'amount': float(p.amount) if p.amount else 0.0, 'payment_method': p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method), 'payment_type': p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type), 'payment_status': p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, 'transaction_id': p.transaction_id, 'payment_date': p.payment_date.isoformat() if p.payment_date else None, 'created_at': p.created_at.isoformat() if p.created_at else None} for p in booking.payments]
else:
booking_dict['payments'] = []
result.append(booking_dict)
return success_response(data={'bookings': result})
except HTTPException:
raise
except Exception as e:
db.rollback()
import logging
logger = logging.getLogger(__name__)
request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None
logger.error(f'Error in get_my_bookings: {str(e)}', extra={'request_id': request_id, 'user_id': current_user.id}, exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while fetching bookings')
@router.post('/')
async def create_booking(booking_data: CreateBookingRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
"""Create a new booking with validated input using Pydantic schema."""
role = db.query(Role).filter(Role.id == current_user.role_id).first()
if role and role.name in ['admin', 'staff', 'accountant']:
raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot create bookings')
import logging
logger = logging.getLogger(__name__)
# Start transaction
transaction = db.begin()
try:
logger.info(f'Received booking request from user {current_user.id}: {booking_data.dict()}')
# Extract validated data from Pydantic model
room_id = booking_data.room_id
check_in_date = booking_data.check_in_date
check_out_date = booking_data.check_out_date
total_price = booking_data.total_price
guest_count = booking_data.guest_count
notes = booking_data.notes
payment_method = booking_data.payment_method
promotion_code = booking_data.promotion_code
referral_code = booking_data.referral_code
services = booking_data.services or []
invoice_info = booking_data.invoice_info.dict() if booking_data.invoice_info else {}
# Lock room row to prevent race conditions (SELECT FOR UPDATE)
room = db.query(Room).filter(Room.id == room_id).with_for_update().first()
if not room:
transaction.rollback()
raise HTTPException(status_code=404, detail='Room not found')
# Parse dates (schema validation already ensures format is valid)
if 'T' in check_in_date or 'Z' in check_in_date or '+' in check_in_date:
check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00'))
else:
check_in = datetime.strptime(check_in_date, '%Y-%m-%d')
if 'T' in check_out_date or 'Z' in check_out_date or '+' in check_out_date:
check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00'))
else:
check_out = datetime.strptime(check_out_date, '%Y-%m-%d')
# Date validation already done in schema, but keeping as safety check
if check_in >= check_out:
transaction.rollback()
raise HTTPException(status_code=400, detail='Check-out date must be after check-in date')
# Check for overlapping bookings with row-level locking to prevent race conditions
overlapping = db.query(Booking).filter(
and_(
Booking.room_id == room_id,
Booking.status != BookingStatus.cancelled,
Booking.check_in_date < check_out,
Booking.check_out_date > check_in
)
).with_for_update().first()
if overlapping:
transaction.rollback()
raise HTTPException(status_code=409, detail='Room already booked for the selected dates')
# Check for maintenance blocks
from ...rooms.models.room_maintenance import RoomMaintenance, MaintenanceStatus
maintenance_block = db.query(RoomMaintenance).filter(
and_(
RoomMaintenance.room_id == room_id,
RoomMaintenance.blocks_room == True,
RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress]),
or_(
and_(
RoomMaintenance.block_start.isnot(None),
RoomMaintenance.block_end.isnot(None),
RoomMaintenance.block_start < check_out,
RoomMaintenance.block_end > check_in
),
and_(
RoomMaintenance.scheduled_start < check_out,
RoomMaintenance.scheduled_end.isnot(None),
RoomMaintenance.scheduled_end > check_in
)
)
)
).first()
if maintenance_block:
raise HTTPException(status_code=409, detail=f'Room is blocked for maintenance: {maintenance_block.title}')
booking_number = generate_booking_number()
# Calculate room price
room_price = float(room.price) if room.price and room.price > 0 else float(room.room_type.base_price) if room.room_type else 0.0
number_of_nights = (check_out - check_in).days
if number_of_nights <= 0:
number_of_nights = 1 # Minimum 1 night
room_total = room_price * number_of_nights
# Calculate services total if any (using Pydantic model)
services_total = 0.0
if services:
from ...hotel_services.models.service import Service
for service_item in services:
service_id = service_item.service_id
quantity = service_item.quantity
service = db.query(Service).filter(Service.id == service_id).first()
if service and service.is_active:
services_total += float(service.price) * quantity
original_price = room_total + services_total
# Validate and use calculated price (same logic as admin booking)
calculated_total = original_price
provided_total = float(total_price) if total_price else 0.0
if promotion_code:
# With promotion, allow the provided price (it might include discount)
discount_amount = max(0.0, original_price - provided_total)
final_total_price = provided_total
else:
# Without promotion, use calculated price to ensure consistency
# Allow small differences (0.01) for rounding, but use calculated price
if abs(calculated_total - provided_total) > 0.01:
logger.warning(f'Price mismatch: calculated={calculated_total}, provided={provided_total}. Using calculated price.')
final_total_price = calculated_total
else:
final_total_price = provided_total
discount_amount = 0.0
requires_deposit = payment_method == 'cash'
deposit_percentage = 20 if requires_deposit else 0
deposit_amount = final_total_price * deposit_percentage / 100 if requires_deposit else 0
initial_status = BookingStatus.pending
if payment_method in ['stripe', 'paypal']:
initial_status = BookingStatus.pending
# Sanitize user-provided notes to prevent XSS
from html import escape
final_notes = escape(notes) if notes else ''
if promotion_code:
promotion_note = f'Promotion Code: {promotion_code}'
final_notes = f'{promotion_note}\n{final_notes}'.strip() if final_notes else promotion_note
booking = Booking(
booking_number=booking_number,
user_id=current_user.id,
room_id=room_id,
check_in_date=check_in,
check_out_date=check_out,
num_guests=guest_count,
total_price=final_total_price,
original_price=original_price if promotion_code else None,
discount_amount=discount_amount if promotion_code and discount_amount > 0 else None,
promotion_code=promotion_code,
special_requests=final_notes,
status=initial_status,
requires_deposit=requires_deposit,
deposit_paid=False
)
db.add(booking)
db.flush()
# Process referral code if provided
if referral_code:
try:
from ...loyalty.services.loyalty_service import LoyaltyService
from ...system.models.system_settings import SystemSettings
# Check if loyalty program is enabled
setting = db.query(SystemSettings).filter(
SystemSettings.key == 'loyalty_program_enabled'
).first()
is_enabled = True # Default to enabled
if setting:
is_enabled = setting.value.lower() == 'true'
if is_enabled:
# Process referral code - this will create referral record and award points when booking is confirmed
LoyaltyService.process_referral(
db,
current_user.id,
referral_code.upper().strip(),
booking.id
)
logger.info(f"Referral code {referral_code} processed for booking {booking.id}")
except Exception as referral_error:
logger.warning(f"Failed to process referral code {referral_code}: {referral_error}")
# Don't fail the booking if referral processing fails
if payment_method in ['stripe', 'paypal']:
from ...payments.models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType
if payment_method == 'stripe':
payment_method_enum = PaymentMethod.stripe
elif payment_method == 'paypal':
payment_method_enum = PaymentMethod.paypal
else:
logger.warning(f'Unexpected payment_method: {payment_method}, defaulting to stripe')
payment_method_enum = PaymentMethod.stripe
logger.info(f'Creating payment for booking {booking.id} with payment_method: {payment_method} -> enum: {payment_method_enum.value}')
payment = Payment(booking_id=booking.id, amount=final_total_price, payment_method=payment_method_enum, payment_type=PaymentType.full, payment_status=PaymentStatus.pending, payment_date=None)
db.add(payment)
db.flush()
logger.info(f'Payment created: ID={payment.id}, method={(payment.payment_method.value if hasattr(payment.payment_method, 'value') else payment.payment_method)}')
if requires_deposit and deposit_amount > 0:
from ...payments.models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType
deposit_payment = Payment(booking_id=booking.id, amount=deposit_amount, payment_method=PaymentMethod.stripe, payment_type=PaymentType.deposit, deposit_percentage=deposit_percentage, payment_status=PaymentStatus.pending, payment_date=None)
db.add(deposit_payment)
db.flush()
logger.info(f'Deposit payment created: ID={deposit_payment.id}, amount={deposit_amount}, percentage={deposit_percentage}%')
# Add service usages (services already extracted from Pydantic model)
if services:
from ...hotel_services.models.service import Service
for service_item in services:
service_id = service_item.service_id
quantity = service_item.quantity
service = db.query(Service).filter(Service.id == service_id).first()
if not service or not service.is_active:
continue
unit_price = float(service.price)
total_price = unit_price * quantity
service_usage = ServiceUsage(booking_id=booking.id, service_id=service_id, quantity=quantity, unit_price=unit_price, total_price=total_price)
db.add(service_usage)
# Commit transaction - all database operations are atomic
transaction.commit()
db.refresh(booking)
# Send booking confirmation notification (outside transaction)
try:
from ...notifications.services.notification_service import NotificationService
if booking.status == BookingStatus.confirmed:
NotificationService.send_booking_confirmation(db, booking)
except Exception as e:
logger.warning(f'Failed to send booking confirmation notification: {e}')
# Create invoice (outside transaction to avoid nested transaction issues)
try:
from ...payments.services.invoice_service import InvoiceService
from ...shared.utils.mailer import send_email
from sqlalchemy.orm import joinedload, selectinload
booking = db.query(Booking).options(selectinload(Booking.service_usages).selectinload(ServiceUsage.service)).filter(Booking.id == booking.id).first()
from ...system.models.system_settings import SystemSettings
company_settings = {}
for key in ['company_name', 'company_address', 'company_phone', 'company_email', 'company_tax_id', 'company_logo_url']:
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
if setting and setting.value:
company_settings[key] = setting.value
tax_rate_setting = db.query(SystemSettings).filter(SystemSettings.key == 'tax_rate').first()
tax_rate = float(tax_rate_setting.value) if tax_rate_setting and tax_rate_setting.value else 0.0
invoice_kwargs = {**company_settings}
if invoice_info:
if invoice_info.get('company_name'):
invoice_kwargs['company_name'] = invoice_info.get('company_name')
if invoice_info.get('company_address'):
invoice_kwargs['company_address'] = invoice_info.get('company_address')
if invoice_info.get('company_tax_id'):
invoice_kwargs['company_tax_id'] = invoice_info.get('company_tax_id')
if invoice_info.get('customer_tax_id'):
invoice_kwargs['customer_tax_id'] = invoice_info.get('customer_tax_id')
if invoice_info.get('notes'):
invoice_kwargs['notes'] = invoice_info.get('notes')
if invoice_info.get('terms_and_conditions'):
invoice_kwargs['terms_and_conditions'] = invoice_info.get('terms_and_conditions')
if invoice_info.get('payment_instructions'):
invoice_kwargs['payment_instructions'] = invoice_info.get('payment_instructions')
booking_discount = float(booking.discount_amount) if booking.discount_amount else 0.0
invoice_notes = invoice_kwargs.get('notes', '')
if booking.promotion_code:
promotion_note = f'Promotion Code: {booking.promotion_code}'
invoice_notes = f'{promotion_note}\n{invoice_notes}'.strip() if invoice_notes else promotion_note
invoice_kwargs['notes'] = invoice_notes
if payment_method == 'cash':
deposit_amount = final_total_price * 0.2
remaining_amount = final_total_price * 0.8
deposit_discount = booking_discount * 0.2 if booking_discount > 0 else 0.0
proforma_discount = booking_discount * 0.8 if booking_discount > 0 else 0.0
deposit_invoice = InvoiceService.create_invoice_from_booking(booking_id=booking.id, db=db, created_by_id=current_user.id, tax_rate=tax_rate, discount_amount=deposit_discount, due_days=30, is_proforma=False, invoice_amount=deposit_amount, **invoice_kwargs)
proforma_invoice = InvoiceService.create_invoice_from_booking(booking_id=booking.id, db=db, created_by_id=current_user.id, tax_rate=tax_rate, discount_amount=proforma_discount, due_days=30, is_proforma=True, invoice_amount=remaining_amount, **invoice_kwargs)
try:
invoice_html = _generate_invoice_email_html(deposit_invoice, is_proforma=False)
await send_email(to=current_user.email, subject=f'Invoice {deposit_invoice['invoice_number']} - Deposit Payment', html=invoice_html)
logger.info(f'Deposit invoice sent to {current_user.email}')
except Exception as email_error:
logger.error(f'Failed to send deposit invoice email: {str(email_error)}')
try:
proforma_html = _generate_invoice_email_html(proforma_invoice, is_proforma=True)
await send_email(to=current_user.email, subject=f'Proforma Invoice {proforma_invoice['invoice_number']} - Remaining Balance', html=proforma_html)
logger.info(f'Proforma invoice sent to {current_user.email}')
except Exception as email_error:
logger.error(f'Failed to send proforma invoice email: {str(email_error)}')
else:
full_invoice = InvoiceService.create_invoice_from_booking(booking_id=booking.id, db=db, created_by_id=current_user.id, tax_rate=tax_rate, discount_amount=booking_discount, due_days=30, is_proforma=False, **invoice_kwargs)
logger.info(f'Invoice {full_invoice['invoice_number']} created for booking {booking.id} (will be sent after payment confirmation)')
except Exception as e:
import logging
import traceback
logger = logging.getLogger(__name__)
logger.error(f'Failed to create invoice for booking {booking.id}: {str(e)}')
logger.error(f'Traceback: {traceback.format_exc()}')
from sqlalchemy.orm import joinedload, selectinload
booking = db.query(Booking).options(joinedload(Booking.payments), selectinload(Booking.service_usages).selectinload(ServiceUsage.service)).filter(Booking.id == booking.id).first()
payment_method_from_payments = None
payment_status_from_payments = 'unpaid'
if booking.payments:
latest_payment = sorted(booking.payments, key=lambda p: p.created_at, reverse=True)[0]
if isinstance(latest_payment.payment_method, PaymentMethod):
payment_method_from_payments = latest_payment.payment_method.value
elif hasattr(latest_payment.payment_method, 'value'):
payment_method_from_payments = latest_payment.payment_method.value
else:
payment_method_from_payments = str(latest_payment.payment_method)
logger.info(f'Booking {booking.id} - Latest payment method: {payment_method_from_payments}, raw: {latest_payment.payment_method}')
if latest_payment.payment_status == PaymentStatus.completed:
payment_status_from_payments = 'paid'
elif latest_payment.payment_status == PaymentStatus.refunded:
payment_status_from_payments = 'refunded'
final_payment_method = payment_method_from_payments if payment_method_from_payments else payment_method
logger.info(f'Booking {booking.id} - Final payment_method: {final_payment_method} (from_payments: {payment_method_from_payments}, request: {payment_method})')
booking_dict = {'id': booking.id, 'booking_number': booking.booking_number, 'user_id': booking.user_id, 'room_id': booking.room_id, 'check_in_date': booking.check_in_date.strftime('%Y-%m-%d') if booking.check_in_date else None, 'check_out_date': booking.check_out_date.strftime('%Y-%m-%d') if booking.check_out_date else None, 'guest_count': booking.num_guests, 'total_price': float(booking.total_price) if booking.total_price else 0.0, 'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, 'payment_method': final_payment_method, 'payment_status': payment_status_from_payments, 'deposit_paid': booking.deposit_paid, 'requires_deposit': booking.requires_deposit, 'notes': booking.special_requests, 'guest_info': {'full_name': current_user.full_name, 'email': current_user.email, 'phone': current_user.phone_number if hasattr(current_user, 'phone_number') else current_user.phone if hasattr(current_user, 'phone') else ''}, 'createdAt': booking.created_at.isoformat() if booking.created_at else None, 'updatedAt': booking.updated_at.isoformat() if booking.updated_at else None, 'created_at': booking.created_at.isoformat() if booking.created_at else None}
if booking.payments:
booking_dict['payments'] = [{'id': p.id, 'booking_id': p.booking_id, 'amount': float(p.amount) if p.amount else 0.0, 'payment_method': p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method), 'payment_type': p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type, 'deposit_percentage': p.deposit_percentage, 'payment_status': p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, 'transaction_id': p.transaction_id, 'payment_date': p.payment_date.isoformat() if p.payment_date else None, 'notes': p.notes, 'created_at': p.created_at.isoformat() if p.created_at else None} for p in booking.payments]
service_usages = getattr(booking, 'service_usages', None)
import logging
logger = logging.getLogger(__name__)
logger.info(f'Booking {booking.id} - service_usages: {service_usages}, type: {type(service_usages)}')
if service_usages and len(service_usages) > 0:
logger.info(f'Booking {booking.id} - Found {len(service_usages)} service usages')
booking_dict['service_usages'] = [{'id': su.id, 'service_id': su.service_id, 'service_name': su.service.name if hasattr(su, 'service') and su.service else 'Unknown Service', 'quantity': su.quantity, 'unit_price': float(su.unit_price) if su.unit_price else 0.0, 'total_price': float(su.total_price) if su.total_price else 0.0} for su in service_usages]
logger.info(f'Booking {booking.id} - Serialized service_usages: {booking_dict['service_usages']}')
else:
logger.info(f'Booking {booking.id} - No service_usages found, initializing empty array')
booking_dict['service_usages'] = []
if booking.room:
booking_dict['room'] = {'id': booking.room.id, 'room_number': booking.room.room_number, 'floor': booking.room.floor}
if booking.room.room_type:
booking_dict['room']['room_type'] = {'id': booking.room.room_type.id, 'name': booking.room.room_type.name, 'base_price': float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0, 'capacity': booking.room.room_type.capacity}
message = f'Booking created. Please pay {deposit_percentage}% deposit to confirm.' if requires_deposit else 'Booking created successfully'
return success_response(data={'booking': booking_dict}, message=message)
except HTTPException:
if 'transaction' in locals():
try:
transaction.rollback()
except Exception:
pass
raise
except IntegrityError as e:
if 'transaction' in locals():
try:
transaction.rollback()
except Exception:
pass
logger.error(f'Database integrity error during booking creation: {str(e)}')
raise HTTPException(status_code=409, detail='Booking conflict detected. Please try again.')
except Exception as e:
if 'transaction' in locals():
try:
transaction.rollback()
except Exception:
pass
logger.error(f'Error creating booking: {str(e)}', exc_info=True)
import logging
import traceback
logger = logging.getLogger(__name__)
logger.error(f'Error creating booking (payment_method: {payment_method}): {str(e)}')
logger.error(f'Traceback: {traceback.format_exc()}')
try:
db.rollback()
except Exception:
pass
raise HTTPException(status_code=500, detail='An error occurred while creating the booking. Please try again.')
@router.get('/{id}')
async def get_booking_by_id(id: int, request: Request, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
from sqlalchemy.orm import selectinload
booking = db.query(Booking).options(selectinload(Booking.payments), selectinload(Booking.service_usages).selectinload(ServiceUsage.service), joinedload(Booking.user), joinedload(Booking.room).joinedload(Room.room_type)).filter(Booking.id == id).first()
if not booking:
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:
raise HTTPException(status_code=403, detail='Forbidden')
import logging
logger = logging.getLogger(__name__)
payment_method_from_payments = None
payment_status = 'unpaid'
if booking.payments:
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
if isinstance(latest_payment.payment_method, PaymentMethod):
payment_method_from_payments = latest_payment.payment_method.value
elif hasattr(latest_payment.payment_method, 'value'):
payment_method_from_payments = latest_payment.payment_method.value
else:
payment_method_from_payments = str(latest_payment.payment_method)
logger.info(f'Get booking {id} - Latest payment method: {payment_method_from_payments}, raw: {latest_payment.payment_method}')
if latest_payment.payment_status == PaymentStatus.completed:
payment_status = 'paid'
elif latest_payment.payment_status == PaymentStatus.refunded:
payment_status = 'refunded'
final_payment_method = payment_method_from_payments if payment_method_from_payments else 'cash'
logger.info(f'Get booking {id} - Final payment_method: {final_payment_method}')
booking_dict = {'id': booking.id, 'booking_number': booking.booking_number, 'user_id': booking.user_id, 'room_id': booking.room_id, 'check_in_date': booking.check_in_date.strftime('%Y-%m-%d') if booking.check_in_date else None, 'check_out_date': booking.check_out_date.strftime('%Y-%m-%d') if booking.check_out_date else None, 'guest_count': booking.num_guests, 'total_price': float(booking.total_price) if booking.total_price else 0.0, 'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, 'payment_method': final_payment_method, 'payment_status': payment_status, 'deposit_paid': booking.deposit_paid, 'requires_deposit': booking.requires_deposit, 'notes': booking.special_requests, 'guest_info': {'full_name': booking.user.full_name if booking.user else '', 'email': booking.user.email if booking.user else '', 'phone': booking.user.phone_number if booking.user and hasattr(booking.user, 'phone_number') else booking.user.phone if booking.user and hasattr(booking.user, 'phone') else ''} if booking.user else None, 'createdAt': booking.created_at.isoformat() if booking.created_at else None, 'updatedAt': booking.updated_at.isoformat() if booking.updated_at else None, 'created_at': booking.created_at.isoformat() if booking.created_at else None}
if booking.room and booking.room.images:
base_url = get_base_url(request)
try:
room_images = normalize_images(booking.room.images, base_url)
except:
room_images = []
else:
room_images = []
if booking.room:
booking_dict['room'] = {'id': booking.room.id, 'room_number': booking.room.room_number, 'floor': booking.room.floor, 'status': booking.room.status.value if isinstance(booking.room.status, RoomStatus) else booking.room.status, 'images': room_images}
if booking.room.room_type:
room_type_images = room_images if room_images else []
booking_dict['room']['room_type'] = {'id': booking.room.room_type.id, 'name': booking.room.room_type.name, 'base_price': float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0, 'capacity': booking.room.room_type.capacity, 'images': room_type_images}
if booking.payments:
booking_dict['payments'] = [{'id': p.id, 'amount': float(p.amount) if p.amount else 0.0, 'payment_method': p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method), 'payment_type': p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type), 'payment_status': p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, 'transaction_id': p.transaction_id, 'payment_date': p.payment_date.isoformat() if p.payment_date else None, 'created_at': p.created_at.isoformat() if p.created_at else None} for p in booking.payments]
service_usages = getattr(booking, 'service_usages', None)
import logging
logger = logging.getLogger(__name__)
logger.info(f'Get booking {id} - service_usages: {service_usages}, type: {type(service_usages)}')
if service_usages and len(service_usages) > 0:
logger.info(f'Get booking {id} - Found {len(service_usages)} service usages')
booking_dict['service_usages'] = [{'id': su.id, 'service_id': su.service_id, 'service_name': su.service.name if hasattr(su, 'service') and su.service else 'Unknown Service', 'quantity': su.quantity, 'unit_price': float(su.unit_price) if su.unit_price else 0.0, 'total_price': float(su.total_price) if su.total_price else 0.0} for su in service_usages]
logger.info(f'Get booking {id} - Serialized service_usages: {booking_dict['service_usages']}')
else:
logger.info(f'Get booking {id} - No service_usages found, initializing empty array')
booking_dict['service_usages'] = []
return success_response(data={'booking': booking_dict})
except HTTPException:
raise
except Exception as e:
db.rollback()
import logging
logger = logging.getLogger(__name__)
request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None
logger.error(f'Error in get_booking_by_id: {str(e)}', extra={'request_id': request_id, 'booking_id': id, 'user_id': current_user.id}, exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while fetching booking')
@router.patch('/{id}/cancel')
async def cancel_booking(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
booking = db.query(Booking).filter(Booking.id == id).first()
if not booking:
raise HTTPException(status_code=404, detail='Booking not found')
if booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden')
if booking.status == BookingStatus.cancelled:
raise HTTPException(status_code=400, detail='Booking already cancelled')
# Customers can only cancel pending bookings
# Admin/Staff can cancel any booking via update_booking endpoint
if booking.status != BookingStatus.pending:
raise HTTPException(status_code=400, detail=f'Cannot cancel booking with status: {booking.status.value}. Only pending bookings can be cancelled. Please contact support for assistance.')
booking = db.query(Booking).options(selectinload(Booking.payments)).filter(Booking.id == id).first()
payments_updated = False
if booking.payments:
for payment in booking.payments:
if payment.payment_status == PaymentStatus.pending:
payment.payment_status = PaymentStatus.failed
existing_notes = payment.notes or ''
cancellation_note = f'\nPayment cancelled due to booking cancellation on {datetime.utcnow().isoformat()}'
payment.notes = existing_notes + cancellation_note if existing_notes else cancellation_note.strip()
payments_updated = True
from sqlalchemy import update, func
pending_payments = db.query(Payment).filter(Payment.booking_id == id, Payment.payment_status == PaymentStatus.pending).all()
for payment in pending_payments:
payment.payment_status = PaymentStatus.failed
existing_notes = payment.notes or ''
cancellation_note = f'\nPayment cancelled due to booking cancellation on {datetime.utcnow().isoformat()}'
payment.notes = existing_notes + cancellation_note if existing_notes else cancellation_note.strip()
payments_updated = True
booking.status = BookingStatus.cancelled
# Update room status when booking is cancelled
if booking.room:
# Check if room has other active bookings
active_booking = db.query(Booking).filter(
and_(
Booking.room_id == booking.room_id,
Booking.id != booking.id,
Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in]),
Booking.check_in_date <= datetime.utcnow(),
Booking.check_out_date > datetime.utcnow()
)
).first()
if not active_booking:
# Check for maintenance
from ...rooms.models.room_maintenance import RoomMaintenance, MaintenanceStatus
active_maintenance = db.query(RoomMaintenance).filter(
and_(
RoomMaintenance.room_id == booking.room_id,
RoomMaintenance.blocks_room == True,
RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress])
)
).first()
if active_maintenance:
booking.room.status = RoomStatus.maintenance
else:
booking.room.status = RoomStatus.available
if payments_updated:
db.flush()
db.commit()
try:
from ...system.models.system_settings import SystemSettings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
email_html = booking_status_changed_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', status='cancelled', client_url=client_url)
await send_email(to=booking.user.email if booking.user else None, subject=f'Booking Cancelled - {booking.booking_number}', html=email_html)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Failed to send cancellation email: {e}')
return success_response(data={'booking': booking})
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin', 'staff'))])
async def update_booking(id: int, booking_data: UpdateBookingRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
booking = db.query(Booking).options(
selectinload(Booking.payments),
joinedload(Booking.user)
).filter(Booking.id == id).first()
if not booking:
raise HTTPException(status_code=404, detail='Booking not found')
old_status = booking.status
status_value = booking_data.status
room = booking.room
new_status = None
if status_value:
try:
new_status = BookingStatus(status_value)
booking.status = new_status
# Update room status based on booking status
if new_status == BookingStatus.checked_in:
# Set room to occupied when checked in
if room and room.status != RoomStatus.maintenance:
room.status = RoomStatus.occupied
elif new_status == BookingStatus.checked_out:
# Set room to cleaning when checked out (housekeeping needed)
if room:
# Check if there's active maintenance
from ...rooms.models.room_maintenance import RoomMaintenance, MaintenanceStatus
active_maintenance = db.query(RoomMaintenance).filter(
and_(
RoomMaintenance.room_id == room.id,
RoomMaintenance.blocks_room == True,
RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress])
)
).first()
if active_maintenance:
room.status = RoomStatus.maintenance
else:
room.status = RoomStatus.cleaning
elif new_status == BookingStatus.cancelled:
# Update room status when booking is cancelled
if booking.payments:
for payment in booking.payments:
if payment.payment_status == PaymentStatus.pending:
payment.payment_status = PaymentStatus.failed
existing_notes = payment.notes or ''
cancellation_note = f'\nPayment cancelled due to booking cancellation on {datetime.utcnow().isoformat()}'
payment.notes = existing_notes + cancellation_note if existing_notes else cancellation_note.strip()
db.flush()
# Check if room has other active bookings
if room:
active_booking = db.query(Booking).filter(
and_(
Booking.room_id == room.id,
Booking.id != booking.id,
Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in]),
Booking.check_in_date <= datetime.utcnow(),
Booking.check_out_date > datetime.utcnow()
)
).first()
if not active_booking:
# Check for maintenance
from ...rooms.models.room_maintenance import RoomMaintenance, MaintenanceStatus
active_maintenance = db.query(RoomMaintenance).filter(
and_(
RoomMaintenance.room_id == room.id,
RoomMaintenance.blocks_room == True,
RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress])
)
).first()
if active_maintenance:
room.status = RoomStatus.maintenance
else:
room.status = RoomStatus.available
except ValueError:
raise HTTPException(status_code=400, detail='Invalid status')
# Update other fields from schema if provided
if booking_data.check_in_date is not None:
if 'T' in booking_data.check_in_date or 'Z' in booking_data.check_in_date or '+' in booking_data.check_in_date:
booking.check_in_date = datetime.fromisoformat(booking_data.check_in_date.replace('Z', '+00:00'))
else:
booking.check_in_date = datetime.strptime(booking_data.check_in_date, '%Y-%m-%d')
if booking_data.check_out_date is not None:
if 'T' in booking_data.check_out_date or 'Z' in booking_data.check_out_date or '+' in booking_data.check_out_date:
booking.check_out_date = datetime.fromisoformat(booking_data.check_out_date.replace('Z', '+00:00'))
else:
booking.check_out_date = datetime.strptime(booking_data.check_out_date, '%Y-%m-%d')
if booking_data.guest_count is not None:
booking.num_guests = booking_data.guest_count
if booking_data.notes is not None:
# Sanitize user-provided notes to prevent XSS
from html import escape
booking.special_requests = escape(booking_data.notes)
# Restrict staff from modifying booking prices (only admin can)
if booking_data.total_price is not None:
from ...shared.utils.role_helpers import is_admin
if not is_admin(current_user, db):
raise HTTPException(
status_code=403,
detail='Staff members cannot modify booking prices. Please contact an administrator.'
)
booking.total_price = booking_data.total_price
db.commit()
# Send booking confirmation notification if status changed to confirmed
if new_status == BookingStatus.confirmed:
try:
from ...notifications.services.notification_service import NotificationService
NotificationService.send_booking_confirmation(db, booking)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Failed to send booking confirmation notification: {e}')
# Reload booking with all relationships after commit
booking = db.query(Booking).options(
selectinload(Booking.payments),
joinedload(Booking.user),
joinedload(Booking.room).joinedload(Room.room_type)
).filter(Booking.id == id).first()
if not booking:
raise HTTPException(status_code=404, detail='Booking not found after update')
payment_warning = None
if status_value and old_status != booking.status and (booking.status == BookingStatus.checked_in):
payment_balance = calculate_booking_payment_balance(booking)
if payment_balance['remaining_balance'] > 0.01:
payment_warning = {'message': f'Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}', 'total_paid': payment_balance['total_paid'], 'total_price': payment_balance['total_price'], 'remaining_balance': payment_balance['remaining_balance'], 'payment_percentage': payment_balance['payment_percentage']}
if status_value and old_status != booking.status:
if booking.status in [BookingStatus.confirmed, BookingStatus.cancelled]:
try:
from ...system.models.system_settings import SystemSettings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
if booking.status == BookingStatus.confirmed:
room = booking.room
room_type_name = room.room_type.name if room and room.room_type else 'Room'
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
currency_symbol = get_currency_symbol(currency)
guest_name = booking.user.full_name if booking.user else 'Guest'
guest_email = booking.user.email if booking.user else None
email_html = booking_confirmation_email_template(booking_number=booking.booking_number, guest_name=guest_name, room_number=room.room_number if room else 'N/A', room_type=room_type_name, check_in=booking.check_in_date.strftime('%B %d, %Y') if booking.check_in_date else 'N/A', check_out=booking.check_out_date.strftime('%B %d, %Y') if booking.check_out_date else 'N/A', num_guests=booking.num_guests, total_price=float(booking.total_price), requires_deposit=booking.requires_deposit, deposit_amount=float(booking.total_price) * 0.2 if booking.requires_deposit else None, original_price=float(booking.original_price) if booking.original_price else None, discount_amount=float(booking.discount_amount) if booking.discount_amount else None, promotion_code=booking.promotion_code, client_url=client_url, currency_symbol=currency_symbol)
if guest_email:
await send_email(to=guest_email, subject=f'Booking Confirmed - {booking.booking_number}', html=email_html)
# Award loyalty points for confirmed booking
if booking.user:
try:
# Check if booking already earned points
from ...loyalty.models.loyalty_point_transaction import LoyaltyPointTransaction, TransactionSource
existing_points = db.query(LoyaltyPointTransaction).filter(
LoyaltyPointTransaction.booking_id == booking.id,
LoyaltyPointTransaction.source == TransactionSource.booking
).first()
if not existing_points:
# Award points based on total price paid
total_paid = sum(float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed)
if total_paid > 0:
LoyaltyService.earn_points_from_booking(db, booking.user_id, booking, total_paid)
# Process referral if applicable - referral is already processed when booking is created
# This section is for backward compatibility with existing referrals
if booking.user:
user_loyalty = db.query(UserLoyalty).filter(UserLoyalty.user_id == booking.user_id).first()
if user_loyalty and user_loyalty.referral_code:
# Check if there's a referral for this user that hasn't been rewarded yet
from ...loyalty.models.referral import Referral
referral = db.query(Referral).filter(
Referral.referred_user_id == booking.user_id,
Referral.booking_id == booking.id,
Referral.status.in_([ReferralStatus.pending, ReferralStatus.completed])
).first()
if referral and referral.status == ReferralStatus.pending:
# Award points now that booking is confirmed
LoyaltyService.process_referral(db, booking.user_id, referral.referral_code, booking.id)
except Exception as loyalty_error:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Failed to award loyalty points: {loyalty_error}')
elif booking.status == BookingStatus.cancelled:
guest_name = booking.user.full_name if booking.user else 'Guest'
guest_email = booking.user.email if booking.user else None
email_html = booking_status_changed_email_template(booking_number=booking.booking_number, guest_name=guest_name, status='cancelled', client_url=client_url)
if guest_email:
await send_email(to=guest_email, subject=f'Booking Cancelled - {booking.booking_number}', html=email_html)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Failed to send status change email: {e}')
import traceback
logger.error(traceback.format_exc())
# Build response with booking data
booking_dict = {
'id': booking.id,
'booking_number': booking.booking_number,
'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
}
message = 'Booking updated successfully. ⚠️ Payment reminder: Guest has remaining balance.' if payment_warning else 'Booking updated successfully'
response_data = success_response(data={'booking': booking_dict}, message=message)
if payment_warning:
response_data['warning'] = payment_warning
return response_data
except HTTPException:
raise
except Exception as e:
import logging
import traceback
logger = logging.getLogger(__name__)
request_id = getattr(current_user, 'request_id', None) if hasattr(current_user, 'request_id') else None
logger.error(f'Error updating booking {id}: {str(e)}', extra={'request_id': request_id, 'booking_id': id, 'user_id': current_user.id}, exc_info=True)
logger.error(traceback.format_exc())
db.rollback()
raise HTTPException(status_code=500, detail='An error occurred while updating booking')
@router.get('/check/{booking_number}')
async def check_booking_by_number(booking_number: str, db: Session=Depends(get_db)):
try:
booking = db.query(Booking).options(selectinload(Booking.payments), joinedload(Booking.user), joinedload(Booking.room).joinedload(Room.room_type)).filter(Booking.booking_number == booking_number).first()
if not booking:
raise HTTPException(status_code=404, detail='Booking not found')
payment_method_from_payments = None
payment_status_from_payments = 'unpaid'
if booking.payments:
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
if isinstance(latest_payment.payment_method, PaymentMethod):
payment_method_from_payments = latest_payment.payment_method.value
elif hasattr(latest_payment.payment_method, 'value'):
payment_method_from_payments = latest_payment.payment_method.value
else:
payment_method_from_payments = str(latest_payment.payment_method)
if latest_payment.payment_status == PaymentStatus.completed:
payment_status_from_payments = 'paid'
elif latest_payment.payment_status == PaymentStatus.refunded:
payment_status_from_payments = 'refunded'
booking_dict = {'id': booking.id, 'booking_number': booking.booking_number, 'user_id': booking.user_id, 'room_id': booking.room_id, 'check_in_date': booking.check_in_date.strftime('%Y-%m-%d') if booking.check_in_date else None, 'check_out_date': booking.check_out_date.strftime('%Y-%m-%d') if booking.check_out_date else None, 'num_guests': booking.num_guests, 'guest_count': booking.num_guests, 'total_price': float(booking.total_price) if booking.total_price else 0.0, 'original_price': float(booking.original_price) if booking.original_price else None, 'discount_amount': float(booking.discount_amount) if booking.discount_amount else None, 'promotion_code': booking.promotion_code, 'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, 'payment_method': payment_method_from_payments if payment_method_from_payments else 'cash', 'payment_status': payment_status_from_payments, 'deposit_paid': booking.deposit_paid, 'requires_deposit': booking.requires_deposit, 'special_requests': booking.special_requests, 'notes': booking.special_requests, 'created_at': booking.created_at.isoformat() if booking.created_at else None, 'createdAt': booking.created_at.isoformat() if booking.created_at else None, 'updated_at': booking.updated_at.isoformat() if booking.updated_at else None, 'updatedAt': booking.updated_at.isoformat() if booking.updated_at else None}
if booking.user:
booking_dict['user'] = {'id': booking.user.id, 'name': booking.user.full_name, 'full_name': booking.user.full_name, 'email': booking.user.email, 'phone': booking.user.phone, 'phone_number': booking.user.phone}
if booking.room:
booking_dict['room'] = {'id': booking.room.id, 'room_number': booking.room.room_number, 'floor': booking.room.floor}
if booking.room.room_type:
booking_dict['room']['room_type'] = {'id': booking.room.room_type.id, 'name': booking.room.room_type.name, 'base_price': float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0, 'capacity': booking.room.room_type.capacity}
if booking.payments:
booking_dict['payments'] = [{'id': p.id, 'amount': float(p.amount) if p.amount else 0.0, 'payment_method': p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method), 'payment_type': p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type), 'payment_status': p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, 'transaction_id': p.transaction_id, 'payment_date': p.payment_date.isoformat() if p.payment_date else None, 'created_at': p.created_at.isoformat() if p.created_at else None} for p in booking.payments]
else:
booking_dict['payments'] = []
payment_balance = calculate_booking_payment_balance(booking)
booking_dict['payment_balance'] = {'total_paid': payment_balance['total_paid'], 'total_price': payment_balance['total_price'], 'remaining_balance': payment_balance['remaining_balance'], 'is_fully_paid': payment_balance['is_fully_paid'], 'payment_percentage': payment_balance['payment_percentage']}
response_data = success_response(data={'booking': booking_dict})
if payment_balance['remaining_balance'] > 0.01:
response_data['warning'] = {'message': f'Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}', 'remaining_balance': payment_balance['remaining_balance'], 'payment_percentage': payment_balance['payment_percentage']}
return response_data
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post('/admin-create', dependencies=[Depends(authorize_roles('admin', 'staff'))])
async def admin_create_booking(booking_data: AdminCreateBookingRequest, current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)):
"""Create a booking on behalf of a user (admin/staff only)"""
import logging
logger = logging.getLogger(__name__)
# Start transaction
transaction = db.begin()
try:
# Extract validated data from Pydantic model
user_id = booking_data.user_id
room_id = booking_data.room_id
check_in_date = booking_data.check_in_date
check_out_date = booking_data.check_out_date
total_price = booking_data.total_price
guest_count = booking_data.guest_count
notes = booking_data.notes
payment_method = booking_data.payment_method
payment_status = booking_data.payment_status
promotion_code = booking_data.promotion_code
status = booking_data.status
services = booking_data.services or []
invoice_info = booking_data.invoice_info.dict() if booking_data.invoice_info else {}
# Verify user exists
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
transaction.rollback()
raise HTTPException(status_code=404, detail='User not found')
logger.info(f'Admin/Staff {current_user.id} creating booking for user {user_id}')
# Lock room row to prevent race conditions
room = db.query(Room).filter(Room.id == room_id).with_for_update().first()
if not room:
transaction.rollback()
raise HTTPException(status_code=404, detail='Room not found')
# Parse dates (already validated by Pydantic)
if 'T' in check_in_date or 'Z' in check_in_date or '+' in check_in_date:
check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00'))
else:
check_in = datetime.strptime(check_in_date, '%Y-%m-%d')
if 'T' in check_out_date or 'Z' in check_out_date or '+' in check_out_date:
check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00'))
else:
check_out = datetime.strptime(check_out_date, '%Y-%m-%d')
# Check for overlapping bookings with row-level locking
overlapping = db.query(Booking).filter(
and_(
Booking.room_id == room_id,
Booking.status != BookingStatus.cancelled,
Booking.check_in_date < check_out,
Booking.check_out_date > check_in
)
).with_for_update().first()
if overlapping:
transaction.rollback()
raise HTTPException(status_code=409, detail='Room already booked for the selected dates')
booking_number = generate_booking_number()
# Calculate room price first (needed for deposit calculation)
room_price = float(room.price) if room.price and room.price > 0 else float(room.room_type.base_price) if room.room_type else 0.0
number_of_nights = (check_out - check_in).days
if number_of_nights <= 0:
number_of_nights = 1
room_total = room_price * number_of_nights
# Calculate services total if any
services_total = 0.0
if services:
from ...hotel_services.models.service import Service
for service_item in services:
service_id = service_item.service_id
quantity = service_item.quantity
service = db.query(Service).filter(Service.id == service_id).first()
if service and service.is_active:
services_total += float(service.price) * quantity
original_price = room_total + services_total
# Validate and use calculated price
calculated_total = original_price
provided_total = float(total_price) if total_price else 0.0
if promotion_code:
discount_amount = max(0.0, original_price - provided_total)
final_total_price = provided_total
else:
if abs(calculated_total - provided_total) > 0.01:
logger.warning(f'Price mismatch: calculated={calculated_total}, provided={provided_total}. Using calculated price.')
final_total_price = calculated_total
else:
final_total_price = provided_total
discount_amount = 0.0
# Determine deposit requirements based on payment_status (use final_total_price)
if payment_status == 'deposit':
requires_deposit = True
deposit_percentage = 20
deposit_amount = final_total_price * 0.2
elif payment_status == 'full':
requires_deposit = False
deposit_percentage = 0
deposit_amount = 0
else: # unpaid
requires_deposit = payment_method == 'cash'
deposit_percentage = 20 if requires_deposit else 0
deposit_amount = final_total_price * deposit_percentage / 100 if requires_deposit else 0
# Set initial status (admin can set it directly)
try:
initial_status = BookingStatus(status) if status else BookingStatus.confirmed
except ValueError:
initial_status = BookingStatus.confirmed
# Sanitize user-provided notes to prevent XSS
from html import escape
final_notes = escape(notes) if notes else ''
if promotion_code:
promotion_note = f'Promotion Code: {promotion_code}'
final_notes = f'{promotion_note}\n{final_notes}'.strip() if final_notes else promotion_note
# Add admin note
admin_note = f'Created by {current_user.full_name} (Admin/Staff)'
final_notes = f'{admin_note}\n{final_notes}'.strip() if final_notes else admin_note
# Determine deposit_paid status
deposit_paid = payment_status == 'deposit' or payment_status == 'full'
# Create booking
booking = Booking(
booking_number=booking_number,
user_id=user_id,
room_id=room_id,
check_in_date=check_in,
check_out_date=check_out,
num_guests=guest_count,
total_price=final_total_price,
original_price=original_price if promotion_code else None,
discount_amount=discount_amount if promotion_code and discount_amount > 0 else None,
promotion_code=promotion_code,
special_requests=final_notes,
status=initial_status,
requires_deposit=requires_deposit,
deposit_paid=deposit_paid
)
db.add(booking)
db.flush()
# Create payment records based on payment_status
from ...payments.models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType
from datetime import datetime as dt
# Determine payment method enum
if payment_method == 'stripe':
payment_method_enum = PaymentMethod.stripe
elif payment_method == 'paypal':
payment_method_enum = PaymentMethod.paypal
else:
payment_method_enum = PaymentMethod.cash
# Handle payment creation based on payment_status
if payment_status == 'full':
# Fully paid - create a single completed payment
full_payment = Payment(
booking_id=booking.id,
amount=final_total_price,
payment_method=payment_method_enum,
payment_type=PaymentType.full,
payment_status=PaymentStatus.completed,
payment_date=dt.utcnow(),
notes=f'Payment received at booking creation by {current_user.full_name}'
)
db.add(full_payment)
db.flush()
elif payment_status == 'deposit':
# Deposit only - create completed deposit payment and pending remaining payment
deposit_payment = Payment(
booking_id=booking.id,
amount=deposit_amount,
payment_method=payment_method_enum,
payment_type=PaymentType.deposit,
deposit_percentage=deposit_percentage,
payment_status=PaymentStatus.completed,
payment_date=dt.utcnow(),
notes=f'Deposit received at booking creation by {current_user.full_name}'
)
db.add(deposit_payment)
db.flush()
# Create pending payment for remaining amount
remaining_amount = final_total_price - deposit_amount
if remaining_amount > 0:
remaining_payment = Payment(
booking_id=booking.id,
amount=remaining_amount,
payment_method=payment_method_enum,
payment_type=PaymentType.remaining,
payment_status=PaymentStatus.pending,
payment_date=None,
notes='Remaining balance to be paid at check-in'
)
db.add(remaining_payment)
db.flush()
else:
# Unpaid - create pending payment(s)
if requires_deposit and deposit_amount > 0:
# Create pending deposit payment
deposit_payment = Payment(
booking_id=booking.id,
amount=deposit_amount,
payment_method=payment_method_enum,
payment_type=PaymentType.deposit,
deposit_percentage=deposit_percentage,
payment_status=PaymentStatus.pending,
payment_date=None,
notes='Deposit to be paid at check-in'
)
db.add(deposit_payment)
db.flush()
# Create pending payment for remaining amount
remaining_amount = final_total_price - deposit_amount
if remaining_amount > 0:
remaining_payment = Payment(
booking_id=booking.id,
amount=remaining_amount,
payment_method=payment_method_enum,
payment_type=PaymentType.remaining,
payment_status=PaymentStatus.pending,
payment_date=None,
notes='Remaining balance to be paid at check-in'
)
db.add(remaining_payment)
db.flush()
else:
# Create single pending full payment
full_payment = Payment(
booking_id=booking.id,
amount=final_total_price,
payment_method=payment_method_enum,
payment_type=PaymentType.full,
payment_status=PaymentStatus.pending,
payment_date=None,
notes='Payment to be made at check-in'
)
db.add(full_payment)
db.flush()
# Add service usages if any
if services:
from ...hotel_services.models.service import Service
for service_item in services:
service_id = service_item.service_id
quantity = service_item.quantity
service = db.query(Service).filter(Service.id == service_id).first()
if not service or not service.is_active:
continue
unit_price = float(service.price)
total_price_service = unit_price * quantity
service_usage = ServiceUsage(
booking_id=booking.id,
service_id=service_id,
quantity=quantity,
unit_price=unit_price,
total_price=total_price_service
)
db.add(service_usage)
# Commit transaction
transaction.commit()
db.refresh(booking)
# Load booking with relationships
from sqlalchemy.orm import joinedload, selectinload
booking = db.query(Booking).options(
joinedload(Booking.payments),
selectinload(Booking.service_usages).selectinload(ServiceUsage.service),
joinedload(Booking.user),
joinedload(Booking.room).joinedload(Room.room_type)
).filter(Booking.id == booking.id).first()
# Build response
payment_method_from_payments = None
payment_status_from_payments = 'unpaid'
if booking.payments:
latest_payment = sorted(booking.payments, key=lambda p: p.created_at, reverse=True)[0]
if isinstance(latest_payment.payment_method, PaymentMethod):
payment_method_from_payments = latest_payment.payment_method.value
elif hasattr(latest_payment.payment_method, 'value'):
payment_method_from_payments = latest_payment.payment_method.value
else:
payment_method_from_payments = str(latest_payment.payment_method)
if latest_payment.payment_status == PaymentStatus.completed:
payment_status_from_payments = 'paid'
elif latest_payment.payment_status == PaymentStatus.refunded:
payment_status_from_payments = 'refunded'
final_payment_method = payment_method_from_payments if payment_method_from_payments else payment_method
booking_dict = {
'id': booking.id,
'booking_number': booking.booking_number,
'user_id': booking.user_id,
'room_id': booking.room_id,
'check_in_date': booking.check_in_date.strftime('%Y-%m-%d') if booking.check_in_date else None,
'check_out_date': booking.check_out_date.strftime('%Y-%m-%d') if booking.check_out_date else None,
'guest_count': booking.num_guests,
'total_price': float(booking.total_price) if booking.total_price else 0.0,
'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
'payment_method': final_payment_method,
'payment_status': payment_status_from_payments,
'deposit_paid': booking.deposit_paid,
'requires_deposit': booking.requires_deposit,
'notes': booking.special_requests,
'guest_info': {
'full_name': booking.user.full_name if booking.user else '',
'email': booking.user.email if booking.user else '',
'phone': booking.user.phone if booking.user else ''
},
'createdAt': booking.created_at.isoformat() if booking.created_at else None,
'updatedAt': booking.updated_at.isoformat() if booking.updated_at else None,
'created_at': booking.created_at.isoformat() if booking.created_at else None
}
if booking.payments:
booking_dict['payments'] = [
{
'id': p.id,
'booking_id': p.booking_id,
'amount': float(p.amount) if p.amount else 0.0,
'payment_method': p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method),
'payment_type': p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type,
'deposit_percentage': p.deposit_percentage,
'payment_status': p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
'transaction_id': p.transaction_id,
'payment_date': p.payment_date.isoformat() if p.payment_date else None,
'notes': p.notes,
'created_at': p.created_at.isoformat() if p.created_at else None
} for p in booking.payments
]
service_usages = getattr(booking, 'service_usages', None)
if service_usages and len(service_usages) > 0:
booking_dict['service_usages'] = [
{
'id': su.id,
'service_id': su.service_id,
'service_name': su.service.name if hasattr(su, 'service') and su.service else 'Unknown Service',
'quantity': su.quantity,
'unit_price': float(su.unit_price) if su.unit_price else 0.0,
'total_price': float(su.total_price) if su.total_price else 0.0
} for su in service_usages
]
else:
booking_dict['service_usages'] = []
if booking.room:
booking_dict['room'] = {
'id': booking.room.id,
'room_number': booking.room.room_number,
'floor': booking.room.floor
}
if booking.room.room_type:
booking_dict['room']['room_type'] = {
'id': booking.room.room_type.id,
'name': booking.room.room_type.name,
'base_price': float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0,
'capacity': booking.room.room_type.capacity
}
return success_response(
data={'booking': booking_dict},
message=f'Booking created successfully by {current_user.full_name}'
)
except HTTPException:
if 'transaction' in locals():
try:
transaction.rollback()
except Exception:
pass
raise
except IntegrityError as e:
if 'transaction' in locals():
try:
transaction.rollback()
except Exception:
pass
logger.error(f'Database integrity error during admin booking creation: {str(e)}')
raise HTTPException(status_code=409, detail='Booking conflict detected. Please try again.')
except Exception as e:
if 'transaction' in locals():
try:
transaction.rollback()
except Exception:
pass
logger.error(f'Error creating booking (admin/staff): {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while creating the booking. Please try again.')