Files
orion/app/api/v1/vendor/usage.py
Samir Boulahtit 7d1a421826 feat: add comprehensive tier-based feature management system
Implement database-driven feature gating with contextual upgrade prompts:

- Add Feature model with 30 features across 8 categories
- Create FeatureService with caching for tier-based feature checking
- Add @require_feature decorator and RequireFeature dependency for backend enforcement
- Create vendor features API (6 endpoints) and admin features API
- Add Alpine.js feature store and upgrade prompts store for frontend
- Create Jinja macros: feature_gate, feature_locked, limit_warning, usage_bar
- Add usage API for tracking orders/products/team limits with upgrade info
- Fix Stripe webhook to create VendorAddOn records on addon purchase
- Integrate upgrade prompts into vendor dashboard with tier badge and usage bars

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 18:28:40 +01:00

381 lines
12 KiB
Python

# app/api/v1/vendor/usage.py
"""
Vendor usage and limits API endpoints.
Provides endpoints for:
- Current usage vs limits
- Upgrade recommendations
- Approaching limit warnings
"""
import logging
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.subscription_service import subscription_service
from models.database.product import Product
from models.database.subscription import SubscriptionTier
from models.database.user import User
from models.database.vendor import VendorUser
router = APIRouter(prefix="/usage")
logger = logging.getLogger(__name__)
# ============================================================================
# Response Schemas
# ============================================================================
class UsageMetric(BaseModel):
"""Single usage metric."""
name: str
current: int
limit: int | None # None = unlimited
percentage: float # 0-100, or 0 if unlimited
is_unlimited: bool
is_at_limit: bool
is_approaching_limit: bool # >= 80%
class TierInfo(BaseModel):
"""Current tier information."""
code: str
name: str
price_monthly_cents: int
is_highest_tier: bool
class UpgradeTierInfo(BaseModel):
"""Next tier upgrade information."""
code: str
name: str
price_monthly_cents: int
price_increase_cents: int
benefits: list[str]
class UsageResponse(BaseModel):
"""Full usage response with limits and upgrade info."""
tier: TierInfo
usage: list[UsageMetric]
has_limits_approaching: bool
has_limits_reached: bool
upgrade_available: bool
upgrade_tier: UpgradeTierInfo | None = None
upgrade_reasons: list[str]
class LimitCheckResponse(BaseModel):
"""Response for checking a specific limit."""
limit_type: str
can_proceed: bool
current: int
limit: int | None
percentage: float
message: str | None = None
upgrade_tier_code: str | None = None
upgrade_tier_name: str | None = None
# ============================================================================
# Endpoints
# ============================================================================
@router.get("", response_model=UsageResponse)
def get_usage(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get current usage, limits, and upgrade recommendations.
Returns comprehensive usage info for displaying in dashboard
and determining when to show upgrade prompts.
"""
vendor_id = current_user.token_vendor_id
# Get subscription
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
# Get current tier
tier = subscription.tier_obj
if not tier:
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == subscription.tier)
.first()
)
# Calculate usage metrics
usage_metrics = []
# Orders this period
orders_current = subscription.orders_this_period or 0
orders_limit = subscription.orders_limit
orders_unlimited = orders_limit is None or orders_limit < 0
orders_percentage = 0 if orders_unlimited else (orders_current / orders_limit * 100 if orders_limit > 0 else 100)
usage_metrics.append(
UsageMetric(
name="orders",
current=orders_current,
limit=None if orders_unlimited else orders_limit,
percentage=orders_percentage,
is_unlimited=orders_unlimited,
is_at_limit=not orders_unlimited and orders_current >= orders_limit,
is_approaching_limit=not orders_unlimited and orders_percentage >= 80,
)
)
# Products
products_count = (
db.query(func.count(Product.id))
.filter(Product.vendor_id == vendor_id)
.scalar()
or 0
)
products_limit = subscription.products_limit
products_unlimited = products_limit is None or products_limit < 0
products_percentage = 0 if products_unlimited else (products_count / products_limit * 100 if products_limit > 0 else 100)
usage_metrics.append(
UsageMetric(
name="products",
current=products_count,
limit=None if products_unlimited else products_limit,
percentage=products_percentage,
is_unlimited=products_unlimited,
is_at_limit=not products_unlimited and products_count >= products_limit,
is_approaching_limit=not products_unlimited and products_percentage >= 80,
)
)
# Team members
team_count = (
db.query(func.count(VendorUser.id))
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) # noqa: E712
.scalar()
or 0
)
team_limit = subscription.team_members_limit
team_unlimited = team_limit is None or team_limit < 0
team_percentage = 0 if team_unlimited else (team_count / team_limit * 100 if team_limit > 0 else 100)
usage_metrics.append(
UsageMetric(
name="team_members",
current=team_count,
limit=None if team_unlimited else team_limit,
percentage=team_percentage,
is_unlimited=team_unlimited,
is_at_limit=not team_unlimited and team_count >= team_limit,
is_approaching_limit=not team_unlimited and team_percentage >= 80,
)
)
# Check for approaching/reached limits
has_limits_approaching = any(m.is_approaching_limit for m in usage_metrics)
has_limits_reached = any(m.is_at_limit for m in usage_metrics)
# Get next tier for upgrade
all_tiers = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.is_active == True) # noqa: E712
.order_by(SubscriptionTier.display_order)
.all()
)
current_tier_order = tier.display_order if tier else 0
next_tier = None
for t in all_tiers:
if t.display_order > current_tier_order:
next_tier = t
break
is_highest_tier = next_tier is None
# Build upgrade info
upgrade_tier_info = None
upgrade_reasons = []
if next_tier:
# Calculate benefits
benefits = []
if next_tier.orders_per_month and (not tier or (tier.orders_per_month and next_tier.orders_per_month > tier.orders_per_month)):
if next_tier.orders_per_month < 0:
benefits.append("Unlimited orders per month")
else:
benefits.append(f"{next_tier.orders_per_month:,} orders/month")
if next_tier.products_limit and (not tier or (tier.products_limit and next_tier.products_limit > tier.products_limit)):
if next_tier.products_limit < 0:
benefits.append("Unlimited products")
else:
benefits.append(f"{next_tier.products_limit:,} products")
if next_tier.team_members and (not tier or (tier.team_members and next_tier.team_members > tier.team_members)):
if next_tier.team_members < 0:
benefits.append("Unlimited team members")
else:
benefits.append(f"{next_tier.team_members} team members")
# Add feature benefits
current_features = set(tier.features) if tier and tier.features else set()
next_features = set(next_tier.features) if next_tier.features else set()
new_features = next_features - current_features
feature_names = {
"analytics_dashboard": "Advanced Analytics",
"api_access": "API Access",
"automation_rules": "Automation Rules",
"team_roles": "Team Roles & Permissions",
"custom_domain": "Custom Domain",
"webhooks": "Webhooks",
"accounting_export": "Accounting Export",
}
for feature in list(new_features)[:3]: # Show top 3
if feature in feature_names:
benefits.append(feature_names[feature])
current_price = tier.price_monthly_cents if tier else 0
upgrade_tier_info = UpgradeTierInfo(
code=next_tier.code,
name=next_tier.name,
price_monthly_cents=next_tier.price_monthly_cents,
price_increase_cents=next_tier.price_monthly_cents - current_price,
benefits=benefits,
)
# Build upgrade reasons
if has_limits_reached:
for m in usage_metrics:
if m.is_at_limit:
upgrade_reasons.append(f"You've reached your {m.name.replace('_', ' ')} limit")
elif has_limits_approaching:
for m in usage_metrics:
if m.is_approaching_limit:
upgrade_reasons.append(f"You're approaching your {m.name.replace('_', ' ')} limit ({int(m.percentage)}%)")
return UsageResponse(
tier=TierInfo(
code=tier.code if tier else subscription.tier,
name=tier.name if tier else subscription.tier.title(),
price_monthly_cents=tier.price_monthly_cents if tier else 0,
is_highest_tier=is_highest_tier,
),
usage=usage_metrics,
has_limits_approaching=has_limits_approaching,
has_limits_reached=has_limits_reached,
upgrade_available=not is_highest_tier,
upgrade_tier=upgrade_tier_info,
upgrade_reasons=upgrade_reasons,
)
@router.get("/check/{limit_type}", response_model=LimitCheckResponse)
def check_limit(
limit_type: str,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Check a specific limit before performing an action.
Use this before creating orders, products, or inviting team members.
Args:
limit_type: One of "orders", "products", "team_members"
Returns:
Whether the action can proceed and upgrade info if not
"""
vendor_id = current_user.token_vendor_id
if limit_type == "orders":
can_proceed, message = subscription_service.can_create_order(db, vendor_id)
subscription = subscription_service.get_subscription(db, vendor_id)
current = subscription.orders_this_period if subscription else 0
limit = subscription.orders_limit if subscription else 0
elif limit_type == "products":
can_proceed, message = subscription_service.can_add_product(db, vendor_id)
subscription = subscription_service.get_subscription(db, vendor_id)
current = (
db.query(func.count(Product.id))
.filter(Product.vendor_id == vendor_id)
.scalar()
or 0
)
limit = subscription.products_limit if subscription else 0
elif limit_type == "team_members":
can_proceed, message = subscription_service.can_add_team_member(db, vendor_id)
subscription = subscription_service.get_subscription(db, vendor_id)
current = (
db.query(func.count(VendorUser.id))
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) # noqa: E712
.scalar()
or 0
)
limit = subscription.team_members_limit if subscription else 0
else:
return LimitCheckResponse(
limit_type=limit_type,
can_proceed=True,
current=0,
limit=None,
percentage=0,
message=f"Unknown limit type: {limit_type}",
)
# Calculate percentage
is_unlimited = limit is None or limit < 0
percentage = 0 if is_unlimited else (current / limit * 100 if limit > 0 else 100)
# Get upgrade info if at limit
upgrade_tier_code = None
upgrade_tier_name = None
if not can_proceed:
subscription = subscription_service.get_subscription(db, vendor_id)
current_tier = subscription.tier_obj if subscription else None
if current_tier:
next_tier = (
db.query(SubscriptionTier)
.filter(
SubscriptionTier.is_active == True, # noqa: E712
SubscriptionTier.display_order > current_tier.display_order,
)
.order_by(SubscriptionTier.display_order)
.first()
)
if next_tier:
upgrade_tier_code = next_tier.code
upgrade_tier_name = next_tier.name
return LimitCheckResponse(
limit_type=limit_type,
can_proceed=can_proceed,
current=current,
limit=None if is_unlimited else limit,
percentage=percentage,
message=message,
upgrade_tier_code=upgrade_tier_code,
upgrade_tier_name=upgrade_tier_name,
)