Match tests to the reworked dashboard, surface the renewals KPI

The club table was rebuilt (logo, status badges, Plan/Dues columns, an Edit
action) and the dashboard dropped the second chart, so three render tests were
asserting columns and a canvas that no longer exist. Updated to the current
layout — the service-level tests were already correct, since the annotations they
check still exist even where the template stopped rendering them.

Worked the renewals KPI into the billing card: "N awaiting renewal", shown only
when non-zero. It should sit at 0 in normal running — the cron renews clubs 30
days out and they fall past the horizon — so a number here means the job has
stopped and a club is about to use the platform free, which nothing else on the
page reveals because nothing has been billed yet.

Fixed two things in the WIP table while here: a debug line that printed the raw
grace/period/owed values into the Dues cell, and a missing {% empty %} clause
(so an empty list showed a headed table with no "no clubs" row, and empty_message
was dead). Removed the stale commented-out copy of the old table.

Answers "auto-renewed but unpaid?": it is not a special case. Renewal opens an
ordinary unpaid Due, which flows unpaid -> grace -> overdue -> archive like any
period — so the safety net that the never-billed club slipped past now fires,
because there is a due to be overdue. Tested both ways.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 08:34:49 +02:00
parent 9a616c20e4
commit 975426a17f
5 changed files with 184 additions and 154 deletions

View File

@@ -457,3 +457,34 @@ class RenewalTests(BillingTestBase):
out = StringIO()
call_command("renew_subscriptions", *args, stdout=out)
return out.getvalue()
class RenewedButUnpaidTests(BillingTestBase):
"""A club auto-renewed that never pays the new fee flows through the ordinary
unpaid -> grace -> overdue -> archive path. Renewal creates a normal Due; it does not
create a special case, and the safety net that the never-billed club slipped past now
fires, because there IS an unpaid due."""
def lapsed_club(self):
"""A club on its first, PAID period — far enough back that a renewal from its end is
itself already past grace, so only the renewal's payment state decides the outcome."""
club = Club.objects.create(name="Renewed FC")
subscribe(club, self.tier, start=self.today - datetime.timedelta(days=800))
first = club.dues.first()
record_payment(first, first.amount) # the FIRST period is settled; only the renewal is in question
return club
def test_an_unpaid_renewal_becomes_overdue_and_archivable(self):
club = self.lapsed_club()
renewed = renew(club.subscription) # continues from the first period's end, unpaid
self.assertTrue(renewed.is_overdue(self.today))
self.assertIn(renewed, dues_overdue(self.today))
self.assertIn(club, [d.club for d in archivable_clubs(self.today)])
def test_a_paid_renewal_is_not_chased(self):
club = self.lapsed_club()
renewed = renew(club.subscription)
record_payment(renewed, renewed.amount)
self.assertNotIn(club, [d.club for d in archivable_clubs(self.today)])

View File

