234 lines
9.3 KiB
Python
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()
|