Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions src/api/routes/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ def _pack_or_plan(package_id: str) -> tuple[str, dict[str, Any]]:
raise HTTPException(status_code=400, detail="Unknown billing package")


def _checkout_package(package_id: str, package: dict[str, Any], region: str) -> dict[str, Any]:
checkout_package = dict(package)
if package_id in billing_config.PLANS:
checkout_package.update(billing_config.plan_price(package_id, region))
return checkout_package


def _pro_plan_id_for_region(region: str) -> str | None:
if region == billing_config.BILLING_REGION_GLOBAL:
return settings.razorpay_global_pro_plan_id
return settings.razorpay_pro_plan_id


@router.get("/plans", response_model=list[PlanPublic])
async def list_billing_plans() -> list[PlanPublic]:
return public_plans()
Expand Down Expand Up @@ -110,6 +123,8 @@ async def create_razorpay_checkout(

user_id = _user_id(current_user)
package_type, package = _pack_or_plan(request.package_id)
billing_region = billing_config.normalize_billing_region(request.billing_region)
checkout_package = _checkout_package(request.package_id, package, billing_region)
service = get_default_billing_service()
account = await asyncio.to_thread(service.ensure_billing_account, current_user)

Expand All @@ -121,13 +136,15 @@ async def create_razorpay_checkout(
"billing_account_id": account["id"],
"package_id": request.package_id,
"package_type": package_type,
"billing_region": billing_region,
}
receipt = _receipt(user_id, request.package_id)

try:
if request.package_id == "pro" and settings.razorpay_pro_plan_id:
pro_plan_id = _pro_plan_id_for_region(billing_region)
if request.package_id == "pro" and pro_plan_id:
subscription = await create_subscription(
plan_id=settings.razorpay_pro_plan_id,
plan_id=pro_plan_id,
notes=notes,
)
checkout_id = str(subscription["id"])
Expand All @@ -139,6 +156,7 @@ async def create_razorpay_checkout(
"user_id": user_id,
"billing_account_id": account["id"],
"package_id": request.package_id,
"billing_region": billing_region,
"subscription_id": checkout_id,
"status": "created",
},
Expand All @@ -147,16 +165,16 @@ async def create_razorpay_checkout(
id=checkout_id,
subscription_id=checkout_id,
package_id=request.package_id,
amount=int(package["price_paise"]),
currency=str(package.get("currency") or "INR"),
amount=int(checkout_package["price_minor_unit"]),
currency=str(checkout_package.get("currency") or "INR"),
key_id=key_id,
receipt=receipt,
)

amount = int(package["price_paise"])
amount = int(checkout_package.get("price_minor_unit") or checkout_package["price_paise"])
order = await create_order(
amount_paise=amount,
currency=str(package.get("currency") or "INR"),
currency=str(checkout_package.get("currency") or "INR"),
receipt=receipt,
notes=notes,
)
Expand All @@ -172,9 +190,10 @@ async def create_razorpay_checkout(
"user_id": user_id,
"billing_account_id": account["id"],
"package_id": request.package_id,
"billing_region": billing_region,
"order_id": order_id,
"amount": amount,
"currency": str(package.get("currency") or "INR"),
"currency": str(checkout_package.get("currency") or "INR"),
"status": "created",
},
)
Expand All @@ -183,7 +202,7 @@ async def create_razorpay_checkout(
order_id=order_id,
package_id=request.package_id,
amount=amount,
currency=str(package.get("currency") or "INR"),
currency=str(checkout_package.get("currency") or "INR"),
key_id=key_id,
receipt=receipt,
)
Expand Down
1 change: 1 addition & 0 deletions src/billing/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def public_plans() -> list[PlanPublic]:
trial_credits=int(plan.get("trial_credits") or 0),
trial_days=int(plan.get("trial_days") or 0),
nominal_paise_per_credit=billing_config.nominal_paise_per_credit(plan_id),
regional_prices=billing_config.plan_price_options(plan_id),
)
for plan_id, plan in billing_config.PLANS.items()
]
Expand Down
14 changes: 14 additions & 0 deletions src/billing/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
from pydantic import BaseModel, Field


class PlanPricePublic(BaseModel):
price_minor_unit: int
currency: str = "INR"


class PlanPublic(BaseModel):
id: str
name: str
Expand All @@ -15,6 +20,7 @@ class PlanPublic(BaseModel):
trial_credits: int = 0
trial_days: int = 0
nominal_paise_per_credit: float = 0.0
regional_prices: dict[str, PlanPricePublic] = Field(default_factory=dict)


class TopUpPackPublic(BaseModel):
Expand Down Expand Up @@ -66,6 +72,13 @@ class ReservationResult(BaseModel):

class CheckoutRequest(BaseModel):
package_id: str = Field(..., description="Plan ID or top-up pack ID")
billing_region: Optional[str] = Field(
default=None,
description=(
"Client billing-region hint, e.g. IN for India or GLOBAL for non-India "
"pricing. Blank or missing hints use global pricing."
),
)


class CheckoutResponse(BaseModel):
Expand All @@ -85,6 +98,7 @@ class VerifyPaymentRequest(BaseModel):
razorpay_signature: str
razorpay_order_id: Optional[str] = None
razorpay_subscription_id: Optional[str] = None
billing_region: Optional[str] = None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 billing_region accepted but silently ignored in verify handler

billing_region is added to VerifyPaymentRequest but verify_razorpay_payment never reads request.billing_region — it neither cross-checks it against the stored checkout's billing_region nor uses it for any grant logic. Callers who pass it will see no effect, and the API surface implies a behavior that isn't implemented.

Fix in Cursor Fix in Codex Fix in Claude Code



