Spaces:
Running on Zero
Running on Zero
Commit ·
8a23643
1
Parent(s): 7f2fc60
fix(security): mandate server-side Razorpay capture verification, state machine, and eliminate unverified grants
Browse files
app/payment_routes.py
CHANGED
|
@@ -117,6 +117,66 @@ async def fetch_razorpay_order_api(order_id: str, key_id: str, key_secret: str)
|
|
| 117 |
return None
|
| 118 |
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
|
| 122 |
# =========================================================
|
|
@@ -681,6 +741,16 @@ async def execute_payment_fulfillment(
|
|
| 681 |
"days_remaining": days_valid,
|
| 682 |
}
|
| 683 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
return fulfillment_result
|
| 685 |
|
| 686 |
|
|
@@ -963,12 +1033,32 @@ async def razorpay_create_order(
|
|
| 963 |
detail=f"Razorpay order initialization failed: {str(e)}"
|
| 964 |
)
|
| 965 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 966 |
return {
|
| 967 |
"success": True,
|
| 968 |
"domain": "web",
|
| 969 |
"gateway": "razorpay",
|
| 970 |
"key_id": key_id,
|
| 971 |
-
"order_id":
|
| 972 |
"amount_paise": amount_paise,
|
| 973 |
"amount": amount_inr,
|
| 974 |
"currency": "INR",
|
|
@@ -986,7 +1076,8 @@ async def razorpay_verify_payment(
|
|
| 986 |
):
|
| 987 |
"""
|
| 988 |
Cryptographically verify Razorpay payment HMAC SHA256 signature
|
| 989 |
-
|
|
|
|
| 990 |
"""
|
| 991 |
user_id = current_user.get("uid")
|
| 992 |
razorpay_order_id = payload.get("razorpay_order_id")
|
|
@@ -1005,7 +1096,7 @@ async def razorpay_verify_payment(
|
|
| 1005 |
|
| 1006 |
key_id, key_secret = get_razorpay_credentials()
|
| 1007 |
|
| 1008 |
-
# 🔐 Verify cryptographic HMAC SHA256 signature
|
| 1009 |
signature_data = f"{razorpay_order_id}|{razorpay_payment_id}".encode("utf-8")
|
| 1010 |
generated_signature = hmac.new(
|
| 1011 |
key_secret.encode("utf-8"),
|
|
@@ -1014,18 +1105,68 @@ async def razorpay_verify_payment(
|
|
| 1014 |
).hexdigest()
|
| 1015 |
|
| 1016 |
if not hmac.compare_digest(generated_signature, razorpay_signature):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1017 |
raise HTTPException(
|
| 1018 |
status_code=400,
|
| 1019 |
detail="Cryptographic payment signature mismatch. Verification failed."
|
| 1020 |
)
|
| 1021 |
|
| 1022 |
-
# 🛡️
|
| 1023 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1024 |
fetched_order = await fetch_razorpay_order_api(razorpay_order_id, key_id, key_secret)
|
| 1025 |
if fetched_order:
|
| 1026 |
-
order_amount_paise = fetched_order.get("amount", 0)
|
| 1027 |
-
if order_amount_paise > 0:
|
| 1028 |
-
authoritative_amount = float(order_amount_paise) / 100.0
|
| 1029 |
order_notes = fetched_order.get("notes") or {}
|
| 1030 |
order_user_id = order_notes.get("user_id")
|
| 1031 |
if order_user_id and str(order_user_id) != str(user_id):
|
|
@@ -1040,17 +1181,6 @@ async def razorpay_verify_payment(
|
|
| 1040 |
reference_id = order_notes["reference_id"]
|
| 1041 |
elif order_notes.get("plan_id"):
|
| 1042 |
reference_id = order_notes["plan_id"]
|
| 1043 |
-
else:
|
| 1044 |
-
# Fallback security validation when Razorpay order API is unreachable or in mock/test mode
|
| 1045 |
-
if purpose == "subscription":
|
| 1046 |
-
plan_id = reference_id or metadata.get("plan_id") or "sahih_pro_monthly"
|
| 1047 |
-
if plan_id in CANONICAL_PLANS:
|
| 1048 |
-
authoritative_amount = float(CANONICAL_PLANS[plan_id]["price"])
|
| 1049 |
-
elif purpose == "business_activation":
|
| 1050 |
-
authoritative_amount = 50.0
|
| 1051 |
-
elif purpose == "ad_wallet":
|
| 1052 |
-
if authoritative_amount < 1.0:
|
| 1053 |
-
raise HTTPException(status_code=400, detail="Wallet top-up amount must be at least ₹1.")
|
| 1054 |
|
| 1055 |
# 🛡️ Intro Offer Single-Use Guard
|
| 1056 |
if purpose == "subscription" and (reference_id == "sahih_pro_intro_5" or metadata.get("plan_id") == "sahih_pro_intro_5"):
|
|
@@ -1105,10 +1235,7 @@ async def razorpay_webhook(request: Request):
|
|
| 1105 |
webhook_secret = settings.razorpay.webhook_secret
|
| 1106 |
|
| 1107 |
# 1. Cryptographic Signature Verification
|
| 1108 |
-
if
|
| 1109 |
-
if not webhook_secret:
|
| 1110 |
-
logger.error("[RazorpayWebhook] RAZORPAY_WEBHOOK_SECRET not configured in production!")
|
| 1111 |
-
raise HTTPException(status_code=500, detail="Webhook verification secret not configured")
|
| 1112 |
if not signature:
|
| 1113 |
logger.warning("[RazorpayWebhook] Missing X-Razorpay-Signature header in webhook request")
|
| 1114 |
raise HTTPException(status_code=401, detail="Missing Razorpay webhook signature header")
|
|
@@ -1122,14 +1249,9 @@ async def razorpay_webhook(request: Request):
|
|
| 1122 |
if not hmac.compare_digest(expected_signature, signature):
|
| 1123 |
logger.warning("[RazorpayWebhook] Invalid signature received on webhook endpoint")
|
| 1124 |
raise HTTPException(status_code=401, detail="Invalid Razorpay webhook signature")
|
| 1125 |
-
elif
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
raw_body,
|
| 1129 |
-
hashlib.sha256
|
| 1130 |
-
).hexdigest()
|
| 1131 |
-
if not hmac.compare_digest(expected_signature, signature):
|
| 1132 |
-
raise HTTPException(status_code=401, detail="Invalid Razorpay webhook signature")
|
| 1133 |
|
| 1134 |
try:
|
| 1135 |
event_payload = json.loads(raw_body.decode("utf-8")) if raw_body else {}
|
|
@@ -2657,9 +2779,29 @@ async def create_subscription_razorpay_order(
|
|
| 2657 |
|
| 2658 |
try:
|
| 2659 |
order = await create_razorpay_order_api(order_data, key_id, key_secret)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2660 |
return {
|
| 2661 |
"success": True,
|
| 2662 |
-
"order_id":
|
| 2663 |
"amount": amount_paise,
|
| 2664 |
"currency": "INR",
|
| 2665 |
"key_id": key_id,
|
|
@@ -2676,7 +2818,8 @@ async def verify_subscription_razorpay(
|
|
| 2676 |
current_user: dict = Depends(get_current_user)
|
| 2677 |
):
|
| 2678 |
"""
|
| 2679 |
-
Verifies Razorpay HMAC signature
|
|
|
|
| 2680 |
"""
|
| 2681 |
user_id = current_user.get("uid")
|
| 2682 |
order_id = payload.get("razorpay_order_id")
|
|
@@ -2694,16 +2837,72 @@ async def verify_subscription_razorpay(
|
|
| 2694 |
raise HTTPException(status_code=400, detail="Missing required Razorpay payment fields")
|
| 2695 |
|
| 2696 |
key_id, key_secret = get_razorpay_credentials()
|
|
|
|
|
|
|
| 2697 |
msg = f"{order_id}|{payment_id}".encode("utf-8")
|
| 2698 |
expected_sig = hmac.new(key_secret.encode("utf-8"), msg, hashlib.sha256).hexdigest()
|
| 2699 |
|
| 2700 |
if not hmac.compare_digest(expected_sig, signature):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2701 |
raise HTTPException(status_code=400, detail="Invalid Razorpay signature")
|
| 2702 |
|
| 2703 |
plan_info = CANONICAL_PLANS[plan_id]
|
| 2704 |
price = float(plan_info["price"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2705 |
|
| 2706 |
-
# 🛡️ Strict single-use check for ₹5 introductory offer
|
| 2707 |
if plan_id == "sahih_pro_intro_5":
|
| 2708 |
user_doc = await users_collection.find_one({"uid": user_id}) or {}
|
| 2709 |
existing_intro_tx = await transactions_collection.find_one({
|
|
|
|
| 117 |
return None
|
| 118 |
|
| 119 |
|
| 120 |
+
async def fetch_razorpay_payment_api(payment_id: str, key_id: str, key_secret: str) -> Optional[dict]:
|
| 121 |
+
"""
|
| 122 |
+
Fetch authoritative payment object from Razorpay REST API to verify final capture status.
|
| 123 |
+
Mandatory defense: HMAC signature alone only proves an attempt was initiated, NOT that funds were captured!
|
| 124 |
+
"""
|
| 125 |
+
if not payment_id or not key_id or not key_secret:
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
if razorpay is not None:
|
| 129 |
+
try:
|
| 130 |
+
client = razorpay.Client(auth=(key_id, key_secret))
|
| 131 |
+
return client.payment.fetch(payment_id)
|
| 132 |
+
except Exception as e:
|
| 133 |
+
logger.debug(f"[Razorpay] SDK fetch error for payment {payment_id}: {e}")
|
| 134 |
+
|
| 135 |
+
try:
|
| 136 |
+
async with httpx.AsyncClient(timeout=10.0) as http_client:
|
| 137 |
+
resp = await http_client.get(
|
| 138 |
+
f"https://api.razorpay.com/v1/payments/{payment_id}",
|
| 139 |
+
auth=(key_id, key_secret)
|
| 140 |
+
)
|
| 141 |
+
if resp.status_code == 200:
|
| 142 |
+
return resp.json()
|
| 143 |
+
logger.warning(f"[Razorpay] REST fetch returned {resp.status_code} for payment {payment_id}: {resp.text}")
|
| 144 |
+
except Exception as e:
|
| 145 |
+
logger.error(f"[Razorpay] REST fetch exception for payment {payment_id}: {e}")
|
| 146 |
+
|
| 147 |
+
return None
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
async def capture_razorpay_payment_api(payment_id: str, amount_paise: int, key_id: str, key_secret: str) -> Optional[dict]:
|
| 151 |
+
"""
|
| 152 |
+
Server-authoritative payment capture for Razorpay payments in 'authorized' state.
|
| 153 |
+
"""
|
| 154 |
+
if not payment_id or not key_id or not key_secret or amount_paise <= 0:
|
| 155 |
+
return None
|
| 156 |
+
|
| 157 |
+
if razorpay is not None:
|
| 158 |
+
try:
|
| 159 |
+
client = razorpay.Client(auth=(key_id, key_secret))
|
| 160 |
+
return client.payment.capture(payment_id, amount_paise)
|
| 161 |
+
except Exception as e:
|
| 162 |
+
logger.debug(f"[Razorpay] SDK capture error for payment {payment_id}: {e}")
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
async with httpx.AsyncClient(timeout=10.0) as http_client:
|
| 166 |
+
resp = await http_client.post(
|
| 167 |
+
f"https://api.razorpay.com/v1/payments/{payment_id}/capture",
|
| 168 |
+
auth=(key_id, key_secret),
|
| 169 |
+
json={"amount": amount_paise, "currency": "INR"}
|
| 170 |
+
)
|
| 171 |
+
if resp.status_code in (200, 201):
|
| 172 |
+
return resp.json()
|
| 173 |
+
logger.warning(f"[Razorpay] REST capture returned {resp.status_code} for payment {payment_id}: {resp.text}")
|
| 174 |
+
except Exception as e:
|
| 175 |
+
logger.error(f"[Razorpay] REST capture exception for payment {payment_id}: {e}")
|
| 176 |
+
|
| 177 |
+
return None
|
| 178 |
+
|
| 179 |
+
|
| 180 |
|
| 181 |
|
| 182 |
# =========================================================
|
|
|
|
| 741 |
"days_remaining": days_valid,
|
| 742 |
}
|
| 743 |
|
| 744 |
+
# 🔄 State Transition: Transition any pre-created order from 'created' -> 'completed'
|
| 745 |
+
if external_order_id:
|
| 746 |
+
try:
|
| 747 |
+
await transactions_collection.update_many(
|
| 748 |
+
{"transaction_id": external_order_id, "status": "created"},
|
| 749 |
+
{"$set": {"status": "completed", "fulfillment_transaction_id": transaction_id, "updated_at": now}}
|
| 750 |
+
)
|
| 751 |
+
except Exception as e:
|
| 752 |
+
logger.debug(f"[Fulfillment] Error updating order status for {external_order_id}: {e}")
|
| 753 |
+
|
| 754 |
return fulfillment_result
|
| 755 |
|
| 756 |
|
|
|
|
| 1033 |
detail=f"Razorpay order initialization failed: {str(e)}"
|
| 1034 |
)
|
| 1035 |
|
| 1036 |
+
order_id = razorpay_order.get("id")
|
| 1037 |
+
now = datetime.utcnow()
|
| 1038 |
+
try:
|
| 1039 |
+
await transactions_collection.insert_one({
|
| 1040 |
+
"transaction_id": order_id,
|
| 1041 |
+
"external_order_id": order_id,
|
| 1042 |
+
"user_id": user_id,
|
| 1043 |
+
"amount": amount_inr,
|
| 1044 |
+
"type": f"{purpose}_order",
|
| 1045 |
+
"reference_id": reference_id,
|
| 1046 |
+
"description": f"Razorpay Order for {purpose} ({order_id})",
|
| 1047 |
+
"payment_method": "razorpay",
|
| 1048 |
+
"payment_domain": "web",
|
| 1049 |
+
"status": "created",
|
| 1050 |
+
"metadata": metadata,
|
| 1051 |
+
"created_at": now,
|
| 1052 |
+
})
|
| 1053 |
+
except Exception as e:
|
| 1054 |
+
logger.warning(f"[RazorpayOrder] Error pre-persisting order {order_id}: {e}")
|
| 1055 |
+
|
| 1056 |
return {
|
| 1057 |
"success": True,
|
| 1058 |
"domain": "web",
|
| 1059 |
"gateway": "razorpay",
|
| 1060 |
"key_id": key_id,
|
| 1061 |
+
"order_id": order_id,
|
| 1062 |
"amount_paise": amount_paise,
|
| 1063 |
"amount": amount_inr,
|
| 1064 |
"currency": "INR",
|
|
|
|
| 1076 |
):
|
| 1077 |
"""
|
| 1078 |
Cryptographically verify Razorpay payment HMAC SHA256 signature
|
| 1079 |
+
AND verify authoritative payment capture status via Razorpay REST API
|
| 1080 |
+
before executing universal fulfillment.
|
| 1081 |
"""
|
| 1082 |
user_id = current_user.get("uid")
|
| 1083 |
razorpay_order_id = payload.get("razorpay_order_id")
|
|
|
|
| 1096 |
|
| 1097 |
key_id, key_secret = get_razorpay_credentials()
|
| 1098 |
|
| 1099 |
+
# 🔐 Step 1: Verify cryptographic HMAC SHA256 signature
|
| 1100 |
signature_data = f"{razorpay_order_id}|{razorpay_payment_id}".encode("utf-8")
|
| 1101 |
generated_signature = hmac.new(
|
| 1102 |
key_secret.encode("utf-8"),
|
|
|
|
| 1105 |
).hexdigest()
|
| 1106 |
|
| 1107 |
if not hmac.compare_digest(generated_signature, razorpay_signature):
|
| 1108 |
+
await transactions_collection.update_many(
|
| 1109 |
+
{"transaction_id": razorpay_order_id, "status": "created"},
|
| 1110 |
+
{"$set": {"status": "failed", "error_description": "Cryptographic signature mismatch", "updated_at": datetime.utcnow()}}
|
| 1111 |
+
)
|
| 1112 |
raise HTTPException(
|
| 1113 |
status_code=400,
|
| 1114 |
detail="Cryptographic payment signature mismatch. Verification failed."
|
| 1115 |
)
|
| 1116 |
|
| 1117 |
+
# 🛡️ Step 2: Fetch and authoritatively verify payment from Razorpay REST API
|
| 1118 |
+
# MANDATORY DEFENSE: HMAC signature alone only proves an attempt was initiated, NOT that funds were captured!
|
| 1119 |
+
payment_info = await fetch_razorpay_payment_api(razorpay_payment_id, key_id, key_secret)
|
| 1120 |
+
if not payment_info:
|
| 1121 |
+
await transactions_collection.update_many(
|
| 1122 |
+
{"transaction_id": razorpay_order_id, "status": "created"},
|
| 1123 |
+
{"$set": {"status": "failed", "error_description": f"Payment {razorpay_payment_id} not found on Razorpay gateway", "updated_at": datetime.utcnow()}}
|
| 1124 |
+
)
|
| 1125 |
+
raise HTTPException(
|
| 1126 |
+
status_code=400,
|
| 1127 |
+
detail="Payment verification failed: Payment not found on gateway. No benefits granted."
|
| 1128 |
+
)
|
| 1129 |
+
|
| 1130 |
+
payment_status = payment_info.get("status")
|
| 1131 |
+
# If authorized and capture is pending, attempt server-side capture
|
| 1132 |
+
if payment_status == "authorized":
|
| 1133 |
+
order_amount_paise = payment_info.get("amount", 0)
|
| 1134 |
+
captured_payment = await capture_razorpay_payment_api(razorpay_payment_id, order_amount_paise, key_id, key_secret)
|
| 1135 |
+
if captured_payment:
|
| 1136 |
+
payment_info = captured_payment
|
| 1137 |
+
payment_status = payment_info.get("status")
|
| 1138 |
+
|
| 1139 |
+
if payment_status != "captured":
|
| 1140 |
+
logger.warning(f"[RazorpayVerify] Payment {razorpay_payment_id} not captured. Current status: '{payment_status}'")
|
| 1141 |
+
err_msg = payment_info.get("error_description") or payment_info.get("description") or f"Payment status is {payment_status}"
|
| 1142 |
+
await transactions_collection.update_many(
|
| 1143 |
+
{"transaction_id": razorpay_order_id, "status": "created"},
|
| 1144 |
+
{"$set": {"status": "failed", "error_description": f"Gateway status '{payment_status}': {err_msg}", "updated_at": datetime.utcnow()}}
|
| 1145 |
+
)
|
| 1146 |
+
raise HTTPException(
|
| 1147 |
+
status_code=400,
|
| 1148 |
+
detail=f"Payment was not captured on gateway (status: {payment_status}). Reason: {err_msg}"
|
| 1149 |
+
)
|
| 1150 |
+
|
| 1151 |
+
# 🛡️ Step 3: Validate payment attributes
|
| 1152 |
+
payment_currency = payment_info.get("currency", "")
|
| 1153 |
+
if payment_currency != "INR":
|
| 1154 |
+
raise HTTPException(status_code=400, detail=f"Invalid currency '{payment_currency}'. Expected INR.")
|
| 1155 |
+
|
| 1156 |
+
payment_order_id = payment_info.get("order_id")
|
| 1157 |
+
if payment_order_id and payment_order_id != razorpay_order_id:
|
| 1158 |
+
logger.warning(f"Payment order mismatch: payment belongs to {payment_order_id}, claimed {razorpay_order_id}")
|
| 1159 |
+
raise HTTPException(status_code=400, detail="Payment order mismatch. Verification failed.")
|
| 1160 |
+
|
| 1161 |
+
# Authoritative amount from gateway
|
| 1162 |
+
payment_amount_paise = payment_info.get("amount", 0)
|
| 1163 |
+
if payment_amount_paise <= 0:
|
| 1164 |
+
raise HTTPException(status_code=400, detail="Authoritative payment amount must be greater than zero.")
|
| 1165 |
+
authoritative_amount = float(payment_amount_paise) / 100.0
|
| 1166 |
+
|
| 1167 |
+
# 🛡️ Step 4: Server-Authoritative Order Verification & User Isolation
|
| 1168 |
fetched_order = await fetch_razorpay_order_api(razorpay_order_id, key_id, key_secret)
|
| 1169 |
if fetched_order:
|
|
|
|
|
|
|
|
|
|
| 1170 |
order_notes = fetched_order.get("notes") or {}
|
| 1171 |
order_user_id = order_notes.get("user_id")
|
| 1172 |
if order_user_id and str(order_user_id) != str(user_id):
|
|
|
|
| 1181 |
reference_id = order_notes["reference_id"]
|
| 1182 |
elif order_notes.get("plan_id"):
|
| 1183 |
reference_id = order_notes["plan_id"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1184 |
|
| 1185 |
# 🛡️ Intro Offer Single-Use Guard
|
| 1186 |
if purpose == "subscription" and (reference_id == "sahih_pro_intro_5" or metadata.get("plan_id") == "sahih_pro_intro_5"):
|
|
|
|
| 1235 |
webhook_secret = settings.razorpay.webhook_secret
|
| 1236 |
|
| 1237 |
# 1. Cryptographic Signature Verification
|
| 1238 |
+
if webhook_secret:
|
|
|
|
|
|
|
|
|
|
| 1239 |
if not signature:
|
| 1240 |
logger.warning("[RazorpayWebhook] Missing X-Razorpay-Signature header in webhook request")
|
| 1241 |
raise HTTPException(status_code=401, detail="Missing Razorpay webhook signature header")
|
|
|
|
| 1249 |
if not hmac.compare_digest(expected_signature, signature):
|
| 1250 |
logger.warning("[RazorpayWebhook] Invalid signature received on webhook endpoint")
|
| 1251 |
raise HTTPException(status_code=401, detail="Invalid Razorpay webhook signature")
|
| 1252 |
+
elif settings.IS_PROD:
|
| 1253 |
+
logger.error("[RazorpayWebhook] RAZORPAY_WEBHOOK_SECRET not configured in production!")
|
| 1254 |
+
raise HTTPException(status_code=500, detail="Webhook verification secret not configured")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1255 |
|
| 1256 |
try:
|
| 1257 |
event_payload = json.loads(raw_body.decode("utf-8")) if raw_body else {}
|
|
|
|
| 2779 |
|
| 2780 |
try:
|
| 2781 |
order = await create_razorpay_order_api(order_data, key_id, key_secret)
|
| 2782 |
+
order_id = order.get("id")
|
| 2783 |
+
now = datetime.utcnow()
|
| 2784 |
+
try:
|
| 2785 |
+
await transactions_collection.insert_one({
|
| 2786 |
+
"transaction_id": order_id,
|
| 2787 |
+
"external_order_id": order_id,
|
| 2788 |
+
"user_id": user_id,
|
| 2789 |
+
"amount": price_inr,
|
| 2790 |
+
"type": "subscription_order",
|
| 2791 |
+
"reference_id": plan_id,
|
| 2792 |
+
"description": f"Subscription Order for {plan_info.get('name', plan_id)} via Razorpay ({order_id})",
|
| 2793 |
+
"payment_method": "razorpay",
|
| 2794 |
+
"payment_domain": "web",
|
| 2795 |
+
"status": "created",
|
| 2796 |
+
"metadata": {"plan_id": plan_id},
|
| 2797 |
+
"created_at": now,
|
| 2798 |
+
})
|
| 2799 |
+
except Exception as e:
|
| 2800 |
+
logger.warning(f"[SubscriptionOrder] Error pre-persisting order {order_id}: {e}")
|
| 2801 |
+
|
| 2802 |
return {
|
| 2803 |
"success": True,
|
| 2804 |
+
"order_id": order_id,
|
| 2805 |
"amount": amount_paise,
|
| 2806 |
"currency": "INR",
|
| 2807 |
"key_id": key_id,
|
|
|
|
| 2818 |
current_user: dict = Depends(get_current_user)
|
| 2819 |
):
|
| 2820 |
"""
|
| 2821 |
+
Verifies Razorpay HMAC signature AND confirms authoritative payment capture
|
| 2822 |
+
via Razorpay REST API before granting Pro entitlements.
|
| 2823 |
"""
|
| 2824 |
user_id = current_user.get("uid")
|
| 2825 |
order_id = payload.get("razorpay_order_id")
|
|
|
|
| 2837 |
raise HTTPException(status_code=400, detail="Missing required Razorpay payment fields")
|
| 2838 |
|
| 2839 |
key_id, key_secret = get_razorpay_credentials()
|
| 2840 |
+
|
| 2841 |
+
# 🔐 Step 1: Verify HMAC SHA256 signature
|
| 2842 |
msg = f"{order_id}|{payment_id}".encode("utf-8")
|
| 2843 |
expected_sig = hmac.new(key_secret.encode("utf-8"), msg, hashlib.sha256).hexdigest()
|
| 2844 |
|
| 2845 |
if not hmac.compare_digest(expected_sig, signature):
|
| 2846 |
+
await transactions_collection.update_many(
|
| 2847 |
+
{"transaction_id": order_id, "status": "created"},
|
| 2848 |
+
{"$set": {"status": "failed", "error_description": "Cryptographic signature mismatch", "updated_at": datetime.utcnow()}}
|
| 2849 |
+
)
|
| 2850 |
raise HTTPException(status_code=400, detail="Invalid Razorpay signature")
|
| 2851 |
|
| 2852 |
plan_info = CANONICAL_PLANS[plan_id]
|
| 2853 |
price = float(plan_info["price"])
|
| 2854 |
+
expected_amount_paise = int(round(price * 100))
|
| 2855 |
+
|
| 2856 |
+
# 🛡️ Step 2: Fetch authoritative payment object directly from Razorpay REST API
|
| 2857 |
+
# MANDATORY GATEWAY VERIFICATION: HMAC signature alone is NEVER proof of funds capture!
|
| 2858 |
+
payment_info = await fetch_razorpay_payment_api(payment_id, key_id, key_secret)
|
| 2859 |
+
if not payment_info:
|
| 2860 |
+
await transactions_collection.update_many(
|
| 2861 |
+
{"transaction_id": order_id, "status": "created"},
|
| 2862 |
+
{"$set": {"status": "failed", "error_description": f"Payment {payment_id} not found on Razorpay gateway", "updated_at": datetime.utcnow()}}
|
| 2863 |
+
)
|
| 2864 |
+
raise HTTPException(
|
| 2865 |
+
status_code=400,
|
| 2866 |
+
detail="Payment verification failed: Payment not found on gateway. No subscription granted."
|
| 2867 |
+
)
|
| 2868 |
+
|
| 2869 |
+
payment_status = payment_info.get("status")
|
| 2870 |
+
# If authorized and capture is pending, attempt server-side capture
|
| 2871 |
+
if payment_status == "authorized":
|
| 2872 |
+
order_amount_paise = payment_info.get("amount", expected_amount_paise)
|
| 2873 |
+
captured_payment = await capture_razorpay_payment_api(payment_id, order_amount_paise, key_id, key_secret)
|
| 2874 |
+
if captured_payment:
|
| 2875 |
+
payment_info = captured_payment
|
| 2876 |
+
payment_status = payment_info.get("status")
|
| 2877 |
+
|
| 2878 |
+
if payment_status != "captured":
|
| 2879 |
+
logger.warning(f"[RazorpaySubVerify] Payment {payment_id} not captured. Current status: '{payment_status}'")
|
| 2880 |
+
err_msg = payment_info.get("error_description") or payment_info.get("description") or f"Payment status is {payment_status}"
|
| 2881 |
+
await transactions_collection.update_many(
|
| 2882 |
+
{"transaction_id": order_id, "status": "created"},
|
| 2883 |
+
{"$set": {"status": "failed", "error_description": f"Gateway status '{payment_status}': {err_msg}", "updated_at": datetime.utcnow()}}
|
| 2884 |
+
)
|
| 2885 |
+
raise HTTPException(
|
| 2886 |
+
status_code=400,
|
| 2887 |
+
detail=f"Payment was not captured on gateway (status: {payment_status}). Reason: {err_msg}"
|
| 2888 |
+
)
|
| 2889 |
+
|
| 2890 |
+
# 🛡️ Step 3: Validate payment attributes
|
| 2891 |
+
payment_currency = payment_info.get("currency", "")
|
| 2892 |
+
if payment_currency != "INR":
|
| 2893 |
+
raise HTTPException(status_code=400, detail=f"Invalid currency '{payment_currency}'. Expected INR.")
|
| 2894 |
+
|
| 2895 |
+
payment_order_id = payment_info.get("order_id")
|
| 2896 |
+
if payment_order_id and payment_order_id != order_id:
|
| 2897 |
+
logger.warning(f"Payment order mismatch: payment belongs to {payment_order_id}, claimed {order_id}")
|
| 2898 |
+
raise HTTPException(status_code=400, detail="Payment order mismatch. Verification failed.")
|
| 2899 |
+
|
| 2900 |
+
payment_amount_paise = payment_info.get("amount", 0)
|
| 2901 |
+
if payment_amount_paise != expected_amount_paise:
|
| 2902 |
+
logger.warning(f"Payment amount mismatch: expected {expected_amount_paise} paise, got {payment_amount_paise}")
|
| 2903 |
+
raise HTTPException(status_code=400, detail=f"Payment amount mismatch. Expected ₹{price}, got ₹{payment_amount_paise / 100.0}")
|
| 2904 |
|
| 2905 |
+
# 🛡️ Step 4: Strict single-use check for ₹5 introductory offer
|
| 2906 |
if plan_id == "sahih_pro_intro_5":
|
| 2907 |
user_doc = await users_collection.find_one({"uid": user_id}) or {}
|
| 2908 |
existing_intro_tx = await transactions_collection.find_one({
|
tests/test_payment_reliability_and_razorpay_migration.py
CHANGED
|
@@ -132,9 +132,18 @@ def test_3_razorpay_signature_verification_and_wallet_credit():
|
|
| 132 |
with patch("app.payment_routes.transactions_collection.find_one", new_callable=AsyncMock) as mock_tx_find, \
|
| 133 |
patch("app.payment_routes.wallets_collection.find_one_and_update", new_callable=AsyncMock) as mock_wallet_upd, \
|
| 134 |
patch("app.payment_routes.transactions_collection.insert_one", new_callable=AsyncMock) as mock_tx_ins, \
|
| 135 |
-
patch("app.payment_routes.
|
|
|
|
|
|
|
| 136 |
|
| 137 |
mock_tx_find.return_value = None # No replay
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
mock_fetch_order.return_value = {
|
| 139 |
"id": order_id,
|
| 140 |
"amount": 25000,
|
|
@@ -164,8 +173,9 @@ def test_4_tampered_signature_rejected():
|
|
| 164 |
"amount": 1000.0,
|
| 165 |
}
|
| 166 |
|
| 167 |
-
with
|
| 168 |
-
|
|
|
|
| 169 |
|
| 170 |
assert exc_info.value.status_code == 400
|
| 171 |
assert "mismatch" in exc_info.value.detail.lower()
|
|
@@ -200,9 +210,18 @@ def test_5_client_price_tampering_prevented():
|
|
| 200 |
with patch("app.payment_routes.transactions_collection.find_one", new_callable=AsyncMock) as mock_tx_find, \
|
| 201 |
patch("app.payment_routes.wallets_collection.find_one_and_update", new_callable=AsyncMock) as mock_wallet_upd, \
|
| 202 |
patch("app.payment_routes.transactions_collection.insert_one", new_callable=AsyncMock), \
|
| 203 |
-
patch("app.payment_routes.
|
|
|
|
|
|
|
| 204 |
|
| 205 |
mock_tx_find.return_value = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
# Authoritative Razorpay API returns 10,000 paise = ₹100.00
|
| 207 |
mock_fetch_order.return_value = {
|
| 208 |
"id": order_id,
|
|
@@ -376,8 +395,18 @@ def test_10_intro_offer_single_use_enforcement_in_verification():
|
|
| 376 |
}
|
| 377 |
|
| 378 |
with patch("app.payment_routes.users_collection.find_one", new_callable=AsyncMock) as mock_u_find, \
|
| 379 |
-
patch("app.payment_routes.transactions_collection.find_one", new_callable=AsyncMock) as mock_tx_find
|
| 380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
# User already has has_claimed_intro_offer in document
|
| 382 |
mock_u_find.return_value = {
|
| 383 |
"uid": user_id,
|
|
|
|
| 132 |
with patch("app.payment_routes.transactions_collection.find_one", new_callable=AsyncMock) as mock_tx_find, \
|
| 133 |
patch("app.payment_routes.wallets_collection.find_one_and_update", new_callable=AsyncMock) as mock_wallet_upd, \
|
| 134 |
patch("app.payment_routes.transactions_collection.insert_one", new_callable=AsyncMock) as mock_tx_ins, \
|
| 135 |
+
patch("app.payment_routes.transactions_collection.update_many", new_callable=AsyncMock), \
|
| 136 |
+
patch("app.payment_routes.fetch_razorpay_order_api", new_callable=AsyncMock) as mock_fetch_order, \
|
| 137 |
+
patch("app.payment_routes.fetch_razorpay_payment_api", new_callable=AsyncMock) as mock_fetch_payment:
|
| 138 |
|
| 139 |
mock_tx_find.return_value = None # No replay
|
| 140 |
+
mock_fetch_payment.return_value = {
|
| 141 |
+
"id": payment_id,
|
| 142 |
+
"order_id": order_id,
|
| 143 |
+
"amount": 25000,
|
| 144 |
+
"currency": "INR",
|
| 145 |
+
"status": "captured",
|
| 146 |
+
}
|
| 147 |
mock_fetch_order.return_value = {
|
| 148 |
"id": order_id,
|
| 149 |
"amount": 25000,
|
|
|
|
| 173 |
"amount": 1000.0,
|
| 174 |
}
|
| 175 |
|
| 176 |
+
with patch("app.payment_routes.transactions_collection.update_many", new_callable=AsyncMock):
|
| 177 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 178 |
+
await razorpay_verify_payment(payload=payload, current_user=mock_user)
|
| 179 |
|
| 180 |
assert exc_info.value.status_code == 400
|
| 181 |
assert "mismatch" in exc_info.value.detail.lower()
|
|
|
|
| 210 |
with patch("app.payment_routes.transactions_collection.find_one", new_callable=AsyncMock) as mock_tx_find, \
|
| 211 |
patch("app.payment_routes.wallets_collection.find_one_and_update", new_callable=AsyncMock) as mock_wallet_upd, \
|
| 212 |
patch("app.payment_routes.transactions_collection.insert_one", new_callable=AsyncMock), \
|
| 213 |
+
patch("app.payment_routes.transactions_collection.update_many", new_callable=AsyncMock), \
|
| 214 |
+
patch("app.payment_routes.fetch_razorpay_order_api", new_callable=AsyncMock) as mock_fetch_order, \
|
| 215 |
+
patch("app.payment_routes.fetch_razorpay_payment_api", new_callable=AsyncMock) as mock_fetch_payment:
|
| 216 |
|
| 217 |
mock_tx_find.return_value = None
|
| 218 |
+
mock_fetch_payment.return_value = {
|
| 219 |
+
"id": payment_id,
|
| 220 |
+
"order_id": order_id,
|
| 221 |
+
"amount": 10000,
|
| 222 |
+
"currency": "INR",
|
| 223 |
+
"status": "captured",
|
| 224 |
+
}
|
| 225 |
# Authoritative Razorpay API returns 10,000 paise = ₹100.00
|
| 226 |
mock_fetch_order.return_value = {
|
| 227 |
"id": order_id,
|
|
|
|
| 395 |
}
|
| 396 |
|
| 397 |
with patch("app.payment_routes.users_collection.find_one", new_callable=AsyncMock) as mock_u_find, \
|
| 398 |
+
patch("app.payment_routes.transactions_collection.find_one", new_callable=AsyncMock) as mock_tx_find, \
|
| 399 |
+
patch("app.payment_routes.transactions_collection.update_many", new_callable=AsyncMock), \
|
| 400 |
+
patch("app.payment_routes.fetch_razorpay_payment_api", new_callable=AsyncMock) as mock_fetch_payment:
|
| 401 |
+
|
| 402 |
+
# Payment exists and is captured on gateway
|
| 403 |
+
mock_fetch_payment.return_value = {
|
| 404 |
+
"id": payment_id,
|
| 405 |
+
"order_id": order_id,
|
| 406 |
+
"amount": 500,
|
| 407 |
+
"currency": "INR",
|
| 408 |
+
"status": "captured",
|
| 409 |
+
}
|
| 410 |
# User already has has_claimed_intro_offer in document
|
| 411 |
mock_u_find.return_value = {
|
| 412 |
"uid": user_id,
|
tests/test_payment_security_and_capture_verification.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Payment Security, Capture Verification & State Machine Remediation Test Suite
|
| 3 |
+
=============================================================================
|
| 4 |
+
Critical security audit test suite to guarantee:
|
| 5 |
+
1. HMAC signature alone NEVER grants paid benefits.
|
| 6 |
+
2. Uncaptured / failed / cancelled payments are strictly rejected with HTTP 400.
|
| 7 |
+
3. Order pre-persistence and state transitions ('created' -> 'captured'/'failed') are enforced.
|
| 8 |
+
4. Tampering with order IDs, amounts, currencies, or user accounts is blocked.
|
| 9 |
+
5. Insecure webhook bypasses are eliminated.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import hmac
|
| 14 |
+
import hashlib
|
| 15 |
+
import json
|
| 16 |
+
import asyncio
|
| 17 |
+
from datetime import datetime
|
| 18 |
+
from unittest.mock import AsyncMock, patch
|
| 19 |
+
|
| 20 |
+
import pytest
|
| 21 |
+
from fastapi import HTTPException, Request
|
| 22 |
+
|
| 23 |
+
os.environ["TEST_MODE"] = "1"
|
| 24 |
+
os.environ["RAZORPAY_KEY_ID"] = "rzp_test_TqgD42MZb3kwwQ"
|
| 25 |
+
os.environ["RAZORPAY_KEY_SECRET"] = "EkbAfQm9ks85jHT1qErb30GJ"
|
| 26 |
+
os.environ["RAZORPAY_WEBHOOK_SECRET"] = "test_webhook_secret_key_123"
|
| 27 |
+
|
| 28 |
+
from app.payment_routes import (
|
| 29 |
+
razorpay_create_order,
|
| 30 |
+
razorpay_verify_payment,
|
| 31 |
+
create_subscription_razorpay_order,
|
| 32 |
+
verify_subscription_razorpay,
|
| 33 |
+
razorpay_webhook,
|
| 34 |
+
CANONICAL_PLANS,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def run_async(coro):
|
| 39 |
+
return asyncio.run(coro)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ==============================================================================
|
| 43 |
+
# 1. WALLET TOP-UP: UNCAPTURED / FAILED / CANCELLED PAYMENT TESTS
|
| 44 |
+
# ==============================================================================
|
| 45 |
+
|
| 46 |
+
def test_wallet_topup_rejected_when_payment_status_is_failed():
|
| 47 |
+
"""Wallet top-up MUST NOT be credited when Razorpay reports payment status == 'failed'."""
|
| 48 |
+
async def _test():
|
| 49 |
+
user_id = "user_victim_1"
|
| 50 |
+
mock_user = {"uid": user_id, "email": "victim@sahihislam.com"}
|
| 51 |
+
order_id = "order_fail_1"
|
| 52 |
+
payment_id = "pay_fail_upi_invalid"
|
| 53 |
+
key_secret = "EkbAfQm9ks85jHT1qErb30GJ"
|
| 54 |
+
|
| 55 |
+
sig = hmac.new(
|
| 56 |
+
key_secret.encode("utf-8"),
|
| 57 |
+
f"{order_id}|{payment_id}".encode("utf-8"),
|
| 58 |
+
hashlib.sha256
|
| 59 |
+
).hexdigest()
|
| 60 |
+
|
| 61 |
+
payload = {
|
| 62 |
+
"razorpay_order_id": order_id,
|
| 63 |
+
"razorpay_payment_id": payment_id,
|
| 64 |
+
"razorpay_signature": sig,
|
| 65 |
+
"purpose": "ad_wallet",
|
| 66 |
+
"amount": 200.0,
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
with patch("app.payment_routes.fetch_razorpay_payment_api", new_callable=AsyncMock) as mock_fetch_payment, \
|
| 70 |
+
patch("app.payment_routes.transactions_collection.update_many", new_callable=AsyncMock) as mock_tx_upd, \
|
| 71 |
+
patch("app.payment_routes.wallets_collection.find_one_and_update", new_callable=AsyncMock) as mock_wallet_upd:
|
| 72 |
+
|
| 73 |
+
# Gateway reports status: failed (e.g. invalid UPI or bank timeout)
|
| 74 |
+
mock_fetch_payment.return_value = {
|
| 75 |
+
"id": payment_id,
|
| 76 |
+
"order_id": order_id,
|
| 77 |
+
"amount": 20000,
|
| 78 |
+
"currency": "INR",
|
| 79 |
+
"status": "failed",
|
| 80 |
+
"error_description": "Payment was declined by UPI PSP / Bank",
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 84 |
+
await razorpay_verify_payment(payload=payload, current_user=mock_user)
|
| 85 |
+
|
| 86 |
+
assert exc_info.value.status_code == 400
|
| 87 |
+
assert "not captured" in exc_info.value.detail.lower()
|
| 88 |
+
# Wallet MUST NOT be touched
|
| 89 |
+
assert not mock_wallet_upd.called
|
| 90 |
+
# Order MUST be marked as failed in DB
|
| 91 |
+
assert mock_tx_upd.called
|
| 92 |
+
update_call = mock_tx_upd.call_args[0]
|
| 93 |
+
assert update_call[1]["$set"]["status"] == "failed"
|
| 94 |
+
|
| 95 |
+
run_async(_test())
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_wallet_topup_rejected_when_payment_status_is_created_or_cancelled():
|
| 99 |
+
"""Wallet top-up MUST NOT be credited when payment was cancelled / only created."""
|
| 100 |
+
async def _test():
|
| 101 |
+
user_id = "user_cancel_1"
|
| 102 |
+
mock_user = {"uid": user_id, "email": "cancel@sahihislam.com"}
|
| 103 |
+
order_id = "order_cancel_1"
|
| 104 |
+
payment_id = "pay_cancel_modal_closed"
|
| 105 |
+
key_secret = "EkbAfQm9ks85jHT1qErb30GJ"
|
| 106 |
+
|
| 107 |
+
sig = hmac.new(
|
| 108 |
+
key_secret.encode("utf-8"),
|
| 109 |
+
f"{order_id}|{payment_id}".encode("utf-8"),
|
| 110 |
+
hashlib.sha256
|
| 111 |
+
).hexdigest()
|
| 112 |
+
|
| 113 |
+
payload = {
|
| 114 |
+
"razorpay_order_id": order_id,
|
| 115 |
+
"razorpay_payment_id": payment_id,
|
| 116 |
+
"razorpay_signature": sig,
|
| 117 |
+
"purpose": "ad_wallet",
|
| 118 |
+
"amount": 200.0,
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
with patch("app.payment_routes.fetch_razorpay_payment_api", new_callable=AsyncMock) as mock_fetch_payment, \
|
| 122 |
+
patch("app.payment_routes.transactions_collection.update_many", new_callable=AsyncMock), \
|
| 123 |
+
patch("app.payment_routes.wallets_collection.find_one_and_update", new_callable=AsyncMock) as mock_wallet_upd:
|
| 124 |
+
|
| 125 |
+
# Gateway reports status: created (user closed checkout without paying)
|
| 126 |
+
mock_fetch_payment.return_value = {
|
| 127 |
+
"id": payment_id,
|
| 128 |
+
"order_id": order_id,
|
| 129 |
+
"amount": 20000,
|
| 130 |
+
"currency": "INR",
|
| 131 |
+
"status": "created",
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 135 |
+
await razorpay_verify_payment(payload=payload, current_user=mock_user)
|
| 136 |
+
|
| 137 |
+
assert exc_info.value.status_code == 400
|
| 138 |
+
assert "not captured" in exc_info.value.detail.lower()
|
| 139 |
+
assert not mock_wallet_upd.called
|
| 140 |
+
|
| 141 |
+
run_async(_test())
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_wallet_topup_rejected_when_payment_does_not_exist_on_gateway():
|
| 145 |
+
"""Rejects payment when payment ID does not exist on Razorpay."""
|
| 146 |
+
async def _test():
|
| 147 |
+
user_id = "user_notfound_1"
|
| 148 |
+
mock_user = {"uid": user_id, "email": "notfound@sahihislam.com"}
|
| 149 |
+
order_id = "order_notfound_1"
|
| 150 |
+
payment_id = "pay_forged_nonexistent"
|
| 151 |
+
key_secret = "EkbAfQm9ks85jHT1qErb30GJ"
|
| 152 |
+
|
| 153 |
+
sig = hmac.new(
|
| 154 |
+
key_secret.encode("utf-8"),
|
| 155 |
+
f"{order_id}|{payment_id}".encode("utf-8"),
|
| 156 |
+
hashlib.sha256
|
| 157 |
+
).hexdigest()
|
| 158 |
+
|
| 159 |
+
payload = {
|
| 160 |
+
"razorpay_order_id": order_id,
|
| 161 |
+
"razorpay_payment_id": payment_id,
|
| 162 |
+
"razorpay_signature": sig,
|
| 163 |
+
"purpose": "ad_wallet",
|
| 164 |
+
"amount": 200.0,
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
with patch("app.payment_routes.fetch_razorpay_payment_api", new_callable=AsyncMock) as mock_fetch_payment, \
|
| 168 |
+
patch("app.payment_routes.transactions_collection.update_many", new_callable=AsyncMock):
|
| 169 |
+
|
| 170 |
+
mock_fetch_payment.return_value = None # 404 from Razorpay
|
| 171 |
+
|
| 172 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 173 |
+
await razorpay_verify_payment(payload=payload, current_user=mock_user)
|
| 174 |
+
|
| 175 |
+
assert exc_info.value.status_code == 400
|
| 176 |
+
assert "not found on gateway" in exc_info.value.detail.lower()
|
| 177 |
+
|
| 178 |
+
run_async(_test())
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# ==============================================================================
|
| 182 |
+
# 2. PRO SUBSCRIPTION (₹5 INTRO OFFER): UNCAPTURED / FAILED TESTS
|
| 183 |
+
# ==============================================================================
|
| 184 |
+
|
| 185 |
+
def test_intro_pro_offer_rejected_when_payment_failed():
|
| 186 |
+
"""₹5 Pro intro offer MUST NOT be granted when Razorpay payment status is 'failed'."""
|
| 187 |
+
async def _test():
|
| 188 |
+
user_id = "user_intro_fail"
|
| 189 |
+
mock_user = {"uid": user_id, "email": "introfail@sahihislam.com"}
|
| 190 |
+
order_id = "order_intro_1"
|
| 191 |
+
payment_id = "pay_intro_upi_error"
|
| 192 |
+
key_secret = "EkbAfQm9ks85jHT1qErb30GJ"
|
| 193 |
+
|
| 194 |
+
sig = hmac.new(
|
| 195 |
+
key_secret.encode("utf-8"),
|
| 196 |
+
f"{order_id}|{payment_id}".encode("utf-8"),
|
| 197 |
+
hashlib.sha256
|
| 198 |
+
).hexdigest()
|
| 199 |
+
|
| 200 |
+
payload = {
|
| 201 |
+
"razorpay_order_id": order_id,
|
| 202 |
+
"razorpay_payment_id": payment_id,
|
| 203 |
+
"razorpay_signature": sig,
|
| 204 |
+
"plan_id": "sahih_pro_intro_5",
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
with patch("app.payment_routes.fetch_razorpay_payment_api", new_callable=AsyncMock) as mock_fetch_payment, \
|
| 208 |
+
patch("app.payment_routes.transactions_collection.update_many", new_callable=AsyncMock) as mock_tx_upd, \
|
| 209 |
+
patch("app.payment_routes.users_collection.update_one", new_callable=AsyncMock) as mock_user_upd, \
|
| 210 |
+
patch("app.payment_routes.execute_payment_fulfillment", new_callable=AsyncMock) as mock_fulfill:
|
| 211 |
+
|
| 212 |
+
mock_fetch_payment.return_value = {
|
| 213 |
+
"id": payment_id,
|
| 214 |
+
"order_id": order_id,
|
| 215 |
+
"amount": 500,
|
| 216 |
+
"currency": "INR",
|
| 217 |
+
"status": "failed",
|
| 218 |
+
"error_description": "Bank servers unavailable",
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 222 |
+
await verify_subscription_razorpay(payload=payload, current_user=mock_user)
|
| 223 |
+
|
| 224 |
+
assert exc_info.value.status_code == 400
|
| 225 |
+
assert "not captured" in exc_info.value.detail.lower()
|
| 226 |
+
assert not mock_fulfill.called
|
| 227 |
+
assert not mock_user_upd.called
|
| 228 |
+
assert mock_tx_upd.called
|
| 229 |
+
|
| 230 |
+
run_async(_test())
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# ==============================================================================
|
| 234 |
+
# 3. ATTRIBUTE TAMPERING: CROSS-ORDER, CROSS-USER, CURRENCY, AMOUNT
|
| 235 |
+
# ==============================================================================
|
| 236 |
+
|
| 237 |
+
def test_cross_order_tampering_rejected():
|
| 238 |
+
"""Rejects verification when payment belongs to a different order on Razorpay."""
|
| 239 |
+
async def _test():
|
| 240 |
+
user_id = "user_cross_order"
|
| 241 |
+
mock_user = {"uid": user_id, "email": "cross@sahihislam.com"}
|
| 242 |
+
order_claimed = "order_claimed_123"
|
| 243 |
+
order_actual = "order_actual_456"
|
| 244 |
+
payment_id = "pay_captured_mismatch"
|
| 245 |
+
key_secret = "EkbAfQm9ks85jHT1qErb30GJ"
|
| 246 |
+
|
| 247 |
+
sig = hmac.new(
|
| 248 |
+
key_secret.encode("utf-8"),
|
| 249 |
+
f"{order_claimed}|{payment_id}".encode("utf-8"),
|
| 250 |
+
hashlib.sha256
|
| 251 |
+
).hexdigest()
|
| 252 |
+
|
| 253 |
+
payload = {
|
| 254 |
+
"razorpay_order_id": order_claimed,
|
| 255 |
+
"razorpay_payment_id": payment_id,
|
| 256 |
+
"razorpay_signature": sig,
|
| 257 |
+
"plan_id": "sahih_pro_intro_5",
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
with patch("app.payment_routes.fetch_razorpay_payment_api", new_callable=AsyncMock) as mock_fetch_payment:
|
| 261 |
+
mock_fetch_payment.return_value = {
|
| 262 |
+
"id": payment_id,
|
| 263 |
+
"order_id": order_actual, # MISMATCH!
|
| 264 |
+
"amount": 500,
|
| 265 |
+
"currency": "INR",
|
| 266 |
+
"status": "captured",
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 270 |
+
await verify_subscription_razorpay(payload=payload, current_user=mock_user)
|
| 271 |
+
|
| 272 |
+
assert exc_info.value.status_code == 400
|
| 273 |
+
assert "order mismatch" in exc_info.value.detail.lower()
|
| 274 |
+
|
| 275 |
+
run_async(_test())
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def test_cross_user_order_hijack_rejected():
|
| 279 |
+
"""Rejects verification when the order belongs to another authenticated user."""
|
| 280 |
+
async def _test():
|
| 281 |
+
caller_id = "user_attacker"
|
| 282 |
+
victim_id = "user_victim"
|
| 283 |
+
mock_user = {"uid": caller_id, "email": "attacker@sahihislam.com"}
|
| 284 |
+
order_id = "order_shared_99"
|
| 285 |
+
payment_id = "pay_captured_ok"
|
| 286 |
+
key_secret = "EkbAfQm9ks85jHT1qErb30GJ"
|
| 287 |
+
|
| 288 |
+
sig = hmac.new(
|
| 289 |
+
key_secret.encode("utf-8"),
|
| 290 |
+
f"{order_id}|{payment_id}".encode("utf-8"),
|
| 291 |
+
hashlib.sha256
|
| 292 |
+
).hexdigest()
|
| 293 |
+
|
| 294 |
+
payload = {
|
| 295 |
+
"razorpay_order_id": order_id,
|
| 296 |
+
"razorpay_payment_id": payment_id,
|
| 297 |
+
"razorpay_signature": sig,
|
| 298 |
+
"plan_id": "sahih_pro_monthly",
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
with patch("app.payment_routes.fetch_razorpay_payment_api", new_callable=AsyncMock) as mock_fetch_payment, \
|
| 302 |
+
patch("app.payment_routes.fetch_razorpay_order_api", new_callable=AsyncMock) as mock_fetch_order:
|
| 303 |
+
|
| 304 |
+
mock_fetch_payment.return_value = {
|
| 305 |
+
"id": payment_id,
|
| 306 |
+
"order_id": order_id,
|
| 307 |
+
"amount": 15900,
|
| 308 |
+
"currency": "INR",
|
| 309 |
+
"status": "captured",
|
| 310 |
+
}
|
| 311 |
+
# Order belongs to victim_id!
|
| 312 |
+
mock_fetch_order.return_value = {
|
| 313 |
+
"id": order_id,
|
| 314 |
+
"amount": 15900,
|
| 315 |
+
"notes": {"user_id": victim_id, "purpose": "subscription"}
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 319 |
+
await verify_subscription_razorpay(payload=payload, current_user=mock_user)
|
| 320 |
+
|
| 321 |
+
assert exc_info.value.status_code == 403
|
| 322 |
+
assert "does not belong" in exc_info.value.detail.lower()
|
| 323 |
+
|
| 324 |
+
run_async(_test())
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def test_currency_mismatch_rejected():
|
| 328 |
+
"""Rejects payments captured in non-INR currency."""
|
| 329 |
+
async def _test():
|
| 330 |
+
user_id = "user_currency_test"
|
| 331 |
+
mock_user = {"uid": user_id, "email": "curr@sahihislam.com"}
|
| 332 |
+
order_id = "order_curr_1"
|
| 333 |
+
payment_id = "pay_usd_captured"
|
| 334 |
+
key_secret = "EkbAfQm9ks85jHT1qErb30GJ"
|
| 335 |
+
|
| 336 |
+
sig = hmac.new(
|
| 337 |
+
key_secret.encode("utf-8"),
|
| 338 |
+
f"{order_id}|{payment_id}".encode("utf-8"),
|
| 339 |
+
hashlib.sha256
|
| 340 |
+
).hexdigest()
|
| 341 |
+
|
| 342 |
+
payload = {
|
| 343 |
+
"razorpay_order_id": order_id,
|
| 344 |
+
"razorpay_payment_id": payment_id,
|
| 345 |
+
"razorpay_signature": sig,
|
| 346 |
+
"plan_id": "sahih_pro_intro_5",
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
with patch("app.payment_routes.fetch_razorpay_payment_api", new_callable=AsyncMock) as mock_fetch_payment:
|
| 350 |
+
mock_fetch_payment.return_value = {
|
| 351 |
+
"id": payment_id,
|
| 352 |
+
"order_id": order_id,
|
| 353 |
+
"amount": 500,
|
| 354 |
+
"currency": "USD", # INVALID CURRENCY
|
| 355 |
+
"status": "captured",
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 359 |
+
await verify_subscription_razorpay(payload=payload, current_user=mock_user)
|
| 360 |
+
|
| 361 |
+
assert exc_info.value.status_code == 400
|
| 362 |
+
assert "invalid currency" in exc_info.value.detail.lower()
|
| 363 |
+
|
| 364 |
+
run_async(_test())
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
# ==============================================================================
|
| 368 |
+
# 4. ORDER PRE-PERSISTENCE & STATE MACHINE
|
| 369 |
+
# ==============================================================================
|
| 370 |
+
|
| 371 |
+
def test_order_pre_persistence_on_creation():
|
| 372 |
+
"""Creating an order pre-persists the record in DB with status: 'created'."""
|
| 373 |
+
async def _test():
|
| 374 |
+
user_id = "user_order_lifecycle"
|
| 375 |
+
mock_user = {"uid": user_id, "email": "lifecycle@sahihislam.com"}
|
| 376 |
+
|
| 377 |
+
with patch("app.payment_routes.create_razorpay_order_api", new_callable=AsyncMock) as mock_create_order, \
|
| 378 |
+
patch("app.payment_routes.transactions_collection.insert_one", new_callable=AsyncMock) as mock_tx_insert:
|
| 379 |
+
|
| 380 |
+
mock_create_order.return_value = {
|
| 381 |
+
"id": "order_prepersist_123",
|
| 382 |
+
"amount": 500,
|
| 383 |
+
"currency": "INR",
|
| 384 |
+
"receipt": "rcpt_pre_1",
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
res = await razorpay_create_order(
|
| 388 |
+
payload={"amount": 5.0, "purpose": "subscription", "reference_id": "sahih_pro_intro_5"},
|
| 389 |
+
current_user=mock_user
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
assert res["success"] is True
|
| 393 |
+
assert res["order_id"] == "order_prepersist_123"
|
| 394 |
+
assert mock_tx_insert.called
|
| 395 |
+
doc = mock_tx_insert.call_args[0][0]
|
| 396 |
+
assert doc["transaction_id"] == "order_prepersist_123"
|
| 397 |
+
assert doc["status"] == "created"
|
| 398 |
+
assert doc["amount"] == 5.0
|
| 399 |
+
|
| 400 |
+
run_async(_test())
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
# ==============================================================================
|
| 404 |
+
# 5. WEBHOOK SIGNATURE SECURITY ENFORCEMENT
|
| 405 |
+
# ==============================================================================
|
| 406 |
+
|
| 407 |
+
def test_webhook_invalid_signature_rejected_in_all_modes():
|
| 408 |
+
"""Webhook with forged signature is rejected with HTTP 401."""
|
| 409 |
+
async def _test():
|
| 410 |
+
payload_data = {"event": "payment.captured", "payload": {}}
|
| 411 |
+
raw_body = json.dumps(payload_data).encode("utf-8")
|
| 412 |
+
|
| 413 |
+
mock_request = AsyncMock(spec=Request)
|
| 414 |
+
mock_request.body = AsyncMock(return_value=raw_body)
|
| 415 |
+
mock_request.headers = {"X-Razorpay-Signature": "forged_invalid_signature_123"}
|
| 416 |
+
|
| 417 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 418 |
+
await razorpay_webhook(request=mock_request)
|
| 419 |
+
|
| 420 |
+
assert exc_info.value.status_code == 401
|
| 421 |
+
assert "invalid razorpay webhook signature" in exc_info.value.detail.lower()
|
| 422 |
+
|
| 423 |
+
run_async(_test())
|