| """ |
| AgentIC Billing β Razorpay webhook handler + order creation. |
| |
| Env vars required: |
| RAZORPAY_KEY_ID β Razorpay API key id |
| RAZORPAY_KEY_SECRET β Razorpay API key secret |
| RAZORPAY_WEBHOOK_SECRET β Webhook secret from Razorpay dashboard |
| """ |
|
|
| import hashlib |
| import hmac |
| import json |
| import os |
| import re |
| from typing import Optional |
|
|
| import httpx |
| from fastapi import APIRouter, Depends, HTTPException, Request |
| from pydantic import BaseModel |
|
|
| from server.auth import ( |
| AUTH_ENABLED, |
| get_current_user, |
| _supabase_insert, |
| _supabase_query, |
| _supabase_update, |
| ) |
|
|
| router = APIRouter(prefix="/billing", tags=["billing"]) |
|
|
| RAZORPAY_KEY_ID = os.environ.get("RAZORPAY_KEY_ID", "") |
| RAZORPAY_KEY_SECRET = os.environ.get("RAZORPAY_KEY_SECRET", "") |
| RAZORPAY_WEBHOOK_SECRET = os.environ.get("RAZORPAY_WEBHOOK_SECRET", "") |
|
|
| |
| PLAN_PRICES = { |
| "starter": 49900, |
| "pro": 149900, |
| } |
|
|
|
|
| class CreateOrderRequest(BaseModel): |
| plan: str |
| user_id: str |
|
|
|
|
| class VerifyPaymentRequest(BaseModel): |
| razorpay_order_id: str |
| razorpay_payment_id: str |
| razorpay_signature: str |
| user_id: str |
| plan: str |
|
|
|
|
| |
| @router.post("/create-order") |
| async def create_order(req: CreateOrderRequest, profile: dict = Depends(get_current_user)): |
| """Create a Razorpay order for plan upgrade.""" |
| if not RAZORPAY_KEY_ID or not RAZORPAY_KEY_SECRET: |
| raise HTTPException(status_code=503, detail="Payment system not configured") |
|
|
| if req.plan not in PLAN_PRICES: |
| raise HTTPException(status_code=400, detail=f"Invalid plan: {req.plan}. Choose 'starter' or 'pro'.") |
|
|
| amount = PLAN_PRICES[req.plan] |
|
|
| |
| resp = httpx.post( |
| "https://api.razorpay.com/v1/orders", |
| auth=(RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET), |
| json={ |
| "amount": amount, |
| "currency": "INR", |
| "receipt": f"agentic_{req.user_id[:8]}_{req.plan}", |
| "notes": { |
| "user_id": req.user_id, |
| "plan": req.plan, |
| }, |
| }, |
| timeout=15, |
| ) |
| if resp.status_code != 200: |
| raise HTTPException(status_code=502, detail="Failed to create Razorpay order") |
|
|
| order = resp.json() |
|
|
| |
| if AUTH_ENABLED: |
| _supabase_insert("payments", { |
| "user_id": req.user_id, |
| "razorpay_order_id": order["id"], |
| "amount_paise": amount, |
| "plan": req.plan, |
| "status": "pending", |
| }) |
|
|
| return { |
| "order_id": order["id"], |
| "amount": amount, |
| "currency": "INR", |
| "key_id": RAZORPAY_KEY_ID, |
| "plan": req.plan, |
| } |
|
|
|
|
| |
| @router.post("/verify-payment") |
| async def verify_payment(req: VerifyPaymentRequest, profile: dict = Depends(get_current_user)): |
| """Verify Razorpay payment signature and upgrade user plan.""" |
| if not RAZORPAY_KEY_SECRET: |
| raise HTTPException(status_code=503, detail="Payment system not configured") |
|
|
| |
| if req.plan not in ("starter", "pro"): |
| raise HTTPException(status_code=400, detail="Invalid plan") |
|
|
| |
| if not re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", req.user_id): |
| raise HTTPException(status_code=400, detail="Invalid user_id") |
|
|
| |
| if profile is not None and profile.get("id") != req.user_id: |
| raise HTTPException(status_code=403, detail="Cannot upgrade another user's plan") |
|
|
| |
| message = f"{req.razorpay_order_id}|{req.razorpay_payment_id}" |
| expected = hmac.new( |
| RAZORPAY_KEY_SECRET.encode(), |
| message.encode(), |
| hashlib.sha256, |
| ).hexdigest() |
|
|
| if not hmac.compare_digest(expected, req.razorpay_signature): |
| raise HTTPException(status_code=400, detail="Payment verification failed β signature mismatch") |
|
|
| if AUTH_ENABLED: |
| |
| _supabase_update( |
| "payments", |
| f"razorpay_order_id=eq.{req.razorpay_order_id}", |
| { |
| "razorpay_payment_id": req.razorpay_payment_id, |
| "razorpay_signature": req.razorpay_signature, |
| "status": "captured", |
| }, |
| ) |
|
|
| |
| _supabase_update( |
| "profiles", |
| f"id=eq.{req.user_id}", |
| {"plan": req.plan, "successful_builds": 0}, |
| ) |
|
|
| return {"success": True, "plan": req.plan, "message": f"Upgraded to {req.plan} plan!"} |
|
|
|
|
| |
| @router.post("/webhook/razorpay") |
| async def razorpay_webhook(request: Request): |
| """Handle Razorpay webhook events (payment.captured, payment.failed). |
| |
| Razorpay sends a POST with a JSON body and X-Razorpay-Signature header. |
| We verify the HMAC-SHA256 signature before processing. |
| """ |
| if not RAZORPAY_WEBHOOK_SECRET: |
| raise HTTPException(status_code=503, detail="Webhook secret not configured") |
|
|
| body = await request.body() |
| signature = request.headers.get("X-Razorpay-Signature", "") |
|
|
| |
| expected = hmac.new( |
| RAZORPAY_WEBHOOK_SECRET.encode(), |
| body, |
| hashlib.sha256, |
| ).hexdigest() |
|
|
| if not hmac.compare_digest(expected, signature): |
| raise HTTPException(status_code=400, detail="Invalid webhook signature") |
|
|
| payload = json.loads(body) |
| event = payload.get("event", "") |
|
|
| if event == "payment.captured": |
| payment = payload.get("payload", {}).get("payment", {}).get("entity", {}) |
| order_id = payment.get("order_id", "") |
| notes = payment.get("notes", {}) |
| user_id = notes.get("user_id", "") |
| plan = notes.get("plan", "") |
|
|
| |
| _uuid_re = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') |
| _order_re = re.compile(r'^order_[a-zA-Z0-9]+$') |
| if not (user_id and _uuid_re.match(user_id)): |
| user_id = "" |
| if not (order_id and _order_re.match(order_id)): |
| order_id = "" |
| if user_id and plan and order_id and AUTH_ENABLED: |
| |
| _supabase_update( |
| "payments", |
| f"razorpay_order_id=eq.{order_id}", |
| { |
| "razorpay_payment_id": payment.get("id", ""), |
| "status": "captured", |
| }, |
| ) |
|
|
| |
| _supabase_update( |
| "profiles", |
| f"id=eq.{user_id}", |
| {"plan": plan, "successful_builds": 0}, |
| ) |
|
|
| elif event == "payment.failed": |
| payment = payload.get("payload", {}).get("payment", {}).get("entity", {}) |
| order_id = payment.get("order_id", "") |
| if order_id and AUTH_ENABLED: |
| _supabase_update( |
| "payments", |
| f"razorpay_order_id=eq.{order_id}", |
| {"status": "failed"}, |
| ) |
|
|
| |
| return {"status": "ok"} |
|
|