This commit is contained in:
Iliyan Angelov
2025-11-19 12:27:01 +02:00
parent 2043ac897c
commit 34b4c969d4
469 changed files with 26870 additions and 8329 deletions

View File

@@ -96,6 +96,11 @@ class Settings(BaseSettings):
STRIPE_PUBLISHABLE_KEY: str = Field(default="", description="Stripe publishable key")
STRIPE_WEBHOOK_SECRET: str = Field(default="", description="Stripe webhook secret")
# PayPal Payment Gateway
PAYPAL_CLIENT_ID: str = Field(default="", description="PayPal client ID")
PAYPAL_CLIENT_SECRET: str = Field(default="", description="PayPal client secret")
PAYPAL_MODE: str = Field(default="sandbox", description="PayPal mode: sandbox or live")
@property
def database_url(self) -> str:
"""Construct database URL"""

View File

@@ -12,6 +12,7 @@ class PaymentMethod(str, enum.Enum):
bank_transfer = "bank_transfer"
e_wallet = "e_wallet"
stripe = "stripe"
paypal = "paypal"
class PaymentType(str, enum.Enum):

View File

@@ -17,6 +17,9 @@ class User(Base):
avatar = Column(String(255), nullable=True)
currency = Column(String(3), nullable=False, default='VND') # ISO 4217 currency code
is_active = Column(Boolean, nullable=False, default=True)
mfa_enabled = Column(Boolean, nullable=False, default=False)
mfa_secret = Column(String(255), nullable=True) # TOTP secret key (encrypted in production)
mfa_backup_codes = Column(Text, nullable=True) # JSON array of backup codes (hashed)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View File

@@ -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)}"
)

View File

@@ -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

View File

@@ -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))

View File

@@ -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,

View File

@@ -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")),

View File

@@ -31,6 +31,7 @@ class LoginRequest(BaseModel):
email: EmailStr
password: str
rememberMe: Optional[bool] = False
mfaToken: Optional[str] = None
class RefreshTokenRequest(BaseModel):
@@ -85,3 +86,23 @@ class MessageResponse(BaseModel):
status: str
message: str
class MFAInitResponse(BaseModel):
secret: str
qr_code: str # Base64 data URL
class EnableMFARequest(BaseModel):
secret: str
verification_token: str
class VerifyMFARequest(BaseModel):
token: str
is_backup_code: Optional[bool] = False
class MFAStatusResponse(BaseModel):
mfa_enabled: bool
backup_codes_count: int

View File

@@ -144,8 +144,8 @@ class AuthService:
"refreshToken": tokens["refreshToken"]
}
async def login(self, db: Session, email: str, password: str, remember_me: bool = False) -> dict:
"""Login user"""
async def login(self, db: Session, email: str, password: str, remember_me: bool = False, mfa_token: str = None) -> dict:
"""Login user with optional MFA verification"""
# Find user with role and password
user = db.query(User).filter(User.email == email).first()
if not user:
@@ -158,6 +158,21 @@ class AuthService:
if not self.verify_password(password, user.password):
raise ValueError("Invalid email or password")
# Check if MFA is enabled
if user.mfa_enabled:
if not mfa_token:
# Return special response indicating MFA is required
return {
"requires_mfa": True,
"user_id": user.id
}
# Verify MFA token
from ..services.mfa_service import mfa_service
is_backup_code = len(mfa_token) == 8 # Backup codes are 8 characters
if not mfa_service.verify_mfa(db, user.id, mfa_token, is_backup_code):
raise ValueError("Invalid MFA token")
# Generate tokens
tokens = self.generate_tokens(user.id)

View File

@@ -66,7 +66,8 @@ class InvoiceService:
booking = db.query(Booking).options(
selectinload(Booking.service_usages).selectinload("service"),
selectinload(Booking.room).selectinload("room_type")
selectinload(Booking.room).selectinload("room_type"),
selectinload(Booking.payments)
).filter(Booking.id == booking_id).first()
if not booking:
raise ValueError("Booking not found")
@@ -82,6 +83,10 @@ class InvoiceService:
# Initial subtotal is booking total (room + services)
subtotal = float(booking.total_price)
# Calculate tax and total amounts
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
total_amount = subtotal + tax_amount - discount_amount
# Calculate amount paid from completed payments
amount_paid = sum(
float(p.amount) for p in booking.payments
@@ -134,6 +139,7 @@ class InvoiceService:
)
db.add(invoice)
db.flush() # Flush to get invoice.id before creating invoice items
# Create invoice items from booking
# Calculate room price (total_price includes services, so subtract services)

