Skip to content

Commit 6c58fe1

Browse files
Sai Sridhar Tarraclaude
authored andcommitted
Feat: replace Stripe with Razorpay for Indian payments
- Swap stripe==11.3.0 for razorpay==1.4.2 in requirements.txt - Replace all Stripe config fields with RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET, RAZORPAY_WEBHOOK_SECRET, RAZORPAY_PRO_PLAN_ID, RAZORPAY_AGENCY_PLAN_ID - New razorpay_service.py: create subscription → short_url, webhook handler (signature verified via HMAC-SHA256), subscription status query - Billing route: checkout now posts plan_id, webhook reads x-razorpay-signature - Frontend: payment-check banner, portal links to razorpay.com, no priceId arg Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d4e115d commit 6c58fe1

8 files changed

Lines changed: 274 additions & 480 deletions

File tree

backend/config.py

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -38,33 +38,12 @@ class Settings(BaseSettings):
3838
# Slack
3939
SLACK_WEBHOOK_URL: str = ""
4040

41-
# Stripe
42-
STRIPE_SECRET_KEY: str = ""
43-
STRIPE_WEBHOOK_SECRET: str = ""
44-
# Primary price IDs (from .env — STRIPE_PRO_PRICE_ID / STRIPE_AGENCY_PRICE_ID)
45-
STRIPE_PRO_PRICE_ID: str = ""
46-
STRIPE_AGENCY_PRICE_ID: str = ""
47-
# Aliases for monthly/yearly — fall back to the primary IDs if not set separately
48-
STRIPE_PRICE_PRO_MONTHLY: str = ""
49-
STRIPE_PRICE_PRO_YEARLY: str = ""
50-
STRIPE_PRICE_AGENCY_MONTHLY: str = ""
51-
STRIPE_PRICE_AGENCY_YEARLY: str = ""
52-
53-
@property
54-
def pro_monthly_price_id(self) -> str:
55-
return self.STRIPE_PRICE_PRO_MONTHLY or self.STRIPE_PRO_PRICE_ID
56-
57-
@property
58-
def pro_yearly_price_id(self) -> str:
59-
return self.STRIPE_PRICE_PRO_YEARLY or self.STRIPE_PRO_PRICE_ID
60-
61-
@property
62-
def agency_monthly_price_id(self) -> str:
63-
return self.STRIPE_PRICE_AGENCY_MONTHLY or self.STRIPE_AGENCY_PRICE_ID
64-
65-
@property
66-
def agency_yearly_price_id(self) -> str:
67-
return self.STRIPE_PRICE_AGENCY_YEARLY or self.STRIPE_AGENCY_PRICE_ID
41+
# Razorpay
42+
RAZORPAY_KEY_ID: str = ""
43+
RAZORPAY_KEY_SECRET: str = ""
44+
RAZORPAY_WEBHOOK_SECRET: str = ""
45+
RAZORPAY_PRO_PLAN_ID: str = "" # plan_xxx from Razorpay dashboard
46+
RAZORPAY_AGENCY_PLAN_ID: str = "" # plan_xxx from Razorpay dashboard
6847

6948
# Frontend base URL (used for Stripe redirect URLs)
7049
FRONTEND_URL: str = "http://localhost:3000"

backend/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ google-auth==2.36.0
1010
google-auth-oauthlib==1.2.1
1111
google-auth-httplib2==0.2.0
1212
google-api-python-client==2.154.0
13-
stripe==11.3.0
13+
razorpay==1.4.2
1414
python-dotenv==1.0.1
1515
pydantic==2.10.3
1616
email-validator==2.2.0

backend/routes/billing.py

Lines changed: 23 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@
55
from pydantic import BaseModel
66

