Skip to content

Commit 770ee8e

Browse files
Sbussisoclaude
andcommitted
Add Clerk Billing integration with plan enforcement and pricing page
Backend: - Add plan limits config (Free: 2 cams/1 node, Pro: 10/5, Business: 50/unlimited) - Parse pla and fea JWT claims for active plan and features - Add GET /api/nodes/plan endpoint returning plan, usage, and limits - Enforce node creation limits based on org plan tier - Rewrite webhook handler with correct Clerk billing event types (subscription.active, subscriptionItem.canceled, subscription.pastDue) - Set org member limits via Clerk API on plan changes (2/10/20) Frontend: - Add PricingPage with Clerk <PricingTable for="organization" /> - Add Subscription section to Settings with plan badge and usage bars - Add /pricing route and nav link - Add getPlanInfo API function Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 160ce43 commit 770ee8e

10 files changed

Lines changed: 374 additions & 23 deletions

File tree

backend/app/api/nodes.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
from sqlalchemy.orm import Session
77

88
from app.core.database import get_db
9-
from app.core.auth import AuthUser, require_admin
9+
from app.core.auth import AuthUser, require_admin, get_current_user
1010
from app.core.limiter import limiter
11+
from app.core.plans import get_plan_limits, get_plan_display_name
1112
from app.models.models import CameraNode, Camera
1213
from app.schemas.schemas import NodeRegister, NodeHeartbeat, CameraReport, NodeCreate
1314
from app.services.storage import get_storage
@@ -232,12 +233,43 @@ async def list_nodes(
232233
return [n.to_dict() for n in nodes]
233234

234235

236+
@router.get("/plan")
237+
async def get_plan_info(
238+
user: AuthUser = Depends(get_current_user),
239+
db: Session = Depends(get_db),
240+
):
241+
"""Return the org's current plan, usage, and limits."""
242+
limits = get_plan_limits(user.plan)
243+
current_nodes = db.query(CameraNode).filter_by(org_id=user.org_id).count()
244+
current_cameras = db.query(Camera).filter_by(org_id=user.org_id).count()
245+
return {
246+
"plan": user.plan,
247+
"plan_name": get_plan_display_name(user.plan),
248+
"features": user.features,
249+
"limits": limits,
250+
"usage": {
251+
"nodes": current_nodes,
252+
"cameras": current_cameras,
253+
},
254+
}
255+
256+
235257
@router.post("")
236258
async def create_node(
237259
data: NodeCreate,
238260
user: AuthUser = Depends(require_admin),
239261
db: Session = Depends(get_db),
240262
):
263+
# Enforce node limit based on plan
264+
limits = get_plan_limits(user.plan)
265+
current_nodes = db.query(CameraNode).filter_by(org_id=user.org_id).count()
266+
if current_nodes >= limits["max_nodes"]:
267+
plan_name = get_plan_display_name(user.plan)
268+
raise HTTPException(
269+
status_code=403,
270+
detail=f"Node limit reached ({limits['max_nodes']} on {plan_name} plan). Upgrade your plan to add more nodes.",
271+
)
272+
241273
node_id = str(uuid.uuid4())[:8]
242274
api_key = str(uuid.uuid4())
243275
api_key_hash = hashlib.sha256(api_key.encode()).hexdigest()

backend/app/api/webhooks.py

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,37 @@
1-
import json
1+
import logging
22
from fastapi import APIRouter, Request, HTTPException, status
33
from svix.webhooks import Webhook, WebhookVerificationError
44
from app.core.config import settings
55
from app.core.clerk import clerk
66

7+
logger = logging.getLogger(__name__)
8+
79
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
810

9-
PRO_TIER_SLUG = "pro_tier"
10-
FREE_TIER_LIMIT = 2
11-
UNLIMITED_LIMIT = 1000000
11+
# Member limits per plan — must match Clerk Dashboard plan keys.
12+
PLAN_MEMBER_LIMITS = {
13+
"free_org": 2,
14+
"pro": 10,
15+
"business": 20,
16+
}
1217

1318

1419
def set_org_member_limit(org_id: str, limit: int):
15-
clerk.organizations.update(organization_id=org_id, max_allowed_memberships=limit)
20+
"""Update the Clerk org's max allowed memberships."""
21+
try:
22+
clerk.organizations.update(organization_id=org_id, max_allowed_memberships=limit)
23+
logger.info("Set org %s member limit to %d", org_id, limit)
24+
except Exception:
25+
logger.error("Failed to set member limit for org %s", org_id, exc_info=True)
1626

1727

18-
def has_active_pro_plan(items: list) -> bool:
19-
return any(
20-
item.get("plan", {}).get("slug") == PRO_TIER_SLUG
21-
and item.get("status") == "active"
22-
for item in items
23-
)
28+
def get_active_plan_slug(items: list) -> str:
29+
"""Extract the active plan slug from subscription items."""
30+
for item in items:
31+
plan = item.get("plan", {})
32+
if item.get("status") == "active" and plan.get("slug"):
33+
return plan["slug"]
34+
return "free_org"
2435

2536

2637
@router.post("/clerk")
@@ -29,8 +40,7 @@ async def clerk_webhook(request: Request):
2940
headers = dict(request.headers)
3041

3142
if not settings.CLERK_WEBHOOK_SECRET:
32-
import logging
33-
logging.getLogger(__name__).warning("CLERK_WEBHOOK_SECRET not set — rejecting unverified webhook")
43+
logger.warning("CLERK_WEBHOOK_SECRET not set — rejecting unverified webhook")
3444
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Webhook secret not configured")
3545

3646
try:
@@ -42,19 +52,39 @@ async def clerk_webhook(request: Request):
4252
event_type = event.get("type")
4353
data = event.get("data", {})
4454

45-
if event_type in ["subscription.created", "subscription.updated"]:
55+
logger.info("Webhook received: %s", event_type)
56+
57+
# ── Subscription lifecycle ──────────────────────────────────────
58+
if event_type in ("subscription.created", "subscription.updated", "subscription.active"):
4659
org_id = data.get("payer", {}).get("organization_id")
4760
if org_id:
48-
limit = (
49-
UNLIMITED_LIMIT
50-
if has_active_pro_plan(data.get("items", []))
51-
else FREE_TIER_LIMIT
52-
)
61+
plan_slug = get_active_plan_slug(data.get("items", []))
62+
limit = PLAN_MEMBER_LIMITS.get(plan_slug, PLAN_MEMBER_LIMITS["free_org"])
5363
set_org_member_limit(org_id, limit)
64+
logger.info("Org %s subscription active on plan '%s'", org_id, plan_slug)
65+
66+
# ── Payment failure ─────────────────────────────────────────────
67+
elif event_type == "subscription.pastDue":
68+
org_id = data.get("payer", {}).get("organization_id")
69+
if org_id:
70+
logger.warning("Org %s subscription is past due — payment failed", org_id)
71+
# Keep current limits for a grace period; Clerk will retry payment.
72+
# If you want to restrict access, downgrade here.
73+
74+
# ── Cancellation / end ──────────────────────────────────────────
75+
elif event_type in ("subscriptionItem.canceled", "subscriptionItem.ended"):
76+
org_id = data.get("payer", {}).get("organization_id")
77+
if org_id:
78+
# Clerk auto-assigns the free default plan on cancellation,
79+
# so the JWT pla claim will revert. Just reset member limit.
80+
set_org_member_limit(org_id, PLAN_MEMBER_LIMITS["free_org"])
81+
logger.info("Org %s subscription canceled — reverted to free limits", org_id)
5482

55-
elif event_type in ["subscription.deleted", "subscription.cancelled"]:
83+
# ── Free trial ending soon ──────────────────────────────────────
84+
elif event_type == "subscriptionItem.freeTrialEnding":
5685
org_id = data.get("payer", {}).get("organization_id")
5786
if org_id:
58-
set_org_member_limit(org_id, FREE_TIER_LIMIT)
87+
logger.info("Org %s free trial ending in 3 days", org_id)
88+
# Future: send notification email
5989

6090
return {"received": True}

backend/app/core/auth.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ def __init__(
1414
org_permissions: list = None,
1515
email: str = "",
1616
username: str = "",
17+
plan: str = "free_org",
18+
features: list = None,
1719
):
1820
self.user_id = user_id
1921
self.sub = user_id
@@ -22,6 +24,8 @@ def __init__(
2224
self.org_permissions = org_permissions or []
2325
self.email = email
2426
self.username = username
27+
self.plan = plan
28+
self.features = features or []
2529

2630
def has_permission(self, permission: str) -> bool:
2731
return permission in self.org_permissions
@@ -124,6 +128,20 @@ async def get_current_user(request: Request) -> AuthUser:
124128
email = claims.get("email", "")
125129
username = claims.get("username", "")
126130

131+
# Extract active plan from V2 JWT (e.g. "o:pro" -> "pro")
132+
plan_claim = claims.get("pla", "")
133+
active_plan = plan_claim.split(":")[-1] if plan_claim else "free_org"
134+
135+
# Extract active features from fea claim
136+
fea_claim = claims.get("fea", "")
137+
active_features = []
138+
for f in fea_claim.split(","):
139+
f = f.strip()
140+
if f.startswith("o:"):
141+
active_features.append(f[2:])
142+
elif f:
143+
active_features.append(f)
144+
127145
# Extract org_id and org_role from V1 or V2 JWT format
128146
# V1: top-level org_id and org_role claims
129147
# V2: compact "o" claim with id and rol fields
@@ -149,6 +167,8 @@ async def get_current_user(request: Request) -> AuthUser:
149167
org_permissions=org_permissions,
150168
email=email,
151169
username=username,
170+
plan=active_plan,
171+
features=active_features,
152172
)
153173
except HTTPException:
154174
raise

backend/app/core/plans.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""
2+
Plan configuration and limit enforcement for OpenSentry billing tiers.
3+
4+
Plan slugs must match the keys defined in the Clerk Dashboard:
5+
- free_org (Free)
6+
- pro (Pro — $19/mo)
7+
- business (Business — $49/mo)
8+
"""
9+
10+
PLAN_LIMITS = {
11+
"free_org": {
12+
"max_cameras": 2,
13+
"max_nodes": 1,
14+
},
15+
"pro": {
16+
"max_cameras": 10,
17+
"max_nodes": 5,
18+
},
19+
"business": {
20+
"max_cameras": 50,
21+
"max_nodes": 999, # effectively unlimited
22+
},
23+
}
24+
25+
26+
def get_plan_limits(plan: str) -> dict:
27+
"""Return the limits dict for a plan slug. Falls back to free tier."""
28+
return PLAN_LIMITS.get(plan, PLAN_LIMITS["free_org"])
29+
30+
31+
def get_plan_display_name(plan: str) -> str:
32+
"""Human-readable plan name."""
33+
names = {
34+
"free_org": "Free",
35+
"pro": "Pro",
36+
"business": "Business",
37+
}
38+
return names.get(plan, "Free")

frontend/src/App.jsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const DashboardPage = lazy(() => import("./pages/DashboardPage.jsx"))
1414
const SettingsPage = lazy(() => import("./pages/SettingsPage.jsx"))
1515
const AdminPage = lazy(() => import("./pages/AdminPage.jsx"))
1616
const TestHlsPage = lazy(() => import("./pages/TestHlsPage.jsx"))
17+
const PricingPage = lazy(() => import("./pages/PricingPage.jsx"))
1718

1819
function RequireOrg({ children }) {
1920
const { organization, isLoaded } = useOrganization()
@@ -111,6 +112,14 @@ function App() {
111112

112113
{/* Authenticated routes with Layout */}
113114
<Route element={<Layout />}>
115+
<Route
116+
path="/pricing"
117+
element={
118+
<RequireOrg>
119+
<PricingPage />
120+
</RequireOrg>
121+
}
122+
/>
114123
<Route
115124
path="/dashboard"
116125
element={

frontend/src/components/Layout.jsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ function Layout() {
4747
</Link>
4848
</>
4949
)}
50+
<Link to="/pricing" className={isActive("/pricing")}>
51+
Pricing
52+
</Link>
5053
</nav>
5154
</>
5255
)}

0 commit comments

Comments
 (0)