View File

@@ -0,0 +1,299 @@
"""
Multi-Factor Authentication (MFA) Service
Handles TOTP-based MFA functionality
"""
import pyotp
import qrcode
import secrets
import hashlib
import json
import base64
import io
from typing import List, Optional, Dict, Tuple
from sqlalchemy.orm import Session
from ..models.user import User
import logging
logger = logging.getLogger(__name__)
class MFAService:
"""Service for managing Multi-Factor Authentication"""
@staticmethod
def generate_secret() -> str:
"""Generate a new TOTP secret"""
return pyotp.random_base32()
@staticmethod
def generate_qr_code(secret: str, email: str, app_name: str = "Hotel Booking") -> str:
"""
Generate QR code data URL for TOTP setup
Args:
secret: TOTP secret key
email: User's email address
app_name: Application name for the authenticator app
Returns:
Base64 encoded QR code image data URL
"""
# Create provisioning URI for authenticator apps
totp_uri = pyotp.totp.TOTP(secret).provisioning_uri(
name=email,
issuer_name=app_name
)
# Generate QR code
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(totp_uri)
qr.make(fit=True)
# Create image
img = qr.make_image(fill_color="black", back_color="white")
# Convert to base64 data URL
buffer = io.BytesIO()
img.save(buffer, format='PNG')
img_data = base64.b64encode(buffer.getvalue()).decode()
return f"data:image/png;base64,{img_data}"
@staticmethod
def generate_backup_codes(count: int = 10) -> List[str]:
"""
Generate backup codes for MFA recovery
Args:
count: Number of backup codes to generate (default: 10)
Returns:
List of backup codes (8-character alphanumeric)
"""
codes = []
for _ in range(count):
# Generate 8-character alphanumeric code
code = secrets.token_urlsafe(6).upper()[:8]
codes.append(code)
return codes
@staticmethod
def hash_backup_code(code: str) -> str:
"""
Hash a backup code for storage (SHA-256)
Args:
code: Plain backup code
Returns:
Hashed backup code
"""
return hashlib.sha256(code.encode()).hexdigest()
@staticmethod
def verify_backup_code(code: str, hashed_codes: List[str]) -> bool:
"""
Verify if a backup code matches any hashed code
Args:
code: Plain backup code to verify
hashed_codes: List of hashed backup codes
Returns:
True if code matches, False otherwise
"""
code_hash = MFAService.hash_backup_code(code)
return code_hash in hashed_codes
@staticmethod
def verify_totp(token: str, secret: str) -> bool:
"""
Verify a TOTP token
Args:
token: 6-digit TOTP token from authenticator app
secret: User's TOTP secret
Returns:
True if token is valid, False otherwise
"""
try:
totp = pyotp.TOTP(secret)
# Allow tokens from current and previous/next time window for clock skew
return totp.verify(token, valid_window=1)
except Exception as e:
logger.error(f"Error verifying TOTP: {str(e)}")
return False
@staticmethod
def enable_mfa(
db: Session,
user_id: int,
secret: str,
verification_token: str
) -> Tuple[bool, List[str]]:
"""
Enable MFA for a user after verifying the token
Args:
db: Database session
user_id: User ID
secret: TOTP secret
verification_token: Token from authenticator app to verify
Returns:
Tuple of (success, backup_codes)
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
# Verify the token before enabling
if not MFAService.verify_totp(verification_token, secret):
raise ValueError("Invalid verification token")
# Generate backup codes
backup_codes = MFAService.generate_backup_codes()
hashed_codes = [MFAService.hash_backup_code(code) for code in backup_codes]
# Update user
user.mfa_enabled = True
user.mfa_secret = secret
user.mfa_backup_codes = json.dumps(hashed_codes)
db.commit()
# Return plain backup codes (only shown once)
return True, backup_codes
@staticmethod
def disable_mfa(db: Session, user_id: int) -> bool:
"""
Disable MFA for a user
Args:
db: Database session
user_id: User ID
Returns:
True if successful
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
user.mfa_enabled = False
user.mfa_secret = None
user.mfa_backup_codes = None
db.commit()
return True
@staticmethod
def verify_mfa(
db: Session,
user_id: int,
token: str,
is_backup_code: bool = False
) -> bool:
"""
Verify MFA token or backup code for a user
Args:
db: Database session
user_id: User ID
token: TOTP token or backup code
is_backup_code: Whether the token is a backup code
Returns:
True if verification successful, False otherwise
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
if not user.mfa_enabled or not user.mfa_secret:
raise ValueError("MFA is not enabled for this user")
if is_backup_code:
# Verify backup code
if not user.mfa_backup_codes:
return False
hashed_codes = json.loads(user.mfa_backup_codes)
if not MFAService.verify_backup_code(token, hashed_codes):
return False
# Remove used backup code
code_hash = MFAService.hash_backup_code(token)
hashed_codes.remove(code_hash)
user.mfa_backup_codes = json.dumps(hashed_codes) if hashed_codes else None
db.commit()
return True
else:
# Verify TOTP token
return MFAService.verify_totp(token, user.mfa_secret)
@staticmethod
def regenerate_backup_codes(db: Session, user_id: int) -> List[str]:
"""
Regenerate backup codes for a user
Args:
db: Database session
user_id: User ID
Returns:
List of new backup codes (plain, shown once)
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
if not user.mfa_enabled:
raise ValueError("MFA is not enabled for this user")
# Generate new backup codes
backup_codes = MFAService.generate_backup_codes()
hashed_codes = [MFAService.hash_backup_code(code) for code in backup_codes]
user.mfa_backup_codes = json.dumps(hashed_codes)
db.commit()
# Return plain backup codes (only shown once)
return backup_codes
@staticmethod
def get_mfa_status(db: Session, user_id: int) -> Dict:
"""
Get MFA status for a user
Args:
db: Database session
user_id: User ID
Returns:
Dictionary with MFA status information
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
backup_codes_count = 0
if user.mfa_backup_codes:
backup_codes_count = len(json.loads(user.mfa_backup_codes))
return {
"mfa_enabled": user.mfa_enabled,
"backup_codes_count": backup_codes_count
}
# Create singleton instance
mfa_service = MFAService()

View File

@@ -0,0 +1,429 @@
"""
PayPal payment service for processing PayPal payments
"""
from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment, LiveEnvironment
from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, OrdersCaptureRequest
from paypalcheckoutsdk.payments import CapturesRefundRequest
from typing import Optional, Dict, Any
from ..config.settings import settings
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..models.booking import Booking, BookingStatus
from ..models.system_settings import SystemSettings
from sqlalchemy.orm import Session
from datetime import datetime
import json
def get_paypal_client_id(db: Session) -> Optional[str]:
"""Get PayPal client ID from database or environment variable"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "paypal_client_id"
).first()
if setting and setting.value:
return setting.value
except Exception:
pass
# Fallback to environment variable
return settings.PAYPAL_CLIENT_ID if settings.PAYPAL_CLIENT_ID else None
def get_paypal_client_secret(db: Session) -> Optional[str]:
"""Get PayPal client secret from database or environment variable"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "paypal_client_secret"
).first()
if setting and setting.value:
return setting.value
except Exception:
pass
# Fallback to environment variable
return settings.PAYPAL_CLIENT_SECRET if settings.PAYPAL_CLIENT_SECRET else None
def get_paypal_mode(db: Session) -> str:
"""Get PayPal mode from database or environment variable"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "paypal_mode"
).first()
if setting and setting.value:
return setting.value
except Exception:
pass
# Fallback to environment variable
return settings.PAYPAL_MODE if settings.PAYPAL_MODE else "sandbox"
def get_paypal_client(db: Optional[Session] = None) -> PayPalHttpClient:
"""
Get PayPal HTTP client
Args:
db: Optional database session to get credentials from database
Returns:
PayPalHttpClient instance
"""
client_id = None
client_secret = None
mode = "sandbox"
if db:
client_id = get_paypal_client_id(db)
client_secret = get_paypal_client_secret(db)
mode = get_paypal_mode(db)
if not client_id:
client_id = settings.PAYPAL_CLIENT_ID
if not client_secret:
client_secret = settings.PAYPAL_CLIENT_SECRET
if not mode:
mode = settings.PAYPAL_MODE or "sandbox"
if not client_id or not client_secret:
raise ValueError("PayPal credentials are not configured")
# Create environment based on mode
if mode.lower() == "live":
environment = LiveEnvironment(client_id=client_id, client_secret=client_secret)
else:
environment = SandboxEnvironment(client_id=client_id, client_secret=client_secret)
return PayPalHttpClient(environment)
class PayPalService:
"""Service for handling PayPal payments"""
@staticmethod
def create_order(
amount: float,
currency: str = "USD",
metadata: Optional[Dict[str, Any]] = None,
db: Optional[Session] = None
) -> Dict[str, Any]:
"""
Create a PayPal order
Args:
amount: Payment amount in currency units
currency: Currency code (default: USD)
metadata: Additional metadata to attach to the order
db: Optional database session to get credentials from database
Returns:
Order object with approval URL and order ID
"""
client = get_paypal_client(db)
# Validate amount
if amount <= 0:
raise ValueError("Amount must be greater than 0")
if amount > 100000:
raise ValueError(f"Amount ${amount:,.2f} exceeds PayPal's maximum of $100,000")
# Create order request
request = OrdersCreateRequest()
request.prefer("return=representation")
# Build order body
order_data = {
"intent": "CAPTURE",
"purchase_units": [
{
"amount": {
"currency_code": currency.upper(),
"value": f"{amount:.2f}"
},
"description": metadata.get("description", "Hotel Booking Payment") if metadata else "Hotel Booking Payment",
"custom_id": metadata.get("booking_id") if metadata else None,
}
],
"application_context": {
"brand_name": "Hotel Booking",
"landing_page": "BILLING",
"user_action": "PAY_NOW",
"return_url": metadata.get("return_url") if metadata else None,
"cancel_url": metadata.get("cancel_url") if metadata else None,
}
}
# Add metadata if provided
if metadata:
order_data["purchase_units"][0]["invoice_id"] = metadata.get("booking_number")
request.request_body(order_data)
try:
response = client.execute(request)
order = response.result
# Extract approval URL
approval_url = None
for link in order.links:
if link.rel == "approve":
approval_url = link.href
break
return {
"id": order.id,
"status": order.status,
"approval_url": approval_url,
"amount": amount,
"currency": currency.upper(),
}
except Exception as e:
error_msg = str(e)
# Try to extract more details from PayPal error
if hasattr(e, 'message'):
error_msg = e.message
elif hasattr(e, 'details') and e.details:
error_msg = json.dumps(e.details)
raise ValueError(f"PayPal error: {error_msg}")
@staticmethod
def get_order(
order_id: str,
db: Optional[Session] = None
) -> Dict[str, Any]:
"""
Retrieve an order by ID
Args:
order_id: PayPal order ID
db: Optional database session to get credentials from database
Returns:
Order object
"""
client = get_paypal_client(db)
request = OrdersGetRequest(order_id)
try:
response = client.execute(request)
order = response.result
# Extract amount from purchase units
amount = 0.0
currency = "USD"
if order.purchase_units and len(order.purchase_units) > 0:
amount_str = order.purchase_units[0].amount.value
currency = order.purchase_units[0].amount.currency_code
amount = float(amount_str)
return {
"id": order.id,
"status": order.status,
"amount": amount,
"currency": currency,
"create_time": order.create_time,
"update_time": order.update_time,
}
except Exception as e:
error_msg = str(e)
if hasattr(e, 'message'):
error_msg = e.message
raise ValueError(f"PayPal error: {error_msg}")
@staticmethod
def capture_order(
order_id: str,
db: Optional[Session] = None
) -> Dict[str, Any]:
"""
Capture a PayPal order
Args:
order_id: PayPal order ID
db: Optional database session to get credentials from database
Returns:
Capture details
"""
client = get_paypal_client(db)
request = OrdersCaptureRequest(order_id)
request.prefer("return=representation")
try:
response = client.execute(request)
order = response.result
# Extract capture details
capture_id = None
amount = 0.0
currency = "USD"
status = order.status
if order.purchase_units and len(order.purchase_units) > 0:
payments = order.purchase_units[0].payments
if payments and payments.captures and len(payments.captures) > 0:
capture = payments.captures[0]
capture_id = capture.id
amount_str = capture.amount.value
currency = capture.amount.currency_code
amount = float(amount_str)
status = capture.status
return {
"order_id": order.id,
"capture_id": capture_id,
"status": status,
"amount": amount,
"currency": currency,
}
except Exception as e:
error_msg = str(e)
if hasattr(e, 'message'):
error_msg = e.message
raise ValueError(f"PayPal error: {error_msg}")
@staticmethod
def confirm_payment(
order_id: str,
db: Session,
booking_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Confirm a payment and update database records
Args:
order_id: PayPal order ID
db: Database session
booking_id: Optional booking ID for metadata lookup
Returns:
Payment record dictionary
"""
try:
# First capture the order
capture_data = PayPalService.capture_order(order_id, db)
# Get order details to extract booking_id from metadata if not provided
if not booking_id:
order_data = PayPalService.get_order(order_id, db)
# Try to get booking_id from custom_id in purchase_units
# Note: We'll need to store booking_id in the order metadata when creating
# For now, we'll require booking_id to be passed
if not booking_id:
raise ValueError("Booking ID is required")
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise ValueError("Booking not found")
# Check capture status
capture_status = capture_data.get("status")
if capture_status not in ["COMPLETED", "PENDING"]:
raise ValueError(f"Payment capture not in a valid state. Status: {capture_status}")
# Find existing payment or create new one
# First try to find by transaction_id (for already captured payments)
payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.transaction_id == order_id,
Payment.payment_method == PaymentMethod.paypal
).first()
# If not found, try to find pending PayPal payment for this booking
if not payment:
payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.payment_method == PaymentMethod.paypal,
Payment.payment_status == PaymentStatus.pending
).order_by(Payment.created_at.desc()).first()
amount = capture_data["amount"]
capture_id = capture_data.get("capture_id")
if payment:
# Update existing payment
if capture_status == "COMPLETED":
payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow()
# If pending, keep as pending
payment.amount = amount
if capture_id:
payment.transaction_id = f"{order_id}|{capture_id}"
else:
# Create new payment record
payment_type = PaymentType.full
if booking.requires_deposit and not booking.deposit_paid:
payment_type = PaymentType.deposit
payment_status_enum = PaymentStatus.completed if capture_status == "COMPLETED" else PaymentStatus.pending
payment_date = datetime.utcnow() if capture_status == "COMPLETED" else None
transaction_id = f"{order_id}|{capture_id}" if capture_id else order_id
payment = Payment(
booking_id=booking_id,
amount=amount,
payment_method=PaymentMethod.paypal,
payment_type=payment_type,
payment_status=payment_status_enum,
transaction_id=transaction_id,
payment_date=payment_date,
notes=f"PayPal payment - Order: {order_id}, Capture: {capture_id} (Status: {capture_status})",
)
db.add(payment)
# Commit payment first
db.commit()
db.refresh(payment)
# Update booking status only if payment is completed
if payment.payment_status == PaymentStatus.completed:
db.refresh(booking)
if payment.payment_type == PaymentType.deposit:
booking.deposit_paid = True
if booking.status == BookingStatus.pending:
booking.status = BookingStatus.confirmed
elif payment.payment_type == PaymentType.full:
total_paid = sum(
float(p.amount) for p in booking.payments
if p.payment_status == PaymentStatus.completed
)
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
booking.status = BookingStatus.confirmed
db.commit()
db.refresh(booking)
# Safely get enum values
def get_enum_value(enum_obj):
if enum_obj is None:
return None
if isinstance(enum_obj, (PaymentMethod, PaymentType, PaymentStatus)):
return enum_obj.value
return enum_obj
return {
"id": payment.id,
"booking_id": payment.booking_id,
"amount": float(payment.amount) if payment.amount else 0.0,
"payment_method": get_enum_value(payment.payment_method),
"payment_type": get_enum_value(payment.payment_type),
"payment_status": get_enum_value(payment.payment_status),
"transaction_id": payment.transaction_id,
"payment_date": payment.payment_date.isoformat() if payment.payment_date else None,
}
except ValueError as e:
db.rollback()
raise
except Exception as e:
import traceback
error_details = traceback.format_exc()
error_msg = str(e) if str(e) else f"{type(e).__name__}: {repr(e)}"
print(f"Error in confirm_payment: {error_msg}")
print(f"Traceback: {error_details}")
db.rollback()
raise ValueError(f"Error confirming payment: {error_msg}")