Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit f556419

Browse files
authored
Merge pull request #223 from XortexAI/fix/billing-payment-invoices
Record Razorpay payments in billing summary
2 parents 52aaf6f + 4b1dd83 commit f556419

5 files changed

Lines changed: 344 additions & 5 deletions

File tree

src/api/routes/billing.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ def _pro_plan_id_for_region(region: str) -> str | None:
9595
return settings.razorpay_pro_plan_id
9696

9797

98+
def _minor_amount(value: Any) -> int | None:
99+
return int(value) if value is not None else None
100+
101+
98102
@router.get("/plans", response_model=list[PlanPublic])
99103
async def list_billing_plans() -> list[PlanPublic]:
100104
return public_plans()
@@ -158,6 +162,11 @@ async def create_razorpay_checkout(
158162
"package_id": request.package_id,
159163
"billing_region": billing_region,
160164
"subscription_id": checkout_id,
165+
"amount": int(checkout_package["price_minor_unit"]),
166+
"currency": str(checkout_package.get("currency") or "INR"),
167+
"credits": int(
168+
billing_config.PLANS["pro"].get("monthly_credits") or 0
169+
),
161170
"status": "created",
162171
},
163172
)
@@ -248,6 +257,9 @@ async def verify_razorpay_payment(
248257
user_id=user_id,
249258
payment_id=request.razorpay_payment_id,
250259
subscription_id=request.razorpay_subscription_id,
260+
amount=_minor_amount(checkout.get("amount")),
261+
currency=checkout.get("currency"),
262+
billing_region=checkout.get("billing_region") or request.billing_region,
251263
)
252264
elif request.razorpay_order_id:
253265
if not verify_order_signature(
@@ -273,6 +285,9 @@ async def verify_razorpay_payment(
273285
user_id=user_id,
274286
payment_id=request.razorpay_payment_id,
275287
subscription_id=request.razorpay_order_id,
288+
amount=_minor_amount(checkout.get("amount")),
289+
currency=checkout.get("currency"),
290+
billing_region=checkout.get("billing_region") or request.billing_region,
276291
)
277292
else:
278293
await asyncio.to_thread(
@@ -281,6 +296,9 @@ async def verify_razorpay_payment(
281296
pack_id=package_id,
282297
payment_id=request.razorpay_payment_id,
283298
order_id=request.razorpay_order_id,
299+
amount=_minor_amount(checkout.get("amount")),
300+
currency=checkout.get("currency"),
301+
billing_region=checkout.get("billing_region") or request.billing_region,
284302
)
285303
else:
286304
raise HTTPException(status_code=400, detail="Missing Razorpay order or subscription id")
@@ -345,6 +363,9 @@ async def razorpay_webhook(request: Request) -> dict[str, str]:
345363
user_id=user_id,
346364
payment_id=payment_id,
347365
subscription_id=subscription_id or order_id,
366+
amount=_minor_amount(payment.get("amount")),
367+
currency=payment.get("currency"),
368+
billing_region=notes.get("billing_region"),
348369
)
349370
elif package_id == "pro":
350371
logger.warning("Razorpay pro webhook missing subscription/order id: %s", event_name)
@@ -356,6 +377,9 @@ async def razorpay_webhook(request: Request) -> dict[str, str]:
356377
pack_id=package_id,
357378
payment_id=payment_id,
358379
order_id=order_id,
380+
amount=_minor_amount(payment.get("amount")),
381+
currency=payment.get("currency"),
382+
billing_region=notes.get("billing_region"),
359383
)
360384
elif package_id in billing_config.TOP_UP_PACKS:
361385
logger.warning("Razorpay top-up webhook missing order id: %s", event_name)

src/billing/service.py

Lines changed: 129 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
BillingSummary,
1717
CreditEstimate,
1818
CreditLotPublic,
19+
PaymentInvoicePublic,
1920
PlanPublic,
2021
ReservationResult,
2122
TopUpPackPublic,
23+
UsageSnapshotPublic,
2224
)
2325
from src.utils import billing as billing_config
2426

