updates
This commit is contained in:
Binary file not shown.
@@ -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"""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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")),
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/services/__pycache__/mfa_service.cpython-312.pyc
Normal file
BIN
Backend/src/services/__pycache__/mfa_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/services/__pycache__/paypal_service.cpython-312.pyc
Normal file
BIN
Backend/src/services/__pycache__/paypal_service.cpython-312.pyc
Normal file
Binary file not shown.
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
299
Backend/src/services/mfa_service.py
Normal file
299
Backend/src/services/mfa_service.py
Normal 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()
|
||||
|
||||
429
Backend/src/services/paypal_service.py
Normal file
429
Backend/src/services/paypal_service.py
Normal 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}")
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user