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:
Deeman
2026-02-26 19:49:55 +01:00
parent 82591514cd
commit a028184a85
2 changed files with 220 additions and 6 deletions

View File

@@ -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
# ============================================================================= # =============================================================================

View 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 &amp; 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">&euro;{{ "%.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">&euro;{{ "%.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 %}