77
from middleware.auth import get_current_user
8-
from services.stripe_service import (
9-
create_billing_portal_session,
10-
create_checkout_session,
8+
from services.razorpay_service import (
9+
create_subscription,
1110
get_subscription_status,
1211
handle_webhook,
1312
)
@@ -17,10 +16,8 @@
1716

1817

1918
class CheckoutRequest(BaseModel):
20-
# Accept either a direct price_id or a plan_id + interval combo
21-
price_id: str | None = None
22-
plan_id: str | None = None
23-
interval: str = "monthly" # "monthly" | "yearly"
19+
plan_id: str # "pro" | "agency"
20+
interval: str = "monthly"
2421

2522

2623
# ---------------------------------------------------------------------------
@@ -29,19 +26,19 @@ class CheckoutRequest(BaseModel):
2926

3027
@router.get("/status")
3128
async def billing_status(current_user: Annotated[dict, Depends(get_current_user)]):
32-
"""Return the current subscription plan and Stripe status."""
29+
"""Return the current subscription plan and usage."""
3330
return await get_subscription_status(user_id=current_user["id"])
3431

3532

36-
@router.get("/stripe-check")
37-
async def stripe_config_check(current_user: Annotated[dict, Depends(get_current_user)]):
38-
"""Return which Stripe env vars are configured (values masked)."""
33+
@router.get("/payment-check")
34+
async def payment_config_check(current_user: Annotated[dict, Depends(get_current_user)]):
35+
"""Return which Razorpay env vars are configured (values masked)."""
3936
from config import settings
4037
return {
41-
"STRIPE_SECRET_KEY": bool(settings.STRIPE_SECRET_KEY),
42-
"STRIPE_WEBHOOK_SECRET": bool(settings.STRIPE_WEBHOOK_SECRET),
43-
"STRIPE_PRO_PRICE_ID": settings.pro_monthly_price_id or "NOT SET",
44-
"STRIPE_AGENCY_PRICE_ID": settings.agency_monthly_price_id or "NOT SET",
38+
"RAZORPAY_KEY_ID": bool(settings.RAZORPAY_KEY_ID),
39+
"RAZORPAY_KEY_SECRET": bool(settings.RAZORPAY_KEY_SECRET),
40+
"RAZORPAY_PRO_PLAN_ID": settings.RAZORPAY_PRO_PLAN_ID or "NOT SET",
41+
"RAZORPAY_AGENCY_PLAN_ID": settings.RAZORPAY_AGENCY_PLAN_ID or "NOT SET",
4542
"FRONTEND_URL": settings.FRONTEND_URL or "NOT SET",
4643
}
4744

@@ -52,86 +49,35 @@ async def create_checkout(
5249
current_user: Annotated[dict, Depends(get_current_user)],
5350
):
5451
"""
55-
Create a Stripe Checkout Session and return the hosted payment URL.
52+
Create a Razorpay subscription and return the hosted checkout URL.
5653
"""
57-
from config import settings
58-
59-
# Resolve price_id from plan_id + interval if needed
60-
price_id = body.price_id
61-
if not price_id and body.plan_id:
62-
plan_to_price = {
63-
"pro": {"monthly": settings.pro_monthly_price_id, "yearly": settings.pro_yearly_price_id},
64-
"agency": {"monthly": settings.agency_monthly_price_id, "yearly": settings.agency_yearly_price_id},
65-
}
66-
price_id = plan_to_price.get(body.plan_id, {}).get(body.interval, "")
67-
68-
if not price_id:
69-
logger.error(
70-
"Checkout failed: no price_id resolved. plan_id=%s interval=%s — "
71-
"set STRIPE_PRO_PRICE_ID / STRIPE_AGENCY_PRICE_ID in backend .env",
72-
body.plan_id,
73-
body.interval,
74-
)
54+
if body.plan_id not in ("pro", "agency"):
7555
raise HTTPException(
7656
status_code=status.HTTP_400_BAD_REQUEST,
77-
detail="No valid price_id or plan_id provided.",
57+
detail="plan_id must be 'pro' or 'agency'.",
7858
)
7959

8060
try:
81-
url = await create_checkout_session(
61+
url = await create_subscription(
8262
user_id=current_user["id"],
83-
price_id=price_id,
63+
plan_id=body.plan_id,
8464
email=current_user.get("email", ""),
8565
)
8666
except RuntimeError as exc:
8767
logger.error("Checkout failed for user %s: %s", current_user["id"], exc)
8868
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
89-
return {"checkout_url": url}
90-
91-
92-
@router.get("/portal")
93-
async def billing_portal(current_user: Annotated[dict, Depends(get_current_user)]):
94-
"""
95-
Create a Stripe Customer Portal session and return the portal URL so
96-
the user can manage/cancel their subscription.
97-
"""
98-
from database import get_supabase
99-
100-
supabase = get_supabase()
101-
result = (
102-
supabase.table("user_profiles")
103-
.select("stripe_customer_id")
104-
.eq("id", current_user["id"])
105-
.single()
106-
.execute()
107-
)
108-
customer_id = (result.data or {}).get("stripe_customer_id")
109-
110-
if not customer_id:
111-
raise HTTPException(
112-
status_code=status.HTTP_404_NOT_FOUND,
113-
detail="No Stripe customer found. Please subscribe first.",
114-
)
11569

116-
url = await create_billing_portal_session(customer_id=customer_id)
117-
if not url:
118-
raise HTTPException(
119-
status_code=status.HTTP_502_BAD_GATEWAY,
120-
detail="Failed to create billing portal session.",
121-
)
122-
return {"portal_url": url}
70+
return {"checkout_url": url}
12371

12472

12573
@router.post("/webhook", include_in_schema=False)
126-
async def stripe_webhook(request: Request):
74+
async def razorpay_webhook(request: Request):
12775
"""
128-
Receive and process Stripe webhook events.
129-
130-
This endpoint must be reachable by Stripe (public URL) and must NOT
131-
require authentication.
76+
Receive and process Razorpay webhook events.
77+
Must be publicly reachable — no auth required.
13278
"""
13379
payload = await request.body()
134-
sig_header = request.headers.get("stripe-signature", "")
80+
sig_header = request.headers.get("x-razorpay-signature", "")
13581

13682
try:
13783
result = await handle_webhook(payload=payload, sig_header=sig_header)
@@ -142,7 +88,7 @@ async def stripe_webhook(request: Request):
14288
detail=str(exc),
14389
)
14490
except Exception as exc:
145-
logger.error("Stripe webhook processing error: %s", exc)
91+
logger.error("Razorpay webhook processing error: %s", exc)
14692
raise HTTPException(
14793
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
14894
detail="Webhook processing failed.",

0 commit comments

Comments
 (0)