from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, Header from sqlalchemy.orm import Session, joinedload, selectinload, load_only from sqlalchemy.exc import IntegrityError from typing import Optional from datetime import datetime import os from ...shared.config.database import get_db from ...shared.config.settings import settings from ...shared.config.logging_config import get_logger from ...security.middleware.auth import get_current_user, authorize_roles from ...auth.models.user import User from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus from ...bookings.models.booking import Booking, BookingStatus from ...shared.utils.role_helpers import can_access_all_payments from ...shared.utils.currency_helpers import get_currency_symbol from ...shared.utils.response_helpers import success_response from ...shared.utils.mailer import send_email from ...shared.utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template from ..services.stripe_service import StripeService from ..services.paypal_service import PayPalService from ..services.borica_service import BoricaService from ...loyalty.services.loyalty_service import LoyaltyService from ...analytics.services.audit_service import audit_service from ..services.financial_audit_service import financial_audit_service from ..models.financial_audit_trail import FinancialActionType from ..schemas.payment import ( CreatePaymentRequest, UpdatePaymentStatusRequest, CreateStripePaymentIntentRequest, ConfirmStripePaymentRequest, CreatePayPalOrderRequest, CancelPayPalPaymentRequest, CapturePayPalPaymentRequest, CreateBoricaPaymentRequest, ConfirmBoricaPaymentRequest ) logger = get_logger(__name__) router = APIRouter(prefix='/payments', tags=['payments']) async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str='Payment failed or canceled'): if booking.status == BookingStatus.cancelled: return from sqlalchemy.orm import selectinload # Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id) booking = db.query(Booking).options( load_only( Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id, Booking.check_in_date, Booking.check_out_date, Booking.num_guests, Booking.total_price, Booking.original_price, Booking.discount_amount, Booking.promotion_code, Booking.status, Booking.deposit_paid, Booking.requires_deposit, Booking.special_requests, Booking.created_at, Booking.updated_at ), selectinload(Booking.payments) ).filter(Booking.id == booking.id).first() if booking.payments: for payment in booking.payments: if payment.payment_status == PaymentStatus.pending: payment.payment_status = PaymentStatus.failed existing_notes = payment.notes or '' cancellation_note = f'\nPayment cancelled due to booking cancellation: {reason} on {datetime.utcnow().isoformat()}' payment.notes = existing_notes + cancellation_note if existing_notes else cancellation_note.strip() booking.status = BookingStatus.cancelled db.commit() db.refresh(booking) try: from ...system.models.system_settings import SystemSettings client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') if booking.user: email_html = booking_status_changed_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', status='cancelled', client_url=client_url) await send_email(to=booking.user.email, subject=f'Booking Cancelled - {booking.booking_number}', html=email_html) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f'Failed to send cancellation email: {e}') @router.get('/') async def get_payments(booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: # SECURITY: Verify booking ownership when booking_id is provided if booking_id and not can_access_all_payments(current_user, db): booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: raise HTTPException(status_code=404, detail='Booking not found') if booking.user_id != current_user.id: raise HTTPException(status_code=403, detail='Forbidden: You do not have permission to access payments for this booking') if booking_id: query = db.query(Payment).filter(Payment.booking_id == booking_id) else: query = db.query(Payment) if status_filter: try: query = query.filter(Payment.payment_status == PaymentStatus(status_filter)) except ValueError: pass if not can_access_all_payments(current_user, db): query = query.join(Booking).filter(Booking.user_id == current_user.id) total = query.count() # Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id) query = query.options( selectinload(Payment.booking).options( load_only( Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id, Booking.check_in_date, Booking.check_out_date, Booking.num_guests, Booking.total_price, Booking.original_price, Booking.discount_amount, Booking.promotion_code, Booking.status, Booking.deposit_paid, Booking.requires_deposit, Booking.special_requests, Booking.created_at, Booking.updated_at ), joinedload(Booking.user) ) ) offset = (page - 1) * limit payments = query.order_by(Payment.created_at.desc()).offset(offset).limit(limit).all() result = [] for payment in payments: payment_dict = {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, 'payment_type': payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, 'deposit_percentage': payment.deposit_percentage, 'related_payment_id': payment.related_payment_id, 'payment_status': payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None, 'notes': payment.notes, 'created_at': payment.created_at.isoformat() if payment.created_at else None} if payment.booking: payment_dict['booking'] = {'id': payment.booking.id, 'booking_number': payment.booking.booking_number} if payment.booking.user: payment_dict['booking']['user'] = {'id': payment.booking.user.id, 'name': payment.booking.user.full_name, 'full_name': payment.booking.user.full_name, 'email': payment.booking.user.email} result.append(payment_dict) return success_response(data={'payments': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}) except HTTPException: raise except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f'Error fetching payments: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=f'Error fetching payments: {str(e)}') @router.get('/booking/{booking_id}') async def get_payments_by_booking_id(booking_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: from ...shared.utils.role_helpers import is_admin booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: raise HTTPException(status_code=404, detail='Booking not found') if not is_admin(current_user, db) and booking.user_id != current_user.id: raise HTTPException(status_code=403, detail='Forbidden') # Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id) payments = db.query(Payment).options( joinedload(Payment.booking).options( load_only( Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id, Booking.check_in_date, Booking.check_out_date, Booking.num_guests, Booking.total_price, Booking.original_price, Booking.discount_amount, Booking.promotion_code, Booking.status, Booking.deposit_paid, Booking.requires_deposit, Booking.special_requests, Booking.created_at, Booking.updated_at ), joinedload(Booking.user) ) ).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all() result = [] for payment in payments: payment_dict = {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, 'payment_type': payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, 'deposit_percentage': payment.deposit_percentage, 'related_payment_id': payment.related_payment_id, 'payment_status': payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None, 'notes': payment.notes, 'created_at': payment.created_at.isoformat() if payment.created_at else None} if payment.booking: payment_dict['booking'] = {'id': payment.booking.id, 'booking_number': payment.booking.booking_number} if payment.booking.user: payment_dict['booking']['user'] = {'id': payment.booking.user.id, 'name': payment.booking.user.full_name, 'full_name': payment.booking.user.full_name, 'email': payment.booking.user.email} result.append(payment_dict) return success_response(data={'payments': result}) except HTTPException: raise except Exception as e: db.rollback() import logging logger = logging.getLogger(__name__) logger.error(f'Error in get_payments_by_booking_id: {str(e)}', extra={'booking_id': booking_id}, exc_info=True) raise HTTPException(status_code=500, detail='An error occurred while fetching payments') @router.get('/{id}') async def get_payment_by_id(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: # SECURITY: Load booking relationship to verify ownership payment = db.query(Payment).options(joinedload(Payment.booking)).filter(Payment.id == id).first() if not payment: raise HTTPException(status_code=404, detail='Payment not found') # SECURITY: Verify payment ownership for non-admin/accountant users if not can_access_all_payments(current_user, db): if not payment.booking: raise HTTPException(status_code=403, detail='Forbidden: Payment does not belong to any booking') if payment.booking.user_id != current_user.id: raise HTTPException(status_code=403, detail='Forbidden: You do not have permission to access this payment') payment_dict = {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, 'payment_type': payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, 'deposit_percentage': payment.deposit_percentage, 'related_payment_id': payment.related_payment_id, 'payment_status': payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None, 'notes': payment.notes, 'created_at': payment.created_at.isoformat() if payment.created_at else None} if payment.booking: payment_dict['booking'] = {'id': payment.booking.id, 'booking_number': payment.booking.booking_number} return success_response(data={'payment': payment_dict}) except HTTPException: raise except Exception as e: db.rollback() import logging logger = logging.getLogger(__name__) logger.error(f'Error in get_payment_by_id: {str(e)}', extra={'payment_id': id}, exc_info=True) raise HTTPException(status_code=500, detail='An error occurred while fetching payment') @router.post('/') async def create_payment( request: Request, payment_data: CreatePaymentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db) ): """Create a payment with validated input using Pydantic schema.""" client_ip = request.client.host if request.client else None user_agent = request.headers.get('User-Agent') request_id = getattr(request.state, 'request_id', None) import logging logger = logging.getLogger(__name__) # Start transaction transaction = db.begin() try: booking_id = payment_data.booking_id amount = payment_data.amount payment_method = payment_data.payment_method payment_type = payment_data.payment_type mark_as_paid = payment_data.mark_as_paid notes = payment_data.notes idempotency_key = payment_data.idempotency_key # Idempotency check: if idempotency_key provided, check for existing payment if idempotency_key: existing_payment = db.query(Payment).filter( Payment.transaction_id == idempotency_key, Payment.booking_id == booking_id, Payment.amount == amount ).first() if existing_payment: transaction.rollback() logger.info(f'Duplicate payment request detected with idempotency_key: {idempotency_key}') return success_response( data={'payment': existing_payment}, message='Payment already exists (idempotency check)' ) # Lock booking row to prevent race conditions booking = db.query(Booking).filter(Booking.id == booking_id).with_for_update().first() if not booking: transaction.rollback() raise HTTPException(status_code=404, detail='Booking not found') from ...shared.utils.role_helpers import is_admin if not is_admin(current_user, db) and booking.user_id != current_user.id: transaction.rollback() raise HTTPException(status_code=403, detail='Forbidden') # Use idempotency_key as transaction_id if provided transaction_id = idempotency_key if idempotency_key else None payment = Payment( booking_id=booking_id, amount=amount, payment_method=PaymentMethod(payment_method), payment_type=PaymentType(payment_type), payment_status=PaymentStatus.pending, payment_date=datetime.utcnow() if mark_as_paid else None, notes=notes, transaction_id=transaction_id ) if mark_as_paid: payment.payment_status = PaymentStatus.completed payment.payment_date = datetime.utcnow() db.add(payment) db.flush() # Commit transaction transaction.commit() db.refresh(payment) # Send payment receipt notification if payment.payment_status == PaymentStatus.completed: try: from ...notifications.services.notification_service import NotificationService NotificationService.send_payment_receipt(db, payment) except Exception as e: import logging logger = logging.getLogger(__name__) logger.warning(f'Failed to send payment receipt notification: {e}') # Award loyalty points if payment completed and booking is confirmed if payment.payment_status == PaymentStatus.completed and booking: try: db.refresh(booking) if booking.status == BookingStatus.confirmed and booking.user: # Check if booking already earned points from ...loyalty.models.loyalty_point_transaction import LoyaltyPointTransaction, TransactionSource existing_points = db.query(LoyaltyPointTransaction).filter( LoyaltyPointTransaction.booking_id == booking.id, LoyaltyPointTransaction.source == TransactionSource.booking ).first() if not existing_points: # Award points based on payment amount total_paid = sum(float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed) if total_paid > 0: LoyaltyService.earn_points_from_booking(db, booking.user_id, booking, total_paid) except Exception as loyalty_error: import logging logger = logging.getLogger(__name__) logger.error(f'Failed to award loyalty points: {loyalty_error}') if payment.payment_status == PaymentStatus.completed and booking.user: try: from ...system.models.system_settings import SystemSettings client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' currency_symbol = get_currency_symbol(currency) email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=float(payment.amount), payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), transaction_id=payment.transaction_id, payment_type=payment.payment_type.value if payment.payment_type else None, total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol) 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.error(f'Failed to send payment confirmation email: {e}') # Log payment transaction await audit_service.log_action( db=db, action='payment_created', resource_type='payment', user_id=current_user.id, resource_id=payment.id, ip_address=client_ip, user_agent=user_agent, request_id=request_id, details={ 'booking_id': booking_id, 'amount': float(amount), 'payment_method': payment_method, 'payment_type': payment_type, 'payment_status': payment.payment_status.value if hasattr(payment.payment_status, 'value') else str(payment.payment_status), 'transaction_id': payment.transaction_id }, status='success' ) return success_response(data={'payment': payment}, message='Payment created successfully') except HTTPException: if 'transaction' in locals(): transaction.rollback() raise except IntegrityError as e: if 'transaction' in locals(): transaction.rollback() logger.error(f'Database integrity error during payment creation: {str(e)}') # Check if it's a duplicate payment error if 'duplicate' in str(e).lower() or 'unique' in str(e).lower(): raise HTTPException(status_code=409, detail='Duplicate payment detected. Please check if payment already exists.') raise HTTPException(status_code=409, detail='Payment conflict detected. Please try again.') except Exception as e: if 'transaction' in locals(): transaction.rollback() logger.error(f'Error creating payment: {str(e)}', exc_info=True) # Log failed payment creation await audit_service.log_action( db=db, action='payment_creation_failed', resource_type='payment', user_id=current_user.id if current_user else None, ip_address=client_ip, user_agent=user_agent, request_id=request_id, details={'booking_id': payment_data.booking_id, 'amount': payment_data.amount}, status='failed', error_message=str(e) ) raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}/status', dependencies=[Depends(authorize_roles('admin', 'staff', 'accountant'))]) async def update_payment_status( request: Request, id: int, status_data: UpdatePaymentStatusRequest, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db) ): """Update payment status with validated input using Pydantic schema.""" client_ip = request.client.host if request.client else None user_agent = request.headers.get('User-Agent') request_id = getattr(request.state, 'request_id', None) try: payment = db.query(Payment).filter(Payment.id == id).first() if not payment: raise HTTPException(status_code=404, detail='Payment not found') status_value = status_data.status notes = status_data.notes old_status = payment.payment_status if status_value: try: new_status = PaymentStatus(status_value) payment.payment_status = new_status # Only cancel booking if it's a full refund or all payments are failed if new_status in [PaymentStatus.failed, PaymentStatus.refunded]: # Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id) booking = db.query(Booking).options( load_only( Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id, Booking.check_in_date, Booking.check_out_date, Booking.num_guests, Booking.total_price, Booking.original_price, Booking.discount_amount, Booking.promotion_code, Booking.status, Booking.deposit_paid, Booking.requires_deposit, Booking.special_requests, Booking.created_at, Booking.updated_at ), selectinload(Booking.payments) ).filter(Booking.id == payment.booking_id).first() if booking and booking.status != BookingStatus.cancelled: # Check if this is a full refund or if all payments are failed total_paid = sum(float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed) total_price = float(booking.total_price) if booking.total_price else 0.0 all_payments_failed = all(p.payment_status in [PaymentStatus.failed, PaymentStatus.refunded] for p in booking.payments) is_full_refund = new_status == PaymentStatus.refunded and float(payment.amount) >= total_price # Only cancel if it's a full refund or all payments failed if is_full_refund or (new_status == PaymentStatus.failed and all_payments_failed): await cancel_booking_on_payment_failure(booking, db, reason=f'Payment {new_status.value}') except ValueError: raise HTTPException(status_code=400, detail='Invalid payment status') # Update notes if provided if notes: existing_notes = payment.notes or '' payment.notes = f'{existing_notes}\n{notes}'.strip() if existing_notes else notes db.commit() db.refresh(payment) # Log payment status update (admin action) await audit_service.log_action( db=db, action='payment_status_updated', resource_type='payment', user_id=current_user.id, resource_id=payment.id, ip_address=client_ip, user_agent=user_agent, request_id=request_id, details={ 'payment_id': id, 'old_status': old_status.value if hasattr(old_status, 'value') else str(old_status), 'new_status': payment.payment_status.value if hasattr(payment.payment_status, 'value') else str(payment.payment_status), 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'notes': notes }, status='success' ) if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed: # Send payment receipt notification try: from ...notifications.services.notification_service import NotificationService NotificationService.send_payment_receipt(db, payment) except Exception as e: import logging logger = logging.getLogger(__name__) logger.warning(f'Failed to send payment receipt notification: {e}') try: from ...system.models.system_settings import SystemSettings client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' currency_symbol = get_currency_symbol(currency) payment = db.query(Payment).filter(Payment.id == id).first() if payment.booking and payment.booking.user: client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' currency_symbol = get_currency_symbol(currency) email_html = payment_confirmation_email_template(booking_number=payment.booking.booking_number, guest_name=payment.booking.user.full_name, amount=float(payment.amount), payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), transaction_id=payment.transaction_id, client_url=client_url, currency_symbol=currency_symbol) await send_email(to=payment.booking.user.email, subject=f'Payment Confirmed - {payment.booking.booking_number}', html=email_html) if payment.payment_type == PaymentType.deposit and payment.booking: payment.booking.deposit_paid = True if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]: payment.booking.status = BookingStatus.confirmed db.commit() elif payment.payment_type == PaymentType.full and payment.booking: total_paid = sum((float(p.amount) for p in payment.booking.payments if p.payment_status == PaymentStatus.completed)) if total_paid >= float(payment.booking.total_price): if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]: payment.booking.status = BookingStatus.confirmed db.commit() except Exception as e: logger.error(f'Failed to send payment confirmation email: {str(e)}', exc_info=True, extra={'payment_id': payment.id if hasattr(payment, 'id') else None}) return success_response(data={'payment': payment}, message='Payment status updated successfully') except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) @router.post('/stripe/create-intent') async def create_stripe_payment_intent(intent_data: CreateStripePaymentIntentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: from ..services.stripe_service import get_stripe_secret_key secret_key = get_stripe_secret_key(db) if not secret_key: secret_key = settings.STRIPE_SECRET_KEY if not secret_key: raise HTTPException(status_code=500, detail='Stripe is not configured. Please configure Stripe settings in Admin Panel or set STRIPE_SECRET_KEY environment variable.') booking_id = intent_data.booking_id amount = intent_data.amount currency = intent_data.currency or 'usd' import logging logger = logging.getLogger(__name__) logger.info(f'Creating Stripe payment intent - Booking ID: {booking_id}, Amount: ${amount:,.2f}, Currency: {currency}') if amount > 999999.99: logger.error(f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99") raise HTTPException(status_code=400, detail=f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99. Please contact support for large payments.") booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: raise HTTPException(status_code=404, detail='Booking not found') from ...shared.utils.role_helpers import is_admin if not is_admin(current_user, db) and booking.user_id != current_user.id: raise HTTPException(status_code=403, detail='Forbidden') if booking.requires_deposit and (not booking.deposit_paid): deposit_payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first() if deposit_payment: expected_deposit_amount = float(deposit_payment.amount) if abs(amount - expected_deposit_amount) > 0.01: logger.warning(f'Amount mismatch for deposit payment: Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, Booking total ${float(booking.total_price):,.2f}') raise HTTPException(status_code=400, detail=f'For pay-on-arrival bookings, only the deposit amount (${expected_deposit_amount:,.2f}) should be charged, not the full booking amount (${float(booking.total_price):,.2f}).') intent = StripeService.create_payment_intent(amount=amount, currency=currency, metadata={'booking_id': str(booking_id), 'booking_number': booking.booking_number, 'user_id': str(current_user.id)}, db=db) from ..services.stripe_service import get_stripe_publishable_key publishable_key = get_stripe_publishable_key(db) if not publishable_key: publishable_key = settings.STRIPE_PUBLISHABLE_KEY if not publishable_key: import logging logger = logging.getLogger(__name__) logger.warning('Stripe publishable key is not configured') raise HTTPException(status_code=500, detail='Stripe publishable key is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_PUBLISHABLE_KEY environment variable.') if not intent.get('client_secret'): import logging logger = logging.getLogger(__name__) logger.error('Payment intent created but client_secret is missing') raise HTTPException(status_code=500, detail='Failed to create payment intent. Client secret is missing.') return success_response(data={'client_secret': intent['client_secret'], 'payment_intent_id': intent['id'], 'publishable_key': publishable_key}, message='Payment intent created successfully') except HTTPException: raise except ValueError as e: import logging logger = logging.getLogger(__name__) logger.error(f'Payment intent 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 payment intent: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post('/stripe/confirm') async def confirm_stripe_payment(payment_data: ConfirmStripePaymentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): """Confirm a Stripe payment with validated input using Pydantic schema.""" try: payment_intent_id = payment_data.payment_intent_id booking_id = payment_data.booking_id payment = await StripeService.confirm_payment(payment_intent_id=payment_intent_id, db=db, booking_id=booking_id) try: db.commit() except Exception: pass booking = db.query(Booking).filter(Booking.id == payment['booking_id']).first() if booking: db.refresh(booking) if booking and booking.user: try: from ...system.models.system_settings import SystemSettings client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' currency_symbol = get_currency_symbol(currency) email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment['amount'], payment_method='stripe', transaction_id=payment['transaction_id'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol) 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 success_response(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}}, message='Payment confirmed successfully') except HTTPException: db.rollback() raise except ValueError as e: import logging logger = logging.getLogger(__name__) logger.error(f'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 payment: {str(e)}', exc_info=True) db.rollback() raise HTTPException(status_code=500, detail=str(e)) @router.post('/stripe/webhook') async def stripe_webhook(request: Request, db: Session=Depends(get_db)): try: from ..services.stripe_service import get_stripe_webhook_secret webhook_secret = get_stripe_webhook_secret(db) if not webhook_secret: webhook_secret = settings.STRIPE_WEBHOOK_SECRET if not webhook_secret: raise HTTPException(status_code=503, detail={'status': 'error', 'message': 'Stripe webhook secret is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_WEBHOOK_SECRET environment variable.'}) payload = await request.body() signature = request.headers.get('stripe-signature') if not signature: raise HTTPException(status_code=400, detail='Missing stripe-signature header') result = await StripeService.handle_webhook(payload=payload, signature=signature, db=db) return success_response(data=result) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) 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: CreatePayPalOrderRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: 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.booking_id amount = float(order_data.amount) currency = order_data.currency or 'USD' 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.") from ...shared.utils.role_helpers import is_admin booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: raise HTTPException(status_code=404, detail='Booking not found') if not is_admin(current_user, db) and booking.user_id != current_user.id: raise HTTPException(status_code=403, detail='Forbidden') if booking.requires_deposit and (not booking.deposit_paid): deposit_payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first() if deposit_payment: expected_deposit_amount = float(deposit_payment.amount) if abs(amount - expected_deposit_amount) > 0.01: import logging logger = logging.getLogger(__name__) logger.warning(f'Amount mismatch for deposit payment: Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, Booking total ${float(booking.total_price):,.2f}') raise HTTPException(status_code=400, detail=f'For pay-on-arrival bookings, only the deposit amount (${expected_deposit_amount:,.2f}) should be charged, not the full booking amount (${float(booking.total_price):,.2f}).') 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') 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 success_response(data={'order_id': order['id'], 'approval_url': order['approval_url'], 'status': order['status']}, message='PayPal order created successfully') 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/cancel') async def cancel_paypal_payment(payment_data: CancelPayPalPaymentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: booking_id = payment_data.booking_id 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() if not payment: payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first() if payment: payment.payment_status = PaymentStatus.failed db.commit() db.refresh(payment) booking = db.query(Booking).filter(Booking.id == booking_id).first() if booking and booking.status != BookingStatus.cancelled: await cancel_booking_on_payment_failure(booking, db, reason='PayPal payment canceled by user') return success_response(message='Payment canceled and booking cancelled') except HTTPException: db.rollback() raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) @router.post('/paypal/capture') async def capture_paypal_payment(payment_data: CapturePayPalPaymentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: order_id = payment_data.order_id booking_id = payment_data.booking_id payment = await PayPalService.confirm_payment(order_id=order_id, db=db, booking_id=booking_id) try: db.commit() except Exception: pass booking = db.query(Booking).filter(Booking.id == payment['booking_id']).first() if booking: db.refresh(booking) if booking and booking.user: try: from ...system.models.system_settings import SystemSettings client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' currency_symbol = get_currency_symbol(currency) 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'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol) 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 success_response(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}}, message='Payment confirmed successfully') 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)) @router.post('/borica/create-payment') async def create_borica_payment(payment_data: CreateBoricaPaymentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: from ..services.borica_service import get_borica_terminal_id, get_borica_merchant_id terminal_id = get_borica_terminal_id(db) merchant_id = get_borica_merchant_id(db) if not terminal_id or not merchant_id: if not settings.BORICA_TERMINAL_ID or not settings.BORICA_MERCHANT_ID: raise HTTPException(status_code=500, detail='Borica is not configured. Please configure Borica settings in Admin Panel or set BORICA_TERMINAL_ID and BORICA_MERCHANT_ID environment variables.') booking_id = payment_data.booking_id amount = float(payment_data.amount) currency = payment_data.currency or 'BGN' if amount > 100000: raise HTTPException(status_code=400, detail=f"Amount {amount:,.2f} exceeds maximum of 100,000. Please contact support for large payments.") from ...shared.utils.role_helpers import is_admin booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: raise HTTPException(status_code=404, detail='Booking not found') if not is_admin(current_user, db) and booking.user_id != current_user.id: raise HTTPException(status_code=403, detail='Forbidden') if booking.requires_deposit and (not booking.deposit_paid): deposit_payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first() if deposit_payment: expected_deposit_amount = float(deposit_payment.amount) if abs(amount - expected_deposit_amount) > 0.01: import logging logger = logging.getLogger(__name__) logger.warning(f'Amount mismatch for deposit payment: Requested {amount:,.2f}, Expected deposit {expected_deposit_amount:,.2f}, Booking total {float(booking.total_price):,.2f}') raise HTTPException(status_code=400, detail=f'For pay-on-arrival bookings, only the deposit amount ({expected_deposit_amount:,.2f}) should be charged, not the full booking amount ({float(booking.total_price):,.2f}).') transaction_id = BoricaService.generate_transaction_id(booking_id) client_url = settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') return_url = f'{client_url}/payment/borica/return' description = f'Hotel Booking Payment - {booking.booking_number}' payment_request = BoricaService.create_payment_request(amount=amount, currency=currency, order_id=transaction_id, description=description, return_url=return_url, db=db) payment_type = PaymentType.full if booking.requires_deposit and (not booking.deposit_paid): payment_type = PaymentType.deposit payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod.borica, payment_type=payment_type, payment_status=PaymentStatus.pending, transaction_id=transaction_id, notes=f'Borica payment initiated - Order: {transaction_id}') db.add(payment) db.commit() db.refresh(payment) return success_response(data={'payment_request': payment_request, 'payment_id': payment.id, 'transaction_id': transaction_id}, message='Borica payment request created successfully') except HTTPException: raise except ValueError as e: import logging logger = logging.getLogger(__name__) logger.error(f'Borica payment 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 Borica payment: {str(e)}', exc_info=True) db.rollback() raise HTTPException(status_code=500, detail=str(e)) @router.post('/borica/callback') async def borica_callback(request: Request, db: Session=Depends(get_db)): """ Handle Borica payment callback (POST from Borica gateway). Borica sends POST data with payment response. """ try: form_data = await request.form() response_data = dict(form_data) # Also try to get from JSON if available try: json_data = await request.json() response_data.update(json_data) except: pass payment = await BoricaService.confirm_payment(response_data=response_data, db=db) try: db.commit() except Exception: pass booking = db.query(Booking).filter(Booking.id == payment['booking_id']).first() if booking: db.refresh(booking) if booking and booking.user: try: from ...system.models.system_settings import SystemSettings client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' currency_symbol = get_currency_symbol(currency) email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment['amount'], payment_method='borica', transaction_id=payment['transaction_id'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol) 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}') # Redirect to return URL with success status return_url = response_data.get('BACKREF', '') if return_url: from fastapi.responses import RedirectResponse return RedirectResponse(url=f"{return_url}?status=success&order={response_data.get('ORDER', '')}&bookingId={payment['booking_id']}") return success_response(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}}, message='Payment confirmed successfully') except HTTPException: db.rollback() raise except ValueError as e: import logging logger = logging.getLogger(__name__) logger.error(f'Borica payment callback error: {str(e)}') db.rollback() # Redirect to return URL with error status return_url = dict(await request.form()).get('BACKREF', '') if hasattr(request, 'form') else '' if return_url: from fastapi.responses import RedirectResponse return RedirectResponse(url=f"{return_url}?status=error&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 in Borica callback: {str(e)}', exc_info=True) db.rollback() raise HTTPException(status_code=500, detail=str(e)) @router.post('/borica/confirm') async def confirm_borica_payment(response_data: dict, db: Session=Depends(get_db)): try: payment = await BoricaService.confirm_payment(response_data=response_data, db=db) try: db.commit() except Exception: pass booking = db.query(Booking).filter(Booking.id == payment['booking_id']).first() if booking: db.refresh(booking) if booking and booking.user: try: from ...system.models.system_settings import SystemSettings client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' currency_symbol = get_currency_symbol(currency) email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment['amount'], payment_method='borica', transaction_id=payment['transaction_id'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol) 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 success_response(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}}, message='Payment confirmed successfully') except HTTPException: db.rollback() raise except ValueError as e: import logging logger = logging.getLogger(__name__) logger.error(f'Borica 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 Borica payment: {str(e)}', exc_info=True) db.rollback() raise HTTPException(status_code=500, detail=str(e))