generated from vignesh/searchengine
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 36s
336 lines
14 KiB
Python
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
|
|
|