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

336 lines
14 KiB
Python

import hmac
import hashlib
import base64
import time
import json
import logging
import requests
from typing import Dict, Any, Optional
from django.conf import settings
from .base import BasePaymentGateway, GatewayOrderResponse, GatewayVerifyResult, GatewayRefundResponse
logger = logging.getLogger(__name__)
class CashfreeGateway(BasePaymentGateway):
"""Cashfree Payment Gateway Integration Adapter."""
GATEWAY_NAME = 'CASHFREE'
def __init__(self, config=None, environment: Optional[str] = None, request=None):
super().__init__(config)
# 1. Determine target environment (production vs sandbox)
resolved_env = None
if environment:
resolved_env = environment
elif request:
req_data = getattr(request, 'data', {}) or {}
if isinstance(req_data, dict) and req_data.get('environment') in ('production', 'sandbox'):
resolved_env = req_data.get('environment')
else:
# Inspect Origin, Referer, Host headers for Tradhox production domains
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', 'www.tradhox.com', 'betapayments.tradhox.com'
])
for domain in prod_domains:
if domain in origin or domain in referer or domain in host:
resolved_env = 'production'
break
if not resolved_env:
if config and config.environment:
resolved_env = config.environment
else:
resolved_env = getattr(settings, 'CASHFREE_ENVIRONMENT', 'sandbox')
self.environment = resolved_env
self.api_version = (config.api_version if config else None) or getattr(settings, 'CASHFREE_API_VERSION', '2023-08-01')
# 2. Select credentials according to resolved environment
if self.environment == 'production':
self.app_id = (config.app_id if config and config.environment == 'production' and config.app_id else getattr(settings, 'CASHFREE_PROD_APP_ID', '1370798f950e30514fa051e93868970731'))
self.secret_key = (config.secret_key if config and config.environment == 'production' and config.secret_key else getattr(settings, 'CASHFREE_PROD_SECRET_KEY', 'cfsk_ma_prod_d441689923a71877068fee6b6e2e0d05_a626ee00'))
self.base_url = 'https://api.cashfree.com/pg'
self.checkout_base = 'https://payments.cashfree.com/order/#'
else:
self.app_id = (config.app_id if config and config.environment == 'sandbox' and config.app_id else getattr(settings, 'CASHFREE_TEST_APP_ID', 'TEST1118017027fc875a9485063df8b007108111'))
self.secret_key = (config.secret_key if config and config.environment == 'sandbox' and config.secret_key else getattr(settings, 'CASHFREE_TEST_SECRET_KEY', 'cfsk_ma_test_3701af3af410228b8e707765dbb68c32_1cf9fb86'))
self.base_url = 'https://sandbox.cashfree.com/pg'
self.checkout_base = 'https://payments-test.cashfree.com/order/#'
logger.info(f"CashfreeGateway initialized in [{self.environment.upper()}] mode (App ID: {self.app_id[:8]}...)")
@property
def gateway_name(self) -> str:
return self.GATEWAY_NAME
def _get_headers(self, idempotency_key: Optional[str] = None) -> Dict[str, str]:
headers = {
'Content-Type': 'application/json',
'x-api-version': self.api_version,
'x-client-id': self.app_id,
'x-client-secret': self.secret_key,
}
if idempotency_key:
headers['x-idempotency-key'] = idempotency_key
return headers
def create_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,
) -> GatewayOrderResponse:
url = f"{self.base_url}/orders"
payload = {
"order_id": str(order_id),
"order_amount": round(float(amount), 2),
"order_currency": currency or "INR",
"customer_details": {
"customer_id": str(customer_id),
"customer_name": customer_name or "Customer",
"customer_email": customer_email or "customer@tradhox.com",
"customer_phone": str(customer_phone or "9999999999")[-10:],
},
"order_meta": {
"return_url": return_url or f"https://betapayments.tradhox.com/portal/?order_id={order_id}",
}
}
# Only attach notify_url if it is a valid public HTTP(S) URL
effective_notify = notify_url or "https://betapayments.tradhox.com/api/payments/webhooks/cashfree/"
if effective_notify and ('localhost' not in effective_notify and '127.0.0.1' not in effective_notify):
payload["order_meta"]["notify_url"] = effective_notify
if order_note:
payload["order_note"] = order_note
if metadata:
payload["order_tags"] = {str(k): str(v) for k, v in list(metadata.items())[:5]}
try:
logger.info(f"Calling Cashfree create_order for order {order_id} at {url}")
response = requests.post(
url,
json=payload,
headers=self._get_headers(idempotency_key=f"cf_ord_{order_id}"),
timeout=12
)
raw_data = response.json() if response.content else {}
if response.status_code in (200, 201):
session_id = raw_data.get("payment_session_id")
cf_order_id = str(raw_data.get("cf_order_id", ""))
checkout_url = f"{self.checkout_base}{session_id}" if session_id else None
return GatewayOrderResponse(
success=True,
gateway_name=self.GATEWAY_NAME,
gateway_order_id=cf_order_id,
payment_session_id=session_id,
checkout_url=checkout_url,
raw_response=raw_data
)
else:
err_code = raw_data.get("code") or f"HTTP_{response.status_code}"
err_msg = raw_data.get("message") or response.text
logger.error(f"Cashfree create_order failed: {err_code} - {err_msg}")
return GatewayOrderResponse(
success=False,
gateway_name=self.GATEWAY_NAME,
error_code=err_code,
error_message=err_msg,
raw_response=raw_data
)
except requests.RequestException as e:
logger.error(f"Network error calling Cashfree create_order: {e}")
return GatewayOrderResponse(
success=False,
gateway_name=self.GATEWAY_NAME,
error_code="NETWORK_ERROR",
error_message=str(e)
)
def get_order_status(self, gateway_order_id: str) -> Dict[str, Any]:
url = f"{self.base_url}/orders/{gateway_order_id}"
try:
response = requests.get(url, headers=self._get_headers(), timeout=10)
return response.json() if response.content else {"status": "HTTP_ERROR", "code": response.status_code}
except requests.RequestException as e:
logger.error(f"Cashfree get_order_status network error: {e}")
return {"error": str(e)}
def verify_webhook(self, raw_body: bytes, headers: Dict[str, str]) -> GatewayVerifyResult:
# Headers may be lowercase or uppercase
signature = headers.get("x-webhook-signature") or headers.get("X-Webhook-Signature")
timestamp = headers.get("x-webhook-timestamp") or headers.get("X-Webhook-Timestamp")
if not signature or not timestamp or not self.secret_key:
return GatewayVerifyResult(
is_valid=False,
event_type="UNKNOWN",
error_message="Missing signature, timestamp or secret key"
)
# Anti-replay check (5 mins)
try:
ts_val = int(timestamp)
if ts_val > 10000000000:
ts_val = ts_val // 1000
if abs(int(time.time()) - ts_val) > 300:
return GatewayVerifyResult(
is_valid=False,
event_type="TIMESTAMP_DRIFT",
error_message="Webhook timestamp older than 300 seconds"
)
except ValueError:
return GatewayVerifyResult(
is_valid=False,
event_type="INVALID_TIMESTAMP",
error_message="Timestamp not a valid integer"
)
# Compute HMAC-SHA256 signature
payload_to_sign = str(timestamp).encode('utf-8') + raw_body
computed_hmac = hmac.new(
key=self.secret_key.encode('utf-8'),
msg=payload_to_sign,
digestmod=hashlib.sha256
).digest()
computed_signature = base64.b64encode(computed_hmac).decode('utf-8')
is_matched = hmac.compare_digest(computed_signature, signature)
if not is_matched:
# Try alternate key (production vs test) in case order was placed under different environment
alt_key = getattr(settings, 'CASHFREE_PROD_SECRET_KEY', '') if self.environment != 'production' else getattr(settings, 'CASHFREE_TEST_SECRET_KEY', '')
if alt_key and alt_key != self.secret_key:
alt_hmac = hmac.new(
key=alt_key.encode('utf-8'),
msg=payload_to_sign,
digestmod=hashlib.sha256
).digest()
alt_sig = base64.b64encode(alt_hmac).decode('utf-8')
if hmac.compare_digest(alt_sig, signature):
is_matched = True
if not is_matched:
return GatewayVerifyResult(
is_valid=False,
event_type="SIGNATURE_MISMATCH",
error_message="Computed signature does not match header signature"
)
# Parse JSON
try:
body_json = json.loads(raw_body.decode('utf-8'))
except Exception as e:
return GatewayVerifyResult(
is_valid=False,
event_type="MALFORMED_JSON",
error_message=str(e)
)
event_type = body_json.get("type", "UNKNOWN")
data = body_json.get("data", {})
order_info = data.get("order", {})
payment_info = data.get("payment", {})
payment_status = payment_info.get("payment_status")
# Map Cashfree status to unified status
mapped_status = payment_status
if payment_status == "SUCCESS":
mapped_status = "SUCCESS"
elif payment_status in ("FAILED", "CANCELLED"):
mapped_status = "FAILED"
elif payment_status == "USER_DROPPED":
mapped_status = "USER_DROPPED"
return GatewayVerifyResult(
is_valid=True,
event_type=event_type,
order_id=order_info.get("order_id"),
gateway_payment_id=str(payment_info.get("cf_payment_id", "")),
payment_status=mapped_status,
payment_amount=float(payment_info.get("payment_amount", 0.0) or order_info.get("order_amount", 0.0)),
payment_currency=payment_info.get("payment_currency", "INR"),
payment_method=payment_info.get("payment_group"),
bank_reference=payment_info.get("bank_reference"),
raw_payload=body_json
)
def create_refund(
self,
order_id: str,
refund_id: str,
amount: float,
note: Optional[str] = None,
speed: str = "STANDARD",
) -> GatewayRefundResponse:
url = f"{self.base_url}/orders/{order_id}/refunds"
payload = {
"refund_id": str(refund_id),
"refund_amount": round(float(amount), 2),
"refund_note": note or "Merchant Refund",
"refund_speed": speed or "STANDARD"
}
try:
response = requests.post(
url,
json=payload,
headers=self._get_headers(idempotency_key=f"cf_ref_{refund_id}"),
timeout=12
)
raw_data = response.json() if response.content else {}
if response.status_code in (200, 201):
return GatewayRefundResponse(
success=True,
gateway_refund_id=str(raw_data.get("cf_refund_id", "")),
status=raw_data.get("refund_status"),
refund_arn=raw_data.get("refund_arn"),
raw_response=raw_data
)
else:
return GatewayRefundResponse(
success=False,
error_message=raw_data.get("message") or response.text,
raw_response=raw_data
)
except requests.RequestException as e:
return GatewayRefundResponse(
success=False,
error_message=str(e)
)
def get_order(self, order_id: str) -> Dict[str, Any]:
"""Fetches raw order details directly from Cashfree."""
url = f"{self.base_url}/orders/{order_id}"
try:
response = requests.get(url, headers=self._get_headers(), timeout=10)
return response.json() if response.content else {}
except Exception as e:
logger.error(f"Failed to fetch order {order_id} from Cashfree: {e}")
return {"error": str(e)}
def health_check(self) -> bool:
"""Pings Cashfree API to verify availability."""
url = f"{self.base_url}/orders"
try:
# Send a check with empty headers to see if server responds (will return 401 or similar, proving connectivity)
response = requests.get(url, headers=self._get_headers(), timeout=5)
# Any HTTP status means the server is reachable and network is up
return response.status_code in (200, 400, 401, 404)
except Exception:
return False