@@ -13,65 +13,98 @@
<thead>
<tr>
<th>Club</th>
<th></th>
<th class="text-right">Members</th>
<th class="text-right">Unpaid</th>
<th class="text-right">Owed</th>
<th>Plan</th>
<th class="text-right">Dues</th>
<th class="text-right">Teams</th>
<th class="text-right">Upcoming</th>
<th class="text-right">Admins</th>
<th class="text-right">Teams</th>
<th class="text-right">Events</th>
<th class="text-right">Plan</th>
<th class="text-right">Dues</th>
<th></th>
</tr>
</thead>
<tbody>
{% for club in clubs %}
<tr>
<td>
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
<div class="mt-1 flex flex-wrap items-center gap-1">
<span class="text-xs opacity-60">{{ club.slug }}</span>
{% if club.is_archived %}
{% comment %}
An archived club's subdomain does not resolve, so "dormant" and
"no season" would be noise: of course nothing is scheduled.
{% endcomment %}
<span class="badge badge-warning badge-xs gap-1">{% lucide "archive" size=10 %} Archived</span>
<div class="flex flex-row items-center gap-4">
<div>
{% if club.logo %}
<img class="h-12 w-12 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
{% else %}
{% if not club.has_season %}<span class="badge badge-warning badge-xs gap-1">{% lucide "calendar-x" size=10 %} No season</span>{% endif %}
{% if not club.upcoming_events %}<span class="badge badge-ghost badge-xs gap-1">{% lucide "moon-star" size=10 %} Dormant</span>{% endif %}
<div class="avatar avatar-placeholder">
<div class="w-12 rounded-full bg-neutral text-neutral-content">
<span>{{ club.initials }}</span>
</div>
</div>
{% endif %}
</div>
<div class="flex flex-col gap-1">
<a class="link link-hover font-semibold tracking-wide" href="{% url "controlpanel:club_detail" club.pk %}">{{ club.name }}</a>
<div class="text-xs opacity-60">{{ club.slug }}.rosterchief.app</div>
</div>
</div>
</td>
<td>
<div class="flex flex-row gap-2">
{% if club.is_archived %}
<span class="badge badge-warning">{% lucide "archive" size=14 %} archived</span>
{% else %}
{% if not club.has_season %}
<span class="badge badge-warning">{% lucide "calendar-x" size=14 %} no seasons</span>
{% endif %}
{% if not club.upcoming_events %}
<span class="badge badge-ghost badge-outline">{% lucide "moon-star" size=14 %}dormant</span>
{% endif %}
{% endif %}
</div>
</td>
<td class="text-right tabular-nums">{{ club.active_members }}</td>
<td class="text-right tabular-nums {% if club.unpaid_members %}text-warning{% endif %}">{{ club.unpaid_members }}</td>
<td class="text-right tabular-nums {% if club.outstanding %}font-semibold text-error{% endif %}">€{{ club.outstanding|floatformat:2 }}</td>
<td>
<td class="text-right tabular-nums">
<div class="flex flex-row gap-2 items-center justify-end">
{% if not club.admin_count %}
<span class="text-error">{% lucide "triangle-alert" size=16 %}</span>
{% endif %}
<span class="{% if not club.admin_count %}font-bold text-error{% endif %}">{{ club.admin_count }}</span>
</div>
</td>
<td class="text-right tabular-nums">{{ club.team_count }}</td>
<td class="text-right tabular-nums">{{ club.upcoming_events }}</td>
<td class="text-right">
{% if club.tier_name %}
<span class="text-sm">{{ club.tier_name }}</span>
<span class="badge badge-accent">{{ club.tier_name|lower }}</span>
{% else %}
<span class="badge badge-warning badge-xs">Not billed</span>
<span class="badge badge-ghost badge-outline">n/a</span>
{% endif %}
</td>
<td class="text-right tabular-nums">
<td class="text-right">
<div class="flex flex-row gap-2 items-center justify-end">
{% if not club.dues_owed %}
{% if club.tier_name %}<span class="badge badge-success badge-xs">Paid</span>{% endif %}
{% if club.tier_name %}
<span class="badge badge-success">paid</span>
{% else %}
<span class="badge badge-ghost badge-outline">n/a</span>
{% endif %}
{% else %}
<span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span>
{% if club.dues_grace_until < today %}
<span class="badge badge-error badge-xs">Overdue</span>
<span class="badge badge-error">overdue</span>
{% elif club.dues_period_end < today %}
<span class="badge badge-warning badge-xs">Grace</span>
<span class="badge badge-warning">grace</span>
{% endif %}
{% endif %}
</div>
</td>
<td class="text-right tabular-nums">
{{ club.team_count }}
{% if club.teams_without_coach %}
<span class="badge badge-error badge-xs ml-1" title="Teams with nobody able to pick the squad">{{ club.teams_without_coach }} no coach</span>
{% endif %}
<td>
<a class="btn btn-sm gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
</td>
<td class="text-right tabular-nums">{{ club.upcoming_events }}</td>
<td class="text-right tabular-nums {% if not club.admin_count %}text-error{% endif %}">{{ club.admin_count }}</td>
</tr>
{% empty %}
<tr>

View File

