This commit is contained in:
Iliyan Angelov
2025-11-17 23:50:14 +02:00
parent 0c59fe1173
commit a1bd576540
43 changed files with 2598 additions and 359 deletions

View File

@@ -194,9 +194,9 @@ app.include_router(privacy_routes.router, prefix=settings.API_V1_PREFIX)
# Import and include other routes
from .routes import (
room_routes, booking_routes, payment_routes, invoice_routes, banner_routes,
favorite_routes, service_routes, promotion_routes, report_routes,
favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes,
review_routes, user_routes, audit_routes, admin_privacy_routes,
system_settings_routes
system_settings_routes, contact_routes
)
# Legacy routes (maintain backward compatibility)
@@ -207,6 +207,7 @@ app.include_router(invoice_routes.router, prefix="/api")
app.include_router(banner_routes.router, prefix="/api")
app.include_router(favorite_routes.router, prefix="/api")
app.include_router(service_routes.router, prefix="/api")
app.include_router(service_booking_routes.router, prefix="/api")
app.include_router(promotion_routes.router, prefix="/api")
app.include_router(report_routes.router, prefix="/api")
app.include_router(review_routes.router, prefix="/api")
@@ -214,6 +215,7 @@ app.include_router(user_routes.router, prefix="/api")
app.include_router(audit_routes.router, prefix="/api")
app.include_router(admin_privacy_routes.router, prefix="/api")
app.include_router(system_settings_routes.router, prefix="/api")
app.include_router(contact_routes.router, prefix="/api")
# Versioned routes (v1)
app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX)
@@ -223,6 +225,7 @@ app.include_router(invoice_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(banner_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(favorite_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(service_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(service_booking_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(promotion_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(report_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(review_routes.router, prefix=settings.API_V1_PREFIX)
@@ -230,6 +233,7 @@ app.include_router(user_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(audit_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(system_settings_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(contact_routes.router, prefix=settings.API_V1_PREFIX)
logger.info("All routes registered successfully")

View File

@@ -8,6 +8,7 @@ from .booking import Booking
from .payment import Payment
from .service import Service
from .service_usage import ServiceUsage
from .service_booking import ServiceBooking, ServiceBookingItem, ServicePayment, ServiceBookingStatus, ServicePaymentStatus, ServicePaymentMethod
from .promotion import Promotion
from .checkin_checkout import CheckInCheckOut
from .banner import Banner
@@ -30,6 +31,12 @@ __all__ = [
"Payment",
"Service",
"ServiceUsage",
"ServiceBooking",
"ServiceBookingItem",
"ServicePayment",
"ServiceBookingStatus",
"ServicePaymentStatus",
"ServicePaymentMethod",
"Promotion",
"CheckInCheckOut",
"Banner",

View File

@@ -0,0 +1,78 @@
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ..config.database import Base
class ServiceBookingStatus(str, enum.Enum):
pending = "pending"
confirmed = "confirmed"
completed = "completed"
cancelled = "cancelled"
class ServiceBooking(Base):
__tablename__ = "service_bookings"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
booking_number = Column(String(50), unique=True, nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
total_amount = Column(Numeric(10, 2), nullable=False)
status = Column(Enum(ServiceBookingStatus), nullable=False, default=ServiceBookingStatus.pending)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="service_bookings")
service_items = relationship("ServiceBookingItem", back_populates="service_booking", cascade="all, delete-orphan")
payments = relationship("ServicePayment", back_populates="service_booking", cascade="all, delete-orphan")
class ServiceBookingItem(Base):
__tablename__ = "service_booking_items"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
service_booking_id = Column(Integer, ForeignKey("service_bookings.id"), nullable=False)
service_id = Column(Integer, ForeignKey("services.id"), nullable=False)
quantity = Column(Integer, nullable=False, default=1)
unit_price = Column(Numeric(10, 2), nullable=False)
total_price = Column(Numeric(10, 2), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
service_booking = relationship("ServiceBooking", back_populates="service_items")
service = relationship("Service")
class ServicePaymentStatus(str, enum.Enum):
pending = "pending"
completed = "completed"
failed = "failed"
refunded = "refunded"
class ServicePaymentMethod(str, enum.Enum):
cash = "cash"
stripe = "stripe"
bank_transfer = "bank_transfer"
class ServicePayment(Base):
__tablename__ = "service_payments"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
service_booking_id = Column(Integer, ForeignKey("service_bookings.id"), nullable=False)
amount = Column(Numeric(10, 2), nullable=False)
payment_method = Column(Enum(ServicePaymentMethod), nullable=False)
payment_status = Column(Enum(ServicePaymentStatus), nullable=False, default=ServicePaymentStatus.pending)
transaction_id = Column(String(100), nullable=True)
payment_date = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
service_booking = relationship("ServiceBooking", back_populates="payments")

View File

@@ -28,4 +28,5 @@ class User(Base):
checkouts_processed = relationship("CheckInCheckOut", foreign_keys="CheckInCheckOut.checkout_by", back_populates="checked_out_by")
reviews = relationship("Review", back_populates="user")
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
service_bookings = relationship("ServiceBooking", back_populates="user")

View File

@@ -14,6 +14,7 @@ from ..models.booking import Booking, BookingStatus
from ..models.room import Room, RoomStatus
from ..models.room_type import RoomType
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..models.service_usage import ServiceUsage
from ..services.room_service import normalize_images, get_base_url
from fastapi import Request
from ..utils.mailer import send_email
@@ -83,8 +84,8 @@ async def get_all_bookings(
"booking_number": booking.booking_number,
"user_id": booking.user_id,
"room_id": booking.room_id,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
"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,
"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,
@@ -148,8 +149,8 @@ async def get_my_bookings(
"id": booking.id,
"booking_number": booking.booking_number,
"room_id": booking.room_id,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
"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,
"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,
@@ -217,8 +218,18 @@ async def create_booking(
if not room:
raise HTTPException(status_code=404, detail="Room not found")
check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00'))
check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00'))
# Parse dates as date-only strings (YYYY-MM-DD) - treat as naive datetime
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:
# Date-only format (YYYY-MM-DD) - parse as naive datetime
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:
# Date-only format (YYYY-MM-DD) - parse as naive datetime
check_out = datetime.strptime(check_out_date, '%Y-%m-%d')
# Check for overlapping bookings
overlapping = db.query(Booking).filter(
@@ -286,12 +297,72 @@ async def create_booking(
# Note: For cash payments, deposit is paid on arrival, so we don't create a pending payment record
# The payment will be created when the customer pays at check-in
# Add services to booking if provided
services = booking_data.get("services", [])
if services:
from ..models.service import Service
from ..models.service_usage import ServiceUsage
for service_item in services:
service_id = service_item.get("service_id")
quantity = service_item.get("quantity", 1)
if not service_id:
continue
# Check if service exists and is active
service = db.query(Service).filter(Service.id == service_id).first()
if not service or not service.is_active:
continue
# Calculate total price for this service
unit_price = float(service.price)
total_price = unit_price * quantity
# Create service usage
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)
db.commit()
db.refresh(booking)
# Fetch with relations for proper serialization (eager load payments)
from sqlalchemy.orm import joinedload
booking = db.query(Booking).options(joinedload(Booking.payments)).filter(Booking.id == booking.id).first()
# Automatically create invoice for the booking
try:
from ..services.invoice_service import InvoiceService
from sqlalchemy.orm import joinedload, selectinload
# Reload booking with service_usages for invoice creation
booking = db.query(Booking).options(
selectinload(Booking.service_usages).selectinload(ServiceUsage.service)
).filter(Booking.id == booking.id).first()
# Create invoice automatically
invoice = InvoiceService.create_invoice_from_booking(
booking_id=booking.id,
db=db,
created_by_id=current_user.id,
tax_rate=0.0, # Default no tax, can be configured
discount_amount=0.0,
due_days=30,
)
except Exception as e:
# Log error but don't fail booking creation if invoice creation fails
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to create invoice for booking {booking.id}: {str(e)}")
# Fetch with relations for proper serialization (eager load payments and service_usages)
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()
# Determine payment_method and payment_status from payments
payment_method_from_payments = None
@@ -310,8 +381,8 @@ async def create_booking(
"booking_number": booking.booking_number,
"user_id": booking.user_id,
"room_id": booking.room_id,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
"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,
@@ -349,6 +420,31 @@ async def create_booking(
for p in booking.payments
]
# Add service usages if they exist
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:
# Initialize empty array if no service_usages
logger.info(f"Booking {booking.id} - No service_usages found, initializing empty array")
booking_dict["service_usages"] = []
# Add room info if available
if booking.room:
booking_dict["room"] = {
@@ -414,9 +510,11 @@ async def get_booking_by_id(
try:
# Eager load all relationships to avoid N+1 queries
# Using selectinload for better performance with multiple relationships
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)
)\
@@ -448,8 +546,8 @@ async def get_booking_by_id(
"booking_number": booking.booking_number,
"user_id": booking.user_id,
"room_id": booking.room_id,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
"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, # Frontend expects guest_count
"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,
@@ -513,6 +611,32 @@ async def get_booking_by_id(
for p in booking.payments
]
# Add service usages if they exist
# Use getattr to safely access service_usages in case relationship isn't loaded
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:
# Initialize empty array if no service_usages
logger.info(f"Get booking {id} - No service_usages found, initializing empty array")
booking_dict["service_usages"] = []
return {
"success": True,
"data": {"booking": booking_dict}
@@ -657,8 +781,8 @@ async def check_booking_by_number(
"id": booking.id,
"booking_number": booking.booking_number,
"room_id": booking.room_id,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
"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,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
}

View File

@@ -0,0 +1,195 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import Optional
import logging
from ..config.database import get_db
from ..models.user import User
from ..models.role import Role
from ..models.system_settings import SystemSettings
from ..utils.mailer import send_email
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/contact", tags=["contact"])
class ContactForm(BaseModel):
name: str
email: EmailStr
subject: str
message: str
phone: Optional[str] = None
def get_admin_email(db: Session) -> str:
"""Get admin email from system settings or find admin user"""
# First, try to get from system settings
admin_email_setting = db.query(SystemSettings).filter(
SystemSettings.key == "admin_email"
).first()
if admin_email_setting and admin_email_setting.value:
return admin_email_setting.value
# If not found in settings, find the first admin user
admin_role = db.query(Role).filter(Role.name == "admin").first()
if admin_role:
admin_user = db.query(User).filter(
User.role_id == admin_role.id,
User.is_active == True
).first()
if admin_user:
return admin_user.email
# Fallback to SMTP_FROM_EMAIL if configured
from ..config.settings import settings
if settings.SMTP_FROM_EMAIL:
return settings.SMTP_FROM_EMAIL
# Last resort: raise error
raise HTTPException(
status_code=500,
detail="Admin email not configured. Please set admin_email in system settings or ensure an admin user exists."
)
@router.post("/submit")
async def submit_contact_form(
contact_data: ContactForm,
db: Session = Depends(get_db)
):
"""Submit contact form and send email to admin"""
try:
# Get admin email
admin_email = get_admin_email(db)
# Create email subject
subject = f"Contact Form: {contact_data.subject}"
# Create email body (HTML)
html_body = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}}
.header {{
background: linear-gradient(135deg, #d4af37 0%, #c9a227 100%);
color: #0f0f0f;
padding: 20px;
text-align: center;
border-radius: 8px 8px 0 0;
}}
.content {{
background-color: #ffffff;
padding: 30px;
border-radius: 0 0 8px 8px;
}}
.field {{
margin-bottom: 20px;
}}
.label {{
font-weight: bold;
color: #d4af37;
display: block;
margin-bottom: 5px;
}}
.value {{
color: #333;
padding: 10px;
background-color: #f5f5f5;
border-radius: 4px;
}}
.footer {{
text-align: center;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
color: #666;
font-size: 12px;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>New Contact Form Submission</h2>
</div>
<div class="content">
<div class="field">
<span class="label">Name:</span>
<div class="value">{contact_data.name}</div>
</div>
<div class="field">
<span class="label">Email:</span>
<div class="value">{contact_data.email}</div>
</div>
{f'<div class="field"><span class="label">Phone:</span><div class="value">{contact_data.phone}</div></div>' if contact_data.phone else ''}
<div class="field">
<span class="label">Subject:</span>
<div class="value">{contact_data.subject}</div>
</div>
<div class="field">
<span class="label">Message:</span>
<div class="value" style="white-space: pre-wrap;">{contact_data.message}</div>
</div>
</div>
<div class="footer">
<p>This email was sent from the hotel booking contact form.</p>
</div>
</div>
</body>
</html>
"""
# Create plain text version
text_body = f"""
New Contact Form Submission
Name: {contact_data.name}
Email: {contact_data.email}
{f'Phone: {contact_data.phone}' if contact_data.phone else ''}
Subject: {contact_data.subject}
Message:
{contact_data.message}
"""
# Send email to admin
await send_email(
to=admin_email,
subject=subject,
html=html_body,
text=text_body
)
logger.info(f"Contact form submitted successfully. Email sent to {admin_email}")
return {
"status": "success",
"message": "Thank you for contacting us! We will get back to you soon."
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to submit contact form: {type(e).__name__}: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500,
detail="Failed to submit contact form. Please try again later."
)

View File

@@ -729,6 +729,59 @@ async def delete_room_images(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}/booked-dates")
async def get_room_booked_dates(
id: int,
db: Session = Depends(get_db)
):
"""Get all booked dates for a specific room"""
try:
# Check if room exists
room = db.query(Room).filter(Room.id == id).first()
if not room:
raise HTTPException(status_code=404, detail="Room not found")
# Get all non-cancelled bookings for this room
bookings = db.query(Booking).filter(
and_(
Booking.room_id == id,
Booking.status != BookingStatus.cancelled
)
).all()
# Generate list of all booked dates
booked_dates = []
for booking in bookings:
# Parse dates
check_in = booking.check_in_date
check_out = booking.check_out_date
# Generate all dates between check-in and check-out (exclusive of check-out)
current_date = check_in.date()
end_date = check_out.date()
while current_date < end_date:
booked_dates.append(current_date.isoformat())
# Move to next day
from datetime import timedelta
current_date += timedelta(days=1)
# Remove duplicates and sort
booked_dates = sorted(list(set(booked_dates)))
return {
"status": "success",
"data": {
"room_id": id,
"booked_dates": booked_dates
}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}/reviews")
async def get_room_reviews_route(
id: int,

View File

@@ -0,0 +1,419 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload
from typing import Optional
from datetime import datetime
import random
from ..config.database import get_db
from ..middleware.auth import get_current_user
from ..models.user import User
from ..models.service import Service
from ..models.service_booking import (
ServiceBooking,
ServiceBookingItem,
ServicePayment,
ServiceBookingStatus,
ServicePaymentStatus,
ServicePaymentMethod
)
from ..services.stripe_service import StripeService, get_stripe_secret_key, get_stripe_publishable_key
from ..config.settings import settings
router = APIRouter(prefix="/service-bookings", tags=["service-bookings"])
def generate_service_booking_number() -> str:
"""Generate unique service booking number"""
prefix = "SB"
timestamp = datetime.utcnow().strftime("%Y%m%d")
random_suffix = random.randint(1000, 9999)
return f"{prefix}{timestamp}{random_suffix}"
@router.post("/")
async def create_service_booking(
booking_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new service booking"""
try:
services = booking_data.get("services", [])
total_amount = float(booking_data.get("total_amount", 0))
notes = booking_data.get("notes")
if not services or len(services) == 0:
raise HTTPException(status_code=400, detail="At least one service is required")
if total_amount <= 0:
raise HTTPException(status_code=400, detail="Total amount must be greater than 0")
# Validate services and calculate total
calculated_total = 0
service_items_data = []
for service_item in services:
service_id = service_item.get("service_id")
quantity = service_item.get("quantity", 1)
if not service_id:
raise HTTPException(status_code=400, detail="Service ID is required for each item")
# Check if service exists and is active
service = db.query(Service).filter(Service.id == service_id).first()
if not service:
raise HTTPException(status_code=404, detail=f"Service with ID {service_id} not found")
if not service.is_active:
raise HTTPException(status_code=400, detail=f"Service {service.name} is not active")
unit_price = float(service.price)
item_total = unit_price * quantity
calculated_total += item_total
service_items_data.append({
"service": service,
"quantity": quantity,
"unit_price": unit_price,
"total_price": item_total
})
# Verify calculated total matches provided total (with small tolerance for floating point)
if abs(calculated_total - total_amount) > 0.01:
raise HTTPException(
status_code=400,
detail=f"Total amount mismatch. Calculated: {calculated_total}, Provided: {total_amount}"
)
# Generate booking number
booking_number = generate_service_booking_number()
# Create service booking
service_booking = ServiceBooking(
booking_number=booking_number,
user_id=current_user.id,
total_amount=total_amount,
status=ServiceBookingStatus.pending,
notes=notes
)
db.add(service_booking)
db.flush() # Flush to get the ID
# Create service booking items
for item_data in service_items_data:
booking_item = ServiceBookingItem(
service_booking_id=service_booking.id,
service_id=item_data["service"].id,
quantity=item_data["quantity"],
unit_price=item_data["unit_price"],
total_price=item_data["total_price"]
)
db.add(booking_item)
db.commit()
db.refresh(service_booking)
# Load relationships
service_booking = db.query(ServiceBooking).options(
joinedload(ServiceBooking.service_items).joinedload(ServiceBookingItem.service)
).filter(ServiceBooking.id == service_booking.id).first()
# Format response
booking_dict = {
"id": service_booking.id,
"booking_number": service_booking.booking_number,
"user_id": service_booking.user_id,
"total_amount": float(service_booking.total_amount),
"status": service_booking.status.value,
"notes": service_booking.notes,
"created_at": service_booking.created_at.isoformat() if service_booking.created_at else None,
"service_items": [
{
"id": item.id,
"service_id": item.service_id,
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"total_price": float(item.total_price),
"service": {
"id": item.service.id,
"name": item.service.name,
"description": item.service.description,
"price": float(item.service.price),
}
}
for item in service_booking.service_items
]
}
return {
"status": "success",
"message": "Service booking created successfully",
"data": {"service_booking": booking_dict}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/me")
async def get_my_service_bookings(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all service bookings for current user"""
try:
bookings = db.query(ServiceBooking).options(
joinedload(ServiceBooking.service_items).joinedload(ServiceBookingItem.service)
).filter(ServiceBooking.user_id == current_user.id).order_by(ServiceBooking.created_at.desc()).all()
result = []
for booking in bookings:
booking_dict = {
"id": booking.id,
"booking_number": booking.booking_number,
"total_amount": float(booking.total_amount),
"status": booking.status.value,
"notes": booking.notes,
"created_at": booking.created_at.isoformat() if booking.created_at else None,
"service_items": [
{
"id": item.id,
"service_id": item.service_id,
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"total_price": float(item.total_price),
"service": {
"id": item.service.id,
"name": item.service.name,
"description": item.service.description,
"price": float(item.service.price),
}
}
for item in booking.service_items
]
}
result.append(booking_dict)
return {
"status": "success",
"data": {"service_bookings": result}
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}")
async def get_service_booking_by_id(
id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get service booking by ID"""
try:
booking = db.query(ServiceBooking).options(
joinedload(ServiceBooking.service_items).joinedload(ServiceBookingItem.service)
).filter(ServiceBooking.id == id).first()
if not booking:
raise HTTPException(status_code=404, detail="Service booking not found")
# Check access
if booking.user_id != current_user.id and current_user.role_id != 1:
raise HTTPException(status_code=403, detail="Forbidden")
booking_dict = {
"id": booking.id,
"booking_number": booking.booking_number,
"user_id": booking.user_id,
"total_amount": float(booking.total_amount),
"status": booking.status.value,
"notes": booking.notes,
"created_at": booking.created_at.isoformat() if booking.created_at else None,
"service_items": [
{
"id": item.id,
"service_id": item.service_id,
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"total_price": float(item.total_price),
"service": {
"id": item.service.id,
"name": item.service.name,
"description": item.service.description,
"price": float(item.service.price),
}
}
for item in booking.service_items
]
}
return {
"status": "success",
"data": {"service_booking": booking_dict}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{id}/payment/stripe/create-intent")
async def create_service_stripe_payment_intent(
id: int,
intent_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create Stripe payment intent for service booking"""
try:
# Check if Stripe is configured
secret_key = get_stripe_secret_key(db)
if not secret_key:
secret_key = settings.STRIPE_SECRET_KEY
if not secret_key:
raise HTTPException(
status_code=500,
detail="Stripe is not configured. Please configure Stripe settings in Admin Panel."
)
amount = float(intent_data.get("amount", 0))
currency = intent_data.get("currency", "usd")
if amount <= 0:
raise HTTPException(status_code=400, detail="Amount must be greater than 0")
# Verify service booking exists and user has access
booking = db.query(ServiceBooking).filter(ServiceBooking.id == id).first()
if not booking:
raise HTTPException(status_code=404, detail="Service booking not found")
if booking.user_id != current_user.id and current_user.role_id != 1:
raise HTTPException(status_code=403, detail="Forbidden")
# Verify amount matches booking total
if abs(float(booking.total_amount) - amount) > 0.01:
raise HTTPException(
status_code=400,
detail=f"Amount mismatch. Booking total: {booking.total_amount}, Provided: {amount}"
)
# Create payment intent
intent = StripeService.create_payment_intent(
amount=amount,
currency=currency,
description=f"Service Booking #{booking.booking_number}",
db=db
)
# Get publishable key
publishable_key = get_stripe_publishable_key(db)
if not publishable_key:
publishable_key = settings.STRIPE_PUBLISHABLE_KEY
if not publishable_key:
raise HTTPException(
status_code=500,
detail="Stripe publishable key is not configured."
)
return {
"status": "success",
"data": {
"client_secret": intent["client_secret"],
"payment_intent_id": intent["id"],
"publishable_key": publishable_key
}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{id}/payment/stripe/confirm")
async def confirm_service_stripe_payment(
id: int,
payment_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Confirm Stripe payment for service booking"""
try:
payment_intent_id = payment_data.get("payment_intent_id")
if not payment_intent_id:
raise HTTPException(status_code=400, detail="payment_intent_id is required")
# Verify service booking exists and user has access
booking = db.query(ServiceBooking).filter(ServiceBooking.id == id).first()
if not booking:
raise HTTPException(status_code=404, detail="Service booking not found")
if booking.user_id != current_user.id and current_user.role_id != 1:
raise HTTPException(status_code=403, detail="Forbidden")
# Retrieve and verify payment intent
intent_data = StripeService.retrieve_payment_intent(payment_intent_id, db)
if intent_data["status"] != "succeeded":
raise HTTPException(
status_code=400,
detail=f"Payment intent status is {intent_data['status']}, expected 'succeeded'"
)
# Verify amount matches
amount_paid = intent_data["amount"] / 100 # Convert from cents
if abs(float(booking.total_amount) - amount_paid) > 0.01:
raise HTTPException(
status_code=400,
detail="Payment amount does not match booking total"
)
# Create payment record
payment = ServicePayment(
service_booking_id=booking.id,
amount=booking.total_amount,
payment_method=ServicePaymentMethod.stripe,
payment_status=ServicePaymentStatus.completed,
transaction_id=payment_intent_id,
payment_date=datetime.utcnow(),
notes=f"Stripe payment - Intent: {payment_intent_id}"
)
db.add(payment)
# Update booking status
booking.status = ServiceBookingStatus.confirmed
db.commit()
db.refresh(payment)
db.refresh(booking)
return {
"status": "success",
"message": "Payment confirmed successfully",
"data": {
"payment": {
"id": payment.id,
"amount": float(payment.amount),
"payment_method": payment.payment_method.value,
"payment_status": payment.payment_status.value,
"transaction_id": payment.transaction_id,
},
"service_booking": {
"id": booking.id,
"booking_number": booking.booking_number,
"status": booking.status.value,
}
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -62,7 +62,12 @@ class InvoiceService:
Returns:
Invoice dictionary
"""
booking = db.query(Booking).filter(Booking.id == booking_id).first()
from sqlalchemy.orm import selectinload
booking = db.query(Booking).options(
selectinload(Booking.service_usages).selectinload("service"),
selectinload(Booking.room).selectinload("room_type")
).filter(Booking.id == booking_id).first()
if not booking:
raise ValueError("Booking not found")
@@ -73,10 +78,9 @@ class InvoiceService:
# Generate invoice number
invoice_number = generate_invoice_number(db)
# Calculate amounts
# Calculate amounts - subtotal will be recalculated after adding items
# Initial subtotal is booking total (room + services)
subtotal = float(booking.total_price)
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
total_amount = subtotal + tax_amount - discount_amount
# Calculate amount paid from completed payments
amount_paid = sum(
@@ -132,15 +136,26 @@ class InvoiceService:
db.add(invoice)
# Create invoice items from booking
# Calculate room price (total_price includes services, so subtract services)
services_total = sum(
float(su.total_price) for su in booking.service_usages
)
room_price = float(booking.total_price) - services_total
# Calculate number of nights
nights = (booking.check_out_date - booking.check_in_date).days
if nights <= 0:
nights = 1
# Room item
room_item = InvoiceItem(
invoice_id=invoice.id,
description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'}",
quantity=1,
unit_price=float(booking.total_price),
description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'} ({nights} night{'s' if nights > 1 else ''})",
quantity=nights,
unit_price=room_price / nights if nights > 0 else room_price,
tax_rate=tax_rate,
discount_amount=0.0,
line_total=float(booking.total_price),
line_total=room_price,
room_id=booking.room_id,
)
db.add(room_item)
@@ -151,25 +166,27 @@ class InvoiceService:
invoice_id=invoice.id,
description=f"Service: {service_usage.service.name}",
quantity=float(service_usage.quantity),
unit_price=float(service_usage.service.price),
unit_price=float(service_usage.unit_price),
tax_rate=tax_rate,
discount_amount=0.0,
line_total=float(service_usage.quantity) * float(service_usage.service.price),
line_total=float(service_usage.total_price),
service_id=service_usage.service_id,
)
db.add(service_item)
subtotal += float(service_usage.quantity) * float(service_usage.service.price)
# Recalculate totals if services were added
if booking.service_usages:
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
total_amount = subtotal + tax_amount - discount_amount
balance_due = total_amount - amount_paid
invoice.subtotal = subtotal
invoice.tax_amount = tax_amount
invoice.total_amount = total_amount
invoice.balance_due = balance_due
# Recalculate subtotal from items (room + services)
subtotal = room_price + services_total
# Recalculate tax and total amounts
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
total_amount = subtotal + tax_amount - discount_amount
balance_due = total_amount - amount_paid
# Update invoice with correct amounts
invoice.subtotal = subtotal
invoice.tax_amount = tax_amount
invoice.total_amount = total_amount
invoice.balance_due = balance_due
db.commit()
db.refresh(invoice)