File size: 7,826 Bytes
98ce687 4208f48 98ce687 c9f5aac 98ce687 611a731 98ce687 4208f48 98ce687 611a731 98ce687 611a731 98ce687 4208f48 98ce687 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | """
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 in paise (βΉ1 = 100 paise)
PLAN_PRICES = {
"starter": 49900, # βΉ499
"pro": 149900, # βΉ1,499
}
class CreateOrderRequest(BaseModel):
plan: str # "starter" or "pro"
user_id: str # Supabase user UUID
class VerifyPaymentRequest(BaseModel):
razorpay_order_id: str
razorpay_payment_id: str
razorpay_signature: str
user_id: str
plan: str
# βββ Create Razorpay Order ββββββββββββββββββββββββββββββββββββββββββ
@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]
# Create order via Razorpay API
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()
# Record pending payment
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,
}
# βββ Verify Payment (client-side callback) βββββββββββββββββββββββββ
@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")
# Validate plan to prevent arbitrary plan escalation
if req.plan not in ("starter", "pro"):
raise HTTPException(status_code=400, detail="Invalid plan")
# Validate user_id is a well-formed UUID to prevent PostgREST filter injection
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")
# Enforce that the authenticated user can only upgrade their own account
if profile is not None and profile.get("id") != req.user_id:
raise HTTPException(status_code=403, detail="Cannot upgrade another user's plan")
# Verify signature: SHA256 HMAC of order_id|payment_id
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:
# Update payment record
_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",
},
)
# Upgrade user plan
_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!"}
# βββ Razorpay Webhook (server-to-server) βββββββββββββββββββββββββββ
@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", "")
# Verify webhook 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", "")
# Sanitize inputs to prevent PostgREST filter injection
_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:
# Update payment status
_supabase_update(
"payments",
f"razorpay_order_id=eq.{order_id}",
{
"razorpay_payment_id": payment.get("id", ""),
"status": "captured",
},
)
# Upgrade user plan and reset build count
_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"},
)
# Razorpay expects 200 OK to acknowledge receipt
return {"status": "ok"}
|