From ae47652d0c7ff581b8be12723e5647aa2b411020 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 19 Mar 2026 13:05:55 +0100 Subject: [PATCH] feat: add user subscription info endpoints New router: routers/subscription.py Endpoints: - GET /api/subscription/me - Own subscription info (tier, trial, grants) - GET /api/subscription/usage - Feature usage with limits - GET /api/subscription/limits - All feature limits for current tier Features: - Shows effective tier (considers access_grants) - Lists active access grants (from coupons, trials) - Per-feature usage tracking - Email verification status Uses new middleware: get_effective_tier(), check_feature_access() Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 3 +- backend/routers/subscription.py | 187 ++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 backend/routers/subscription.py diff --git a/backend/main.py b/backend/main.py index 88204b3..90c112b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,7 +17,7 @@ from db import init_db # Import routers from routers import auth, profiles, weight, circumference, caliper from routers import activity, nutrition, photos, insights, prompts -from routers import admin, stats, exportdata, importdata +from routers import admin, stats, exportdata, importdata, subscription # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -73,6 +73,7 @@ app.include_router(admin.router) # /api/admin/* app.include_router(stats.router) # /api/stats app.include_router(exportdata.router) # /api/export/* app.include_router(importdata.router) # /api/import/* +app.include_router(subscription.router) # /api/subscription/* # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/routers/subscription.py b/backend/routers/subscription.py new file mode 100644 index 0000000..d3633d2 --- /dev/null +++ b/backend/routers/subscription.py @@ -0,0 +1,187 @@ +""" +User Subscription Endpoints for Mitai Jinkendo + +User-facing subscription info (own tier, usage, limits). +""" +from datetime import datetime +from fastapi import APIRouter, Depends + +from db import get_db, get_cursor, r2d +from auth import require_auth, get_effective_tier, check_feature_access + +router = APIRouter(prefix="/api/subscription", tags=["subscription"]) + + +@router.get("/me") +def get_my_subscription(session: dict = Depends(require_auth)): + """ + Get current user's subscription info. + + Returns: + - tier: Current effective tier (considers access_grants) + - profile_tier: Base tier from profile + - trial_ends_at: Trial expiration (if applicable) + - email_verified: Email verification status + - active_grants: List of active access grants (coupons, trials) + """ + profile_id = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Get profile info + cur.execute(""" + SELECT tier, trial_ends_at, email_verified + FROM profiles + WHERE id = %s + """, (profile_id,)) + profile = cur.fetchone() + + if not profile: + return {"error": "Profile not found"} + + # Get effective tier (considers access_grants) + effective_tier = get_effective_tier(profile_id) + + # Get active access grants + cur.execute(""" + SELECT + ag.id, + ag.tier_id, + ag.granted_by, + ag.valid_from, + ag.valid_until, + ag.is_active, + ag.paused_by, + ag.remaining_days, + t.name as tier_name + FROM access_grants ag + JOIN tiers t ON t.id = ag.tier_id + WHERE ag.profile_id = %s + AND ag.valid_until > CURRENT_TIMESTAMP + ORDER BY ag.valid_until DESC + """, (profile_id,)) + grants = [r2d(r) for r in cur.fetchall()] + + # Get tier info + cur.execute(""" + SELECT id, name, description, price_monthly_cents, price_yearly_cents + FROM tiers + WHERE id = %s + """, (effective_tier,)) + tier_info = r2d(cur.fetchone()) + + return { + "tier": effective_tier, + "tier_info": tier_info, + "profile_tier": profile['tier'], + "trial_ends_at": profile['trial_ends_at'].isoformat() if profile['trial_ends_at'] else None, + "email_verified": profile['email_verified'], + "active_grants": grants + } + + +@router.get("/usage") +def get_my_usage(session: dict = Depends(require_auth)): + """ + Get current user's feature usage. + + Returns list of features with current usage and limits. + """ + profile_id = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Get all active features + cur.execute(""" + SELECT id, name, category, limit_type, reset_period + FROM features + WHERE active = true + ORDER BY category, name + """) + features = [r2d(r) for r in cur.fetchall()] + + # Get usage for each feature + usage_list = [] + for feature in features: + access = check_feature_access(profile_id, feature['id']) + usage_list.append({ + "feature_id": feature['id'], + "feature_name": feature['name'], + "category": feature['category'], + "limit_type": feature['limit_type'], + "reset_period": feature['reset_period'], + "allowed": access['allowed'], + "limit": access['limit'], + "used": access['used'], + "remaining": access['remaining'], + "reason": access['reason'] + }) + + return { + "tier": get_effective_tier(profile_id), + "features": usage_list + } + + +@router.get("/limits") +def get_my_limits(session: dict = Depends(require_auth)): + """ + Get all feature limits for current tier. + + Simplified view - just shows what's allowed/not allowed. + """ + profile_id = session['profile_id'] + tier_id = get_effective_tier(profile_id) + + with get_db() as conn: + cur = get_cursor(conn) + + # Get all features with their limits for this tier + cur.execute(""" + SELECT + f.id, + f.name, + f.category, + f.limit_type, + COALESCE(tl.limit_value, f.default_limit) as limit_value + FROM features f + LEFT JOIN tier_limits tl ON tl.feature_id = f.id AND tl.tier_id = %s + WHERE f.active = true + ORDER BY f.category, f.name + """, (tier_id,)) + + features = [] + for row in cur.fetchall(): + rd = r2d(row) + limit = rd['limit_value'] + + # Interpret limit + if limit is None: + status = "unlimited" + elif limit == 0: + status = "disabled" + elif rd['limit_type'] == 'boolean': + status = "enabled" if limit == 1 else "disabled" + else: + status = f"limit: {limit}" + + features.append({ + "feature_id": rd['id'], + "feature_name": rd['name'], + "category": rd['category'], + "limit": limit, + "status": status + }) + + # Get tier info + cur.execute("SELECT name, description FROM tiers WHERE id = %s", (tier_id,)) + tier = cur.fetchone() + + return { + "tier_id": tier_id, + "tier_name": tier['name'] if tier else tier_id, + "tier_description": tier['description'] if tier else '', + "features": features + }