Files
Hotel-Booking/Backend/src/payments/routes/payment_routes.py
Iliyan Angelov 3d634b4fce updates
2025-12-04 01:07:34 +02:00

913 lines
57 KiB
Python

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