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 CHANGED
@@ -1,8 +1,67 @@
1
  {
 
 
2
  "python.defaultInterpreterPath": "${workspaceFolder}/.venv/Scripts/python.exe",
 
 
3
  "python.analysis.extraPaths": [
4
- "${workspaceFolder}/apps",
5
- "${workspaceFolder}/config"
6
  ],
7
- "python.terminal.activateEnvironment": true
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 %}