This commit is contained in:
Iliyan Angelov
2025-11-30 23:29:01 +02:00
parent 39fcfff811
commit 0fa2adeb19
1058 changed files with 4630 additions and 296 deletions

View File

@@ -1,5 +1,6 @@
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
@@ -20,7 +21,19 @@ 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 ..schemas.payment import CreatePaymentRequest, UpdatePaymentStatusRequest, CreateStripePaymentIntentRequest
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'])
@@ -186,6 +199,11 @@ async def create_payment(
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
@@ -193,19 +211,56 @@ async def create_payment(
payment_type = payment_data.payment_type
mark_as_paid = payment_data.mark_as_paid
notes = payment_data.notes
idempotency_key = payment_data.idempotency_key
booking = db.query(Booking).filter(Booking.id == booking_id).first()
# 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')
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)
# 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.commit()
db.flush()
# Commit transaction
transaction.commit()
db.refresh(payment)
# Send payment receipt notification
@@ -278,9 +333,21 @@ async def create_payment(
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:
db.rollback()
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,
@@ -482,12 +549,11 @@ async def create_stripe_payment_intent(intent_data: CreateStripePaymentIntentReq
raise HTTPException(status_code=500, detail=str(e))
@router.post('/stripe/confirm')
async def confirm_stripe_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
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.get('payment_intent_id')
booking_id = payment_data.get('booking_id')
if not payment_intent_id:
raise HTTPException(status_code=400, detail='payment_intent_id is required')
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()
@@ -549,7 +615,7 @@ async def stripe_webhook(request: Request, db: Session=Depends(get_db)):
raise HTTPException(status_code=500, detail=str(e))
@router.post('/paypal/create-order')
async def create_paypal_order(order_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
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)
@@ -560,11 +626,9 @@ async def create_paypal_order(order_data: dict, current_user: User=Depends(get_c
client_secret = settings.PAYPAL_CLIENT_SECRET
if not client_id or not client_secret:
raise HTTPException(status_code=500, detail='PayPal is not configured. Please configure PayPal settings in Admin Panel or set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET environment variables.')
booking_id = order_data.get('booking_id')
amount = float(order_data.get('amount', 0))
currency = order_data.get('currency', 'USD')
if not booking_id or amount <= 0:
raise HTTPException(status_code=400, detail='booking_id and amount are required')
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
@@ -603,11 +667,9 @@ async def create_paypal_order(order_data: dict, current_user: User=Depends(get_c
raise HTTPException(status_code=500, detail=str(e))
@router.post('/paypal/cancel')
async def cancel_paypal_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
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.get('booking_id')
if not booking_id:
raise HTTPException(status_code=400, detail='booking_id is required')
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()
@@ -627,12 +689,10 @@ async def cancel_paypal_payment(payment_data: dict, current_user: User=Depends(g
raise HTTPException(status_code=500, detail=str(e))
@router.post('/paypal/capture')
async def capture_paypal_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
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.get('order_id')
booking_id = payment_data.get('booking_id')
if not order_id:
raise HTTPException(status_code=400, detail='order_id is required')
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()
@@ -673,7 +733,7 @@ async def capture_paypal_payment(payment_data: dict, current_user: User=Depends(
raise HTTPException(status_code=500, detail=str(e))
@router.post('/borica/create-payment')
async def create_borica_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
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)
@@ -681,11 +741,9 @@ async def create_borica_payment(payment_data: dict, current_user: User=Depends(g
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.get('booking_id')
amount = float(payment_data.get('amount', 0))
currency = payment_data.get('currency', 'BGN')
if not booking_id or amount <= 0:
raise HTTPException(status_code=400, detail='booking_id and amount are required')
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
@@ -705,7 +763,7 @@ async def create_borica_payment(payment_data: dict, current_user: User=Depends(g
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 = payment_data.get('return_url', f'{client_url}/payment/borica/return')
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