updates
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,10 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Cookie, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Cookie, Response, Request, UploadFile, File
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from pathlib import Path
|
||||
import aiofiles
|
||||
import uuid
|
||||
import os
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..services.auth_service import auth_service
|
||||
@@ -12,7 +16,11 @@ from ..schemas.auth import (
|
||||
ResetPasswordRequest,
|
||||
AuthResponse,
|
||||
TokenResponse,
|
||||
MessageResponse
|
||||
MessageResponse,
|
||||
MFAInitResponse,
|
||||
EnableMFARequest,
|
||||
VerifyMFARequest,
|
||||
MFAStatusResponse
|
||||
)
|
||||
from ..middleware.auth import get_current_user
|
||||
from ..models.user import User
|
||||
@@ -20,6 +28,22 @@ from ..models.user import User
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def get_base_url(request: Request) -> str:
|
||||
"""Get base URL for image normalization"""
|
||||
return os.getenv("SERVER_URL") or f"http://{request.headers.get('host', 'localhost:8000')}"
|
||||
|
||||
|
||||
def normalize_image_url(image_url: str, base_url: str) -> str:
|
||||
"""Normalize image URL to absolute URL"""
|
||||
if not image_url:
|
||||
return image_url
|
||||
if image_url.startswith('http://') or image_url.startswith('https://'):
|
||||
return image_url
|
||||
if image_url.startswith('/'):
|
||||
return f"{base_url}{image_url}"
|
||||
return f"{base_url}/{image_url}"
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
request: RegisterRequest,
|
||||
@@ -79,9 +103,18 @@ async def login(
|
||||
db=db,
|
||||
email=request.email,
|
||||
password=request.password,
|
||||
remember_me=request.rememberMe or False
|
||||
remember_me=request.rememberMe or False,
|
||||
mfa_token=request.mfaToken
|
||||
)
|
||||
|
||||
# Check if MFA is required
|
||||
if result.get("requires_mfa"):
|
||||
return {
|
||||
"status": "success",
|
||||
"requires_mfa": True,
|
||||
"user_id": result["user_id"]
|
||||
}
|
||||
|
||||
# Set refresh token as HttpOnly cookie
|
||||
max_age = 7 * 24 * 60 * 60 if request.rememberMe else 1 * 24 * 60 * 60
|
||||
response.set_cookie(
|
||||
@@ -104,7 +137,7 @@ async def login(
|
||||
}
|
||||
except ValueError as e:
|
||||
error_message = str(e)
|
||||
status_code = status.HTTP_401_UNAUTHORIZED if "Invalid email or password" in error_message else status.HTTP_400_BAD_REQUEST
|
||||
status_code = status.HTTP_401_UNAUTHORIZED if "Invalid email or password" in error_message or "Invalid MFA token" in error_message else status.HTTP_400_BAD_REQUEST
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={
|
||||
@@ -260,3 +293,229 @@ async def reset_password(
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
# MFA Routes
|
||||
from ..services.mfa_service import mfa_service
|
||||
from ..config.settings import settings
|
||||
|
||||
|
||||
@router.get("/mfa/init")
|
||||
async def init_mfa(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Initialize MFA setup - generate secret and QR code"""
|
||||
try:
|
||||
if current_user.mfa_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="MFA is already enabled"
|
||||
)
|
||||
|
||||
secret = mfa_service.generate_secret()
|
||||
app_name = getattr(settings, 'APP_NAME', 'Hotel Booking')
|
||||
qr_code = mfa_service.generate_qr_code(secret, current_user.email, app_name)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"secret": secret,
|
||||
"qr_code": qr_code
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error initializing MFA: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/mfa/enable")
|
||||
async def enable_mfa(
|
||||
request: EnableMFARequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Enable MFA after verifying token"""
|
||||
try:
|
||||
success, backup_codes = mfa_service.enable_mfa(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
secret=request.secret,
|
||||
verification_token=request.verification_token
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "MFA enabled successfully",
|
||||
"data": {
|
||||
"backup_codes": backup_codes
|
||||
}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error enabling MFA: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/mfa/disable")
|
||||
async def disable_mfa(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Disable MFA"""
|
||||
try:
|
||||
mfa_service.disable_mfa(db=db, user_id=current_user.id)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "MFA disabled successfully"
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error disabling MFA: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mfa/status", response_model=MFAStatusResponse)
|
||||
async def get_mfa_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get MFA status for current user"""
|
||||
try:
|
||||
status_data = mfa_service.get_mfa_status(db=db, user_id=current_user.id)
|
||||
return status_data
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error getting MFA status: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/mfa/regenerate-backup-codes")
|
||||
async def regenerate_backup_codes(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Regenerate backup codes for MFA"""
|
||||
try:
|
||||
backup_codes = mfa_service.regenerate_backup_codes(db=db, user_id=current_user.id)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Backup codes regenerated successfully",
|
||||
"data": {
|
||||
"backup_codes": backup_codes
|
||||
}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error regenerating backup codes: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/avatar/upload")
|
||||
async def upload_avatar(
|
||||
request: Request,
|
||||
image: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Upload user avatar"""
|
||||
try:
|
||||
# Validate file type
|
||||
if not image.content_type or not image.content_type.startswith('image/'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must be an image"
|
||||
)
|
||||
|
||||
# Validate file size (max 2MB)
|
||||
content = await image.read()
|
||||
if len(content) > 2 * 1024 * 1024: # 2MB
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Avatar file size must be less than 2MB"
|
||||
)
|
||||
|
||||
# Create uploads directory
|
||||
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "avatars"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Delete old avatar if exists
|
||||
if current_user.avatar:
|
||||
old_avatar_path = Path(__file__).parent.parent.parent / current_user.avatar.lstrip('/')
|
||||
if old_avatar_path.exists() and old_avatar_path.is_file():
|
||||
try:
|
||||
old_avatar_path.unlink()
|
||||
except Exception:
|
||||
pass # Ignore deletion errors
|
||||
|
||||
# Generate filename
|
||||
ext = Path(image.filename).suffix or '.png'
|
||||
filename = f"avatar-{current_user.id}-{uuid.uuid4()}{ext}"
|
||||
file_path = upload_dir / filename
|
||||
|
||||
# Save file
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
|
||||
# Update user avatar
|
||||
image_url = f"/uploads/avatars/{filename}"
|
||||
current_user.avatar = image_url
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
|
||||
# Return the image URL
|
||||
base_url = get_base_url(request)
|
||||
full_url = normalize_image_url(image_url, base_url)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Avatar uploaded successfully",
|
||||
"data": {
|
||||
"avatar_url": image_url,
|
||||
"full_url": full_url,
|
||||
"user": {
|
||||
"id": current_user.id,
|
||||
"name": current_user.full_name,
|
||||
"email": current_user.email,
|
||||
"phone": current_user.phone,
|
||||
"avatar": image_url,
|
||||
"role": current_user.role.name if current_user.role else "customer"
|
||||
}
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error uploading avatar: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -202,6 +202,16 @@ async def create_booking(
|
||||
):
|
||||
"""Create new booking"""
|
||||
try:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Validate that booking_data is a dict
|
||||
if not isinstance(booking_data, dict):
|
||||
logger.error(f"Invalid booking_data type: {type(booking_data)}, value: {booking_data}")
|
||||
raise HTTPException(status_code=400, detail="Invalid request body. Expected JSON object.")
|
||||
|
||||
logger.info(f"Received booking request from user {current_user.id}: {booking_data}")
|
||||
|
||||
room_id = booking_data.get("room_id")
|
||||
check_in_date = booking_data.get("check_in_date")
|
||||
check_out_date = booking_data.get("check_out_date")
|
||||
@@ -210,8 +220,21 @@ async def create_booking(
|
||||
notes = booking_data.get("notes")
|
||||
payment_method = booking_data.get("payment_method", "cash")
|
||||
|
||||
if not all([room_id, check_in_date, check_out_date, total_price]):
|
||||
raise HTTPException(status_code=400, detail="Missing required booking fields")
|
||||
# Detailed validation with specific error messages
|
||||
missing_fields = []
|
||||
if not room_id:
|
||||
missing_fields.append("room_id")
|
||||
if not check_in_date:
|
||||
missing_fields.append("check_in_date")
|
||||
if not check_out_date:
|
||||
missing_fields.append("check_out_date")
|
||||
if total_price is None:
|
||||
missing_fields.append("total_price")
|
||||
|
||||
if missing_fields:
|
||||
error_msg = f"Missing required booking fields: {', '.join(missing_fields)}"
|
||||
logger.error(error_msg)
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
# Check if room exists
|
||||
room = db.query(Room).filter(Room.id == room_id).first()
|
||||
@@ -250,15 +273,15 @@ async def create_booking(
|
||||
booking_number = generate_booking_number()
|
||||
|
||||
# Determine if deposit is required
|
||||
# Cash requires deposit, Stripe doesn't require deposit (full payment or deposit handled via payment flow)
|
||||
# Cash requires deposit, Stripe and PayPal don't require deposit (full payment or deposit handled via payment flow)
|
||||
requires_deposit = payment_method == "cash"
|
||||
deposit_percentage = 20 if requires_deposit else 0
|
||||
deposit_amount = (float(total_price) * deposit_percentage) / 100 if requires_deposit else 0
|
||||
|
||||
# For Stripe, booking can be confirmed immediately after payment
|
||||
# For Stripe and PayPal, booking can be confirmed immediately after payment
|
||||
initial_status = BookingStatus.pending
|
||||
if payment_method == "stripe":
|
||||
# Will be confirmed after successful Stripe payment
|
||||
if payment_method in ["stripe", "paypal"]:
|
||||
# Will be confirmed after successful payment
|
||||
initial_status = BookingStatus.pending
|
||||
|
||||
# Create booking
|
||||
@@ -279,19 +302,32 @@ async def create_booking(
|
||||
db.add(booking)
|
||||
db.flush()
|
||||
|
||||
# Create payment record if Stripe payment method is selected
|
||||
if payment_method == "stripe":
|
||||
# Create payment record if Stripe or PayPal payment method is selected
|
||||
if payment_method in ["stripe", "paypal"]:
|
||||
from ..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:
|
||||
# This shouldn't happen, but just in case
|
||||
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=total_price,
|
||||
payment_method=PaymentMethod.stripe,
|
||||
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}")
|
||||
|
||||
# Create deposit payment if required (for cash method)
|
||||
# Note: For cash payments, deposit is paid on arrival, so we don't create a pending payment record
|
||||
@@ -301,7 +337,7 @@ async def create_booking(
|
||||
services = booking_data.get("services", [])
|
||||
if services:
|
||||
from ..models.service import Service
|
||||
from ..models.service_usage import ServiceUsage
|
||||
# ServiceUsage is already imported at the top of the file
|
||||
|
||||
for service_item in services:
|
||||
service_id = service_item.get("service_id")
|
||||
@@ -354,8 +390,10 @@ async def create_booking(
|
||||
except Exception as e:
|
||||
# Log error but don't fail booking creation if invoice creation fails
|
||||
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()}")
|
||||
|
||||
# Fetch with relations for proper serialization (eager load payments and service_usages)
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
@@ -369,12 +407,25 @@ async def create_booking(
|
||||
payment_status_from_payments = "unpaid"
|
||||
if booking.payments:
|
||||
latest_payment = sorted(booking.payments, key=lambda p: p.created_at, reverse=True)[0]
|
||||
payment_method_from_payments = latest_payment.payment_method.value if isinstance(latest_payment.payment_method, PaymentMethod) else latest_payment.payment_method
|
||||
# Safely extract payment method value
|
||||
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"
|
||||
|
||||
# Use payment_method from payments if available, otherwise fall back to request payment_method
|
||||
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})")
|
||||
|
||||
# Serialize booking properly
|
||||
booking_dict = {
|
||||
"id": booking.id,
|
||||
@@ -386,7 +437,7 @@ async def create_booking(
|
||||
"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": payment_method_from_payments or payment_method,
|
||||
"payment_method": final_payment_method,
|
||||
"payment_status": payment_status_from_payments,
|
||||
"deposit_paid": booking.deposit_paid,
|
||||
"requires_deposit": booking.requires_deposit,
|
||||
@@ -408,7 +459,7 @@ async def create_booking(
|
||||
"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,
|
||||
"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,
|
||||
@@ -495,6 +546,11 @@ async def create_booking(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
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()}")
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -530,17 +586,33 @@ async def get_booking_by_id(
|
||||
|
||||
# Determine payment_method and payment_status from payments
|
||||
# Get latest payment efficiently (already loaded via joinedload)
|
||||
payment_method = None
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
payment_method_from_payments = None
|
||||
payment_status = "unpaid"
|
||||
if booking.payments:
|
||||
# Find latest payment (payments are already loaded, so this is fast)
|
||||
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
|
||||
payment_method = latest_payment.payment_method.value if isinstance(latest_payment.payment_method, PaymentMethod) else latest_payment.payment_method
|
||||
# Safely extract payment method value
|
||||
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"
|
||||
|
||||
# Use payment_method from payments, fallback to "cash" if no payments
|
||||
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,
|
||||
@@ -551,7 +623,7 @@ async def get_booking_by_id(
|
||||
"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,
|
||||
"payment_method": payment_method or "cash",
|
||||
"payment_method": final_payment_method,
|
||||
"payment_status": payment_status,
|
||||
"deposit_paid": booking.deposit_paid,
|
||||
"requires_deposit": booking.requires_deposit,
|
||||
@@ -605,7 +677,7 @@ async def get_booking_by_id(
|
||||
{
|
||||
"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,
|
||||
"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_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
|
||||
}
|
||||
for p in booking.payments
|
||||
|
||||
@@ -13,6 +13,7 @@ from ..models.booking import Booking, BookingStatus
|
||||
from ..utils.mailer import send_email
|
||||
from ..utils.email_templates import payment_confirmation_email_template
|
||||
from ..services.stripe_service import StripeService
|
||||
from ..services.paypal_service import PayPalService
|
||||
|
||||
router = APIRouter(prefix="/payments", tags=["payments"])
|
||||
|
||||
@@ -588,3 +589,187 @@ async def stripe_webhook(
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/paypal/create-order")
|
||||
async def create_paypal_order(
|
||||
order_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a PayPal order"""
|
||||
try:
|
||||
# Check if PayPal is configured
|
||||
from ..services.paypal_service import get_paypal_client_id, get_paypal_client_secret
|
||||
client_id = get_paypal_client_id(db)
|
||||
if not client_id:
|
||||
client_id = settings.PAYPAL_CLIENT_ID
|
||||
|
||||
client_secret = get_paypal_client_secret(db)
|
||||
if not client_secret:
|
||||
client_secret = settings.PAYPAL_CLIENT_SECRET
|
||||
|
||||
if not client_id or not client_secret:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="PayPal is not configured. Please configure PayPal settings in Admin Panel or set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET environment variables."
|
||||
)
|
||||
|
||||
booking_id = order_data.get("booking_id")
|
||||
amount = float(order_data.get("amount", 0))
|
||||
currency = order_data.get("currency", "USD")
|
||||
|
||||
if not booking_id or amount <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="booking_id and amount are required"
|
||||
)
|
||||
|
||||
# Validate amount
|
||||
if amount > 100000:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Amount ${amount:,.2f} exceeds PayPal's maximum of $100,000. Please contact support for large payments."
|
||||
)
|
||||
|
||||
# Verify booking exists and user has access
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# Get return URLs from request or use defaults
|
||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
return_url = order_data.get("return_url", f"{client_url}/payment/paypal/return")
|
||||
cancel_url = order_data.get("cancel_url", f"{client_url}/payment/paypal/cancel")
|
||||
|
||||
# Create PayPal order
|
||||
order = PayPalService.create_order(
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
metadata={
|
||||
"booking_id": str(booking_id),
|
||||
"booking_number": booking.booking_number,
|
||||
"user_id": str(current_user.id),
|
||||
"description": f"Hotel Booking Payment - {booking.booking_number}",
|
||||
"return_url": return_url,
|
||||
"cancel_url": cancel_url,
|
||||
},
|
||||
db=db
|
||||
)
|
||||
|
||||
if not order.get("approval_url"):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to create PayPal order. Approval URL is missing."
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "PayPal order created successfully",
|
||||
"data": {
|
||||
"order_id": order["id"],
|
||||
"approval_url": order["approval_url"],
|
||||
"status": order["status"],
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"PayPal order creation error: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Unexpected error creating PayPal order: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/paypal/capture")
|
||||
async def capture_paypal_payment(
|
||||
payment_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Capture a PayPal payment"""
|
||||
try:
|
||||
order_id = payment_data.get("order_id")
|
||||
booking_id = payment_data.get("booking_id")
|
||||
|
||||
if not order_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="order_id is required"
|
||||
)
|
||||
|
||||
# Confirm payment (this commits the transaction internally)
|
||||
payment = PayPalService.confirm_payment(
|
||||
order_id=order_id,
|
||||
db=db,
|
||||
booking_id=booking_id
|
||||
)
|
||||
|
||||
# Ensure the transaction is committed
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get fresh booking from database
|
||||
booking = db.query(Booking).filter(Booking.id == payment["booking_id"]).first()
|
||||
if booking:
|
||||
db.refresh(booking)
|
||||
|
||||
# Send payment confirmation email (non-blocking)
|
||||
if booking and booking.user:
|
||||
try:
|
||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
email_html = payment_confirmation_email_template(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name,
|
||||
amount=payment["amount"],
|
||||
payment_method="paypal",
|
||||
transaction_id=payment["transaction_id"],
|
||||
client_url=client_url
|
||||
)
|
||||
await send_email(
|
||||
to=booking.user.email,
|
||||
subject=f"Payment Confirmed - {booking.booking_number}",
|
||||
html=email_html
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Failed to send payment confirmation email: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Payment confirmed successfully",
|
||||
"data": {
|
||||
"payment": payment,
|
||||
"booking": {
|
||||
"id": booking.id if booking else None,
|
||||
"booking_number": booking.booking_number if booking else None,
|
||||
"status": booking.status.value if booking else None,
|
||||
}
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except ValueError as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"PayPal payment confirmation error: {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Unexpected error confirming PayPal payment: {str(e)}", exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -122,17 +122,80 @@ async def search_available_rooms(
|
||||
request: Request,
|
||||
from_date: str = Query(..., alias="from"),
|
||||
to_date: str = Query(..., alias="to"),
|
||||
roomId: Optional[int] = Query(None, alias="roomId"),
|
||||
type: Optional[str] = Query(None),
|
||||
capacity: Optional[int] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(12, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Search for available rooms"""
|
||||
"""Search for available rooms or check specific room availability"""
|
||||
try:
|
||||
check_in = datetime.fromisoformat(from_date.replace('Z', '+00:00'))
|
||||
check_out = datetime.fromisoformat(to_date.replace('Z', '+00:00'))
|
||||
# Parse dates - handle both date-only and datetime formats
|
||||
try:
|
||||
if 'T' in from_date or 'Z' in from_date or '+' in from_date:
|
||||
check_in = datetime.fromisoformat(from_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
check_in = datetime.strptime(from_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid from date format: {from_date}")
|
||||
|
||||
try:
|
||||
if 'T' in to_date or 'Z' in to_date or '+' in to_date:
|
||||
check_out = datetime.fromisoformat(to_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
check_out = datetime.strptime(to_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid to date format: {to_date}")
|
||||
|
||||
# If checking a specific room, handle it differently
|
||||
if roomId:
|
||||
# Check if room exists
|
||||
room = db.query(Room).filter(Room.id == roomId).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
# Check if room is available
|
||||
if room.status != RoomStatus.available:
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"available": False,
|
||||
"message": "Room is not available",
|
||||
"room_id": roomId
|
||||
}
|
||||
}
|
||||
|
||||
# Check for overlapping bookings
|
||||
overlapping = db.query(Booking).filter(
|
||||
and_(
|
||||
Booking.room_id == roomId,
|
||||
Booking.status != BookingStatus.cancelled,
|
||||
Booking.check_in_date < check_out,
|
||||
Booking.check_out_date > check_in
|
||||
)
|
||||
).first()
|
||||
|
||||
if overlapping:
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"available": False,
|
||||
"message": "Room is already booked for the selected dates",
|
||||
"room_id": roomId
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"available": True,
|
||||
"message": "Room is available",
|
||||
"room_id": roomId
|
||||
}
|
||||
}
|
||||
|
||||
# Original search functionality
|
||||
if check_in >= check_out:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
||||
@@ -323,6 +323,162 @@ async def update_stripe_settings(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/paypal")
|
||||
async def get_paypal_settings(
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get PayPal payment settings (Admin only)"""
|
||||
try:
|
||||
client_id_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "paypal_client_id"
|
||||
).first()
|
||||
|
||||
client_secret_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "paypal_client_secret"
|
||||
).first()
|
||||
|
||||
mode_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "paypal_mode"
|
||||
).first()
|
||||
|
||||
# Mask secret for security (only show last 4 characters)
|
||||
def mask_key(key_value: str) -> str:
|
||||
if not key_value or len(key_value) < 4:
|
||||
return ""
|
||||
return "*" * (len(key_value) - 4) + key_value[-4:]
|
||||
|
||||
result = {
|
||||
"paypal_client_id": "",
|
||||
"paypal_client_secret": "",
|
||||
"paypal_mode": "sandbox",
|
||||
"paypal_client_secret_masked": "",
|
||||
"has_client_id": False,
|
||||
"has_client_secret": False,
|
||||
}
|
||||
|
||||
if client_id_setting:
|
||||
result["paypal_client_id"] = client_id_setting.value
|
||||
result["has_client_id"] = bool(client_id_setting.value)
|
||||
result["updated_at"] = client_id_setting.updated_at.isoformat() if client_id_setting.updated_at else None
|
||||
result["updated_by"] = client_id_setting.updated_by.full_name if client_id_setting.updated_by else None
|
||||
|
||||
if client_secret_setting:
|
||||
result["paypal_client_secret"] = client_secret_setting.value
|
||||
result["paypal_client_secret_masked"] = mask_key(client_secret_setting.value)
|
||||
result["has_client_secret"] = bool(client_secret_setting.value)
|
||||
|
||||
if mode_setting:
|
||||
result["paypal_mode"] = mode_setting.value or "sandbox"
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/paypal")
|
||||
async def update_paypal_settings(
|
||||
paypal_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update PayPal payment settings (Admin only)"""
|
||||
try:
|
||||
client_id = paypal_data.get("paypal_client_id", "").strip()
|
||||
client_secret = paypal_data.get("paypal_client_secret", "").strip()
|
||||
mode = paypal_data.get("paypal_mode", "sandbox").strip().lower()
|
||||
|
||||
# Validate mode
|
||||
if mode and mode not in ["sandbox", "live"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid PayPal mode. Must be 'sandbox' or 'live'"
|
||||
)
|
||||
|
||||
# Update or create client ID setting
|
||||
if client_id:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "paypal_client_id"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = client_id
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="paypal_client_id",
|
||||
value=client_id,
|
||||
description="PayPal client ID for processing payments",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
# Update or create client secret setting
|
||||
if client_secret:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "paypal_client_secret"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = client_secret
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="paypal_client_secret",
|
||||
value=client_secret,
|
||||
description="PayPal client secret for processing payments",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
# Update or create mode setting
|
||||
if mode:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "paypal_mode"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = mode
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="paypal_mode",
|
||||
value=mode,
|
||||
description="PayPal mode: sandbox or live",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Return masked values
|
||||
def mask_key(key_value: str) -> str:
|
||||
if not key_value or len(key_value) < 4:
|
||||
return ""
|
||||
return "*" * (len(key_value) - 4) + key_value[-4:]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "PayPal settings updated successfully",
|
||||
"data": {
|
||||
"paypal_client_id": client_id if client_id else "",
|
||||
"paypal_client_secret": client_secret if client_secret else "",
|
||||
"paypal_mode": mode,
|
||||
"paypal_client_secret_masked": mask_key(client_secret) if client_secret else "",
|
||||
"has_client_id": bool(client_id),
|
||||
"has_client_secret": bool(client_secret),
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/smtp")
|
||||
async def get_smtp_settings(
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
|
||||
Reference in New Issue
Block a user