Files
verify/backend/app/routers/billing.py
Mohamed Mathar Irfan ed6610d5d8 Initial project upload
2026-07-28 17:57:02 +05:30

251 lines
11 KiB
Python

"""Billing & Subscription — Razorpay orders, GST invoices, plan changes.
GST is 18% on the plan price (INR only). Invoices store subtotal + gst + total.
Plan price changes never affect an active subscription until renewal.
"""
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status
from app.core.config import settings
from app.core.deps import require_org, require_client_roles, Principal
from app.models import (
Organization, Plan, Subscription, Invoice, Payment, PlanHistory,
StorageUsage, AuditLog,
)
from app.models.documents import now
from app.models.enums import ClientRole, PaymentStatus, SubscriptionStatus
from app.services import razorpay_svc
from app.services.invoice_pdf import build_invoice_pdf
router = APIRouter(prefix="/billing", tags=["billing"])
# Simple demo coupons: code -> percent off
COUPONS = {"WELCOME10": 10, "VERIFY20": 20, "LAUNCH50": 50}
def _gst(amount: int) -> int:
return round(amount * settings.gst_rate)
async def _next_invoice_number() -> str:
count = await Invoice.find_all().count()
yr = datetime.now().year
return f"VP/{yr}/{count + 1:05d}"
@router.get("/plans")
async def list_plans_for_clients(p: Principal = Depends(require_org)):
"""Plans available for upgrade/downgrade (enabled only), visible to any org member."""
plans = await Plan.find(Plan.enabled == True).to_list() # noqa: E712
plans.sort(key=lambda x: x.price)
return [{"id": str(pl.id), "name": pl.name, "price": pl.price,
"product_limit": pl.product_limit, "user_limit": pl.user_limit,
"storage_gb": pl.storage_gb, "features": pl.features} for pl in plans]
@router.get("/subscription")
async def get_subscription(p: Principal = Depends(require_org)):
sub = await Subscription.find_one(Subscription.org_id == str(p.org.id))
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
storage = await StorageUsage.find_one(StorageUsage.org_id == str(p.org.id))
days_remaining = None
if sub and sub.renewal_date:
rd = sub.renewal_date
if rd.tzinfo is None:
rd = rd.replace(tzinfo=timezone.utc)
days_remaining = max(0, (rd - now()).days)
return {
"status": p.org.subscription_status,
"plan": {"id": str(plan.id), "name": plan.name, "price": plan.price,
"product_limit": plan.product_limit, "user_limit": plan.user_limit,
"storage_gb": plan.storage_gb} if plan else None,
"auto_renew": sub.auto_renew if sub else True,
"start_date": sub.start_date if sub else None,
"renewal_date": sub.renewal_date if sub else None,
"days_remaining": days_remaining,
"payment_status": sub.payment_status if sub else None,
"usage": {
"products": p.org.product_count,
"product_limit": plan.product_limit if plan else None,
"users": p.org.user_count,
"user_limit": plan.user_limit if plan else None,
"storage_used_gb": round((storage.used_bytes if storage else 0) / 1024 ** 3, 1),
"storage_total_gb": round((storage.total_bytes if storage else 0) / 1024 ** 3, 1),
},
}
@router.put("/subscription/auto-renewal")
async def set_auto_renewal(body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.finance))):
sub = await Subscription.find_one(Subscription.org_id == str(p.org.id))
if not sub:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No subscription")
sub.auto_renew = bool(body.get("auto_renew", True))
await sub.save()
return {"auto_renew": sub.auto_renew}
@router.post("/coupon/apply")
async def apply_coupon(body: dict = Body(...), p: Principal = Depends(require_org)):
code = (body.get("code") or "").strip().upper()
pct = COUPONS.get(code)
if not pct:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid coupon code")
return {"code": code, "percent_off": pct}
@router.post("/create-order")
async def create_order(body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.finance))):
"""Create a Razorpay order for a plan upgrade/downgrade.
body = {"plan_id": "...", "coupon": "WELCOME10"?}
Returns order details + GST breakdown for the checkout modal.
"""
plan = await Plan.get(body.get("plan_id"))
if not plan:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Plan not found")
base = plan.price
discount = 0
coupon = (body.get("coupon") or "").strip().upper()
if coupon and coupon in COUPONS:
discount = round(base * COUPONS[coupon] / 100)
subtotal = max(0, base - discount)
gst = _gst(subtotal)
total = subtotal + gst
if total == 0:
# Free plan or fully discounted — activate immediately, no Razorpay.
await _activate_plan(p.org, plan, subtotal, gst, total, method="free")
return {"free": True, "plan": plan.name}
receipt = f"rcpt_{str(p.org.id)[-6:]}_{int(now().timestamp())}"
order = razorpay_svc.create_order(total * 100, receipt,
{"org_id": str(p.org.id), "plan_id": str(plan.id)})
await Payment(org_id=str(p.org.id), razorpay_order_id=order["id"],
amount=total, status=PaymentStatus.pending).insert()
return {
"order_id": order["id"],
"amount": total,
"currency": "INR",
"key_id": settings.razorpay_key_id,
"mock": order.get("mock", False),
"breakdown": {"base": base, "discount": discount, "subtotal": subtotal,
"gst": gst, "total": total, "coupon": coupon or None},
"plan_id": str(plan.id),
"plan_name": plan.name,
}
@router.post("/verify")
async def verify_payment(body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.finance))):
"""Verify Razorpay payment signature and activate the plan."""
order_id = body.get("razorpay_order_id")
payment_id = body.get("razorpay_payment_id")
signature = body.get("razorpay_signature", "")
plan_id = body.get("plan_id")
if not razorpay_svc.verify_payment_signature(order_id, payment_id, signature):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid payment signature")
plan = await Plan.get(plan_id)
if not plan:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Plan not found")
pay = await Payment.find_one(Payment.razorpay_order_id == order_id)
subtotal = plan.price
if pay:
subtotal = pay.amount - _gst(pay.amount) # back out gst-inclusive stored total
gst = _gst(plan.price)
total = plan.price + gst
if pay:
pay.razorpay_payment_id = payment_id
pay.status = PaymentStatus.paid
await pay.save()
invoice = await _activate_plan(p.org, plan, plan.price, gst, plan.price + gst,
method="razorpay", txn=payment_id)
return {"success": True, "plan": plan.name, "invoice_number": invoice}
@router.post("/webhook")
async def razorpay_webhook(request: Request):
"""Razorpay webhook handler (payment.captured / payment.failed)."""
payload = await request.body()
sig = request.headers.get("x-razorpay-signature", "")
if not razorpay_svc.verify_webhook_signature(payload, sig):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook signature")
# In production: parse event, reconcile Payment/Invoice by order id.
return {"ok": True}
async def _activate_plan(org: Organization, plan: Plan, subtotal: int, gst: int,
total: int, method: str, txn: Optional[str] = None) -> str:
"""Switch org to a plan, write subscription + invoice + plan_history."""
old_plan = await Plan.get(org.plan_id) if org.plan_id else None
org.plan_id = str(plan.id)
org.subscription_status = SubscriptionStatus.active
await org.save()
storage = await StorageUsage.find_one(StorageUsage.org_id == str(org.id))
if storage:
storage.total_bytes = plan.storage_gb * 1024 ** 3
await storage.save()
sub = await Subscription.find_one(Subscription.org_id == str(org.id))
renewal = now() + timedelta(days=30)
if sub:
sub.plan_id = str(plan.id)
sub.plan_name = plan.name
sub.status = SubscriptionStatus.active
sub.start_date = now()
sub.renewal_date = renewal
sub.payment_status = PaymentStatus.paid
await sub.save()
else:
await Subscription(org_id=str(org.id), plan_id=str(plan.id), plan_name=plan.name,
renewal_date=renewal).insert()
inv_no = await _next_invoice_number()
if total > 0:
await Invoice(org_id=str(org.id), invoice_number=inv_no, plan_name=plan.name,
period=f"{now():%b %Y} - {renewal:%b %Y}",
subtotal=subtotal, gst_amount=gst, total=total,
status=PaymentStatus.paid, payment_method=method,
transaction_id=txn, gstin=org.gstin,
place_of_supply="Karnataka").insert()
await PlanHistory(org_id=str(org.id),
from_plan=old_plan.name if old_plan else None,
to_plan=plan.name, reason=f"{method} payment").insert()
await AuditLog(org_id=str(org.id), action="subscription.change", module="billing",
details=f"Plan changed to {plan.name}").insert()
return inv_no
@router.get("/invoices")
async def list_invoices(p: Principal = Depends(require_org)):
invs = await Invoice.find(Invoice.org_id == str(p.org.id)).to_list()
invs.sort(key=lambda i: i.created_at, reverse=True)
return [{"id": str(i.id), "invoice_number": i.invoice_number, "plan_name": i.plan_name,
"period": i.period, "subtotal": i.subtotal, "gst_amount": i.gst_amount,
"total": i.total, "status": i.status, "payment_method": i.payment_method,
"transaction_id": i.transaction_id, "created_at": i.created_at} for i in invs]
@router.get("/invoices/{invoice_id}/pdf")
async def invoice_pdf(invoice_id: str, p: Principal = Depends(require_org)):
inv = await Invoice.get(invoice_id)
if not inv or inv.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Invoice not found")
pdf = build_invoice_pdf(inv.model_dump(), p.org.model_dump())
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{inv.invoice_number.replace("/", "-")}.pdf"'})