@@ -2,52 +2,70 @@
{% load static lucide %}
{% block heading %}RosterChief Platform Dashboard{% endblock heading %}
{% block subheading %}Welcome back {{ user.member.first_name }}!{% endblock subheading %}
{% block subheading %}Welcome back {{ user.member.first_name }} &middot; {% now "d b Y" %}{% endblock subheading %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} Create new club</a>
{% endblock actions %}
{% block panel %}
{% comment %}
Needs attention first: these are the numbers that are supposed to be zero. A club with
no current season cannot take a signup or schedule a match — and it fails silently,
nothing errors — while an admin without a second factor is locked out of their own
club. Both are work queues, not statistics. The vanity totals sit further down.
{% endcomment %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="card bg-base-100 shadow {% if attention.clubs_without_season %}border-l-4 border-warning{% endif %}">
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
<div class="card bg-base-100 shadow border-l-4 border-info">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "calendar-x" size=16 %} No current season</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.clubs_without_season }}</div>
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "building-2" size=16 %} Clubs</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ totals.clubs }}</div>
<div class="text-xs opacity-60">Managing {{ totals.members }} member{{ totals.members|pluralize }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-info">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "archive" size=16 %} Archived clubs</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ totals.archived_clubs }}</div>
<div class="text-xs opacity-60">Not accessible but data maintained</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.clubs_without_season %}border-warning{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "calendar-x" size=16 %} No current season</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.clubs_without_season }}</div>
<div class="text-xs opacity-60">Clubs that cannot take signups</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.dormant_clubs %}border-l-4 border-warning{% endif %}">
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.dormant_clubs %}border-warning{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "moon-star" size=16 %} Dormant</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.dormant_clubs }}</div>
<div class="text-xs opacity-60">Nothing scheduled in 30 days</div>
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "moon-star" size=16 %} Dormant clubs</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.dormant_clubs }}</div>
<div class="text-xs opacity-60">No events scheduled next 30 days</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.admins_pending_mfa %}border-l-4 border-error{% endif %}">
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.admins_pending_mfa %}border-warning{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "shield-alert" size=16 %} MFA pending</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.admins_pending_mfa }}</div>
<div class="text-xs opacity-60">Admins locked out until they enrol</div>
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "shield-alert" size=16 %} MFA pending</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.admins_pending_mfa }}</div>
<div class="text-xs opacity-60">Admins without MFA configured</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.dues_owed %}border-l-4 border-error{% endif %}">
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.dues_owed %}border-warning{% endif %}">
<div class="card-body p-4">
{% comment %}
What the CLUBS owe US. Not to be confused with the club-shop money below,
which members owe their clubs and is never ours.
{% endcomment %}
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "receipt-euro" size=16 %} Dues owed</div>
<div class="text-3xl font-bold tabular-nums">€{{ attention.dues_owed|floatformat:2 }}</div>
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "receipt-euro" size=16 %} Payment pending</div>
<div class="text-4xl font-bold tabular-nums font-mono">&euro;{{ attention.dues_owed|floatformat:2 }}</div>
<div class="text-xs opacity-60">
{{ attention.dues_in_grace }} in grace ·
<span class="{% if attention.dues_overdue %}font-semibold text-error{% endif %}">{{ attention.dues_overdue }} overdue</span>
{% comment %}
Renewals pending should sit at ~0: the cron job renews clubs 30 days out and
then they fall past the horizon. A number that lingers here means the job has
stopped and a club is about to use the platform for free — which no other
figure on this page reveals, because nothing has been billed yet.
{% endcomment %}
{% if attention.renewals_pending %}
· <span class="font-semibold text-warning">{{ attention.renewals_pending }} awaiting renewal</span>
{% endif %}
</div>
</div>
</div>
@@ -63,22 +81,11 @@
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Platform dues per month</h2>
<p class="text-sm opacity-70">What clubs paid us. Club-shop money is theirs, not ours.</p>
<div class="h-56">
<canvas id="revenue-chart"></canvas>
</div>
</div>
</div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "milestone" size=18 %} Onboarding</h2>
<p class="text-sm opacity-70">Where clubs stall. One with no team or no events is a shell.</p>
<p class="text-sm opacity-70">Tracking club onboarding to ensure a smooth start</p>
<div class="mt-2 space-y-3">
{% for step in funnel %}
<div>
@@ -86,76 +93,18 @@
<span class="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span>
<span class="font-semibold tabular-nums">{{ step.count }}</span>
</div>
<progress class="progress progress-primary w-full" value="{{ step.count }}" max="{{ funnel.0.count }}"></progress>
<progress class="progress {% if step.count == funnel.0.count %}progress-success{% elif step.count == 0 %}progress-error{% else %}progress-warning{% endif %} w-full" value="{{ step.count }}"
max="{{ funnel.0.count }}"></progress>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Feature adoption</h2>
<a class="btn btn-ghost btn-xs" href="{% url 'controlpanel:features' %}">Manage</a>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<tbody>
{% for flag in flags %}
<tr>
<td class="font-mono font-medium">{{ flag.name }}</td>
<td class="text-right">
{% if flag.overridden %}
{# `everyone` overrides club targeting, so the club count says nothing here. #}
<span class="badge badge-sm {% if flag.everyone %}badge-success{% else %}badge-error{% endif %}">
{% if flag.everyone %}On for all{% else %}Off everywhere{% endif %}
</span>
{% else %}
<span class="tabular-nums">{{ flag.clubs }} / {{ totals.clubs }} clubs</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">No features yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="stats mb-6 w-full bg-base-100 shadow">
<div class="stat">
<div class="stat-title">Active clubs</div>
<div class="stat-value">{{ totals.clubs }}</div>
</div>
<div class="stat">
<div class="stat-title">Archived</div>
<div class="stat-value">{{ totals.archived_clubs }}</div>
</div>
<div class="stat">
<div class="stat-title">Members</div>
<div class="stat-value">{{ totals.members }}</div>
<div class="stat-desc">{{ attention.members_without_login }} without a login</div>
</div>
<div class="stat">
<div class="stat-title">Club admins</div>
<div class="stat-value">{{ totals.admins }}</div>
</div>
<div class="stat">
<div class="stat-title">Not billed</div>
<div class="stat-value {% if attention.clubs_unbilled %}text-warning{% endif %}">{{ attention.clubs_unbilled }}</div>
<div class="stat-desc">Clubs on no tier</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Clubs</h2>
<h2 class="card-title">{% lucide "building-2" size=18 %} Clubs</h2>
{% include "controlpanel/_club_health_table.html" %}
</div>
</div>
@@ -219,22 +168,22 @@
data: {
labels: data.signups.map((point) => point.month),
datasets: [
{ label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5") },
{ label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9") },
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: "bottom", labels: { color: ink } } },
plugins: {legend: {position: "bottom", labels: {color: ink}}},
scales: {
x: { stacked: true, ticks: { color: ink }, grid: { color: grid } },
y: { stacked: true, beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } },
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
},
},
});
return [signups, build("revenue-chart", "Dues", data.dues, css("--color-accent", "#0ea5e9"), "bar", true)];
return [signups]; // build("revenue-chart", "Dues", data.dues, css("--color-accent", "#0ea5e9"), "bar", true)];
};
let charts = render();

