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
24 changes: 24 additions & 0 deletions src/api/routes/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ def _pro_plan_id_for_region(region: str) -> str | None:
return settings.razorpay_pro_plan_id


def _minor_amount(value: Any) -> int | None:
return int(value) if value is not None else None


@router.get("/plans", response_model=list[PlanPublic])
async def list_billing_plans() -> list[PlanPublic]:
return public_plans()
Expand Down Expand Up @@ -158,6 +162,11 @@ async def create_razorpay_checkout(
"package_id": request.package_id,
"billing_region": billing_region,
"subscription_id": checkout_id,
"amount": int(checkout_package["price_minor_unit"]),
"currency": str(checkout_package.get("currency") or "INR"),
"credits": int(
billing_config.PLANS["pro"].get("monthly_credits") or 0
),
"status": "created",
},
)
Expand Down Expand Up @@ -248,6 +257,9 @@ async def verify_razorpay_payment(
user_id=user_id,
payment_id=request.razorpay_payment_id,
subscription_id=request.razorpay_subscription_id,
amount=_minor_amount(checkout.get("amount")),
currency=checkout.get("currency"),
billing_region=checkout.get("billing_region") or request.billing_region,
)
elif request.razorpay_order_id:
if not verify_order_signature(
Expand All @@ -273,6 +285,9 @@ async def verify_razorpay_payment(
user_id=user_id,
payment_id=request.razorpay_payment_id,
subscription_id=request.razorpay_order_id,
amount=_minor_amount(checkout.get("amount")),
currency=checkout.get("currency"),
billing_region=checkout.get("billing_region") or request.billing_region,
)
else:
await asyncio.to_thread(
Expand All @@ -281,6 +296,9 @@ async def verify_razorpay_payment(
pack_id=package_id,
payment_id=request.razorpay_payment_id,
order_id=request.razorpay_order_id,
amount=_minor_amount(checkout.get("amount")),
currency=checkout.get("currency"),
billing_region=checkout.get("billing_region") or request.billing_region,
)
else:
raise HTTPException(status_code=400, detail="Missing Razorpay order or subscription id")
Expand Down Expand Up @@ -345,6 +363,9 @@ async def razorpay_webhook(request: Request) -> dict[str, str]:
user_id=user_id,
payment_id=payment_id,
subscription_id=subscription_id or order_id,
amount=_minor_amount(payment.get("amount")),
currency=payment.get("currency"),
billing_region=notes.get("billing_region"),
)
elif package_id == "pro":
logger.warning("Razorpay pro webhook missing subscription/order id: %s", event_name)
Expand All @@ -356,6 +377,9 @@ async def razorpay_webhook(request: Request) -> dict[str, str]:
pack_id=package_id,
payment_id=payment_id,
order_id=order_id,
amount=_minor_amount(payment.get("amount")),
currency=payment.get("currency"),
billing_region=notes.get("billing_region"),
)
elif package_id in billing_config.TOP_UP_PACKS:
logger.warning("Razorpay top-up webhook missing order id: %s", event_name)
Expand Down
134 changes: 129 additions & 5 deletions src/billing/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
BillingSummary,
CreditEstimate,
CreditLotPublic,
PaymentInvoicePublic,
PlanPublic,
ReservationResult,
TopUpPackPublic,
UsageSnapshotPublic,
)
from src.utils import billing as billing_config

