This commit is contained in:
Iliyan Angelov
2025-11-17 18:26:30 +02:00
parent 48353cde9c
commit 0c59fe1173
2535 changed files with 278997 additions and 2480 deletions

View File

@@ -0,0 +1,409 @@
"""
Stripe payment service for processing card payments
"""
import stripe
from typing import Optional, Dict, Any
from ..config.settings import settings
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..models.booking import Booking, BookingStatus
from ..models.system_settings import SystemSettings
from sqlalchemy.orm import Session
from datetime import datetime
def get_stripe_secret_key(db: Session) -> Optional[str]:
"""Get Stripe secret key from database or environment variable"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_secret_key"
).first()
if setting and setting.value:
return setting.value
except Exception:
pass
# Fallback to environment variable
return settings.STRIPE_SECRET_KEY if settings.STRIPE_SECRET_KEY else None
def get_stripe_publishable_key(db: Session) -> Optional[str]:
"""Get Stripe publishable key from database or environment variable"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_publishable_key"
).first()
if setting and setting.value:
return setting.value
except Exception:
pass
# Fallback to environment variable
return settings.STRIPE_PUBLISHABLE_KEY if settings.STRIPE_PUBLISHABLE_KEY else None
def get_stripe_webhook_secret(db: Session) -> Optional[str]:
"""Get Stripe webhook secret from database or environment variable"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_webhook_secret"
).first()
if setting and setting.value:
return setting.value
except Exception:
pass
# Fallback to environment variable
return settings.STRIPE_WEBHOOK_SECRET if settings.STRIPE_WEBHOOK_SECRET else None
class StripeService:
"""Service for handling Stripe payments"""
@staticmethod
def create_payment_intent(
amount: float,
currency: str = "usd",
metadata: Optional[Dict[str, Any]] = None,
customer_id: Optional[str] = None,
db: Optional[Session] = None
) -> Dict[str, Any]:
"""
Create a Stripe Payment Intent
Args:
amount: Payment amount in smallest currency unit (cents for USD)
currency: Currency code (default: usd)
metadata: Additional metadata to attach to the payment intent
customer_id: Optional Stripe customer ID
db: Optional database session to get keys from database
Returns:
Payment intent object
"""
# Get secret key from database or environment
secret_key = None
if db:
secret_key = get_stripe_secret_key(db)
if not secret_key:
secret_key = settings.STRIPE_SECRET_KEY
if not secret_key:
raise ValueError("Stripe secret key is not configured")
# Set the API key for this request
stripe.api_key = secret_key
# Validate amount is reasonable (Stripe max is $999,999.99)
if amount <= 0:
raise ValueError("Amount must be greater than 0")
if amount > 999999.99:
raise ValueError(f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99")
# Convert amount to cents (smallest currency unit)
# Amount should be in dollars, so multiply by 100 to get cents
amount_in_cents = int(round(amount * 100))
# Double-check the cents amount doesn't exceed Stripe's limit
if amount_in_cents > 99999999: # $999,999.99 in cents
raise ValueError(f"Amount ${amount:,.2f} (${amount_in_cents} cents) exceeds Stripe's maximum")
intent_params = {
"amount": amount_in_cents,
"currency": currency,
"automatic_payment_methods": {
"enabled": True,
},
"metadata": metadata or {},
}
if customer_id:
intent_params["customer"] = customer_id
try:
intent = stripe.PaymentIntent.create(**intent_params)
return {
"client_secret": intent.client_secret,
"id": intent.id,
"status": intent.status,
"amount": intent.amount,
"currency": intent.currency,
}
except stripe.StripeError as e:
raise ValueError(f"Stripe error: {str(e)}")
@staticmethod
def retrieve_payment_intent(
payment_intent_id: str,
db: Optional[Session] = None
) -> Dict[str, Any]:
"""
Retrieve a payment intent by ID
Args:
payment_intent_id: Stripe payment intent ID
db: Optional database session to get keys from database
Returns:
Payment intent object
"""
# Get secret key from database or environment
secret_key = None
if db:
secret_key = get_stripe_secret_key(db)
if not secret_key:
secret_key = settings.STRIPE_SECRET_KEY
if not secret_key:
raise ValueError("Stripe secret key is not configured")
# Set the API key for this request
stripe.api_key = secret_key
try:
intent = stripe.PaymentIntent.retrieve(payment_intent_id)
# Safely access charges - they may not exist on all payment intents
charges = []
if hasattr(intent, 'charges') and intent.charges:
charges_data = getattr(intent.charges, 'data', [])
charges = [
{
"id": charge.id,
"paid": charge.paid,
"status": charge.status,
}
for charge in charges_data
]
return {
"id": intent.id,
"status": intent.status,
"amount": intent.amount / 100, # Convert from cents
"currency": intent.currency,
"metadata": intent.metadata,
"charges": charges,
}
except stripe.StripeError as e:
raise ValueError(f"Stripe error: {str(e)}")
@staticmethod
def confirm_payment(
payment_intent_id: str,
db: Session,
booking_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Confirm a payment and update database records
Args:
payment_intent_id: Stripe payment intent ID
db: Database session
booking_id: Optional booking ID for metadata lookup
Returns:
Payment record dictionary
"""
try:
intent_data = StripeService.retrieve_payment_intent(payment_intent_id, db)
# Find or get booking_id from metadata
if not booking_id and intent_data.get("metadata"):
booking_id = intent_data["metadata"].get("booking_id")
if booking_id:
booking_id = int(booking_id)
if not booking_id:
raise ValueError("Booking ID is required")
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise ValueError("Booking not found")
# Check payment intent status
payment_status = intent_data.get("status")
print(f"Payment intent status: {payment_status}")
# Accept succeeded or processing status (processing means payment is being processed)
if payment_status not in ["succeeded", "processing"]:
raise ValueError(f"Payment intent not in a valid state. Status: {payment_status}. Payment may still be processing or may have failed.")
# Find existing payment or create new one
payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.transaction_id == payment_intent_id,
Payment.payment_method == PaymentMethod.stripe
).first()
amount = intent_data["amount"]
if payment:
# Update existing payment
# Only mark as completed if payment intent succeeded
if payment_status == "succeeded":
payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow()
# If processing, keep as pending (will be updated by webhook)
payment.amount = amount
else:
# Create new payment record
payment_type = PaymentType.full
if booking.requires_deposit and not booking.deposit_paid:
payment_type = PaymentType.deposit
# Only mark as completed if payment intent succeeded
payment_status_enum = PaymentStatus.completed if payment_status == "succeeded" else PaymentStatus.pending
payment_date = datetime.utcnow() if payment_status == "succeeded" else None
payment = Payment(
booking_id=booking_id,
amount=amount,
payment_method=PaymentMethod.stripe,
payment_type=payment_type,
payment_status=payment_status_enum,
transaction_id=payment_intent_id,
payment_date=payment_date,
notes=f"Stripe payment - Intent: {payment_intent_id} (Status: {payment_status})",
)
db.add(payment)
# Commit payment first to ensure it's saved
db.commit()
db.refresh(payment)
# Update booking status only if payment is completed
if payment.payment_status == PaymentStatus.completed:
# Refresh booking to get updated payments relationship
db.refresh(booking)
if payment.payment_type == PaymentType.deposit:
# Mark deposit as paid and confirm booking
booking.deposit_paid = True
if booking.status == BookingStatus.pending:
booking.status = BookingStatus.confirmed
elif payment.payment_type == PaymentType.full:
# Calculate total paid from all completed payments (now includes current payment)
total_paid = sum(
float(p.amount) for p in booking.payments
if p.payment_status == PaymentStatus.completed
)
# Confirm booking if:
# 1. Total paid (all payments) covers the booking price, OR
# 2. This single payment covers the entire booking amount
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
booking.status = BookingStatus.confirmed
# Commit booking status update
db.commit()
db.refresh(booking)
# Safely get enum values
def get_enum_value(enum_obj):
"""Safely extract value from enum or return as-is"""
if enum_obj is None:
return None
if isinstance(enum_obj, (PaymentMethod, PaymentType, PaymentStatus)):
return enum_obj.value
return enum_obj
try:
return {
"id": payment.id,
"booking_id": payment.booking_id,
"amount": float(payment.amount) if payment.amount else 0.0,
"payment_method": get_enum_value(payment.payment_method),
"payment_type": get_enum_value(payment.payment_type),
"payment_status": get_enum_value(payment.payment_status),
"transaction_id": payment.transaction_id,
"payment_date": payment.payment_date.isoformat() if payment.payment_date else None,
}
except AttributeError as ae:
print(f"AttributeError accessing payment fields: {ae}")
print(f"Payment object: {payment}")
print(f"Payment payment_method: {payment.payment_method if hasattr(payment, 'payment_method') else 'missing'}")
print(f"Payment payment_type: {payment.payment_type if hasattr(payment, 'payment_type') else 'missing'}")
print(f"Payment payment_status: {payment.payment_status if hasattr(payment, 'payment_status') else 'missing'}")
raise
except ValueError as e:
# Re-raise ValueError as-is (these are expected errors)
db.rollback()
raise
except Exception as e:
import traceback
error_details = traceback.format_exc()
error_msg = str(e) if str(e) else f"{type(e).__name__}: {repr(e)}"
print(f"Error in confirm_payment: {error_msg}")
print(f"Traceback: {error_details}")
db.rollback()
raise ValueError(f"Error confirming payment: {error_msg}")
@staticmethod
def handle_webhook(
payload: bytes,
signature: str,
db: Session
) -> Dict[str, Any]:
"""
Handle Stripe webhook events
Args:
payload: Raw webhook payload
signature: Stripe signature header
db: Database session
Returns:
Webhook event data
"""
webhook_secret = get_stripe_webhook_secret(db)
if not webhook_secret:
webhook_secret = settings.STRIPE_WEBHOOK_SECRET
if not webhook_secret:
raise ValueError("Stripe webhook secret is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_WEBHOOK_SECRET environment variable.")
try:
event = stripe.Webhook.construct_event(
payload, signature, webhook_secret
)
except ValueError as e:
raise ValueError(f"Invalid payload: {str(e)}")
except stripe.SignatureVerificationError as e:
raise ValueError(f"Invalid signature: {str(e)}")
# Handle the event
if event["type"] == "payment_intent.succeeded":
payment_intent = event["data"]["object"]
payment_intent_id = payment_intent["id"]
metadata = payment_intent.get("metadata", {})
booking_id = metadata.get("booking_id")
if booking_id:
try:
StripeService.confirm_payment(
payment_intent_id=payment_intent_id,
db=db,
booking_id=int(booking_id)
)
except Exception as e:
print(f"Error processing webhook for booking {booking_id}: {str(e)}")
elif event["type"] == "payment_intent.payment_failed":
payment_intent = event["data"]["object"]
payment_intent_id = payment_intent["id"]
metadata = payment_intent.get("metadata", {})
booking_id = metadata.get("booking_id")
if booking_id:
# Update payment status to failed
payment = db.query(Payment).filter(
Payment.transaction_id == payment_intent_id,
Payment.booking_id == int(booking_id)
).first()
if payment:
payment.payment_status = PaymentStatus.failed
db.commit()
return {
"status": "success",
"event_type": event["type"],
"event_id": event["id"],
}