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()