@@ -242,10 +244,15 @@ def grant_pro_subscription(
242244
user_id: str,
243245
payment_id: str,
244246
subscription_id: str,
247+
amount: Optional[int] = None,
248+
currency: Optional[str] = None,
249+
billing_region: Optional[str] = None,
250+
receipt_url: Optional[str] = None,
245251
period_end=None,
246252
) -> dict[str, Any]:
247253
account = self.store.ensure_account(owner_id=user_id)
248254
plan = billing_config.PLANS["pro"]
255+
price = billing_config.plan_price("pro", billing_region)
249256
expires_at = period_end or (utc_now() + timedelta(days=30))
250257
self.store.update_account(
251258
account["id"],
@@ -256,14 +263,40 @@ def grant_pro_subscription(
256263
"current_period_end": expires_at,
257264
},
258265
)
259-
return self.store.grant_credits(
266+
grant = self.store.grant_credits(
260267
account_id=account["id"],
261268
amount=int(plan["monthly_credits"]),
262269
source="pro_monthly",
263270
expires_at=expires_at,
264271
idempotency_key=f"pro_grant:{subscription_id}:{payment_id}",
265-
metadata={"payment_id": payment_id, "subscription_id": subscription_id},
272+
metadata={
273+
"payment_id": payment_id,
274+
"subscription_id": subscription_id,
275+
"billing_region": billing_config.normalize_billing_region(
276+
billing_region
277+
),
278+
},
279+
)
280+
self.store.record_payment_invoice(
281+
payment_id=payment_id,
282+
payload={
283+
"billing_account_id": account["id"],
284+
"user_id": user_id,
285+
"package_id": "pro",
286+
"package_type": "plan",
287+
"amount_paise": int(
288+
amount if amount is not None else price["price_minor_unit"]
289+
),
290+
"currency": str(currency or price.get("currency") or "INR"),
291+
"credits": int(plan["monthly_credits"]),
292+
"razorpay_subscription_id": subscription_id,
293+
"billing_region": billing_config.normalize_billing_region(
294+
billing_region
295+
),
296+
"receipt_url": receipt_url,
297+
},
266298
)
299+
return grant
267300

268301
def grant_topup(
269302
self,
@@ -272,10 +305,14 @@ def grant_topup(
272305
pack_id: str,
273306
payment_id: str,
274307
order_id: str,
308+
amount: Optional[int] = None,
309+
currency: Optional[str] = None,
310+
billing_region: Optional[str] = None,
311+
receipt_url: Optional[str] = None,
275312
) -> dict[str, Any]:
276313
pack = billing_config.TOP_UP_PACKS[pack_id]
277314
account = self.store.ensure_account(owner_id=user_id)
278-
return self.store.grant_credits(
315+
grant = self.store.grant_credits(
279316
account_id=account["id"],
280317
amount=int(pack["credits"]),
281318
source=pack_id,
@@ -287,12 +324,87 @@ def grant_topup(
287324
"pack_id": pack_id,
288325
},
289326
)
327+
self.store.record_payment_invoice(
328+
payment_id=payment_id,
329+
payload={
330+
"billing_account_id": account["id"],
331+
"user_id": user_id,
332+
"package_id": pack_id,
333+
"package_type": "topup",
334+
"amount_paise": int(
335+
amount if amount is not None else pack["price_paise"]
336+
),
337+
"currency": str(currency or pack.get("currency") or "INR"),
338+
"credits": int(pack["credits"]),
339+
"razorpay_order_id": order_id,
340+
"billing_region": billing_config.normalize_billing_region(
341+
billing_region
342+
),
343+
"receipt_url": receipt_url,
344+
},
345+
)
346+
return grant
290347

291348
def get_billing_summary(self, user: Mapping[str, Any]) -> BillingSummary:
292349
account = self.ensure_billing_account(user)
293350
wallet = self.store.get_wallet(account["id"])
294351
plan_id = str(account.get("plan_id") or "free")
295352
plan = billing_config.PLANS.get(plan_id, billing_config.PLANS["free"])
353+
available_credits = int(wallet.get("available_credits") or 0)
354+
status = str(account.get("status") or "trialing")
355+
account_status = "trial" if status == "trialing" else status
356+
invoices = []
357+
for invoice in self.store.list_payment_invoices(account["id"]):
358+
raw_amount = (
359+
invoice.get("amount_minor_units")
360+
if invoice.get("amount_minor_units") is not None
361+
else (
362+
invoice.get("amount_paise")
363+
if invoice.get("amount_paise") is not None
364+
else invoice.get("amount")
365+
)
366+
)
367+
amount_minor_units = int(raw_amount or 0)
368+
invoices.append(
369+
PaymentInvoicePublic(
370+
id=str(invoice.get("id") or invoice.get("razorpay_payment_id")),
371+
date=invoice.get("paid_at")
372+
or invoice.get("created_at")
373+
or utc_now(),
374+
amount_minor_units=amount_minor_units,
375+
amount_paise=amount_minor_units,
376+
currency=str(
377+
invoice.get("currency") or plan.get("currency") or "INR"
378+
),
379+
status=str(invoice.get("status") or "paid"),
380+
credits=int(invoice.get("credits") or 0),
381+
receipt_url=invoice.get("receipt_url"),
382+
package_id=invoice.get("package_id"),
383+
razorpay_payment_id=invoice.get("razorpay_payment_id"),
384+
)
385+
)
386+
last_payment_at = invoices[0].date if invoices else None
387+
if plan_id == "free" and plan.get("trial_credits") is not None:
388+
credits_limit = int(plan.get("trial_credits") or 0)
389+
elif plan.get("monthly_credits") is not None:
390+
credits_limit = int(plan.get("monthly_credits") or 0)
391+
else:
392+
credits_limit = int(plan.get("trial_credits") or available_credits or 0)
393+
394+
period_start = account.get("current_period_start") or utc_now().replace(
395+
day=1, hour=0, minute=0, second=0, microsecond=0
396+
)
397+
ledger_entries = self.store.list_ledger(account["id"], limit=500)
398+
current_usage = sum(
399+
abs(int(entry.get("amount") or 0))
400+
for entry in ledger_entries
401+
if entry.get("type") == "debit"
402+
and (
403+
not period_start
404+
or not entry.get("created_at")
405+
or entry["created_at"] >= period_start
406+
)
407+
)
296408
lots = [
297409
CreditLotPublic(
298410
id=str(lot["id"]),
@@ -308,12 +420,24 @@ def get_billing_summary(self, user: Mapping[str, Any]) -> BillingSummary:
308420
owner_id=str(account.get("owner_id")),
309421
plan_id=plan_id,
310422
plan_name=str(plan.get("name") or plan_id),
311-
status=str(account.get("status") or "trialing"),
423+
status=status,
424+
account_status=account_status,
312425
currency=str(plan.get("currency") or "INR"),
313-
available_credits=int(wallet.get("available_credits") or 0),
426+
available_credits=available_credits,
427+
credit_balance=available_credits,
314428
reserved_credits=int(wallet.get("reserved_credits") or 0),
429+
prepaid_balance_paise=int(
430+
available_credits * billing_config.nominal_paise_per_credit(plan_id)
431+
),
315432
current_period_start=account.get("current_period_start"),
316433
current_period_end=account.get("current_period_end"),
434+
current_month=UsageSnapshotPublic(
435+
credits_used=current_usage,
436+
credits_limit=credits_limit,
437+
),
438+
next_invoice_paise=0,
439+
last_payment_at=last_payment_at,
440+
invoices=invoices,
317441
credit_lots=lots,
318442
)
319443

src/billing/store.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
_memory_usage_events: list[dict[str, Any]] = []
2323
_memory_checkouts: dict[str, dict[str, Any]] = {}
2424
_memory_payment_events: dict[str, dict[str, Any]] = {}
25+
_memory_payment_records: dict[str, dict[str, Any]] = {}
2526

2627

2728
class BillingStoreError(RuntimeError):
@@ -139,6 +140,13 @@ def _try_connect(self) -> None:
139140
self.payments.create_index(
140141
[("razorpay_payment_id", ASCENDING)], unique=True, sparse=True
141142
)
143+
self.payments.create_index(
144+
[
145+
("billing_account_id", ASCENDING),
146+
("type", ASCENDING),
147+
("paid_at", ASCENDING),
148+
]
149+
)
142150

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

1058+
def record_payment_invoice(
1059+
self,
1060+
*,
1061+
payment_id: str,
1062+
payload: dict[str, Any],
1063+
) -> dict[str, Any]:
1064+
if not payment_id:
1065+
raise ValueError("Razorpay payment id is required")
1066+
now = utc_now()
1067+
checkout_id = payload.get("razorpay_subscription_id") or payload.get(
1068+
"razorpay_order_id"
1069+
)
1070+
doc = {
1071+
"id": payment_id,
1072+
"type": "invoice",
1073+
"razorpay_payment_id": payment_id,
1074+
"status": "paid",
1075+
**payload,
1076+
"paid_at": payload.get("paid_at") or now,
1077+
"updated_at": now,
1078+
}
1079+
if self._in_memory:
1080+
existing = _memory_payment_records.get(payment_id)
1081+
if existing:
1082+
_memory_payment_records[payment_id] = {**existing, **doc}
1083+
else:
1084+
_memory_payment_records[payment_id] = {**doc, "created_at": now}
1085+
if checkout_id and checkout_id in _memory_checkouts:
1086+
_memory_checkouts[checkout_id]["status"] = "paid"
1087+
_memory_checkouts[checkout_id]["payment_id"] = payment_id
1088+
_memory_checkouts[checkout_id]["updated_at"] = now
1089+
return dict(_memory_payment_records[payment_id])
1090+
1091+
from pymongo import ReturnDocument
1092+
1093+
invoice = self.payments.find_one_and_update(
1094+
{"razorpay_payment_id": payment_id},
1095+
{"$set": doc, "$setOnInsert": {"created_at": now}},
1096+
upsert=True,
1097+
return_document=ReturnDocument.AFTER,
1098+
)
1099+
if checkout_id:
1100+
self.payments.update_one(
1101+
{"id": checkout_id},
1102+
{
1103+
"$set": {
1104+
"status": "paid",
1105+
"payment_id": payment_id,
1106+
"updated_at": now,
1107+
}
1108+
},
1109+
)
1110+
return _without_id(invoice) or doc
1111+
1112+
def list_payment_invoices(
1113+
self, account_id: str, limit: int = 20
1114+
) -> list[dict[str, Any]]:
1115+
if self._in_memory:
1116+
invoices = [
1117+
dict(invoice)
1118+
for invoice in _memory_payment_records.values()
1119+
if invoice.get("billing_account_id") == account_id
1120+
and invoice.get("type") == "invoice"
1121+
]
1122+
return sorted(
1123+
invoices,
1124+
key=lambda item: item.get("paid_at") or item.get("created_at"),
1125+
reverse=True,
1126+
)[:limit]
1127+
return [
1128+
_without_id(invoice) or {}
1129+
for invoice in self.payments.find(
1130+
{"billing_account_id": account_id, "type": "invoice"}
1131+
)
1132+
.sort("paid_at", -1)
1133+
.limit(limit)
1134+
]
1135+
10501136
def mark_payment_event(self, event_id: str, payload: dict[str, Any]) -> bool:
10511137
if not event_id:
10521138
raise ValueError("Razorpay webhook event id is required")

0 commit comments

Comments
 (0)