updates
This commit is contained in:
Binary file not shown.
@@ -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")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
BIN
Backend/src/models/__pycache__/service_booking.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/service_booking.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
78
Backend/src/models/service_booking.py
Normal file
78
Backend/src/models/service_booking.py
Normal 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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Binary file not shown.
BIN
Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
195
Backend/src/routes/contact_routes.py
Normal file
195
Backend/src/routes/contact_routes.py
Normal 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."
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
419
Backend/src/routes/service_booking_routes.py
Normal file
419
Backend/src/routes/service_booking_routes.py
Normal 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))
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user