Expand Down Expand Up @@ -242,10 +244,15 @@ def grant_pro_subscription(
user_id: str,
payment_id: str,
subscription_id: str,
amount: Optional[int] = None,
currency: Optional[str] = None,
billing_region: Optional[str] = None,
receipt_url: Optional[str] = None,
period_end=None,
) -> dict[str, Any]:
account = self.store.ensure_account(owner_id=user_id)
plan = billing_config.PLANS["pro"]
price = billing_config.plan_price("pro", billing_region)
expires_at = period_end or (utc_now() + timedelta(days=30))
self.store.update_account(
account["id"],
Expand All @@ -256,14 +263,40 @@ def grant_pro_subscription(
"current_period_end": expires_at,
},
)
return self.store.grant_credits(
grant = self.store.grant_credits(
account_id=account["id"],
amount=int(plan["monthly_credits"]),
source="pro_monthly",
expires_at=expires_at,
idempotency_key=f"pro_grant:{subscription_id}:{payment_id}",
metadata={"payment_id": payment_id, "subscription_id": subscription_id},
metadata={
"payment_id": payment_id,
"subscription_id": subscription_id,
"billing_region": billing_config.normalize_billing_region(
billing_region
),
},
)
self.store.record_payment_invoice(
payment_id=payment_id,
payload={
"billing_account_id": account["id"],
"user_id": user_id,
"package_id": "pro",
"package_type": "plan",
"amount_paise": int(
amount if amount is not None else price["price_minor_unit"]
),
"currency": str(currency or price.get("currency") or "INR"),
"credits": int(plan["monthly_credits"]),
"razorpay_subscription_id": subscription_id,
"billing_region": billing_config.normalize_billing_region(
billing_region
),
"receipt_url": receipt_url,
},
)
return grant

def grant_topup(
self,
Expand All @@ -272,10 +305,14 @@ def grant_topup(
pack_id: str,
payment_id: str,
order_id: str,
amount: Optional[int] = None,
currency: Optional[str] = None,
billing_region: Optional[str] = None,
receipt_url: Optional[str] = None,
) -> dict[str, Any]:
pack = billing_config.TOP_UP_PACKS[pack_id]
account = self.store.ensure_account(owner_id=user_id)
return self.store.grant_credits(
grant = self.store.grant_credits(
account_id=account["id"],
amount=int(pack["credits"]),
source=pack_id,
Expand All @@ -287,12 +324,87 @@ def grant_topup(
"pack_id": pack_id,
},
)
self.store.record_payment_invoice(
payment_id=payment_id,
payload={
"billing_account_id": account["id"],
"user_id": user_id,
"package_id": pack_id,
"package_type": "topup",
"amount_paise": int(
amount if amount is not None else pack["price_paise"]
),
"currency": str(currency or pack.get("currency") or "INR"),
"credits": int(pack["credits"]),
"razorpay_order_id": order_id,
"billing_region": billing_config.normalize_billing_region(
billing_region
),
"receipt_url": receipt_url,
},
)
return grant

def get_billing_summary(self, user: Mapping[str, Any]) -> BillingSummary:
account = self.ensure_billing_account(user)
wallet = self.store.get_wallet(account["id"])
plan_id = str(account.get("plan_id") or "free")
plan = billing_config.PLANS.get(plan_id, billing_config.PLANS["free"])
available_credits = int(wallet.get("available_credits") or 0)
status = str(account.get("status") or "trialing")
account_status = "trial" if status == "trialing" else status
invoices = []
for invoice in self.store.list_payment_invoices(account["id"]):
raw_amount = (
invoice.get("amount_minor_units")
if invoice.get("amount_minor_units") is not None
else (
invoice.get("amount_paise")
if invoice.get("amount_paise") is not None
else invoice.get("amount")
)
)
amount_minor_units = int(raw_amount or 0)
invoices.append(
PaymentInvoicePublic(
id=str(invoice.get("id") or invoice.get("razorpay_payment_id")),
date=invoice.get("paid_at")
or invoice.get("created_at")
or utc_now(),
amount_minor_units=amount_minor_units,
amount_paise=amount_minor_units,
currency=str(
invoice.get("currency") or plan.get("currency") or "INR"
),
status=str(invoice.get("status") or "paid"),
credits=int(invoice.get("credits") or 0),
receipt_url=invoice.get("receipt_url"),
package_id=invoice.get("package_id"),
razorpay_payment_id=invoice.get("razorpay_payment_id"),
)
)
last_payment_at = invoices[0].date if invoices else None
if plan_id == "free" and plan.get("trial_credits") is not None:
credits_limit = int(plan.get("trial_credits") or 0)
elif plan.get("monthly_credits") is not None:
credits_limit = int(plan.get("monthly_credits") or 0)
else:
credits_limit = int(plan.get("trial_credits") or available_credits or 0)

period_start = account.get("current_period_start") or utc_now().replace(
day=1, hour=0, minute=0, second=0, microsecond=0
)
ledger_entries = self.store.list_ledger(account["id"], limit=500)
current_usage = sum(
abs(int(entry.get("amount") or 0))
for entry in ledger_entries
if entry.get("type") == "debit"
and (
not period_start
or not entry.get("created_at")
or entry["created_at"] >= period_start
)
)
lots = [
CreditLotPublic(
id=str(lot["id"]),
Expand All @@ -308,12 +420,24 @@ def get_billing_summary(self, user: Mapping[str, Any]) -> BillingSummary:
owner_id=str(account.get("owner_id")),
plan_id=plan_id,
plan_name=str(plan.get("name") or plan_id),
status=str(account.get("status") or "trialing"),
status=status,
account_status=account_status,
currency=str(plan.get("currency") or "INR"),
available_credits=int(wallet.get("available_credits") or 0),
available_credits=available_credits,
credit_balance=available_credits,
reserved_credits=int(wallet.get("reserved_credits") or 0),
prepaid_balance_paise=int(
available_credits * billing_config.nominal_paise_per_credit(plan_id)
),
current_period_start=account.get("current_period_start"),
current_period_end=account.get("current_period_end"),
current_month=UsageSnapshotPublic(
credits_used=current_usage,
credits_limit=credits_limit,
),
next_invoice_paise=0,
last_payment_at=last_payment_at,
invoices=invoices,
credit_lots=lots,
)

Expand Down
86 changes: 86 additions & 0 deletions src/billing/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
_memory_usage_events: list[dict[str, Any]] = []
_memory_checkouts: dict[str, dict[str, Any]] = {}
_memory_payment_events: dict[str, dict[str, Any]] = {}
_memory_payment_records: dict[str, dict[str, Any]] = {}


class BillingStoreError(RuntimeError):
Expand Down Expand Up @@ -139,6 +140,13 @@ def _try_connect(self) -> None:
self.payments.create_index(
[("razorpay_payment_id", ASCENDING)], unique=True, sparse=True
)
self.payments.create_index(
[
("billing_account_id", ASCENDING),
("type", ASCENDING),
("paid_at", ASCENDING),
]
)

self._connected = True
self._in_memory = False
Expand Down Expand Up @@ -1047,6 +1055,84 @@ def get_checkout(self, checkout_id: str) -> Optional[dict[str, Any]]:
)
return _without_id(self.payments.find_one({"id": checkout_id}))

def record_payment_invoice(
self,
*,
payment_id: str,
payload: dict[str, Any],
) -> dict[str, Any]:
if not payment_id:
raise ValueError("Razorpay payment id is required")
now = utc_now()
checkout_id = payload.get("razorpay_subscription_id") or payload.get(
"razorpay_order_id"
)
doc = {
"id": payment_id,
"type": "invoice",
"razorpay_payment_id": payment_id,
"status": "paid",
**payload,
"paid_at": payload.get("paid_at") or now,
"updated_at": now,
}
if self._in_memory:
existing = _memory_payment_records.get(payment_id)
if existing:
_memory_payment_records[payment_id] = {**existing, **doc}
else:
_memory_payment_records[payment_id] = {**doc, "created_at": now}
if checkout_id and checkout_id in _memory_checkouts:
_memory_checkouts[checkout_id]["status"] = "paid"
_memory_checkouts[checkout_id]["payment_id"] = payment_id
_memory_checkouts[checkout_id]["updated_at"] = now
return dict(_memory_payment_records[payment_id])

from pymongo import ReturnDocument

invoice = self.payments.find_one_and_update(
{"razorpay_payment_id": payment_id},
{"$set": doc, "$setOnInsert": {"created_at": now}},
upsert=True,
return_document=ReturnDocument.AFTER,
)
if checkout_id:
self.payments.update_one(
{"id": checkout_id},
{
"$set": {
"status": "paid",
"payment_id": payment_id,
"updated_at": now,
}
},
)
return _without_id(invoice) or doc

def list_payment_invoices(
self, account_id: str, limit: int = 20
) -> list[dict[str, Any]]:
Comment thread
ishaanxgupta marked this conversation as resolved.
if self._in_memory:
invoices = [
dict(invoice)
for invoice in _memory_payment_records.values()
if invoice.get("billing_account_id") == account_id
and invoice.get("type") == "invoice"
]
return sorted(
invoices,
key=lambda item: item.get("paid_at") or item.get("created_at"),
reverse=True,
)[:limit]
return [
_without_id(invoice) or {}
for invoice in self.payments.find(
{"billing_account_id": account_id, "type": "invoice"}
)
.sort("paid_at", -1)
.limit(limit)
]

def mark_payment_event(self, event_id: str, payload: dict[str, Any]) -> bool:
if not event_id:
raise ValueError("Razorpay webhook event id is required")
Expand Down
Loading
Loading