feat: admin billing products page — /admin/billing/products
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
137
web/src/padelnomics/admin/templates/admin/billing_products.html
Normal file
137
web/src/padelnomics/admin/templates/admin/billing_products.html
Normal file
@@ -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 %}
|
||||
<style>
|
||||
.billing-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem; }
|
||||
@media (max-width: 900px) { .billing-stats { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.product-section { margin-bottom: 2rem; }
|
||||
.product-section-title {
|
||||
font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: #64748B; margin-bottom: 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid #E2E8F0;
|
||||
}
|
||||
.product-table { width: 100%; border-collapse: collapse; }
|
||||
.product-table th {
|
||||
padding: 8px 12px; text-align: left; font-size: 0.6875rem; font-weight: 600;
|
||||
color: #94A3B8; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid #E2E8F0; white-space: nowrap;
|
||||
}
|
||||
.product-table td { padding: 10px 12px; border-bottom: 1px solid #F1F5F9; vertical-align: middle; }
|
||||
.product-table tr:last-child td { border-bottom: none; }
|
||||
.product-table tr:hover td { background: #F8FAFC; }
|
||||
|
||||
.product-name { font-weight: 600; color: #0F172A; font-size: 0.875rem; }
|
||||
.product-key { font-family: monospace; font-size: 0.75rem; color: #94A3B8; }
|
||||
.product-price { font-weight: 600; color: #0F172A; }
|
||||
|
||||
.type-badge {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 2px 8px; border-radius: 9999px; font-size: 0.6875rem; font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.type-badge--subscription { background: #DBEAFE; color: #1D4ED8; }
|
||||
.type-badge--one_time { background: #F3E8FF; color: #7C3AED; }
|
||||
|
||||
.active-count { font-weight: 700; font-size: 0.9375rem; color: #0F172A; }
|
||||
.active-count-zero { color: #CBD5E1; }
|
||||
|
||||
.paddle-ids { font-family: monospace; font-size: 0.6875rem; color: #CBD5E1; line-height: 1.6; }
|
||||
.paddle-ids span { display: block; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl">Billing & Products</h1>
|
||||
<p class="text-sm text-slate mt-1">Read-only overview — manage products via <code>setup_paddle.py</code> and the Paddle dashboard.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# Stats cards #}
|
||||
<div class="billing-stats">
|
||||
<div class="card text-center">
|
||||
<p class="card-header">Active Subscriptions</p>
|
||||
<p class="text-3xl font-bold text-navy metric">{{ stats.active_subs }}</p>
|
||||
</div>
|
||||
<div class="card text-center">
|
||||
<p class="card-header">Est. MRR</p>
|
||||
<p class="text-3xl font-bold text-navy metric">€{{ "%.0f"|format(stats.mrr_cents / 100) }}</p>
|
||||
<p class="text-xs text-slate mt-1">approx (yearly ÷ 12)</p>
|
||||
</div>
|
||||
<div class="card text-center">
|
||||
<p class="card-header">Active Boosts</p>
|
||||
<p class="text-3xl font-bold text-navy metric">{{ stats.active_boosts }}</p>
|
||||
</div>
|
||||
<div class="card text-center">
|
||||
<p class="card-header">BP Exports</p>
|
||||
<p class="text-3xl font-bold text-navy metric">{{ stats.bp_exports }}</p>
|
||||
<p class="text-xs text-slate mt-1">completed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Product categories #}
|
||||
{% for cat in categories %}
|
||||
<div class="product-section">
|
||||
<p class="product-section-title">{{ cat.name }}</p>
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<table class="product-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Price</th>
|
||||
<th>Type</th>
|
||||
<th style="text-align:center;">Active</th>
|
||||
<th>Paddle IDs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in cat.products %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="product-name">{{ p.name }}</span>
|
||||
<br><span class="product-key">{{ p.key }}</span>
|
||||
</td>
|
||||
<td class="product-price">€{{ "%.2f"|format(p.price_cents / 100) }}</td>
|
||||
<td>
|
||||
<span class="type-badge type-badge--{{ p.billing_type }}">
|
||||
{% if p.billing_type == 'subscription' %}
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
|
||||
Subscription
|
||||
{% else %}
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z"/></svg>
|
||||
One-time
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
{% if p.active_count > 0 %}
|
||||
<span class="active-count">{{ p.active_count }}</span>
|
||||
{% else %}
|
||||
<span class="active-count-zero">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="paddle-ids">
|
||||
<span title="Product ID">{{ p.paddle_product_id }}</span>
|
||||
<span title="Price ID">{{ p.paddle_price_id }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if not categories %}
|
||||
<div class="card text-center" style="padding: 3rem;">
|
||||
<p class="text-slate">No products found in the database.</p>
|
||||
<p class="text-xs text-slate mt-2">Run <code>uv run python -m padelnomics.scripts.setup_paddle</code> to populate products.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user