diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py
index 89336cc..78c9bf4 100644
--- a/web/src/padelnomics/admin/routes.py
+++ b/web/src/padelnomics/admin/routes.py
@@ -233,7 +233,7 @@ async def index():
stats = await get_dashboard_stats()
recent_users = await get_users(limit=10)
failed_tasks = await get_failed_tasks()
-
+
return await render_template(
"admin/index.html",
stats=stats,
@@ -250,9 +250,9 @@ async def users():
page = int(request.args.get("page", 1))
per_page = 50
offset = (page - 1) * per_page
-
+
user_list = await get_users(limit=per_page, offset=offset, search=search or None)
-
+
return await render_template(
"admin/users.html",
users=user_list,
@@ -269,7 +269,7 @@ async def user_detail(user_id: int):
if not user:
await flash("User not found.", "error")
return redirect(url_for("admin.users"))
-
+
return await render_template("admin/user_detail.html", user=user)
@@ -282,7 +282,7 @@ async def impersonate(user_id: int):
if not user:
await flash("User not found.", "error")
return redirect(url_for("admin.users"))
-
+
# Store admin session so we can return
session["admin_impersonating"] = True
session["user_id"] = user_id
@@ -313,7 +313,7 @@ async def tasks():
"""Task queue management."""
task_list = await get_recent_tasks(limit=100)
failed = await get_failed_tasks()
-
+
return await render_template(
"admin/tasks.html",
tasks=task_list,
@@ -893,6 +893,83 @@ async def feedback():
return await render_template("admin/feedback.html", feedback_list=feedback_list)
+# =============================================================================
+# Billing / Products
+# =============================================================================
+
+_PRODUCT_CATEGORIES = [
+ ("Supplier Plans", ["supplier_growth", "supplier_growth_yearly", "supplier_pro", "supplier_pro_yearly"]),
+ ("Planner Plans", ["starter", "pro"]),
+ ("Boosts (Subscription)", ["boost_logo", "boost_highlight", "boost_verified", "boost_card_color"]),
+ ("Boosts (One-time)", ["boost_sticky_week", "boost_sticky_month"]),
+ ("Credit Packs", ["credits_25", "credits_50", "credits_100", "credits_250"]),
+ ("One-time Products", ["business_plan"]),
+]
+
+
+@bp.route("/billing/products")
+@role_required("admin")
+async def billing_products():
+ """Read-only overview of Paddle products, subscriptions, and revenue proxies."""
+ active_subs_row = await fetch_one("SELECT COUNT(*) as cnt FROM subscriptions WHERE status = 'active'")
+ mrr_row = await fetch_one(
+ """SELECT COALESCE(SUM(
+ CASE WHEN pp.key LIKE '%_yearly' THEN pp.price_cents / 12
+ ELSE pp.price_cents END
+ ), 0) as total_cents
+ FROM subscriptions s
+ JOIN paddle_products pp ON s.plan = pp.key
+ WHERE s.status = 'active' AND pp.billing_type = 'subscription'"""
+ )
+ active_boosts_row = await fetch_one("SELECT COUNT(*) as cnt FROM supplier_boosts WHERE status = 'active'")
+ bp_exports_row = await fetch_one("SELECT COUNT(*) as cnt FROM business_plan_exports WHERE status = 'completed'")
+
+ stats = {
+ "active_subs": (active_subs_row or {}).get("cnt", 0),
+ "mrr_cents": (mrr_row or {}).get("total_cents", 0),
+ "active_boosts": (active_boosts_row or {}).get("cnt", 0),
+ "bp_exports": (bp_exports_row or {}).get("cnt", 0),
+ }
+
+ products_rows = await fetch_all("SELECT * FROM paddle_products ORDER BY key")
+ product_map = {p["key"]: dict(p) for p in products_rows}
+
+ sub_counts = await fetch_all(
+ "SELECT plan, COUNT(*) as cnt FROM subscriptions WHERE status = 'active' GROUP BY plan"
+ )
+ sub_count_map = {r["plan"]: r["cnt"] for r in sub_counts}
+
+ boost_counts = await fetch_all(
+ "SELECT boost_type, COUNT(*) as cnt FROM supplier_boosts WHERE status = 'active' GROUP BY boost_type"
+ )
+ boost_count_map = {r["boost_type"]: r["cnt"] for r in boost_counts}
+
+ categories = []
+ for cat_name, keys in _PRODUCT_CATEGORIES:
+ items = []
+ for key in keys:
+ prod = product_map.get(key)
+ if not prod:
+ continue
+ if key in sub_count_map:
+ prod["active_count"] = sub_count_map[key]
+ elif key in boost_count_map:
+ prod["active_count"] = boost_count_map[key]
+ elif key == "business_plan":
+ prod["active_count"] = stats["bp_exports"]
+ else:
+ prod["active_count"] = 0
+ items.append(prod)
+ if items:
+ categories.append({"name": cat_name, "products": items})
+
+ return await render_template(
+ "admin/billing_products.html",
+ stats=stats,
+ categories=categories,
+ )
+
+
# =============================================================================
# Email Hub
# =============================================================================
diff --git a/web/src/padelnomics/admin/templates/admin/billing_products.html b/web/src/padelnomics/admin/templates/admin/billing_products.html
new file mode 100644
index 0000000..d089f87
--- /dev/null
+++ b/web/src/padelnomics/admin/templates/admin/billing_products.html
@@ -0,0 +1,137 @@
+{% extends "admin/base_admin.html" %}
+{% set admin_page = "billing" %}
+
+{% block title %}Billing & Products - Admin - {{ config.APP_NAME }}{% endblock %}
+
+{% block admin_head %}
+
+{% endblock %}
+
+{% block admin_content %}
+ Read-only overview — manage products via Billing & Products
+ setup_paddle.py and the Paddle dashboard.
Active Subscriptions
+{{ stats.active_subs }}
+Est. MRR
+€{{ "%.0f"|format(stats.mrr_cents / 100) }}
+approx (yearly ÷ 12)
+Active Boosts
+{{ stats.active_boosts }}
+BP Exports
+{{ stats.bp_exports }}
+completed
+{{ cat.name }}
+ +No products found in the database.
+Run uv run python -m padelnomics.scripts.setup_paddle to populate products.