View File

@@ -751,8 +751,8 @@ class DashboardMetricsTests(ControlPanelTestBase):
self.assertContains(response, "No current season")
self.assertContains(response, "MFA pending")
self.assertContains(response, "Payment pending")
self.assertContains(response, 'id="signups-chart"')
self.assertContains(response, 'id="revenue-chart"')
self.assertContains(response, "js/chart.js")
self.assertIn("signups", response.context["charts"])
@@ -1014,18 +1014,17 @@ class ClubHealthTableTests(TestCase):
response = self.client.get(reverse("controlpanel:dashboard"))
self.assertContains(response, "Owed")
self.assertContains(response, "Upcoming")
self.assertContains(response, "Unpaid")
# Health, not vanity: Plan and Dues each name something to act on, next to the counts.
for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"):
self.assertContains(response, f">{column}</th>")
class ClubListHealthTests(ControlPanelTestBase):
def test_the_list_shows_the_same_health_columns_as_the_dashboard(self):
response = self.client.get(reverse("controlpanel:club_list"))
self.assertContains(response, "Owed")
self.assertContains(response, "Upcoming")
self.assertContains(response, "Unpaid")
for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"):
self.assertContains(response, f">{column}</th>")
self.assertTemplateUsed(response, "controlpanel/_club_health_table.html")
def test_an_archived_club_is_badged_archived_rather_than_dormant(self):
@@ -1097,6 +1096,24 @@ class PlatformDuesMetricTests(TestCase):
self.assertEqual(platform_attention()["clubs_unbilled"], 0)
def test_renewals_pending_counts_clubs_about_to_lapse(self):
# ~0 in normal running; a number here means the renewal cron has stopped.
self.assertEqual(platform_attention()["renewals_pending"], 0)
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=350)) # ends in 15 days
self.assertEqual(platform_attention()["renewals_pending"], 1)
def test_the_dashboard_surfaces_pending_renewals(self):
# The whole point of the KPI: a club about to go free is visible, though nothing is
# owed yet, so no other figure on the page would show it.
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=350))
staff = User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True)
enrol_mfa(staff)
self.client.force_login(staff)
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "awaiting renewal")
def test_platform_dues_and_club_shop_money_are_different_charts(self):
subscribe(self.club, self.tier)
record_payment(self.club.dues.first(), Decimal("500.00"))

File diff suppressed because one or more lines are too long