paymentservice/payments/engine.py
2026-10-18 22:00:00 +05:30

218 lines
8.1 KiB
Python

import logging
from typing import Optional, Dict, Any, Tuple
from django.utils import timezone
from .models import PaymentGatewayConfig, PaymentOrder, PaymentAttempt, PaymentRefund
from .gateways.base import BasePaymentGateway, GatewayOrderResponse, GatewayRefundResponse
from .gateways.cashfree import CashfreeGateway
from .gateways.razorpay import RazorpayGateway
logger = logging.getLogger(__name__)
class PaymentEngine:
"""
Multi-Vendor Payment Orchestration Engine.
Handles smart gateway routing, circuit breaking, and automatic failover.
"""
def __init__(self):
self.gateway_registry = {
'CASHFREE': CashfreeGateway,
'RAZORPAY': RazorpayGateway,
}
def register_gateway_class(self, name: str, gateway_class):
"""Allows dynamic registration of gateway adapters (e.g. Razorpay)."""
self.gateway_registry[name.upper()] = gateway_class
def get_adapter(self, config: PaymentGatewayConfig, request=None, environment=None) -> Optional[BasePaymentGateway]:
gateway_class = self.gateway_registry.get(config.name.upper())
if gateway_class:
if config.name.upper() == 'CASHFREE':
return gateway_class(config=config, request=request, environment=environment)
return gateway_class(config=config)
logger.warning(f"No adapter registered for gateway: {config.name}")
return None
def get_candidate_gateways(self):
"""
Retrieves active gateways ordered by priority.
Prefers HEALTHY gateways over DEGRADED gateways.
Skips DOWN gateways unless all are DOWN.
"""
active_configs = list(PaymentGatewayConfig.objects.filter(is_active=True).order_by('priority'))
if not active_configs:
# Fallback: create default Cashfree sandbox config in DB
default_cf, _ = PaymentGatewayConfig.objects.get_or_create(
name='CASHFREE',
defaults={
'display_name': 'Cashfree Payments',
'is_active': True,
'priority': 1,
'environment': 'sandbox',
'health_status': 'HEALTHY'
}
)
active_configs = [default_cf]
healthy = [c for c in active_configs if c.health_status == 'HEALTHY']
degraded = [c for c in active_configs if c.health_status == 'DEGRADED']
down = [c for c in active_configs if c.health_status == 'DOWN']
# Prioritize healthy, then degraded, and lastly down as last resort
candidates = healthy + degraded
if not candidates:
candidates = down
return candidates
def initiate_payment_order(
self,
order_id: str,
amount: float,
currency: str,
customer_id: str,
customer_name: str,
customer_email: str,
customer_phone: str,
return_url: str,
notify_url: str,
order_note: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
request=None,
environment=None,
) -> Tuple[bool, PaymentOrder, Optional[PaymentAttempt], Optional[str]]:
"""
Creates an order with automatic failover across registered gateways.
Returns: (success, PaymentOrder, PaymentAttempt, error_message)
"""
order, _ = PaymentOrder.objects.update_or_create(
order_id=order_id,
defaults={
'amount': amount,
'currency': currency or 'INR',
'customer_id': customer_id,
'customer_name': customer_name or '',
'customer_email': customer_email,
'customer_phone': customer_phone or '',
'return_url': return_url,
'notify_url': notify_url,
'order_note': order_note,
'metadata': metadata or {},
'status': 'INITIATED',
}
)
candidates = self.get_candidate_gateways()
last_error = "No active gateways available"
for config in candidates:
adapter = self.get_adapter(config, request=request, environment=environment)
if not adapter:
continue
logger.info(f"Attempting order {order_id} via {adapter.gateway_name} (Priority {config.priority})")
# Execute order creation with gateway
resp: GatewayOrderResponse = adapter.create_order(
order_id=order_id,
amount=amount,
currency=currency,
customer_id=customer_id,
customer_name=customer_name,
customer_email=customer_email,
customer_phone=customer_phone,
return_url=return_url,
notify_url=notify_url,
order_note=order_note,
metadata=metadata,
)
if resp.success:
# Gateway succeeded!
config.record_success()
order.status = 'ACTIVE'
order.active_gateway = adapter.gateway_name
order.save(update_fields=['status', 'active_gateway', 'updated_at'])
attempt = PaymentAttempt.objects.create(
order=order,
gateway_name=adapter.gateway_name,
gateway_order_id=resp.gateway_order_id,
payment_session_id=resp.payment_session_id,
status='INITIATED',
raw_response=resp.raw_response
)
return True, order, attempt, None
else:
# Gateway failed: record failure and auto-failover to next candidate!
logger.warning(
f"Gateway {adapter.gateway_name} failed for order {order_id}: "
f"[{resp.error_code}] {resp.error_message}. Attempting automatic failover..."
)
config.record_failure()
PaymentAttempt.objects.create(
order=order,
gateway_name=adapter.gateway_name,
status='FAILED',
error_code=resp.error_code or 'GATEWAY_ERROR',
error_message=resp.error_message,
raw_response=resp.raw_response
)
last_error = f"{adapter.gateway_name} error: {resp.error_message}"
# If all candidates failed
order.status = 'FAILED'
order.save(update_fields=['status', 'updated_at'])
return False, order, None, last_error
def process_refund(
self,
order_id: str,
refund_id: str,
amount: float,
note: Optional[str] = None,
speed: str = "STANDARD"
) -> Tuple[bool, Optional[PaymentRefund], Optional[str]]:
"""Processes refund through the active gateway that captured the payment."""
try:
order = PaymentOrder.objects.get(order_id=order_id)
except PaymentOrder.DoesNotExist:
return False, None, f"Order {order_id} not found"
config = PaymentGatewayConfig.objects.filter(name=order.active_gateway).first()
adapter = self.get_adapter(config) if config else CashfreeGateway()
resp: GatewayRefundResponse = adapter.create_refund(
order_id=order_id,
refund_id=refund_id,
amount=amount,
note=note,
speed=speed
)
refund = PaymentRefund.objects.create(
refund_id=refund_id,
order=order,
gateway_name=adapter.gateway_name,
gateway_refund_id=resp.gateway_refund_id,
amount=amount,
status='SUCCESS' if resp.success else 'FAILED',
speed=speed,
refund_arn=resp.refund_arn,
reason=note,
raw_response=resp.raw_response
)
if resp.success:
order.status = 'REFUNDED'
order.save(update_fields=['status', 'updated_at'])
return True, refund, None
else:
return False, refund, resp.error_message
# Global singleton instance
payment_engine = PaymentEngine()