From 357d67236c171923c55d45cc43a00aed463582f6 Mon Sep 17 00:00:00 2001 From: Sk_Akib_Ahammed Date: Tue, 27 Jan 2026 21:45:51 +0530 Subject: [PATCH 1/2] now the system is generating refund_id --- .gitignore | 3 +- app/app.py | 4 +- app/user.py | 188 +++++++++++++++++++++++++++++++++++-------------- app/webhook.py | 47 ++++++++++++- 4 files changed, 186 insertions(+), 56 deletions(-) diff --git a/.gitignore b/.gitignore index 76a0a46..24fc861 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ __pycache__/ .env .DS_Store -secrets/ \ No newline at end of file +secrets/ +test.html diff --git a/app/app.py b/app/app.py index 898ceef..abcd80a 100644 --- a/app/app.py +++ b/app/app.py @@ -62,8 +62,8 @@ "capacitor://localhost", "http://localhost:3000", "http://127.0.0.1:3000", - "http://localhost:5000", - "http://127.0.0.1:5000", + "http://localhost:5500", + "http://127.0.0.1:5500", "http://10.0.2.2:3000", "http://10.0.2.2:5000", "http://10.0.2.2:8000", diff --git a/app/user.py b/app/user.py index a0db0c7..8e49385 100644 --- a/app/user.py +++ b/app/user.py @@ -260,6 +260,14 @@ async def create_payment_order(order_data: CreateOrderSchema, id_token: str): "user_details": user_snapshot, "stall_id": stall_id, "stall_name": stall_name, + "refund_policy": { + "ready_refund_percent": 50, + "cancellation_allowed": True + }, + "refund": { + "status": "NOT_APPLICABLE", + "amount": 0 + }, "college_id": college_id, "items": order_items, "total_amount": total_amount, @@ -326,13 +334,19 @@ async def get_user_orders(id_token: str): visible_code = data.get("pickup_code") if data.get("status") in ["PAID", "READY"] else None + refund_data = data.get("refund") + if refund_data: + refund_data = serialize_firestore_data(refund_data) + orders.append({ "id": doc.id, "items": data["items"], "cafeteriaName": data.get("stall_name", "Unknown Stall"), "status": normalize_order_status(data["status"]), "qrCode": visible_code, - "total_amount": data.get("total_amount", 0) + "total_amount": data.get("total_amount", 0), + "refund": refund_data, + "refund_policy": data.get("refund_policy") }) return JSONResponse( @@ -347,51 +361,70 @@ async def get_user_orders(id_token: str): ) def normalize_order_status(status:str): - return{ - "PENDING": "Payment Pending", - "PAID": "Reserved", - "CLAIMED": "Claimed", - "READY": "Ready", - "COMPLETED": "Completed" - }.get(status,"Unknown") + status = status.upper() if status else "" + return { + "PENDING": "Payment Pending", + "PAID": "Reserved", + "CLAIMED": "Claimed", + "READY": "Ready", + "COMPLETED": "Completed", + "CANCELLED": "Cancelled" + }.get(status, "Unknown") + +def calculate_refund(order: dict): + total = order.get("total_amount", 0) + status = order.get("status") + + if status in ["CREATED"]: + return total, "FULL_REFUND" + + if status == "PAID": + return total, "FULL_REFUND" + + if status == "READY": + percent = ( + order.get("refund_policy", {}) + .get("ready_refund_percent", 50) + ) + refund_amount = int(total * percent / 100) + return refund_amount, "PARTIAL_REFUND" + + return 0, "NO_REFUND" + async def cancel_order(order_id: str, id_token: str): try: user_data, user_uid = await get_user_details(id_token) - if not user_data: - return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={"message": "Unauthorized"}) - now = datetime.now() + if not user_data: + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"message": "Unauthorized"} + ) + now = datetime.utcnow().replace(tzinfo=None) week_start = user_data.get("cancellation_week_start") current_count = user_data.get("cancellations_this_week", 0) if week_start: if isinstance(week_start, str): week_start = datetime.fromisoformat(week_start) - else: - week_start = now + if hasattr(week_start, "tzinfo") and week_start.tzinfo is not None: + week_start = week_start.replace(tzinfo=None) - if (now.replace(tzinfo=None) - week_start.replace(tzinfo=None)).days >= 7: + if not week_start or (now - week_start).days >= 7: current_count = 0 week_start = now - db.collection("users").document(user_uid).update({ - "cancellation_week_start": firestore.SERVER_TIMESTAMP, - "cancellations_this_week": 0 - }) - if current_count >= 2: - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={"message": "Cancellation limit reached. You can only cancel 2 orders per week."} - ) + if current_count >= 3: + return JSONResponse(status_code=400, content={"message": "Weekly cancellation limit reached."}) - order_ref = db.collection("orders").document(order_id) - order_doc = order_ref.get() + order_doc = db.collection("orders").document(order_id).get() if not order_doc.exists: return JSONResponse(status_code=404, content={"message": "Order not found"}) + order_ref = order_doc.reference order_data = order_doc.to_dict() if order_data.get("user_id") != user_uid: @@ -400,25 +433,53 @@ async def cancel_order(order_id: str, id_token: str): current_status = order_data.get("status") if current_status in ["CLAIMED", "COMPLETED", "CANCELLED"]: - return JSONResponse( - status_code=400, - content={"message": f"Cannot cancel order with status: {current_status}"} - ) - - resale_created = False + return JSONResponse(status_code=400, content={"message": f"Cannot cancel status: {current_status}"}) + + refund_amount, refund_type = calculate_refund(order_data) + total_amount = order_data.get("total_amount", 0) + payment_id = order_data.get("razorpay_payment_id") + + refund_status = "NOT_APPLICABLE" + refund_id = None + + if refund_amount > 0 and payment_id: + try: + payment_details = razorpay_client.payment.fetch(payment_id) + payment_status = payment_details.get("status") + + if payment_status == "authorized": + print(f"ℹ️ Payment {payment_id} is authorized. Capturing now...") + razorpay_client.payment.capture(payment_id, int(total_amount * 100)) + print(f"✅ Payment Captured.") + + refund_payload = { + "amount": int(refund_amount * 100), # paise + "speed": "normal", + "notes": { + "reason": "User Cancelled", + "order_id": order_id, + "type": refund_type + } + } - if current_status == "READY": - college_id = order_data.get("college_id") - stall_id = order_data.get("stall_id") - original_price = order_data.get("total_amount", 0) + refund_response = razorpay_client.payment.refund(payment_id, refund_payload) + refund_id = refund_response.get("id") + refund_status = "INITIATED" + print(f"✅ Refund Initiated: {refund_id}") - discounted_price = original_price * 0.5 + except Exception as e: + print(f"[Refund Error] {e}") + refund_status = "FAILED" + resale_created = False + if current_status == "READY": + original_price = total_amount + discounted_price = int(original_price * 0.5) resale_item = { "original_order_id": order_id, "original_user_id": user_uid, - "college_id": college_id, - "stall_id": stall_id, + "college_id": order_data.get("college_id"), + "stall_id": order_data.get("stall_id"), "stall_name": order_data.get("stall_name"), "items": order_data.get("items", []), "original_price": original_price, @@ -426,34 +487,57 @@ async def cancel_order(order_id: str, id_token: str): "status": "AVAILABLE", "created_at": firestore.SERVER_TIMESTAMP } - db.collection("resale_items").add(resale_item) resale_created = True batch = db.batch() - batch.update(order_ref, { + update_payload = { "status": "CANCELLED", "cancelled_at": firestore.SERVER_TIMESTAMP, - "cancellation_reason": "User requested" - }) + "cancellation_reason": "User requested", + + "refund": { + "eligible": refund_amount > 0, + "amount": refund_amount, + "type": refund_type, + "status": refund_status, + "razorpay_refund_id": refund_id, + "initiated_at": firestore.SERVER_TIMESTAMP + }, + + "staff_payout": { + "amount": total_amount - refund_amount, + "status": "PENDING" + } + } user_ref = db.collection("users").document(user_uid) - batch.update(user_ref, { - "cancellations_this_week": current_count + 1, - "cancellation_week_start": week_start - }) - batch.commit() + batch.update(order_ref, update_payload) + batch.update( + user_ref, + { + "cancellations_this_week": current_count + 1, + "cancellation_week_start": week_start + } + ) - msg = "Order cancelled." - if resale_created: - msg += " Item has been added to the discounted feed." + batch.commit() - return JSONResponse(status_code=200, content={"message": msg, "resale_created": resale_created}) + return JSONResponse( + status_code=status.HTTP_200_OK, + content={ + "message": f"Order cancelled. Refund status: {refund_status}", + "refund_amount": refund_amount, + "refund_type": refund_type, + "resale_created": resale_created + } + ) except Exception as e: - return JSONResponse(status_code=500, content={"message": str(e)}) + print("[Cancel Order Error]", repr(e)) + return JSONResponse(status_code=500, content={"message": "Internal server error"}) async def buy_resale_item(resale_id: str, id_token: str): try: diff --git a/app/webhook.py b/app/webhook.py index fec0224..349b04e 100644 --- a/app/webhook.py +++ b/app/webhook.py @@ -17,6 +17,9 @@ async def razorpay_webhook(request: Request): body = await request.body() try: + if not secret: + raise Exception("RAZORPAY_WEBHOOK_SECRET not set") + expected_signature = hmac.new( key=secret.encode(), msg=body, @@ -25,7 +28,8 @@ async def razorpay_webhook(request: Request): if not hmac.compare_digest(expected_signature, signature): raise HTTPException(status_code=400, detail="Invalid signature") - except Exception: + except Exception as e: + print(f"Webhook Signature Error: {e}") raise HTTPException(status_code=400, detail="Signature verification failed") payload = await request.json() @@ -87,4 +91,45 @@ def update_in_transaction(transaction, order_ref): else: print(f"⚠️ Payment received without internal_order_id: {payment.get('id')}") + elif event_type == 'refund.processed': + try: + refund_entity = payload['payload']['refund']['entity'] + payment_id = refund_entity.get('payment_id') + + notes = refund_entity.get('notes', {}) + order_id = notes.get('order_id') + + if order_id: + order_ref = db.collection('orders').document(order_id) + + order_ref.update({ + "refund.status": "COMPLETED", + "refund.processed_at": firestore.SERVER_TIMESTAMP, + "refund.razorpay_refund_id": refund_entity.get('id'), + "refund.bank_ref": refund_entity.get('acquirer_data', {}).get('rrn'), + "updated_at": firestore.SERVER_TIMESTAMP + }) + print(f"✅ REFUND COMPLETE: Order {order_id} refunded successfully.") + else: + print(f"⚠️ Refund processed but no order_id found in notes. Payment ID: {payment_id}") + + except Exception as e: + print(f"❌ Error processing refund webhook: {e}") + + elif event_type == 'refund.failed': + try: + refund_entity = payload['payload']['refund']['entity'] + notes = refund_entity.get('notes', {}) + order_id = notes.get('order_id') + + if order_id: + db.collection('orders').document(order_id).update({ + "refund.status": "FAILED", + "refund.failure_reason": refund_entity.get('status_details', {}).get('description', 'Unknown Error'), + "updated_at": firestore.SERVER_TIMESTAMP + }) + print(f"❌ REFUND FAILED: Order {order_id}") + except Exception as e: + print(f"❌ Error handling refund failure: {e}") + return {"status": "ok"} From adfbbe762a29361df0c0ec744846042d6c80a0ea Mon Sep 17 00:00:00 2001 From: Sk_Akib_Ahammed Date: Tue, 27 Jan 2026 22:04:03 +0530 Subject: [PATCH 2/2] feat: add idempotency protection for cancel, payment, and webhook flows --- app/user.py | 117 +++++++++++++++++++++++++++---------------------- app/webhook.py | 6 +++ 2 files changed, 71 insertions(+), 52 deletions(-) diff --git a/app/user.py b/app/user.py index 8e49385..faa5233 100644 --- a/app/user.py +++ b/app/user.py @@ -98,6 +98,14 @@ async def verify_payment_and_update_order(payment_data: VerifyPaymentSchema, id_ content={"message": "Order not found"} ) + current_status = order_doc.to_dict().get("status") + + if current_status == "PAID": + return JSONResponse( + status_code=200, + content={"message": "Payment already verified"} + ) + pickup_code = str(1000 + secrets.randbelow(9000)) order_ref.update({ @@ -391,33 +399,35 @@ def calculate_refund(order: dict): return 0, "NO_REFUND" - async def cancel_order(order_id: str, id_token: str): try: user_data, user_uid = await get_user_details(id_token) if not user_data: - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={"message": "Unauthorized"} - ) + return JSONResponse(status_code=401, content={"message": "Unauthorized"}) now = datetime.utcnow().replace(tzinfo=None) + week_start = user_data.get("cancellation_week_start") current_count = user_data.get("cancellations_this_week", 0) if week_start: if isinstance(week_start, str): week_start = datetime.fromisoformat(week_start) - if hasattr(week_start, "tzinfo") and week_start.tzinfo is not None: + if hasattr(week_start, "tzinfo") and week_start.tzinfo: week_start = week_start.replace(tzinfo=None) + else: + week_start = now - if not week_start or (now - week_start).days >= 7: + if (now - week_start).days >= 7: current_count = 0 week_start = now if current_count >= 3: - return JSONResponse(status_code=400, content={"message": "Weekly cancellation limit reached."}) + return JSONResponse( + status_code=400, + content={"message": "Weekly cancellation limit reached"} + ) order_doc = db.collection("orders").document(order_id).get() @@ -432,49 +442,55 @@ async def cancel_order(order_id: str, id_token: str): current_status = order_data.get("status") - if current_status in ["CLAIMED", "COMPLETED", "CANCELLED"]: - return JSONResponse(status_code=400, content={"message": f"Cannot cancel status: {current_status}"}) + if current_status == "CANCELLED": + refund = order_data.get("refund", {}) + return JSONResponse( + status_code=200, + content={ + "message": "Order already cancelled", + "refund_amount": refund.get("amount", 0), + "refund_type": refund.get("type"), + "resale_created": False + } + ) + + if current_status in ["CLAIMED", "COMPLETED"]: + return JSONResponse( + status_code=400, + content={"message": f"Cannot cancel order with status {current_status}"} + ) refund_amount, refund_type = calculate_refund(order_data) total_amount = order_data.get("total_amount", 0) payment_id = order_data.get("razorpay_payment_id") - refund_status = "NOT_APPLICABLE" - refund_id = None + existing_refund = order_data.get("refund", {}) + + refund_status = existing_refund.get("status", "NOT_APPLICABLE") + refund_id = existing_refund.get("razorpay_refund_id") - if refund_amount > 0 and payment_id: + if refund_amount > 0 and payment_id and refund_status not in ["INITIATED", "COMPLETED"]: try: - payment_details = razorpay_client.payment.fetch(payment_id) - payment_status = payment_details.get("status") - - if payment_status == "authorized": - print(f"ℹ️ Payment {payment_id} is authorized. Capturing now...") - razorpay_client.payment.capture(payment_id, int(total_amount * 100)) - print(f"✅ Payment Captured.") - - refund_payload = { - "amount": int(refund_amount * 100), # paise - "speed": "normal", - "notes": { - "reason": "User Cancelled", - "order_id": order_id, - "type": refund_type + refund_response = razorpay_client.payment.refund( + payment_id, + { + "amount": int(refund_amount * 100), + "speed": "normal", + "notes": { + "order_id": order_id, + "type": refund_type, + "reason": "User Cancelled" + } } - } - - refund_response = razorpay_client.payment.refund(payment_id, refund_payload) + ) refund_id = refund_response.get("id") refund_status = "INITIATED" - print(f"✅ Refund Initiated: {refund_id}") - except Exception as e: - print(f"[Refund Error] {e}") + print("[Refund Error]", e) refund_status = "FAILED" resale_created = False if current_status == "READY": - original_price = total_amount - discounted_price = int(original_price * 0.5) resale_item = { "original_order_id": order_id, "original_user_id": user_uid, @@ -482,8 +498,8 @@ async def cancel_order(order_id: str, id_token: str): "stall_id": order_data.get("stall_id"), "stall_name": order_data.get("stall_name"), "items": order_data.get("items", []), - "original_price": original_price, - "discounted_price": discounted_price, + "original_price": total_amount, + "discounted_price": int(total_amount * 0.5), "status": "AVAILABLE", "created_at": firestore.SERVER_TIMESTAMP } @@ -492,31 +508,25 @@ async def cancel_order(order_id: str, id_token: str): batch = db.batch() - update_payload = { + batch.update(order_ref, { "status": "CANCELLED", "cancelled_at": firestore.SERVER_TIMESTAMP, - "cancellation_reason": "User requested", - "refund": { "eligible": refund_amount > 0, "amount": refund_amount, "type": refund_type, "status": refund_status, - "razorpay_refund_id": refund_id, - "initiated_at": firestore.SERVER_TIMESTAMP + "razorpay_refund_id": refund_id }, - "staff_payout": { "amount": total_amount - refund_amount, "status": "PENDING" - } - } - - user_ref = db.collection("users").document(user_uid) + }, + "updated_at": firestore.SERVER_TIMESTAMP + }) - batch.update(order_ref, update_payload) batch.update( - user_ref, + db.collection("users").document(user_uid), { "cancellations_this_week": current_count + 1, "cancellation_week_start": week_start @@ -526,7 +536,7 @@ async def cancel_order(order_id: str, id_token: str): batch.commit() return JSONResponse( - status_code=status.HTTP_200_OK, + status_code=200, content={ "message": f"Order cancelled. Refund status: {refund_status}", "refund_amount": refund_amount, @@ -537,7 +547,10 @@ async def cancel_order(order_id: str, id_token: str): except Exception as e: print("[Cancel Order Error]", repr(e)) - return JSONResponse(status_code=500, content={"message": "Internal server error"}) + return JSONResponse( + status_code=500, + content={"message": "Internal server error"} + ) async def buy_resale_item(resale_id: str, id_token: str): try: diff --git a/app/webhook.py b/app/webhook.py index 349b04e..66f29d6 100644 --- a/app/webhook.py +++ b/app/webhook.py @@ -102,6 +102,12 @@ def update_in_transaction(transaction, order_ref): if order_id: order_ref = db.collection('orders').document(order_id) + snapshot = order_ref.get() + if snapshot.exists: + if snapshot.to_dict().get("refund", {}).get("status") == "COMPLETED": + print("ℹ️ Refund already completed, skipping") + return + order_ref.update({ "refund.status": "COMPLETED", "refund.processed_at": firestore.SERVER_TIMESTAMP,