class LedgerEntryPublic(BaseModel):
Expand Down
6 changes: 5 additions & 1 deletion src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,11 @@ class Settings(BaseSettings):
)
razorpay_pro_plan_id: Optional[str] = Field(
default=None,
description="Razorpay subscription plan ID for the Pro plan",
description="Razorpay subscription plan ID for the India Pro plan",
)
razorpay_global_pro_plan_id: Optional[str] = Field(
default=None,
description="Razorpay subscription plan ID for the global USD Pro plan",
)

@field_validator("fallback_order")
Expand Down
56 changes: 56 additions & 0 deletions src/utils/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@
},
}

BILLING_REGION_IN = "IN"
BILLING_REGION_GLOBAL = "GLOBAL"
BILLING_REGION_ALIASES = {
"IN": BILLING_REGION_IN,
"IND": BILLING_REGION_IN,
"INDIA": BILLING_REGION_IN,
"GLOBAL": BILLING_REGION_GLOBAL,
"US": BILLING_REGION_GLOBAL,
"USD": BILLING_REGION_GLOBAL,
"INTERNATIONAL": BILLING_REGION_GLOBAL,
"WORLD": BILLING_REGION_GLOBAL,
}

PLAN_REGIONAL_PRICES: dict[str, dict[str, dict[str, Any]]] = {
"pro": {
BILLING_REGION_IN: {"price_minor_unit": 9_900, "currency": "INR"},
BILLING_REGION_GLOBAL: {"price_minor_unit": 300, "currency": "USD"},
},
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

TOP_UP_PACKS: dict[str, dict[str, Any]] = {
"topup_99": {"price_paise": 9_900, "credits": 5_000, "currency": "INR"},
"topup_199": {"price_paise": 19_900, "credits": 12_000, "currency": "INR"},
Expand Down Expand Up @@ -75,6 +95,42 @@ def workflow_multiplier(job_type: str, payload: Mapping[str, Any]) -> float:
return WORKFLOW_MULTIPLIERS.get(job_type, 1.0)


def normalize_billing_region(region: str | None) -> str:
# Client-provided region is only a pricing hint; blank hints use global
# pricing to avoid undercharging when a client cannot derive location.
if not region or not region.strip():
return BILLING_REGION_GLOBAL
return BILLING_REGION_ALIASES.get(region.strip().upper(), BILLING_REGION_GLOBAL)
Comment on lines +98 to +103
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The normalize_billing_region function currently defaults to BILLING_REGION_IN only when region is falsy (e.g., None or ""). However, if a whitespace-only string (e.g., " ") is passed, region.strip().upper() becomes "", which is not in BILLING_REGION_ALIASES, causing it to fall back to BILLING_REGION_GLOBAL.

To ensure consistent default behavior for all empty or whitespace-only inputs, we should check if the stripped string is empty before performing the lookup.

Suggested change
def normalize_billing_region(region: str | None) -> str:
if not region:
return BILLING_REGION_IN
return BILLING_REGION_ALIASES.get(region.strip().upper(), BILLING_REGION_GLOBAL)
def normalize_billing_region(region: str | None) -> str:
if not region or not region.strip():
return BILLING_REGION_IN
return BILLING_REGION_ALIASES.get(region.strip().upper(), BILLING_REGION_GLOBAL)

Comment thread
greptile-apps[bot] marked this conversation as resolved.


def plan_price(plan_id: str, region: str | None = None) -> dict[str, Any]:
plan = PLANS[plan_id]
normalized_region = normalize_billing_region(region)
regional_price = PLAN_REGIONAL_PRICES.get(plan_id, {}).get(normalized_region)
if not regional_price:
return {
"price_minor_unit": int(plan.get("price_paise") or 0),
"currency": str(plan.get("currency") or "INR"),
}
return {
"price_minor_unit": int(regional_price["price_minor_unit"]),
"currency": str(regional_price.get("currency") or plan.get("currency") or "INR"),
}


def plan_price_options(plan_id: str) -> dict[str, dict[str, Any]]:
options = PLAN_REGIONAL_PRICES.get(plan_id)
if not options:
return {}
return {
region: {
"price_minor_unit": int(price["price_minor_unit"]),
"currency": str(price.get("currency") or "INR"),
}
for region, price in options.items()
}


def nominal_paise_per_credit(plan_id: str) -> float:
plan = PLANS[plan_id]
credits = int(plan.get("monthly_credits") or plan.get("trial_credits") or 0)
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/test_billing_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from src.utils import billing


def test_pro_plan_has_india_and_global_prices() -> None:
assert billing.plan_price("pro", "IN") == {
"price_minor_unit": 9_900,
"currency": "INR",
}
assert billing.plan_price("pro", "GLOBAL") == {
"price_minor_unit": 300,
"currency": "USD",
}
assert billing.PLANS["pro"]["monthly_credits"] == 5_000


def test_billing_region_defaults_to_global_and_unknowns_are_global() -> None:
assert billing.normalize_billing_region(None) == billing.BILLING_REGION_GLOBAL
assert billing.normalize_billing_region("") == billing.BILLING_REGION_GLOBAL
assert billing.normalize_billing_region(" ") == billing.BILLING_REGION_GLOBAL
assert billing.normalize_billing_region("india") == billing.BILLING_REGION_IN
assert billing.normalize_billing_region("outside-india") == billing.BILLING_REGION_GLOBAL
assert billing.normalize_billing_region("UK") == billing.BILLING_REGION_GLOBAL


def test_plan_price_options_are_serializable() -> None:
assert billing.plan_price_options("pro") == {
"IN": {"price_minor_unit": 9_900, "currency": "INR"},
"GLOBAL": {"price_minor_unit": 300, "currency": "USD"},
}
Loading