suhailshafi007 commited on
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": razorpay_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
- and execute universal fulfillment for Web domain.
 
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
- # 🛡️ Server-Authoritative Price & Order Verification: Never trust client-submitted amounts!
1023
- authoritative_amount = amount
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 settings.IS_PROD and os.getenv("TEST_MODE") != "1":
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 webhook_secret and signature:
1126
- expected_signature = hmac.new(
1127
- webhook_secret.encode("utf-8"),
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": 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 and unlocks entitlements via universal fulfillment.
 
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.fetch_razorpay_order_api", new_callable=AsyncMock) as mock_fetch_order:
 
 
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 pytest.raises(HTTPException) as exc_info:
168
- await razorpay_verify_payment(payload=payload, current_user=mock_user)
 
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.fetch_razorpay_order_api", new_callable=AsyncMock) as mock_fetch_order:
 
 
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())