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:
@@ -893,6 +893,83 @@ async def feedback():
|
|||||||
return await render_template("admin/feedback.html", feedback_list=feedback_list)
|
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
|
# 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