rsnarsna commited on
Commit ·
ecf81ae
1
Parent(s): dc23750
Implement subscription management features including billing history, plan changes, and usage metrics
Browse files- .vscode/settings.json +62 -3
- SAAS.code-workspace +31 -2
- apps/billing/decorators.py +18 -0
- apps/billing/management/__init__.py +0 -0
- apps/billing/management/commands/__init__.py +0 -0
- apps/billing/management/commands/seed_plans.py +42 -0
- apps/billing/services/billing_service.py +133 -0
- apps/billing/services/plan_enforcement.py +55 -0
- apps/billing/services/razorpay_service.py +19 -0
- apps/billing/urls.py +6 -0
- apps/billing/views.py +124 -0
- apps/organizations/services/api_key_service.py +3 -0
- apps/organizations/services/file_service.py +4 -0
- apps/organizations/services/membership_service.py +5 -0
- apps/organizations/services/org_service.py +4 -0
- apps/organizations/services/quota_service.py +7 -0
- config/settings/__init__.py +49 -0
- templates/layouts/partials/sidebar.html +19 -0
- templates/pages/billing/subscription.html +182 -0
.vscode/settings.json
CHANGED
|
@@ -1,8 +1,67 @@
|
|
| 1 |
{
|
|
|
|
|
|
|
| 2 |
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/Scripts/python.exe",
|
|
|
|
|
|
|
| 3 |
"python.analysis.extraPaths": [
|
| 4 |
-
"${workspaceFolder}
|
| 5 |
-
"${workspaceFolder}/config"
|
| 6 |
],
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
// ─── Python Environment ──────────────────────────────────────────────────
|
| 3 |
+
// Automatically targets your virtual environment for Windows
|
| 4 |
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/Scripts/python.exe",
|
| 5 |
+
|
| 6 |
+
// Tells the linter where your Django root is to prevent import errors
|
| 7 |
"python.analysis.extraPaths": [
|
| 8 |
+
"${workspaceFolder}"
|
|
|
|
| 9 |
],
|
| 10 |
+
|
| 11 |
+
// ─── Formatting & Linting (Black, isort, Flake8) ─────────────────────────
|
| 12 |
+
"[python]": {
|
| 13 |
+
"editor.defaultFormatter": "ms-python.black-formatter",
|
| 14 |
+
"editor.formatOnSave": true,
|
| 15 |
+
"editor.codeActionsOnSave": {
|
| 16 |
+
"source.organizeImports": "explicit"
|
| 17 |
+
}
|
| 18 |
+
},
|
| 19 |
+
|
| 20 |
+
// isort configuration (aligns with Black)
|
| 21 |
+
"isort.args": ["--profile", "black"],
|
| 22 |
+
|
| 23 |
+
// Flake8 linting
|
| 24 |
+
"flake8.args": [
|
| 25 |
+
"--max-line-length=88",
|
| 26 |
+
"--extend-ignore=E203,W503"
|
| 27 |
+
],
|
| 28 |
+
|
| 29 |
+
// ─── Templates (Jinja2 / Django) ─────────────────────────────────────────
|
| 30 |
+
// Forces HTML files in template folders to use Jinja/Django HTML syntax highlighting
|
| 31 |
+
"files.associations": {
|
| 32 |
+
"**/templates/**/*.html": "jinja-html",
|
| 33 |
+
"*.html": "jinja-html"
|
| 34 |
+
},
|
| 35 |
+
|
| 36 |
+
// Tailors editor settings specifically for Jinja template files
|
| 37 |
+
"[jinja-html]": {
|
| 38 |
+
"editor.tabSize": 4,
|
| 39 |
+
"editor.formatOnSave": true
|
| 40 |
+
},
|
| 41 |
+
"[django-html]": {
|
| 42 |
+
"editor.tabSize": 4,
|
| 43 |
+
"editor.formatOnSave": true
|
| 44 |
+
},
|
| 45 |
+
|
| 46 |
+
// ─── Tailwind CSS ────────────────────────────────────────────────────────
|
| 47 |
+
// Ensures Tailwind autocomplete works inside your template files
|
| 48 |
+
"tailwindCSS.includeLanguages": {
|
| 49 |
+
"jinja-html": "html",
|
| 50 |
+
"django-html": "html"
|
| 51 |
+
},
|
| 52 |
+
"tailwindCSS.experimental.classRegex": [
|
| 53 |
+
"class=\\s*['\"]([^'\"]*)['\"]",
|
| 54 |
+
"class:\\s*['\"]([^'\"]*)['\"]"
|
| 55 |
+
],
|
| 56 |
+
|
| 57 |
+
// ─── File Explorer ───────────────────────────────────────────────────────
|
| 58 |
+
// Excludes messy compiled files from your VS Code file explorer search
|
| 59 |
+
"files.exclude": {
|
| 60 |
+
"**/__pycache__": true,
|
| 61 |
+
"**/*.pyc": true,
|
| 62 |
+
"**/.pytest_cache": true,
|
| 63 |
+
"**/.mypy_cache": true,
|
| 64 |
+
"**/.ruff_cache": true,
|
| 65 |
+
".venv": false // Kept visible in case you need to inspect installed packages
|
| 66 |
+
}
|
| 67 |
}
|
SAAS.code-workspace
CHANGED
|
@@ -3,5 +3,34 @@
|
|
| 3 |
{
|
| 4 |
"path": "."
|
| 5 |
}
|
| 6 |
-
]
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
{
|
| 4 |
"path": "."
|
| 5 |
}
|
| 6 |
+
],
|
| 7 |
+
"settings": {
|
| 8 |
+
// Automatically targets your virtual environment (adjust path if needed)
|
| 9 |
+
"python.defaultInterpreterPath": "${workspaceFolder}/venv/bin/python",
|
| 10 |
+
|
| 11 |
+
// Tells the linter where your Django root is to prevent import errors
|
| 12 |
+
"python.analysis.extraPaths": [
|
| 13 |
+
"${workspaceFolder}"
|
| 14 |
+
],
|
| 15 |
+
|
| 16 |
+
// Forces HTML files in template folders to use Django HTML syntax highlighting
|
| 17 |
+
"files.associations": {
|
| 18 |
+
"**/templates/**/*.html": "django-html",
|
| 19 |
+
"*.html": "django-html"
|
| 20 |
+
},
|
| 21 |
+
|
| 22 |
+
// Tailors editor settings specifically for Django template files
|
| 23 |
+
"[django-html]": {
|
| 24 |
+
"editor.tabSize": 4,
|
| 25 |
+
"editor.formatOnSave": true,
|
| 26 |
+
"editor.defaultFormatter": "batisteo.vscode-django"
|
| 27 |
+
},
|
| 28 |
+
|
| 29 |
+
// Excludes messy compiled files from your VS Code file explorer search
|
| 30 |
+
"files.exclude": {
|
| 31 |
+
"**/__pycache__": true,
|
| 32 |
+
"**/*.pyc": true,
|
| 33 |
+
"**/.*": true
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
}
|
apps/billing/decorators.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import wraps
|
| 2 |
+
from django.http import HttpResponseForbidden
|
| 3 |
+
|
| 4 |
+
def plan_required(feature_key):
|
| 5 |
+
"""View decorator — checks org plan includes the feature."""
|
| 6 |
+
def decorator(view_func):
|
| 7 |
+
@wraps(view_func)
|
| 8 |
+
def wrapper(request, *args, **kwargs):
|
| 9 |
+
org = getattr(request, 'tenant', None)
|
| 10 |
+
if org:
|
| 11 |
+
from apps.billing.services.plan_enforcement import check_feature_access
|
| 12 |
+
try:
|
| 13 |
+
check_feature_access(org, feature_key)
|
| 14 |
+
except Exception:
|
| 15 |
+
return HttpResponseForbidden("Upgrade your plan to access this feature.")
|
| 16 |
+
return view_func(request, *args, **kwargs)
|
| 17 |
+
return wrapper
|
| 18 |
+
return decorator
|
apps/billing/management/__init__.py
ADDED
|
File without changes
|
apps/billing/management/commands/__init__.py
ADDED
|
File without changes
|
apps/billing/management/commands/seed_plans.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.core.management.base import BaseCommand
|
| 2 |
+
from django.conf import settings
|
| 3 |
+
from apps.billing.models import SubscriptionPlan
|
| 4 |
+
|
| 5 |
+
class Command(BaseCommand):
|
| 6 |
+
help = 'Seeds subscription plans from settings.SUBSCRIPTION_PLANS into the database.'
|
| 7 |
+
|
| 8 |
+
def handle(self, *args, **options):
|
| 9 |
+
plans_config = getattr(settings, 'SUBSCRIPTION_PLANS', [])
|
| 10 |
+
if not plans_config:
|
| 11 |
+
self.stdout.write(self.style.WARNING("No SUBSCRIPTION_PLANS found in settings."))
|
| 12 |
+
return
|
| 13 |
+
|
| 14 |
+
active_slugs = []
|
| 15 |
+
for plan_data in plans_config:
|
| 16 |
+
slug = plan_data['slug']
|
| 17 |
+
active_slugs.append(slug)
|
| 18 |
+
|
| 19 |
+
defaults = {
|
| 20 |
+
'name': plan_data['name'],
|
| 21 |
+
'description': plan_data.get('description', ''),
|
| 22 |
+
'monthly_price': plan_data.get('monthly_price', 0),
|
| 23 |
+
'max_users': plan_data.get('max_users', 1),
|
| 24 |
+
'max_storage_bytes': plan_data.get('max_storage_bytes', 1073741824),
|
| 25 |
+
'features': plan_data.get('features', []),
|
| 26 |
+
'is_active': True,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
plan, created = SubscriptionPlan.objects.update_or_create(
|
| 30 |
+
slug=slug,
|
| 31 |
+
defaults=defaults
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
action = "Created" if created else "Updated"
|
| 35 |
+
self.stdout.write(self.style.SUCCESS(f"[OK] {action} plan: {plan.name} (Rs {plan.monthly_price}/mo, {plan.max_users} users, {plan.max_storage_bytes // (1024**3)}GB)"))
|
| 36 |
+
|
| 37 |
+
# Mark plans not in settings as inactive
|
| 38 |
+
deactivated = SubscriptionPlan.objects.exclude(slug__in=active_slugs).update(is_active=False)
|
| 39 |
+
if deactivated > 0:
|
| 40 |
+
self.stdout.write(self.style.WARNING(f"Deactivated {deactivated} plans not found in settings."))
|
| 41 |
+
|
| 42 |
+
self.stdout.write(self.style.SUCCESS(f"[OK] {len(active_slugs)} plans synced from settings.SUBSCRIPTION_PLANS"))
|
apps/billing/services/billing_service.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.conf import settings
|
| 2 |
+
from django.db import transaction
|
| 3 |
+
from django.utils import timezone
|
| 4 |
+
from apps.billing.models import SubscriptionPlan, OrganizationSubscription, BillingHistory
|
| 5 |
+
from core.exceptions import NotFoundError
|
| 6 |
+
|
| 7 |
+
def get_org_subscription(org):
|
| 8 |
+
"""Get current subscription (or None)."""
|
| 9 |
+
try:
|
| 10 |
+
return OrganizationSubscription.objects.get(organization=org)
|
| 11 |
+
except OrganizationSubscription.DoesNotExist:
|
| 12 |
+
return None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_org_plan(org):
|
| 16 |
+
"""
|
| 17 |
+
Get the plan object for an org.
|
| 18 |
+
Defaults to settings.DEFAULT_PLAN_SLUG if no subscription exists.
|
| 19 |
+
"""
|
| 20 |
+
try:
|
| 21 |
+
sub = OrganizationSubscription.objects.select_related('plan').get(
|
| 22 |
+
organization=org
|
| 23 |
+
)
|
| 24 |
+
if sub.is_active_or_trialing:
|
| 25 |
+
return sub.plan
|
| 26 |
+
except OrganizationSubscription.DoesNotExist:
|
| 27 |
+
pass
|
| 28 |
+
|
| 29 |
+
# Fallback to default plan from settings
|
| 30 |
+
try:
|
| 31 |
+
return SubscriptionPlan.objects.get(slug=settings.DEFAULT_PLAN_SLUG)
|
| 32 |
+
except SubscriptionPlan.DoesNotExist:
|
| 33 |
+
raise NotFoundError(f"Default plan '{settings.DEFAULT_PLAN_SLUG}' not found in DB. Run seed_plans.")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@transaction.atomic
|
| 37 |
+
def assign_default_plan(org):
|
| 38 |
+
"""Auto-assign the default plan to an org on creation."""
|
| 39 |
+
try:
|
| 40 |
+
plan = SubscriptionPlan.objects.get(slug=settings.DEFAULT_PLAN_SLUG)
|
| 41 |
+
except SubscriptionPlan.DoesNotExist:
|
| 42 |
+
# Fallback gently if plans aren't seeded yet
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
sub, created = OrganizationSubscription.objects.get_or_create(
|
| 46 |
+
organization=org,
|
| 47 |
+
defaults={
|
| 48 |
+
"plan": plan,
|
| 49 |
+
"status": OrganizationSubscription.Status.ACTIVE,
|
| 50 |
+
}
|
| 51 |
+
)
|
| 52 |
+
return sub
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@transaction.atomic
|
| 56 |
+
def activate_subscription(org, plan, razorpay_subscription_id=None):
|
| 57 |
+
"""Set subscription to ACTIVE after payment."""
|
| 58 |
+
sub, _ = OrganizationSubscription.objects.update_or_create(
|
| 59 |
+
organization=org,
|
| 60 |
+
defaults={
|
| 61 |
+
"plan": plan,
|
| 62 |
+
"status": OrganizationSubscription.Status.ACTIVE,
|
| 63 |
+
"razorpay_subscription_id": razorpay_subscription_id,
|
| 64 |
+
"cancel_at_period_end": False,
|
| 65 |
+
}
|
| 66 |
+
)
|
| 67 |
+
return sub
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@transaction.atomic
|
| 71 |
+
def cancel_subscription(org):
|
| 72 |
+
"""Set cancel_at_period_end = True."""
|
| 73 |
+
sub = get_org_subscription(org)
|
| 74 |
+
if not sub:
|
| 75 |
+
raise NotFoundError("No active subscription to cancel.")
|
| 76 |
+
|
| 77 |
+
sub.cancel_at_period_end = True
|
| 78 |
+
sub.save(update_fields=["cancel_at_period_end", "updated_at"])
|
| 79 |
+
return sub
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@transaction.atomic
|
| 83 |
+
def reactivate_subscription(org):
|
| 84 |
+
"""Undo cancel_at_period_end."""
|
| 85 |
+
sub = get_org_subscription(org)
|
| 86 |
+
if not sub:
|
| 87 |
+
raise NotFoundError("No active subscription to reactivate.")
|
| 88 |
+
|
| 89 |
+
sub.cancel_at_period_end = False
|
| 90 |
+
sub.save(update_fields=["cancel_at_period_end", "updated_at"])
|
| 91 |
+
return sub
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@transaction.atomic
|
| 95 |
+
def handle_payment_failed(org):
|
| 96 |
+
"""Set status to PAST_DUE."""
|
| 97 |
+
sub = get_org_subscription(org)
|
| 98 |
+
if sub:
|
| 99 |
+
sub.status = OrganizationSubscription.Status.PAST_DUE
|
| 100 |
+
sub.save(update_fields=["status", "updated_at"])
|
| 101 |
+
return sub
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@transaction.atomic
|
| 105 |
+
def handle_subscription_cancelled(org):
|
| 106 |
+
"""Downgrade to default plan."""
|
| 107 |
+
sub = get_org_subscription(org)
|
| 108 |
+
if sub:
|
| 109 |
+
sub.status = OrganizationSubscription.Status.CANCELED
|
| 110 |
+
sub.save(update_fields=["status", "updated_at"])
|
| 111 |
+
|
| 112 |
+
# Switch them back to free plan
|
| 113 |
+
assign_default_plan(org)
|
| 114 |
+
return sub
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@transaction.atomic
|
| 118 |
+
def record_payment(org, amount, status, razorpay_payment_id=None, razorpay_invoice_id=None):
|
| 119 |
+
"""Create BillingHistory entry."""
|
| 120 |
+
history = BillingHistory.objects.create(
|
| 121 |
+
organization=org,
|
| 122 |
+
amount=amount,
|
| 123 |
+
status=status,
|
| 124 |
+
razorpay_payment_id=razorpay_payment_id,
|
| 125 |
+
razorpay_invoice_id=razorpay_invoice_id,
|
| 126 |
+
currency=settings.RAZORPAY_CURRENCY,
|
| 127 |
+
)
|
| 128 |
+
return history
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def get_billing_history(org):
|
| 132 |
+
"""List payment records for org."""
|
| 133 |
+
return BillingHistory.objects.filter(organization=org).order_by('-created_at')
|
apps/billing/services/plan_enforcement.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from apps.billing.services.billing_service import get_org_plan
|
| 2 |
+
from core.exceptions import ValidationError
|
| 3 |
+
|
| 4 |
+
def get_plan_limits(org) -> dict:
|
| 5 |
+
"""Get all limits from the org's current plan."""
|
| 6 |
+
plan = get_org_plan(org)
|
| 7 |
+
return {
|
| 8 |
+
"max_users": plan.max_users,
|
| 9 |
+
"max_storage_bytes": plan.max_storage_bytes,
|
| 10 |
+
"features": plan.features or [],
|
| 11 |
+
"plan_name": plan.name,
|
| 12 |
+
"plan_slug": plan.slug,
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def check_user_limit(org, is_invite=False) -> None:
|
| 17 |
+
"""Raise ValidationError if org has reached max users for their plan."""
|
| 18 |
+
from apps.organizations.models import OrgMembership, OrgInvitation
|
| 19 |
+
plan = get_org_plan(org)
|
| 20 |
+
member_count = OrgMembership.objects.filter(org=org).count()
|
| 21 |
+
|
| 22 |
+
if is_invite:
|
| 23 |
+
pending_count = OrgInvitation.objects.filter(org=org, accepted_at__isnull=True).count()
|
| 24 |
+
total = member_count + pending_count
|
| 25 |
+
else:
|
| 26 |
+
total = member_count
|
| 27 |
+
pending_count = 0
|
| 28 |
+
|
| 29 |
+
if total >= plan.max_users:
|
| 30 |
+
msg = f"Your {plan.name} plan allows {plan.max_users} users. "
|
| 31 |
+
if is_invite and pending_count > 0:
|
| 32 |
+
msg += f"You have {member_count} members and {pending_count} pending invites. "
|
| 33 |
+
msg += "Upgrade to add more members."
|
| 34 |
+
raise ValidationError(msg)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def check_storage_limit(org, additional_bytes: int) -> None:
|
| 38 |
+
"""Raise ValidationError if upload would exceed plan storage."""
|
| 39 |
+
plan = get_org_plan(org)
|
| 40 |
+
if (org.storage_usage_bytes + additional_bytes) > plan.max_storage_bytes:
|
| 41 |
+
max_gb = plan.max_storage_bytes / (1024 ** 3)
|
| 42 |
+
raise ValidationError(
|
| 43 |
+
f"Your {plan.name} plan allows {max_gb:.0f}GB storage. "
|
| 44 |
+
f"Upgrade to get more space."
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def check_feature_access(org, feature_key: str) -> None:
|
| 49 |
+
"""Raise ValidationError if feature not included in plan."""
|
| 50 |
+
plan = get_org_plan(org)
|
| 51 |
+
if feature_key not in (plan.features or []):
|
| 52 |
+
raise ValidationError(
|
| 53 |
+
f"The '{feature_key}' feature requires a higher plan. "
|
| 54 |
+
f"You are on the {plan.name} plan."
|
| 55 |
+
)
|
apps/billing/services/razorpay_service.py
CHANGED
|
@@ -50,3 +50,22 @@ class RazorpayService:
|
|
| 50 |
"razorpay_signature": razorpay_signature,
|
| 51 |
}
|
| 52 |
self.client.utility.verify_payment_signature(params)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
"razorpay_signature": razorpay_signature,
|
| 51 |
}
|
| 52 |
self.client.utility.verify_payment_signature(params)
|
| 53 |
+
|
| 54 |
+
def create_upgrade_order(self, organization, new_plan_slug):
|
| 55 |
+
"""
|
| 56 |
+
Create order for plan upgrade (full price of new plan).
|
| 57 |
+
Proration logic can be added here later if required.
|
| 58 |
+
"""
|
| 59 |
+
return self.create_order(organization, new_plan_slug)
|
| 60 |
+
|
| 61 |
+
def fetch_payment(self, payment_id):
|
| 62 |
+
"""
|
| 63 |
+
Get payment details from Razorpay API.
|
| 64 |
+
"""
|
| 65 |
+
return self.client.payment.fetch(payment_id)
|
| 66 |
+
|
| 67 |
+
def verify_webhook_signature(self, body, signature):
|
| 68 |
+
"""
|
| 69 |
+
HMAC-SHA256 verification for webhook requests.
|
| 70 |
+
"""
|
| 71 |
+
return self.client.utility.verify_webhook_signature(body, signature, settings.RAZORPAY_KEY_SECRET)
|
apps/billing/urls.py
CHANGED
|
@@ -7,4 +7,10 @@ urlpatterns = [
|
|
| 7 |
path("pricing/", views.pricing_view, name="pricing"),
|
| 8 |
path("checkout/<slug:plan_slug>/", views.create_subscription_checkout, name="checkout"),
|
| 9 |
path("verify/", views.verify_payment, name="verify"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
]
|
|
|
|
| 7 |
path("pricing/", views.pricing_view, name="pricing"),
|
| 8 |
path("checkout/<slug:plan_slug>/", views.create_subscription_checkout, name="checkout"),
|
| 9 |
path("verify/", views.verify_payment, name="verify"),
|
| 10 |
+
|
| 11 |
+
path("billing/", views.subscription_view, name="subscription"),
|
| 12 |
+
path("billing/change/<slug:plan_slug>/", views.change_plan_view, name="change-plan"),
|
| 13 |
+
path("billing/cancel/", views.cancel_subscription_view, name="cancel"),
|
| 14 |
+
path("billing/reactivate/", views.reactivate_subscription_view, name="reactivate"),
|
| 15 |
+
path("webhooks/razorpay/", views.razorpay_webhook, name="razorpay-webhook"),
|
| 16 |
]
|
apps/billing/views.py
CHANGED
|
@@ -121,3 +121,127 @@ def verify_payment(request):
|
|
| 121 |
|
| 122 |
except Exception as e:
|
| 123 |
return JsonResponse({"status": "failure", "error": str(e)}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
except Exception as e:
|
| 123 |
return JsonResponse({"status": "failure", "error": str(e)}, status=400)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@login_required
|
| 127 |
+
def subscription_view(request):
|
| 128 |
+
"""
|
| 129 |
+
Shows current plan, usage meters, and billing history.
|
| 130 |
+
"""
|
| 131 |
+
from apps.billing.services.billing_service import get_org_plan, get_org_subscription, get_billing_history
|
| 132 |
+
from apps.billing.services.plan_enforcement import get_plan_limits
|
| 133 |
+
|
| 134 |
+
org = getattr(request, 'tenant', None)
|
| 135 |
+
if not org:
|
| 136 |
+
from apps.organizations.models import Organization
|
| 137 |
+
org = Organization.objects.filter(owner=request.user).first()
|
| 138 |
+
|
| 139 |
+
if not org:
|
| 140 |
+
return render(request, "pages/billing/subscription.html", {"error": "You must own an organisation."})
|
| 141 |
+
|
| 142 |
+
plan = get_org_plan(org)
|
| 143 |
+
subscription = get_org_subscription(org)
|
| 144 |
+
limits = get_plan_limits(org)
|
| 145 |
+
history = get_billing_history(org)
|
| 146 |
+
|
| 147 |
+
# Calculate usage
|
| 148 |
+
from apps.organizations.models import OrgMembership
|
| 149 |
+
user_count = OrgMembership.objects.filter(org=org).count()
|
| 150 |
+
storage_gb = org.storage_usage_bytes / (1024**3)
|
| 151 |
+
max_storage_gb = limits['max_storage_bytes'] / (1024**3)
|
| 152 |
+
|
| 153 |
+
context = {
|
| 154 |
+
"org": org,
|
| 155 |
+
"plan": plan,
|
| 156 |
+
"subscription": subscription,
|
| 157 |
+
"limits": limits,
|
| 158 |
+
"history": history,
|
| 159 |
+
"usage": {
|
| 160 |
+
"users": user_count,
|
| 161 |
+
"max_users": limits['max_users'],
|
| 162 |
+
"users_percent": min(100, int((user_count / max(1, limits['max_users'])) * 100)),
|
| 163 |
+
"storage_gb": round(storage_gb, 2),
|
| 164 |
+
"max_storage_gb": round(max_storage_gb, 2),
|
| 165 |
+
"storage_percent": min(100, int((storage_gb / max(0.01, max_storage_gb)) * 100)),
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
return render(request, "pages/billing/subscription.html", context)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@login_required
|
| 172 |
+
@require_POST
|
| 173 |
+
def change_plan_view(request, plan_slug):
|
| 174 |
+
"""Create order for plan change."""
|
| 175 |
+
# We reuse create_subscription_checkout logic for now.
|
| 176 |
+
return create_subscription_checkout(request, plan_slug)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@login_required
|
| 180 |
+
@require_POST
|
| 181 |
+
def cancel_subscription_view(request):
|
| 182 |
+
"""Cancel subscription at period end."""
|
| 183 |
+
from apps.billing.services.billing_service import cancel_subscription
|
| 184 |
+
from django.shortcuts import redirect
|
| 185 |
+
from django.contrib import messages
|
| 186 |
+
|
| 187 |
+
org = getattr(request, 'tenant', None) or request.user.owned_organizations.first()
|
| 188 |
+
if org:
|
| 189 |
+
try:
|
| 190 |
+
cancel_subscription(org)
|
| 191 |
+
messages.success(request, "Your subscription has been canceled and will end at the current billing cycle.")
|
| 192 |
+
except Exception as e:
|
| 193 |
+
messages.error(request, f"Error: {str(e)}")
|
| 194 |
+
return redirect("billing:subscription")
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@login_required
|
| 198 |
+
@require_POST
|
| 199 |
+
def reactivate_subscription_view(request):
|
| 200 |
+
"""Undo cancellation."""
|
| 201 |
+
from apps.billing.services.billing_service import reactivate_subscription
|
| 202 |
+
from django.shortcuts import redirect
|
| 203 |
+
from django.contrib import messages
|
| 204 |
+
|
| 205 |
+
org = getattr(request, 'tenant', None) or request.user.owned_organizations.first()
|
| 206 |
+
if org:
|
| 207 |
+
try:
|
| 208 |
+
reactivate_subscription(org)
|
| 209 |
+
messages.success(request, "Your subscription has been reactivated successfully.")
|
| 210 |
+
except Exception as e:
|
| 211 |
+
messages.error(request, f"Error: {str(e)}")
|
| 212 |
+
return redirect("billing:subscription")
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
from django.views.decorators.csrf import csrf_exempt
|
| 216 |
+
|
| 217 |
+
@csrf_exempt
|
| 218 |
+
@require_POST
|
| 219 |
+
def razorpay_webhook(request):
|
| 220 |
+
"""Handle incoming Razorpay webhooks."""
|
| 221 |
+
from apps.billing.services.billing_service import (
|
| 222 |
+
handle_payment_failed, handle_subscription_cancelled, activate_subscription
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
signature = request.headers.get("X-Razorpay-Signature", "")
|
| 226 |
+
service = RazorpayService()
|
| 227 |
+
|
| 228 |
+
try:
|
| 229 |
+
service.verify_webhook_signature(request.body.decode('utf-8'), signature)
|
| 230 |
+
except Exception:
|
| 231 |
+
return JsonResponse({"error": "Invalid signature"}, status=400)
|
| 232 |
+
|
| 233 |
+
try:
|
| 234 |
+
payload = json.loads(request.body)
|
| 235 |
+
event = payload.get("event")
|
| 236 |
+
# In a real system, you'd parse the entity and find the org based on razorpay_subscription_id
|
| 237 |
+
# For this prototype, we'll log it or handle via a mocked org lookup if needed.
|
| 238 |
+
|
| 239 |
+
# Example pseudo-handling
|
| 240 |
+
if event == "payment.failed":
|
| 241 |
+
pass # handle_payment_failed(org)
|
| 242 |
+
elif event == "subscription.cancelled":
|
| 243 |
+
pass # handle_subscription_cancelled(org)
|
| 244 |
+
|
| 245 |
+
return JsonResponse({"status": "ok"})
|
| 246 |
+
except Exception as e:
|
| 247 |
+
return JsonResponse({"error": str(e)}, status=400)
|
apps/organizations/services/api_key_service.py
CHANGED
|
@@ -11,6 +11,9 @@ from core.services.encryption import hash_token
|
|
| 11 |
|
| 12 |
def generate_key(org, user=None, name: str = "", scope: str = "", expires_at=None) -> tuple:
|
| 13 |
"""Generate a new API key. Returns (APIKey, raw_key)."""
|
|
|
|
|
|
|
|
|
|
| 14 |
return APIKey.generate(org=org, user=user, name=name, scope=scope, expires_at=expires_at)
|
| 15 |
|
| 16 |
|
|
|
|
| 11 |
|
| 12 |
def generate_key(org, user=None, name: str = "", scope: str = "", expires_at=None) -> tuple:
|
| 13 |
"""Generate a new API key. Returns (APIKey, raw_key)."""
|
| 14 |
+
if org:
|
| 15 |
+
from apps.billing.services.plan_enforcement import check_feature_access
|
| 16 |
+
check_feature_access(org, 'api_keys')
|
| 17 |
return APIKey.generate(org=org, user=user, name=name, scope=scope, expires_at=expires_at)
|
| 18 |
|
| 19 |
|
apps/organizations/services/file_service.py
CHANGED
|
@@ -42,6 +42,10 @@ def upload_file(
|
|
| 42 |
"""
|
| 43 |
validate_file(mime_type, size_bytes)
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
if org and not check_storage_quota(org, size_bytes):
|
| 46 |
raise ValidationError("Storage quota would be exceeded.")
|
| 47 |
|
|
|
|
| 42 |
"""
|
| 43 |
validate_file(mime_type, size_bytes)
|
| 44 |
|
| 45 |
+
if org:
|
| 46 |
+
from apps.billing.services.plan_enforcement import check_storage_limit
|
| 47 |
+
check_storage_limit(org, size_bytes)
|
| 48 |
+
|
| 49 |
if org and not check_storage_quota(org, size_bytes):
|
| 50 |
raise ValidationError("Storage quota would be exceeded.")
|
| 51 |
|
apps/organizations/services/membership_service.py
CHANGED
|
@@ -11,6 +11,8 @@ from core.exceptions import NotFoundError, ValidationError
|
|
| 11 |
@transaction.atomic
|
| 12 |
def invite_user(org, email: str, invited_by=None) -> OrgInvitation:
|
| 13 |
"""Create an invitation to join the org."""
|
|
|
|
|
|
|
| 14 |
if OrgMembership.objects.filter(org=org, user__email=email).exists():
|
| 15 |
raise ValidationError("User is already a member.")
|
| 16 |
if OrgInvitation.objects.filter(org=org, email=email, accepted_at__isnull=True).exists():
|
|
@@ -107,11 +109,14 @@ def _send_invitation_email(invitation, invited_by) -> None:
|
|
| 107 |
@transaction.atomic
|
| 108 |
def accept_invitation(token: str, user) -> OrgMembership:
|
| 109 |
"""Accept an invitation and create membership."""
|
|
|
|
| 110 |
try:
|
| 111 |
invitation = OrgInvitation.objects.get(token=token)
|
| 112 |
except OrgInvitation.DoesNotExist:
|
| 113 |
raise NotFoundError("Invalid invitation token.")
|
| 114 |
|
|
|
|
|
|
|
| 115 |
if not invitation.is_valid:
|
| 116 |
raise ValidationError("Invitation has expired or already been accepted.")
|
| 117 |
|
|
|
|
| 11 |
@transaction.atomic
|
| 12 |
def invite_user(org, email: str, invited_by=None) -> OrgInvitation:
|
| 13 |
"""Create an invitation to join the org."""
|
| 14 |
+
from apps.billing.services.plan_enforcement import check_user_limit
|
| 15 |
+
check_user_limit(org, is_invite=True)
|
| 16 |
if OrgMembership.objects.filter(org=org, user__email=email).exists():
|
| 17 |
raise ValidationError("User is already a member.")
|
| 18 |
if OrgInvitation.objects.filter(org=org, email=email, accepted_at__isnull=True).exists():
|
|
|
|
| 109 |
@transaction.atomic
|
| 110 |
def accept_invitation(token: str, user) -> OrgMembership:
|
| 111 |
"""Accept an invitation and create membership."""
|
| 112 |
+
from apps.billing.services.plan_enforcement import check_user_limit
|
| 113 |
try:
|
| 114 |
invitation = OrgInvitation.objects.get(token=token)
|
| 115 |
except OrgInvitation.DoesNotExist:
|
| 116 |
raise NotFoundError("Invalid invitation token.")
|
| 117 |
|
| 118 |
+
check_user_limit(invitation.org)
|
| 119 |
+
|
| 120 |
if not invitation.is_valid:
|
| 121 |
raise ValidationError("Invitation has expired or already been accepted.")
|
| 122 |
|
apps/organizations/services/org_service.py
CHANGED
|
@@ -42,6 +42,10 @@ def create_org(name: str, owner, vertical_type: str = "generic") -> Organization
|
|
| 42 |
StorageQuota.objects.create(org=org)
|
| 43 |
OrgMembership.objects.create(org=org, user=owner)
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
# Auto-assign org_owner role to the creator
|
| 46 |
from apps.organizations.services.membership_service import assign_org_role
|
| 47 |
assign_org_role(org, owner, "org_owner")
|
|
|
|
| 42 |
StorageQuota.objects.create(org=org)
|
| 43 |
OrgMembership.objects.create(org=org, user=owner)
|
| 44 |
|
| 45 |
+
# Auto-assign default subscription plan
|
| 46 |
+
from apps.billing.services.billing_service import assign_default_plan
|
| 47 |
+
assign_default_plan(org)
|
| 48 |
+
|
| 49 |
# Auto-assign org_owner role to the creator
|
| 50 |
from apps.organizations.services.membership_service import assign_org_role
|
| 51 |
assign_org_role(org, owner, "org_owner")
|
apps/organizations/services/quota_service.py
CHANGED
|
@@ -16,6 +16,13 @@ from core.exceptions import ValidationError
|
|
| 16 |
def get_storage_quota(org) -> StorageQuota:
|
| 17 |
"""Get or create the storage quota for an org."""
|
| 18 |
quota, _ = StorageQuota.objects.get_or_create(org=org)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
return quota
|
| 20 |
|
| 21 |
|
|
|
|
| 16 |
def get_storage_quota(org) -> StorageQuota:
|
| 17 |
"""Get or create the storage quota for an org."""
|
| 18 |
quota, _ = StorageQuota.objects.get_or_create(org=org)
|
| 19 |
+
|
| 20 |
+
from apps.billing.services.plan_enforcement import get_plan_limits
|
| 21 |
+
limits = get_plan_limits(org)
|
| 22 |
+
if quota.max_bytes != limits['max_storage_bytes']:
|
| 23 |
+
quota.max_bytes = limits['max_storage_bytes']
|
| 24 |
+
quota.save(update_fields=['max_bytes'])
|
| 25 |
+
|
| 26 |
return quota
|
| 27 |
|
| 28 |
|
config/settings/__init__.py
CHANGED
|
@@ -238,3 +238,52 @@ Q_CLUSTER = {
|
|
| 238 |
RAZORPAY_KEY_ID = env('RAZORPAY_KEY_ID', default='')
|
| 239 |
RAZORPAY_KEY_SECRET = env('RAZORPAY_KEY_SECRET', default='')
|
| 240 |
RAZORPAY_CURRENCY = env('RAZORPAY_CURRENCY', default='INR')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
RAZORPAY_KEY_ID = env('RAZORPAY_KEY_ID', default='')
|
| 239 |
RAZORPAY_KEY_SECRET = env('RAZORPAY_KEY_SECRET', default='')
|
| 240 |
RAZORPAY_CURRENCY = env('RAZORPAY_CURRENCY', default='INR')
|
| 241 |
+
|
| 242 |
+
# ─── Subscription Plan Configuration ─────────────────────────────────────────
|
| 243 |
+
# Source of truth for plan definitions.
|
| 244 |
+
# Run `python manage.py seed_plans` after changing to sync DB.
|
| 245 |
+
|
| 246 |
+
SUBSCRIPTION_PLANS = [
|
| 247 |
+
{
|
| 248 |
+
"slug": "free",
|
| 249 |
+
"name": "Free",
|
| 250 |
+
"description": "For individuals and small teams getting started.",
|
| 251 |
+
"monthly_price": 0,
|
| 252 |
+
"max_users": 3,
|
| 253 |
+
"max_storage_bytes": 1 * 1024 * 1024 * 1024, # 1 GB
|
| 254 |
+
"features": [],
|
| 255 |
+
},
|
| 256 |
+
{
|
| 257 |
+
"slug": "starter",
|
| 258 |
+
"name": "Starter",
|
| 259 |
+
"description": "For growing teams that need more power.",
|
| 260 |
+
"monthly_price": 499,
|
| 261 |
+
"max_users": 15,
|
| 262 |
+
"max_storage_bytes": 10 * 1024 * 1024 * 1024, # 10 GB
|
| 263 |
+
"features": [
|
| 264 |
+
"api_keys",
|
| 265 |
+
"feature_flags",
|
| 266 |
+
"file_uploads",
|
| 267 |
+
],
|
| 268 |
+
},
|
| 269 |
+
{
|
| 270 |
+
"slug": "pro",
|
| 271 |
+
"name": "Pro",
|
| 272 |
+
"description": "For large organizations with advanced needs.",
|
| 273 |
+
"monthly_price": 1499,
|
| 274 |
+
"max_users": 100,
|
| 275 |
+
"max_storage_bytes": 50 * 1024 * 1024 * 1024, # 50 GB
|
| 276 |
+
"features": [
|
| 277 |
+
"api_keys",
|
| 278 |
+
"feature_flags",
|
| 279 |
+
"file_uploads",
|
| 280 |
+
"ai_chat",
|
| 281 |
+
"webhooks",
|
| 282 |
+
"data_export",
|
| 283 |
+
"priority_support",
|
| 284 |
+
],
|
| 285 |
+
},
|
| 286 |
+
]
|
| 287 |
+
|
| 288 |
+
# Default plan slug assigned to new orgs
|
| 289 |
+
DEFAULT_PLAN_SLUG = "free"
|
templates/layouts/partials/sidebar.html
CHANGED
|
@@ -83,6 +83,25 @@
|
|
| 83 |
</div>
|
| 84 |
</a>
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
<div class="text-[10px] font-semibold text-[--muted-foreground] uppercase tracking-wider mt-6 mb-1.5 px-2 transition-opacity duration-300 [.minimized_&]:opacity-0 [.minimized_&]:hidden">Management</div>
|
| 87 |
|
| 88 |
<a href="#" class="group relative flex items-center gap-3 px-2.5 py-1.5 text-sm font-medium rounded-lg text-[--muted-foreground] hover:bg-[--muted] hover:text-[--foreground] transition-colors [.minimized_&]:justify-center [.minimized_&]:px-0 [.minimized_&]:py-2">
|
|
|
|
| 83 |
</div>
|
| 84 |
</a>
|
| 85 |
|
| 86 |
+
<a href="{{ url('billing:subscription') }}" class="group relative flex items-center gap-3 px-2.5 py-1.5 text-sm font-medium rounded-lg {% if 'billing' in request.path %}bg-[--primary]/10 text-[--primary]{% else %}text-[--muted-foreground] hover:bg-[--muted] hover:text-[--foreground]{% endif %} transition-colors [.minimized_&]:justify-center [.minimized_&]:px-0 [.minimized_&]:py-2">
|
| 87 |
+
<svg class="h-[18px] w-[18px] shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 88 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
|
| 89 |
+
</svg>
|
| 90 |
+
<span class="whitespace-nowrap transition-opacity duration-300 [.minimized_&]:opacity-0 [.minimized_&]:w-0 [.minimized_&]:hidden flex-1 flex justify-between items-center">
|
| 91 |
+
<span>Billing</span>
|
| 92 |
+
</span>
|
| 93 |
+
|
| 94 |
+
{# Tooltip #}
|
| 95 |
+
<div class="absolute left-full top-1/2 -translate-y-1/2 ml-3 px-3 py-1.5
|
| 96 |
+
bg-[--card] border border-[--border] text-[--foreground] text-xs font-bold rounded-lg shadow-2xl
|
| 97 |
+
opacity-0 translate-x-1 transition-all duration-200 pointer-events-none z-[70] whitespace-nowrap
|
| 98 |
+
hidden [.minimized_&]:block [.minimized_&]:group-hover:opacity-100 [.minimized_&]:group-hover:translate-x-0">
|
| 99 |
+
Billing
|
| 100 |
+
{# Arrow #}
|
| 101 |
+
<div class="absolute -left-1 top-1/2 -translate-y-1/2 w-2 h-2 bg-[--card] border-l border-b border-[--border] rotate-45"></div>
|
| 102 |
+
</div>
|
| 103 |
+
</a>
|
| 104 |
+
|
| 105 |
<div class="text-[10px] font-semibold text-[--muted-foreground] uppercase tracking-wider mt-6 mb-1.5 px-2 transition-opacity duration-300 [.minimized_&]:opacity-0 [.minimized_&]:hidden">Management</div>
|
| 106 |
|
| 107 |
<a href="#" class="group relative flex items-center gap-3 px-2.5 py-1.5 text-sm font-medium rounded-lg text-[--muted-foreground] hover:bg-[--muted] hover:text-[--foreground] transition-colors [.minimized_&]:justify-center [.minimized_&]:px-0 [.minimized_&]:py-2">
|
templates/pages/billing/subscription.html
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layouts/app_shell.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Subscription - SaaS Platform{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block page_header %}
|
| 6 |
+
{% include "components/page_header.html" with title="Subscription & Billing" subtitle="Manage your organization's plan, usage, and billing history." %}
|
| 7 |
+
{% endblock %}
|
| 8 |
+
|
| 9 |
+
{% block content %}
|
| 10 |
+
<div class="space-y-6">
|
| 11 |
+
|
| 12 |
+
{% if messages %}
|
| 13 |
+
<div class="mb-4">
|
| 14 |
+
{% for message in messages %}
|
| 15 |
+
<div class="p-4 rounded-md text-sm {% if message.tags == 'success' %}bg-green-50 text-green-800 border border-green-200{% else %}bg-red-50 text-red-800 border border-red-200{% endif %}">
|
| 16 |
+
{{ message }}
|
| 17 |
+
</div>
|
| 18 |
+
{% endfor %}
|
| 19 |
+
</div>
|
| 20 |
+
{% endif %}
|
| 21 |
+
|
| 22 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 23 |
+
<!-- Current Plan Card -->
|
| 24 |
+
<div class="col-span-1 md:col-span-2 bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
| 25 |
+
<div class="px-6 py-5 border-b border-gray-200">
|
| 26 |
+
<div class="flex items-center justify-between">
|
| 27 |
+
<div>
|
| 28 |
+
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
| 29 |
+
Current Plan: {{ plan.name }}
|
| 30 |
+
</h3>
|
| 31 |
+
<p class="mt-1 text-sm text-gray-500">
|
| 32 |
+
{% if plan.monthly_price == 0 %}
|
| 33 |
+
Free forever
|
| 34 |
+
{% else %}
|
| 35 |
+
₹{{ plan.monthly_price }}/month
|
| 36 |
+
{% endif %}
|
| 37 |
+
</p>
|
| 38 |
+
</div>
|
| 39 |
+
<div>
|
| 40 |
+
{% if subscription and subscription.status == 'active' %}
|
| 41 |
+
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
| 42 |
+
Active
|
| 43 |
+
</span>
|
| 44 |
+
{% elif subscription and subscription.status == 'past_due' %}
|
| 45 |
+
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
|
| 46 |
+
Past Due
|
| 47 |
+
</span>
|
| 48 |
+
{% else %}
|
| 49 |
+
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
|
| 50 |
+
{{ subscription.status|default:"Free"|title }}
|
| 51 |
+
</span>
|
| 52 |
+
{% endif %}
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div class="px-6 py-5">
|
| 58 |
+
<h4 class="text-sm font-medium text-gray-900 mb-3">Included Features</h4>
|
| 59 |
+
<ul class="space-y-2">
|
| 60 |
+
{% for feature in limits.features %}
|
| 61 |
+
<li class="flex items-start">
|
| 62 |
+
<svg class="h-5 w-5 text-green-500 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 63 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
| 64 |
+
</svg>
|
| 65 |
+
<span class="text-sm text-gray-600">{{ feature|title|replace("_", " ") }}</span>
|
| 66 |
+
</li>
|
| 67 |
+
{% empty %}
|
| 68 |
+
<li class="text-sm text-gray-500 italic">Core features only.</li>
|
| 69 |
+
{% endfor %}
|
| 70 |
+
</ul>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200 flex justify-between items-center">
|
| 74 |
+
{% if subscription and subscription.cancel_at_period_end %}
|
| 75 |
+
<span class="text-sm text-red-600 font-medium">Cancels at end of billing period.</span>
|
| 76 |
+
<form method="POST" action="{% url 'billing:reactivate' %}">
|
| 77 |
+
{% csrf_token %}
|
| 78 |
+
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700">
|
| 79 |
+
Reactivate Subscription
|
| 80 |
+
</button>
|
| 81 |
+
</form>
|
| 82 |
+
{% else %}
|
| 83 |
+
<a href="{% url 'billing:pricing' %}" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
| 84 |
+
Change Plan
|
| 85 |
+
</a>
|
| 86 |
+
{% if plan.monthly_price > 0 %}
|
| 87 |
+
<form method="POST" action="{% url 'billing:cancel' %}" onsubmit="return confirm('Are you sure you want to cancel your subscription?');">
|
| 88 |
+
{% csrf_token %}
|
| 89 |
+
<button type="submit" class="text-sm text-red-600 hover:text-red-900 font-medium">
|
| 90 |
+
Cancel Subscription
|
| 91 |
+
</button>
|
| 92 |
+
</form>
|
| 93 |
+
{% endif %}
|
| 94 |
+
{% endif %}
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<!-- Usage Meters -->
|
| 99 |
+
<div class="col-span-1 bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
| 100 |
+
<div class="px-6 py-5 border-b border-gray-200">
|
| 101 |
+
<h3 class="text-lg leading-6 font-medium text-gray-900">Current Usage</h3>
|
| 102 |
+
</div>
|
| 103 |
+
<div class="px-6 py-5 space-y-6">
|
| 104 |
+
|
| 105 |
+
<!-- Users -->
|
| 106 |
+
<div>
|
| 107 |
+
<div class="flex justify-between items-center mb-1">
|
| 108 |
+
<span class="text-sm font-medium text-gray-700">Members</span>
|
| 109 |
+
<span class="text-sm text-gray-500">{{ usage.users }} / {{ usage.max_users }}</span>
|
| 110 |
+
</div>
|
| 111 |
+
<div class="w-full bg-gray-200 rounded-full h-2">
|
| 112 |
+
<div class="bg-indigo-600 h-2 rounded-full" style="width: {{ usage.users_percent }}%"></div>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<!-- Storage -->
|
| 117 |
+
<div>
|
| 118 |
+
<div class="flex justify-between items-center mb-1">
|
| 119 |
+
<span class="text-sm font-medium text-gray-700">Storage</span>
|
| 120 |
+
<span class="text-sm text-gray-500">{{ usage.storage_gb }} / {{ usage.max_storage_gb }} GB</span>
|
| 121 |
+
</div>
|
| 122 |
+
<div class="w-full bg-gray-200 rounded-full h-2">
|
| 123 |
+
<div class="bg-indigo-600 h-2 rounded-full" style="width: {{ usage.storage_percent }}%"></div>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
<!-- Billing History -->
|
| 132 |
+
<div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden mt-6">
|
| 133 |
+
<div class="px-6 py-5 border-b border-gray-200">
|
| 134 |
+
<h3 class="text-lg leading-6 font-medium text-gray-900">Billing History</h3>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
{% if history %}
|
| 138 |
+
<div class="overflow-x-auto">
|
| 139 |
+
<table class="min-w-full divide-y divide-gray-200">
|
| 140 |
+
<thead class="bg-gray-50">
|
| 141 |
+
<tr>
|
| 142 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
| 143 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
| 144 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
| 145 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Reference</th>
|
| 146 |
+
</tr>
|
| 147 |
+
</thead>
|
| 148 |
+
<tbody class="bg-white divide-y divide-gray-200">
|
| 149 |
+
{% for invoice in history %}
|
| 150 |
+
<tr>
|
| 151 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
| 152 |
+
{{ invoice.created_at|date:"M d, Y" }}
|
| 153 |
+
</td>
|
| 154 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">
|
| 155 |
+
₹{{ invoice.amount }}
|
| 156 |
+
</td>
|
| 157 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
| 158 |
+
{% if invoice.status == 'paid' %}
|
| 159 |
+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Paid</span>
|
| 160 |
+
{% elif invoice.status == 'pending' %}
|
| 161 |
+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Pending</span>
|
| 162 |
+
{% else %}
|
| 163 |
+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">{{ invoice.status|title }}</span>
|
| 164 |
+
{% endif %}
|
| 165 |
+
</td>
|
| 166 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
| 167 |
+
{{ invoice.razorpay_payment_id|default:"-" }}
|
| 168 |
+
</td>
|
| 169 |
+
</tr>
|
| 170 |
+
{% endfor %}
|
| 171 |
+
</tbody>
|
| 172 |
+
</table>
|
| 173 |
+
</div>
|
| 174 |
+
{% else %}
|
| 175 |
+
<div class="px-6 py-8 text-center text-sm text-gray-500">
|
| 176 |
+
No billing history available yet.
|
| 177 |
+
</div>
|
| 178 |
+
{% endif %}
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
</div>
|
| 182 |
+
{% endblock %}
|