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