1362 lines
87 KiB
Python
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.') |