From a028184a85fe262b833d261fbe866e2a02b42ada Mon Sep 17 00:00:00 2001 From: Deeman Date: Thu, 26 Feb 2026 19:49:55 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20admin=20billing=20products=20page=20?= =?UTF-8?q?=E2=80=94=20/admin/billing/products?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only overview of all Paddle products with live metrics: - Stats cards: active subscriptions, estimated MRR (yearly÷12), active boosts, completed business plan exports - Products grouped by category: Supplier Plans, Planner Plans, Boosts (sub + one-time), Credit Packs, One-time Products - Per-product: name, key, price, type badge, active count, Paddle IDs - Empty-state message when paddle_products table is unpopulated - PRODUCT_CATEGORIES constant in routes.py defines grouping + ordering Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/routes.py | 89 +++++++++++- .../templates/admin/billing_products.html | 137 ++++++++++++++++++ 2 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 web/src/padelnomics/admin/templates/admin/billing_products.html 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 %} +
+
+

Billing & Products

+

Read-only overview — manage products via setup_paddle.py and the Paddle dashboard.

+
+
+ +{# Stats cards #} +
+
+

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

+
+
+ +{# Product categories #} +{% for cat in categories %} +
+

{{ cat.name }}

+
+ + + + + + + + + + + + {% for p in cat.products %} + + + + + + + + {% endfor %} + +
ProductPriceTypeActivePaddle IDs
+ {{ p.name }} +
{{ p.key }} +
€{{ "%.2f"|format(p.price_cents / 100) }} + + {% if p.billing_type == 'subscription' %} + + Subscription + {% else %} + + One-time + {% endif %} + + + {% if p.active_count > 0 %} + {{ p.active_count }} + {% else %} + + {% endif %} + +
+ {{ p.paddle_product_id }} + {{ p.paddle_price_id }} +
+
+
+
+{% endfor %} + +{% if not categories %} +
+

No products found in the database.

+

Run uv run python -m padelnomics.scripts.setup_paddle to populate products.

+
+{% endif %} +{% endblock %}