251 lines
11 KiB
Python
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"'})
|