ifixkart-backend/app/core/payment_orchestrator.py

234 lines
9.3 KiB
Python

import time
import hmac
import hashlib
import json
import logging
from datetime import datetime, timezone
from typing import Dict, Any, Optional
from sqlalchemy.orm import Session
import ulid
from app.core.config.Config import settings
from app.models.OrderModel import Order
from app.models.PaymentLedgerModel import PaymentLedger
from app.models.WebhookReceiptModel import WebhookReceipt
from app.core.razorpay import razorpay_service
from app.services.OrderService import OrderService
logger = logging.getLogger("app.payment_orchestrator")
class PaymentOrchestrator:
def initiate_payment(self, db: Session, order_id: str, client_ip: str = None, user_agent: str = None) -> Dict[str, Any]:
"""
Initiates a new payment attempt for an e-commerce order.
Row-locks the order and verifies status.
"""
order = db.query(Order).filter(Order.order_id == order_id).with_for_update().first()
if not order:
return {"error": "Order not found", "status_code": 404}
if order.payment_status == "PAYMENT_CAPTURED":
return {"error": "Order is already paid", "status_code": 400}
attempt_count = db.query(PaymentLedger).filter(PaymentLedger.order_id == order_id).count()
if attempt_count >= 5:
return {"error": "Max payment attempts exceeded for this order", "status_code": 429}
amount_paise = int(round(float(order.final_amount) * 100))
# Handle 0-amount orders directly without invoking Razorpay
if amount_paise <= 0:
invoice = OrderService.confirm_ecommerce_payment(db, order, payment_method="ZERO_AMOUNT")
return {
"order_id": order_id,
"order_no": order.order_no,
"rzp_order_id": f"ZERO_{order_id}",
"rzp_key_id": settings.RAZORPAY_KEY_ID or "",
"amount": 0,
"currency": "INR",
"security_token": "ZERO_AMOUNT_SECURE",
"status": "SUCCESS",
"zero_amount": True,
"invoice_id": invoice.invoice_id if invoice else None,
"invoice_no": invoice.invoice_no if invoice else None,
"status_code": 200
}
receipt = f"order_{order.order_no}_{int(time.time())}"
try:
rzp_order = razorpay_service.create_order(amount_paise, receipt)
except Exception as e:
logger.error(f"Gateway Failure: {str(e)}")
return {"error": f"Payment gateway currently unavailable: {str(e)}", "status_code": 503}
token_payload = f"{order_id}|{amount_paise}|{int(time.time())}"
security_token = hmac.new(
settings.SECRET_KEY.encode('utf-8'),
token_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
payment_entry = PaymentLedger(
payment_id=str(ulid.ULID()),
order_id=order_id,
provider="RAZORPAY",
razorpay_order_id=rzp_order['id'],
amount=order.final_amount,
currency="INR",
status="PENDING",
raw_response_json=json.dumps(rzp_order)
)
db.add(payment_entry)
db.commit()
return {
"order_id": order_id,
"rzp_order_id": rzp_order['id'],
"rzp_key_id": settings.RAZORPAY_KEY_ID,
"amount": amount_paise,
"currency": "INR",
"security_token": security_token,
"status_code": 200
}
def cancel_payment(self, db: Session, order_id: str, reason: str = "Payment cancelled by user") -> Dict[str, Any]:
"""
Cancels pending order payment and releases reserved stock.
"""
order = db.query(Order).filter(Order.order_id == order_id).with_for_update().first()
if not order:
return {"error": "Order not found", "status_code": 404}
cancelled_order = OrderService.cancel_ecommerce_order(db, order, reason=reason)
return {
"status": "SUCCESS",
"message": "Payment session cancelled and order status updated",
"order_id": cancelled_order.order_id,
"order_status": cancelled_order.status,
"payment_status": cancelled_order.payment_status
}
def verify_payment(
self,
db: Session,
order_id: str,
rzp_order_id: str,
rzp_payment_id: str,
rzp_signature: str
) -> Dict[str, Any]:
"""
Verifies client-side signature + executes secondary server-side fetch from Razorpay API.
"""
order = db.query(Order).filter(Order.order_id == order_id).with_for_update().first()
if not order:
return {"error": "Order not found", "status_code": 404}
if order.payment_status == "PAYMENT_CAPTURED":
return {"status": "SUCCESS", "message": "Payment already captured"}
payment_entry = (
db.query(PaymentLedger)
.filter(PaymentLedger.order_id == order_id, PaymentLedger.razorpay_order_id == rzp_order_id)
.with_for_update()
.first()
)
if not payment_entry:
return {"error": "Payment session not found for this order", "status_code": 404}
# 1. Cryptographic Signature Verification
sig_valid = razorpay_service.verify_payment_signature(rzp_order_id, rzp_payment_id, rzp_signature)
if not sig_valid:
payment_entry.status = "FAILED"
db.commit()
return {"error": "Cryptographic payment signature mismatch", "status_code": 400}
# 2. Server-side fetch from Razorpay API (Replay Protection)
try:
rzp_payment = razorpay_service.fetch_payment(rzp_payment_id)
except Exception as e:
return {"error": f"Failed to fetch payment details from provider: {str(e)}", "status_code": 502}
# Verify amount & order match
expected_paise = int(round(float(order.final_amount) * 100))
if int(rzp_payment.get("amount", 0)) != expected_paise:
logger.error(f"Amount mismatch! Expected {expected_paise}, got {rzp_payment.get('amount')}")
return {"error": "Transaction amount integrity failure", "status_code": 400}
if rzp_payment.get("order_id") != rzp_order_id:
logger.error("Order ID mismatch detected!")
return {"error": "Security violation: Cross-order replay attempt", "status_code": 403}
# 3. Finalize capture
payment_entry.transaction_ref = rzp_payment_id
payment_entry.razorpay_signature = rzp_signature
payment_entry.status = "CAPTURED"
payment_entry.raw_response_json = json.dumps(rzp_payment)
invoice = OrderService.confirm_ecommerce_payment(db, order, payment_method="RAZORPAY")
return {
"status": "SUCCESS",
"message": "Payment verified and order confirmed successfully",
"order_id": order.order_id,
"order_no": order.order_no,
"invoice_id": invoice.invoice_id if invoice else None,
"invoice_no": invoice.invoice_no if invoice else None
}
def process_webhook(self, db: Session, raw_body: bytes, signature: str) -> bool:
"""
Processes incoming Razorpay webhooks asynchronously with idempotency check.
"""
if not razorpay_service.verify_webhook_signature(raw_body, signature):
logger.warning("Invalid webhook signature received")
return False
try:
payload = json.loads(raw_body.decode('utf-8'))
except Exception:
return False
event_id = payload.get("id")
event_type = payload.get("event")
if not event_id:
return False
# Idempotency Guard
existing = db.query(WebhookReceipt).filter(WebhookReceipt.event_key == event_id).first()
if existing:
logger.info(f"Webhook event {event_id} already processed. Skipping.")
return True
receipt = WebhookReceipt(
receipt_id=str(ulid.ULID()),
provider="RAZORPAY",
event_key=event_id,
signature_valid=True,
payload_json=raw_body.decode('utf-8')
)
db.add(receipt)
if event_type in ["payment.captured", "order.paid"]:
entity = payload.get("payload", {}).get("payment", {}).get("entity", {})
rzp_order_id = entity.get("order_id")
rzp_payment_id = entity.get("id")
if rzp_order_id:
payment_entry = db.query(PaymentLedger).filter(PaymentLedger.razorpay_order_id == rzp_order_id).first()
if payment_entry and payment_entry.status != "CAPTURED":
order = db.query(Order).filter(Order.order_id == payment_entry.order_id).first()
if order:
payment_entry.transaction_ref = rzp_payment_id
payment_entry.status = "CAPTURED"
payment_entry.raw_response_json = json.dumps(entity)
OrderService.confirm_ecommerce_payment(db, order, payment_method="RAZORPAY")
receipt.processing_result = "CAPTURED_CONFIRMED"
receipt.processed_at = datetime.now(timezone.utc)
db.commit()
return True
payment_orchestrator = PaymentOrchestrator()