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

683 lines
27 KiB
Python

import json
import logging
from django.db import models
from django.conf import settings
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from django.utils import timezone
from rest_framework import status, permissions
from rest_framework.views import APIView
from rest_framework.response import Response
from .models import PaymentGatewayConfig, PaymentOrder, PaymentAttempt, PaymentRefund, WebhookLog
from .serializers import (
CreateOrderRequestSerializer,
PaymentOrderSerializer,
CreateRefundRequestSerializer,
PaymentRefundSerializer,
PaymentGatewayConfigSerializer,
)
from .engine import payment_engine
from .gateways.cashfree import CashfreeGateway
logger = logging.getLogger(__name__)
class CreatePaymentOrderView(APIView):
"""
POST /api/payments/orders/create/
Creates a new payment order and returns a checkout session.
Automatically handles smart routing and fallback if primary gateway is degraded.
"""
permission_classes = [permissions.AllowAny]
def post(self, request):
serializer = CreateOrderRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
data = serializer.validated_data
requested_env = request.data.get('environment')
success, order, attempt, error_msg = payment_engine.initiate_payment_order(
order_id=data['order_id'],
amount=float(data['amount']),
currency=data.get('currency', 'INR'),
customer_id=data.get('customer_id') or f"cust_{data['order_id'][-8:]}",
customer_name=data.get('customer_name', ''),
customer_email=data.get('customer_email', ''),
customer_phone=data.get('customer_phone', ''),
return_url=data.get('return_url', ''),
notify_url=data.get('notify_url', ''),
order_note=data.get('order_note', ''),
metadata=data.get('metadata', {}),
request=request,
environment=requested_env,
)
if not success:
return Response({
"success": False,
"order_id": data['order_id'],
"error": error_msg or "Failed to initiate payment on all configured gateways",
"status": order.status
}, status=status.HTTP_502_BAD_GATEWAY)
# Build checkout URLs depending on gateway
checkout_url = None
if attempt and attempt.raw_response:
checkout_url = attempt.raw_response.get("payment_session_id")
if checkout_url and attempt.gateway_name == 'CASHFREE':
origin = request.META.get('HTTP_ORIGIN', '').lower()
referer = request.META.get('HTTP_REFERER', '').lower()
host = request.META.get('HTTP_HOST', '').lower()
prod_domains = getattr(settings, 'TRADHOX_PRODUCTION_DOMAINS', ['beta.tradhox.com', 'tradhox.com'])
is_prod = (requested_env == 'production') or any(d in origin or d in referer or d in host for d in prod_domains)
base = 'https://payments.cashfree.com/order/#' if is_prod else 'https://payments-test.cashfree.com/order/#'
checkout_url = f"{base}{attempt.payment_session_id}"
return Response({
"success": True,
"order_id": order.order_id,
"amount": float(order.amount),
"currency": order.currency,
"active_gateway": order.active_gateway,
"gateway_name": order.active_gateway,
"gateway_order_id": attempt.gateway_order_id if attempt else None,
"payment_session_id": attempt.payment_session_id if attempt else None,
"checkout_url": checkout_url,
"status": order.status,
"created_at": order.created_at
}, status=status.HTTP_201_CREATED)
class PaymentOrderDetailView(APIView):
"""
GET /api/payments/orders/<order_id>/
Fetch status and attempt history for an order.
"""
permission_classes = [permissions.AllowAny]
def get(self, request, order_id):
try:
order = PaymentOrder.objects.prefetch_related('attempts', 'refunds').get(order_id=order_id)
except PaymentOrder.DoesNotExist:
return Response({"error": f"Order {order_id} not found"}, status=status.HTTP_404_NOT_FOUND)
serializer = PaymentOrderSerializer(order)
return Response(serializer.data, status=status.HTTP_200_OK)
@method_decorator(csrf_exempt, name='dispatch')
class CashfreeWebhookView(APIView):
"""
POST /api/payments/webhooks/cashfree/
Receives and cryptographically verifies webhooks from Cashfree.
Updates order and attempt status upon payment confirmation.
"""
permission_classes = [permissions.AllowAny]
def post(self, request):
raw_body = request.body
headers = {k.lower(): v for k, v in request.headers.items()}
# Audit log incoming webhook
try:
parsed_body = json.loads(raw_body.decode('utf-8')) if raw_body else {}
except Exception:
parsed_body = {}
event_type = parsed_body.get('type', 'UNKNOWN')
webhook_log = WebhookLog.objects.create(
gateway_name='CASHFREE',
event_type=event_type,
payload=parsed_body
)
config = PaymentGatewayConfig.objects.filter(name='CASHFREE').first()
gateway = CashfreeGateway(config=config)
# Cryptographic Verification
verify_result = gateway.verify_webhook(raw_body=raw_body, headers=headers)
webhook_log.signature_valid = verify_result.is_valid
if not verify_result.is_valid:
webhook_log.error_message = verify_result.error_message
webhook_log.save(update_fields=['signature_valid', 'error_message'])
logger.warning(f"Cashfree Webhook signature invalid: {verify_result.error_message}")
return Response({"error": verify_result.error_message}, status=status.HTTP_400_BAD_REQUEST)
order_id = verify_result.order_id
if not order_id:
webhook_log.error_message = "No order_id in webhook"
webhook_log.save(update_fields=['error_message'])
return Response({"status": "ignored", "reason": "No order_id in payload"}, status=status.HTTP_200_OK)
logger.info(f"Processing verified Cashfree webhook for order {order_id}: {verify_result.event_type}")
try:
order = PaymentOrder.objects.get(order_id=order_id)
# Find or create latest attempt for this gateway
attempt = PaymentAttempt.objects.filter(order=order, gateway_name='CASHFREE').first()
if not attempt:
attempt = PaymentAttempt.objects.create(
order=order,
gateway_name='CASHFREE',
gateway_order_id=verify_result.gateway_payment_id,
)
# Update attempt details
attempt.gateway_payment_id = verify_result.gateway_payment_id
attempt.status = verify_result.payment_status or attempt.status
attempt.payment_method = verify_result.payment_method or attempt.payment_method
attempt.bank_reference = verify_result.bank_reference or attempt.bank_reference
attempt.raw_response = verify_result.raw_payload
attempt.save()
# Update Order status
if verify_result.payment_status == 'SUCCESS':
order.status = 'PAID'
order.save(update_fields=['status', 'updated_at'])
elif verify_result.payment_status == 'FAILED' and order.status != 'PAID':
order.status = 'FAILED'
order.save(update_fields=['status', 'updated_at'])
webhook_log.processed = True
webhook_log.save(update_fields=['signature_valid', 'processed'])
return Response({"status": "acknowledged", "order_id": order_id}, status=status.HTTP_200_OK)
except PaymentOrder.DoesNotExist:
logger.error(f"Order {order_id} not found in database for webhook")
webhook_log.error_message = f"Order {order_id} not found"
webhook_log.save(update_fields=['error_message'])
return Response({"status": "acknowledged", "warning": "Order not found in DB"}, status=status.HTTP_200_OK)
class CreateRefundView(APIView):
"""
POST /api/payments/orders/<order_id>/refund/
Initiate a refund for a previously captured order.
"""
permission_classes = [permissions.AllowAny]
def post(self, request, order_id):
serializer = CreateRefundRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
data = serializer.validated_data
success, refund, error_msg = payment_engine.process_refund(
order_id=order_id,
refund_id=data['refund_id'],
amount=float(data['amount']),
note=data.get('reason', 'Customer Refund'),
speed=data.get('speed', 'STANDARD')
)
if not success:
return Response({
"success": False,
"error": error_msg or "Refund processing failed"
}, status=status.HTTP_400_BAD_REQUEST)
return Response(PaymentRefundSerializer(refund).data, status=status.HTTP_201_CREATED)
class GatewayHealthView(APIView):
"""
GET /api/payments/engine/health/
Live health and circuit status check across all configured payment gateways.
"""
permission_classes = [permissions.AllowAny]
def get(self, request):
configs = PaymentGatewayConfig.objects.all()
results = []
for config in configs:
adapter = payment_engine.get_adapter(config)
is_healthy = adapter.health_check() if adapter else False
config.last_health_check = timezone.now()
if is_healthy:
config.record_success()
else:
config.record_failure()
results.append({
"name": config.name,
"display_name": config.display_name,
"priority": config.priority,
"is_active": config.is_active,
"health_status": config.health_status,
"consecutive_failures": config.consecutive_failures,
"environment": config.environment,
"last_health_check": config.last_health_check
})
return Response({
"service": "Tradhox Multi-Vendor Payment Engine",
"timestamp": timezone.now(),
"gateways": results
}, status=status.HTTP_200_OK)
class PortalStatsView(APIView):
"""
GET /api/payments/engine/stats/
Returns dashboard metrics, recent orders, and webhook logs for the portal.
"""
permission_classes = [permissions.AllowAny]
def get(self, request):
total_orders = PaymentOrder.objects.count()
paid_orders = PaymentOrder.objects.filter(status='PAID').count()
total_volume = PaymentOrder.objects.filter(status='PAID').aggregate(total=models.Sum('amount'))['total'] or 0.0
total_refunds = PaymentRefund.objects.count()
recent_orders = PaymentOrder.objects.prefetch_related('attempts').order_by('-created_at')[:15]
recent_webhooks = WebhookLog.objects.order_by('-received_at')[:10]
gateways = PaymentGatewayConfig.objects.all().order_by('priority')
return Response({
"metrics": {
"total_orders": total_orders,
"paid_orders": paid_orders,
"total_volume": float(total_volume),
"total_refunds": total_refunds,
"success_rate": round((paid_orders / total_orders * 100), 1) if total_orders > 0 else 100.0,
},
"gateways": PaymentGatewayConfigSerializer(gateways, many=True).data,
"recent_orders": PaymentOrderSerializer(recent_orders, many=True).data,
"recent_webhooks": [
{
"id": w.id,
"gateway": w.gateway_name,
"event_type": w.event_type,
"signature_valid": w.signature_valid,
"processed": w.processed,
"received_at": w.received_at,
"payload": w.payload,
"error_message": w.error_message
}
for w in recent_webhooks
]
}, status=status.HTTP_200_OK)
class GatewayToggleView(APIView):
"""
POST /api/payments/engine/gateway-toggle/
Allows manual simulation of gateway status (HEALTHY, DEGRADED, DOWN, or priority change)
for testing automatic failover live.
"""
permission_classes = [permissions.AllowAny]
def post(self, request):
gateway_name = request.data.get('name', 'CASHFREE').upper()
target_status = request.data.get('health_status')
is_active = request.data.get('is_active')
try:
config = PaymentGatewayConfig.objects.get(name=gateway_name)
except PaymentGatewayConfig.DoesNotExist:
return Response({"error": f"Gateway {gateway_name} not found"}, status=status.HTTP_404_NOT_FOUND)
if target_status:
config.health_status = target_status
if target_status == 'HEALTHY':
config.consecutive_failures = 0
if is_active is not None:
config.is_active = bool(is_active)
config.save()
return Response({
"message": f"Gateway {config.name} updated successfully",
"gateway": PaymentGatewayConfigSerializer(config).data
}, status=status.HTTP_200_OK)
class SimulateWebhookView(APIView):
"""
POST /api/payments/engine/simulate-webhook/
Generates a cryptographically signed test webhook and invokes the internal webhook processor.
"""
permission_classes = [permissions.AllowAny]
def post(self, request):
order_id = request.data.get('order_id')
event_type = request.data.get('event_type', 'PAYMENT_SUCCESS_WEBHOOK')
try:
order = PaymentOrder.objects.get(order_id=order_id)
except PaymentOrder.DoesNotExist:
return Response({"error": f"Order {order_id} not found"}, status=status.HTTP_404_NOT_FOUND)
config = PaymentGatewayConfig.objects.filter(name='CASHFREE').first()
secret_key = config.secret_key if config and config.secret_key else getattr(settings, 'CASHFREE_SECRET_KEY', '')
# Build realistic Cashfree webhook payload
payload = {
"data": {
"order": {
"order_id": order.order_id,
"order_amount": float(order.amount),
"order_currency": order.currency,
"order_tags": order.metadata
},
"payment": {
"cf_payment_id": f"cf_sim_{int(timezone.now().timestamp())}",
"payment_status": "SUCCESS" if "SUCCESS" in event_type else "FAILED",
"payment_amount": float(order.amount),
"payment_currency": order.currency,
"payment_message": "Simulation: Transaction Completed",
"payment_time": timezone.now().isoformat(),
"payment_completion_time": timezone.now().isoformat(),
"bank_reference": f"SIM_UTR_{int(timezone.now().timestamp())}",
"payment_group": "upi"
},
"customer_details": {
"customer_id": order.customer_id,
"customer_name": order.customer_name,
"customer_email": order.customer_email,
"customer_phone": order.customer_phone
}
},
"event_time": timezone.now().isoformat(),
"type": event_type
}
raw_body = json.dumps(payload).encode('utf-8')
timestamp = str(int(timezone.now().timestamp()))
to_sign = timestamp.encode('utf-8') + raw_body
import hmac, hashlib, base64
computed_hmac = hmac.new(
key=secret_key.encode('utf-8'),
msg=to_sign,
digestmod=hashlib.sha256
).digest()
signature = base64.b64encode(computed_hmac).decode('utf-8')
# Trigger internal webhook view directly
from django.test import RequestFactory
factory = RequestFactory()
sim_request = factory.post(
'/api/payments/webhooks/cashfree/',
data=raw_body,
content_type='application/json',
HTTP_X_WEBHOOK_SIGNATURE=signature,
HTTP_X_WEBHOOK_TIMESTAMP=timestamp
)
webhook_view = CashfreeWebhookView.as_view()
response = webhook_view(sim_request)
order.refresh_from_db()
return Response({
"message": "Webhook simulated and dispatched successfully",
"webhook_response_status": response.status_code,
"order_id": order.order_id,
"order_status": order.status,
"simulated_signature": signature,
"timestamp": timestamp,
"payload": payload
}, status=status.HTTP_200_OK)
from django.views.generic import TemplateView
class PortalView(TemplateView):
"""Renders the interactive Payment Engine Test & Operations Portal."""
template_name = "payments/portal.html"
class CashfreeDirectPaySessionView(APIView):
"""
POST /api/payments/cashfree/pay/
Directly invokes Cashfree /orders/sessions for UPI Dynamic QR or Collect.
"""
permission_classes = [permissions.AllowAny]
def post(self, request):
session_id = request.data.get('payment_session_id')
channel = request.data.get('channel', 'qrcode') # 'qrcode' or 'collect'
upi_id = request.data.get('upi_id', 'success@upi')
if not session_id:
return Response({"error": "payment_session_id is required"}, status=status.HTTP_400_BAD_REQUEST)
config = PaymentGatewayConfig.objects.filter(name='CASHFREE').first()
gateway = CashfreeGateway(config=config, request=request, environment=request.data.get('environment'))
url = f"{gateway.base_url}/orders/sessions"
method_payload = {"channel": channel}
if channel == 'collect':
method_payload["upi_id"] = upi_id
payload = {
"payment_session_id": session_id,
"payment_method": {
"upi": method_payload
}
}
import requests
try:
r = requests.post(url, json=payload, headers=gateway._get_headers(), timeout=12)
data = r.json() if r.content else {}
return Response(data, status=r.status_code)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class CashfreeDirectPaymentsListView(APIView):
"""
GET /api/payments/cashfree/orders/<order_id>/payments/
Directly queries Cashfree API for all payment attempts associated with an order.
"""
permission_classes = [permissions.AllowAny]
def get(self, request, order_id):
config = PaymentGatewayConfig.objects.filter(name='CASHFREE').first()
gateway = CashfreeGateway(config=config, request=request, environment=request.query_params.get('environment'))
url = f"{gateway.base_url}/orders/{order_id}/payments"
import requests
try:
r = requests.get(url, headers=gateway._get_headers(), timeout=10)
data = r.json() if r.content else []
return Response(data, status=r.status_code)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class CashfreeDirectSettlementsView(APIView):
"""
GET /api/payments/cashfree/orders/<order_id>/settlements/
Directly queries Cashfree API for settlement details of an order.
"""
permission_classes = [permissions.AllowAny]
def get(self, request, order_id):
config = PaymentGatewayConfig.objects.filter(name='CASHFREE').first()
gateway = CashfreeGateway(config=config, request=request, environment=request.query_params.get('environment'))
url = f"{gateway.base_url}/orders/{order_id}/settlements"
import requests
try:
r = requests.get(url, headers=gateway._get_headers(), timeout=10)
data = r.json() if r.content else {}
return Response(data, status=r.status_code)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class CashfreeDirectCreateOrderView(APIView):
"""
POST /api/payments/cashfree/orders/create/
Creates an order directly against Cashfree, bypassing orchestrator routing.
Records the order and attempt in the database for tracking and refunds.
"""
permission_classes = [permissions.AllowAny]
def post(self, request):
order_id = request.data.get('order_id')
amount = request.data.get('amount')
currency = request.data.get('currency', 'INR')
customer_name = request.data.get('customer_name', 'Cashfree Tester')
customer_email = request.data.get('customer_email', 'tester@tradhox.com')
customer_phone = request.data.get('customer_phone', '9876543210')
order_note = request.data.get('order_note', 'Direct Cashfree Order')
return_url = request.data.get('return_url')
notify_url = request.data.get('notify_url')
if not order_id or not amount:
return Response({"error": "order_id and amount are required"}, status=status.HTTP_400_BAD_REQUEST)
config = PaymentGatewayConfig.objects.filter(name='CASHFREE').first()
gateway = CashfreeGateway(config=config, request=request, environment=request.data.get('environment'))
# Call Cashfree API directly
resp = gateway.create_order(
order_id=order_id,
amount=float(amount),
currency=currency,
customer_id=f"cust_{order_id[-6:]}",
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={"engine": "CASHFREE_DIRECT", "env": gateway.environment}
)
if not resp.success:
return Response({
"success": False,
"error": resp.error_message or "Failed to create order on Cashfree",
"raw_response": resp.raw_response
}, status=status.HTTP_400_BAD_REQUEST)
# Save to DB for historical explorer and refunds
order, _ = PaymentOrder.objects.update_or_create(
order_id=order_id,
defaults={
"amount": float(amount),
"currency": currency,
"customer_id": f"cust_{order_id[-6:]}",
"customer_name": customer_name,
"customer_email": customer_email,
"customer_phone": customer_phone,
"status": "PENDING",
"active_gateway": "CASHFREE",
"order_note": order_note,
"metadata": {"direct_engine": True}
}
)
PaymentAttempt.objects.create(
order=order,
gateway_name="CASHFREE",
gateway_order_id=resp.gateway_order_id,
status="PENDING",
raw_response=resp.raw_response
)
return Response({
"success": True,
"order_id": order_id,
"gateway_name": "CASHFREE",
"gateway_order_id": resp.gateway_order_id,
"payment_session_id": resp.payment_session_id,
"checkout_url": resp.checkout_url,
"raw_response": resp.raw_response
}, status=status.HTTP_201_CREATED)
class CashfreeDirectRawOrderView(APIView):
"""
GET /api/payments/cashfree/orders/<order_id>/raw/
Queries Cashfree live server for raw order object.
"""
permission_classes = [permissions.AllowAny]
def get(self, request, order_id):
config = PaymentGatewayConfig.objects.filter(name='CASHFREE').first()
gateway = CashfreeGateway(config=config, request=request, environment=request.query_params.get('environment'))
raw_order = gateway.get_order(order_id)
return Response(raw_order, status=status.HTTP_200_OK)
class CashfreeQuickSessionView(APIView):
"""
POST /api/payments/cashfree/quick-session/
Automatically generates a live Cashfree order and returns the payment_session_id.
Requires zero manual parameters, enabling 1-click dynamic session creation.
"""
permission_classes = [permissions.AllowAny]
def post(self, request):
amount = float(request.data.get('amount') or 100.00)
rand_id = f"CF-UPI-{int(timezone.now().timestamp())}"
config = PaymentGatewayConfig.objects.filter(name='CASHFREE').first()
gateway = CashfreeGateway(config=config, request=request, environment=request.data.get('environment'))
resp = gateway.create_order(
order_id=rand_id,
amount=amount,
currency="INR",
customer_id=f"cust_{rand_id[-6:]}",
customer_name="Tradhox UPI Tester",
customer_email="vignesh@tradhox.com",
customer_phone="9876543210",
return_url=f"https://betapayments.tradhox.com/portal/?order_id={rand_id}",
notify_url="https://betapayments.tradhox.com/api/payments/webhooks/cashfree/",
order_note="Quick UPI Session Auto-Generated",
metadata={"source": "upi_studio_auto_gen", "env": gateway.environment}
)
if not resp.success:
return Response({
"success": False,
"error": resp.error_message or "Failed to auto-generate session on Cashfree"
}, status=status.HTTP_400_BAD_REQUEST)
# Record in DB
order, _ = PaymentOrder.objects.update_or_create(
order_id=rand_id,
defaults={
"amount": amount,
"currency": "INR",
"customer_id": f"cust_{rand_id[-6:]}",
"customer_name": "Tradhox UPI Tester",
"customer_email": "vignesh@tradhox.com",
"customer_phone": "9876543210",
"status": "PENDING",
"active_gateway": "CASHFREE",
"order_note": "Quick UPI Session Auto-Generated",
"metadata": {"auto_generated_session": True}
}
)
PaymentAttempt.objects.create(
order=order,
gateway_name="CASHFREE",
gateway_order_id=resp.gateway_order_id,
payment_session_id=resp.payment_session_id,
status="PENDING",
raw_response=resp.raw_response
)
return Response({
"success": True,
"order_id": rand_id,
"payment_session_id": resp.payment_session_id,
"amount": amount,
"checkout_url": resp.checkout_url
}, status=status.HTTP_201_CREATED)