Compare commits

...

7 Commits

Author SHA1 Message Date
9bc5377cc5 Drop the sidebar's "Platform" title, add top margin
Cosmetic: the section label was redundant with the panel it lives in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:58:24 +02:00
7c874aa87b Stack the paid-until date under the paid badge
The "until <date>" cover-end now sits on its own row beneath the paid badge,
right-aligned, rather than to its left on one line. Only the paid-with-plan branch
changed; overdue and n/a are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:48:20 +02:00
5283262e6b Show a paid club's cover end date in the health table
For a club on a plan whose dues are settled, the Dues cell now reads "until
<date> · paid" — the end of the current paid period, which is the day the grace
period would start if nothing renews. It is exactly the "when does this lapse?"
question the paid badge alone could not answer.

Driven by a new paid_until annotation: the furthest-out PAID period end, null when
the club owes or was never billed (so a fully-paid free tier shows just "paid",
and an owing club shows the amount, unchanged). It rides the SAME single query —
the assertNumQueries(1) test still holds — and the date is whitespace-nowrap so it
does not wrap in the narrow cell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:41:20 +02:00
65a2f741f6 Format: collapse a chained queryset to one line
ruff format, no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:35:19 +02:00
975426a17f 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>
2026-07-15 08:34:49 +02:00
9a616c20e4 Auto-renew subscriptions before they lapse
Answers "does a plan renew itself?": until now, no — and that was a silent revenue
leak, not merely a missing convenience. A club whose period ended with its last due
PAID owes nothing, so dues_overdue() is empty, so archive_overdue_clubs never fires.
The club kept using the platform for free and no dashboard number went red, because
nothing was ever billed. The safety net only caught clubs you remembered to invoice.

`renew_subscriptions` (cron) issues the next period 30 days before the current one
ends, so the invoice lands before the period lapses and grace only matters for
genuine non-payers. It is idempotent by construction: a just-renewed club has a
latest period a year out, past the horizon, so a second run is a no-op.

It ACTS by default and previews with --dry-run — the opposite asymmetry to
archiving, and deliberately so. Archiving switches off a customer, so not-acting is
safe there; here, not-acting is the expensive failure, because an unbilled club is
also an unchased one. An unpriced tier fails that one club loudly (non-zero exit, so
cron mails you) without stopping the rest.

Opt-out per club via Subscription.auto_renew, mirroring auto_archive: off means you
invoice that club by hand. The dashboard gains a "renewals pending" count that
should sit at ~0 — a number here means cron has died and a club is about to go free,
which no other metric would reveal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 07:26:41 +02:00
2d43b0b903 Make the control panel a real app shell with a fixed sidebar
The navbar and the menu stay put; only the content scrolls.

That is a property of the layout, not a `sticky` class: the body is a flex column
pinned to the viewport with overflow hidden, and <main> is the single scrolling
region. The sidebar is a SIBLING of <main>, not inside it, so it sits outside that
region and cannot drift by construction. A sticky sidebar in a scrollable body
still slides on a long page, which is the failure this avoids.

The tabs become a daisyUI menu with an active indicator (`menu-active`) and the
hover/focus states `.menu` already provides. Sidebar below `lg` would be a menu
nobody can reach, so the same items render as a horizontal menu there -- from ONE
partial, because two copies of a link list is how a new section ends up reachable on
a desktop and invisible on a phone.

Auth pages leave the menu block empty and <main> simply takes the full width.

Verified in a browser: content scrolled 800px, sidebar and navbar moved 0px, and
the body is not scrollable at all. Below `lg` the sidebar hides and all five links
are still there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:06:31 +02:00
17 changed files with 555 additions and 5223 deletions

View File

@@ -25,8 +25,8 @@ class TierPriceAdmin(admin.ModelAdmin):
@admin.register(Subscription) @admin.register(Subscription)
class SubscriptionAdmin(admin.ModelAdmin): class SubscriptionAdmin(admin.ModelAdmin):
list_display = ["club", "tier", "auto_archive"] list_display = ["club", "tier", "auto_renew", "auto_archive"]
list_filter = ["tier", "auto_archive"] list_filter = ["tier", "auto_renew", "auto_archive"]
search_fields = ["club__name"] search_fields = ["club__name"]

View File

@@ -0,0 +1,57 @@
"""Issue the next billing period for clubs whose current one is running out.
Unlike archive_overdue_clubs, this ACTS by default and only previews with --dry-run. The
asymmetry is deliberate and runs the other way: archiving switches off a paying customer, so
not acting is the safe failure. Here, not acting means a club keeps using the platform for
free — and because nothing is owed, no dashboard number goes red and the archive job never
fires either. A missed renewal is silent, and silence is the expensive failure.
"""
from django.core.management.base import CommandError
from billing.models import RENEWAL_LEAD_DAYS
from billing.services import BillingError
from billing.services.dues import renew, subscriptions_due_for_renewal
from features.commands import MaintenanceAwareCommand
class Command(MaintenanceAwareCommand):
help = "Open the next billing period for clubs whose current period ends soon."
def add_arguments(self, parser):
parser.add_argument("--dry-run", action="store_true", help="Report what would be issued, and issue nothing.")
parser.add_argument("--lead-days", type=int, default=RENEWAL_LEAD_DAYS, help=f"Issue this many days before the period ends (default {RENEWAL_LEAD_DAYS}).")
def handle(self, *args, **options):
due_for_renewal = subscriptions_due_for_renewal(lead_days=options["lead_days"])
if not due_for_renewal:
self.stdout.write(self.style.SUCCESS("Nothing to renew."))
return
failures = []
for subscription in due_for_renewal:
club = subscription.club
if options["dry_run"]:
self.stdout.write(f"would renew {club}{subscription.tier}, current period ends {subscription.latest_period_end or 'never opened'}")
continue
try:
due = renew(subscription)
except BillingError as error:
# One unpriced tier must not stop every other club from being billed.
failures.append(f"{club}: {error}")
self.stdout.write(self.style.ERROR(f"{club}{error}"))
continue
self.stdout.write(self.style.SUCCESS(f"{club}{due.period_start} to {due.period_end}, {due.amount} ({due.invoice.number})"))
if options["dry_run"]:
self.stdout.write(self.style.WARNING(f"\nDry run: {len(due_for_renewal)} club(s) would be renewed."))
return
if failures:
# Non-zero, so cron mails you: a club that could not be billed is revenue quietly
# not being collected.
raise CommandError(f"{len(failures)} club(s) could not be renewed:\n " + "\n ".join(failures))

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-07-14 22:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='subscription',
name='auto_renew',
field=models.BooleanField(default=True, help_text='Issue the next period automatically before this one ends. Off means you invoice this club by hand.', verbose_name='auto renew'),
),
]

View File

@@ -22,6 +22,11 @@ ZERO = Decimal("0.00")
#: A club stays live for six weeks past the end of an unpaid period before it is archived. #: A club stays live for six weeks past the end of an unpaid period before it is archived.
GRACE_DAYS = 45 GRACE_DAYS = 45
#: The next period is issued this long before the current one ends, so the invoice reaches the
#: club — and can be paid — before the old period lapses. Grace then only matters for genuine
#: non-payers, rather than for everyone who takes a fortnight to pay a bank transfer.
RENEWAL_LEAD_DAYS = 30
def add_one_year(day: date) -> date: def add_one_year(day: date) -> date:
"""The day one year on. 29 February has no counterpart in a common year, so it falls """The day one year on. 29 February has no counterpart in a common year, so it falls
@@ -93,6 +98,7 @@ class Subscription(UUIDModel):
club = models.OneToOneField("club.Club", on_delete=models.CASCADE, related_name="subscription", verbose_name=_("club")) club = models.OneToOneField("club.Club", on_delete=models.CASCADE, related_name="subscription", verbose_name=_("club"))
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("tier")) tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("tier"))
auto_renew = models.BooleanField(_("auto renew"), default=True, help_text=_("Issue the next period automatically before this one ends. Off means you invoice this club by hand."))
auto_archive = models.BooleanField(_("auto archive"), default=True, help_text=_("Archive this club when a period goes unpaid past its grace period.")) auto_archive = models.BooleanField(_("auto archive"), default=True, help_text=_("Archive this club when a period goes unpaid past its grace period."))
notes = models.TextField(_("notes"), blank=True) notes = models.TextField(_("notes"), blank=True)

View File

@@ -6,17 +6,17 @@ from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from django.db import transaction from django.db import transaction
from django.db.models import Sum from django.db.models import DateField, OuterRef, Subquery, Sum
from django.utils import timezone from django.utils import timezone
from billing.models import ZERO, Due, DuePayment, Subscription, Tier from billing.models import RENEWAL_LEAD_DAYS, ZERO, Due, DuePayment, Subscription, Tier
from billing.services import BillingError from billing.services import BillingError
from billing.services.invoices import issue_invoice from billing.services.invoices import issue_invoice
def subscribe(club, tier: Tier, *, start: date | None = None, auto_archive: bool = True) -> Subscription: def subscribe(club, tier: Tier, *, start: date | None = None, auto_archive: bool = True, auto_renew: bool = True) -> Subscription:
"""Put a club on a tier and open its first period.""" """Put a club on a tier and open its first period."""
subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"tier": tier, "auto_archive": auto_archive}) subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"tier": tier, "auto_archive": auto_archive, "auto_renew": auto_renew})
open_period(club, start=start) open_period(club, start=start)
return subscription return subscription
@@ -151,3 +151,31 @@ def reactivate(club, *, start: date | None = None) -> Due:
club.restore() club.restore()
return open_period(club, start=start) return open_period(club, start=start)
def subscriptions_due_for_renewal(today: date | None = None, lead_days: int = RENEWAL_LEAD_DAYS):
"""Clubs whose next period should be issued now.
Idempotent by construction: a club that has just been renewed has a latest period ending a
year out, which is past the horizon, so it cannot be picked up twice. Running the job twice
a day is harmless.
A subscription with no period at all (its only due was cancelled) counts too — a club on a
plan and billed for nothing is the leak this whole job exists to close.
"""
today = today or timezone.localdate()
horizon = today + timedelta(days=lead_days)
latest_period_end = Subquery(
Due.objects.filter(club=OuterRef("club")).exclude(status=Due.Status.CANCELLED).order_by("-period_end").values("period_end")[:1],
output_field=DateField(),
)
subscriptions = Subscription.objects.filter(auto_renew=True, club__archived_at__isnull=True).select_related("club", "tier").annotate(latest_period_end=latest_period_end).order_by("club__name")
return [subscription for subscription in subscriptions if subscription.latest_period_end is None or subscription.latest_period_end <= horizon]
def renew(subscription: Subscription) -> Due:
"""Open the club's next period, continuing from the last one."""
return open_period(subscription.club)

View File

@@ -5,6 +5,7 @@ from io import StringIO
from unittest import mock from unittest import mock
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
@@ -12,7 +13,7 @@ from club.models import Club
from .models import GRACE_DAYS, Due, Invoice, Subscription, Tier, TierPrice, add_one_year from .models import GRACE_DAYS, Due, Invoice, Subscription, Tier, TierPrice, add_one_year
from .services import BillingError from .services import BillingError
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, subscribe, waive from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, renew, subscribe, subscriptions_due_for_renewal, waive
from .services.invoices import invoice_pdf, issue_invoice, render_pdf from .services.invoices import invoice_pdf, issue_invoice, render_pdf
@@ -335,3 +336,155 @@ class ModelStringTests(BillingTestBase):
self.assertIn("10.00", str(payment)) self.assertIn("10.00", str(payment))
self.assertIn("INV-", str(due.invoice)) self.assertIn("INV-", str(due.invoice))
self.assertIn("Standard", str(subscribe(Club.objects.create(name="PSV"), self.tier))) self.assertIn("Standard", str(subscribe(Club.objects.create(name="PSV"), self.tier)))
class RenewalTests(BillingTestBase):
"""The leak this closes: a club whose period lapses with its last due PAID owes nothing,
so dues_overdue() is empty, so archive_overdue_clubs never fires — and the club keeps
using the platform for free while every number on the dashboard stays green."""
def ending_in(self, days, **kwargs):
"""A club whose current period ends `days` from now."""
club = Club.objects.create(name=f"Club {days}")
subscribe(club, self.tier, start=self.today - datetime.timedelta(days=365 - days), **kwargs)
return club
def test_a_club_nearing_its_end_date_is_picked_up(self):
club = self.ending_in(20)
due = [s.club for s in subscriptions_due_for_renewal()]
self.assertIn(club, due)
def test_a_club_with_a_period_beyond_the_horizon_is_left_alone(self):
club = self.ending_in(200)
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_renewing_continues_from_the_last_period(self):
club = self.ending_in(20)
first = club.dues.first()
renew(club.subscription)
latest = club.dues.order_by("-period_start").first()
self.assertEqual(latest.period_start, first.period_end + datetime.timedelta(days=1))
self.assertEqual(club.dues.count(), 2)
def test_running_twice_does_not_bill_twice(self):
# Idempotent by construction: once renewed, the club's latest period ends a year out,
# which is past the horizon.
club = self.ending_in(20)
call_command("renew_subscriptions", stdout=StringIO())
call_command("renew_subscriptions", stdout=StringIO())
self.assertEqual(club.dues.count(), 2)
def test_a_club_that_opted_out_is_not_renewed(self):
club = self.ending_in(20, auto_renew=False)
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_an_archived_club_is_not_renewed(self):
# Reactivation is the way back, and it opens a period of its own.
club = self.ending_in(20)
club.archive()
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_the_new_period_is_billed_at_the_price_in_force_then(self):
club = self.ending_in(20)
TierPrice.objects.create(tier=self.tier, active_from=self.today, amount=Decimal("900.00"))
due = renew(club.subscription)
self.assertEqual(due.amount, Decimal("900.00")) # the new rate
self.assertEqual(club.dues.order_by("period_start").first().amount, Decimal("500.00")) # the old one, untouched
def test_the_new_period_is_invoiced(self):
club = self.ending_in(20)
due = renew(club.subscription)
self.assertTrue(due.invoice.number.startswith("INV-"))
def test_a_dry_run_issues_nothing(self):
club = self.ending_in(20)
out = StringIO()
call_command("renew_subscriptions", "--dry-run", stdout=out)
self.assertEqual(club.dues.count(), 1)
self.assertIn("would renew", out.getvalue())
def test_the_command_issues_by_default(self):
# The opposite asymmetry to archiving: NOT acting is the expensive failure here,
# because a club that is never billed is never chased either.
club = self.ending_in(20)
call_command("renew_subscriptions", stdout=StringIO())
self.assertEqual(club.dues.count(), 2)
def test_an_unpriced_tier_fails_loudly_without_stopping_the_others(self):
priced = self.ending_in(20)
broken = Club.objects.create(name="Unpriced FC")
subscribe(broken, self.tier, start=self.today - datetime.timedelta(days=350))
# Its next period starts beyond the last price... by removing every price, it cannot bill.
TierPrice.objects.all().delete()
cheap = Tier.objects.create(name="Cheap")
TierPrice.objects.create(tier=cheap, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("100.00"))
priced.subscription.tier = cheap
priced.subscription.save()
with self.assertRaises(CommandError):
call_command("renew_subscriptions", stdout=StringIO(), stderr=StringIO())
# ...and the club that COULD be billed still was.
self.assertEqual(priced.dues.count(), 2)
def test_a_subscription_with_no_period_at_all_is_renewed(self):
club = Club.objects.create(name="Orphan FC")
Subscription.objects.create(club=club, tier=self.tier)
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_it_says_so_when_there_is_nothing_to_renew(self):
self.assertIn("Nothing to renew", self.run_renewal())
def run_renewal(self, *args):
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

@@ -82,7 +82,7 @@ class SubscriptionForm(forms.ModelForm):
class Meta: class Meta:
model = Subscription model = Subscription
fields = ["tier", "auto_archive", "notes"] fields = ["tier", "auto_renew", "auto_archive", "notes"]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@@ -18,7 +18,7 @@ from waffle import get_waffle_flag_model
from authentication.middleware import ELEVATED_ROLES from authentication.middleware import ELEVATED_ROLES
from billing.models import Due, DuePayment, Subscription from billing.models import Due, DuePayment, Subscription
from billing.services.dues import dues_in_grace, dues_overdue from billing.services.dues import dues_in_grace, dues_overdue, subscriptions_due_for_renewal
from club.models import Club, ClubMembership, ClubRole, Season from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Attendance, Event from events.models import Attendance, Event
from members.models import Member from members.models import Member
@@ -84,6 +84,9 @@ def clubs_with_health(queryset=None, today=None, now=None):
dues_owed=_subquery(Due.objects.filter(status__in=Due.OWING), Sum(F("amount") - F("amount_paid")), DecimalField(max_digits=10, decimal_places=2)), dues_owed=_subquery(Due.objects.filter(status__in=Due.OWING), Sum(F("amount") - F("amount_paid")), DecimalField(max_digits=10, decimal_places=2)),
dues_grace_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("grace_until").values("grace_until")[:1]), dues_grace_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("grace_until").values("grace_until")[:1]),
dues_period_end=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("period_end").values("period_end")[:1]), dues_period_end=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("period_end").values("period_end")[:1]),
# How far a fully-paid club is covered: the furthest-out PAID period end — the day
# grace would start if nothing is renewed. Null when the club owes, or was never billed.
paid_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status=Due.Status.PAID).order_by("-period_end").values("period_end")[:1]),
) )
.annotate(teams_without_coach=F("team_count") - F("teams_managed")) .annotate(teams_without_coach=F("team_count") - F("teams_managed"))
.order_by("name") .order_by("name")
@@ -141,7 +144,7 @@ def onboarding_funnel():
return [ return [
{"label": "Clubs", "count": total, "icon": "building-2"}, {"label": "Clubs", "count": total, "icon": "building-2"},
{"label": "With members", "count": sum(1 for club in clubs if club.member_count), "icon": "users"}, {"label": "With members", "count": sum(1 for club in clubs if club.member_count), "icon": "users"},
{"label": "With a team", "count": sum(1 for club in clubs if club.team_count), "icon": "shield"}, {"label": "With a team", "count": sum(1 for club in clubs if club.team_count), "icon": "trophy"},
{"label": "With events", "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"}, {"label": "With events", "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"},
] ]
@@ -172,6 +175,10 @@ def platform_attention():
"dues_in_grace": dues_in_grace().count(), "dues_in_grace": dues_in_grace().count(),
"dues_overdue": dues_overdue().count(), "dues_overdue": dues_overdue().count(),
"clubs_unbilled": Club.objects.active().filter(subscription__isnull=True).count(), "clubs_unbilled": Club.objects.active().filter(subscription__isnull=True).count(),
# Normally ~0: the renewal job keeps it there. A number that sits here means cron is
# dead, and a club is about to use the platform for free — silently, because nothing is
# owed, so no other number on this page would go red.
"renewals_pending": len(subscriptions_due_for_renewal()),
} }

View File

@@ -13,65 +13,104 @@
<thead> <thead>
<tr> <tr>
<th>Club</th> <th>Club</th>
<th></th>
<th class="text-right">Members</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">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> </tr>
</thead> </thead>
<tbody> <tbody>
{% for club in clubs %} {% for club in clubs %}
<tr> <tr>
<td> <td>
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a> <div class="flex flex-row items-center gap-4">
<div class="mt-1 flex flex-wrap items-center gap-1"> <div>
<span class="text-xs opacity-60">{{ club.slug }}</span> {% if club.logo %}
<img class="h-12 w-12 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
{% else %}
<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 %} {% if club.is_archived %}
{% comment %} <span class="badge badge-warning">{% lucide "archive" size=14 %} archived</span>
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>
{% else %} {% 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.has_season %}
{% if not club.upcoming_events %}<span class="badge badge-ghost badge-xs gap-1">{% lucide "moon-star" size=10 %} Dormant</span>{% endif %} <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 %} {% endif %}
</div> </div>
</td> </td>
<td class="text-right tabular-nums">{{ club.active_members }}</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>
{% if club.tier_name %}
<span class="text-sm">{{ club.tier_name }}</span>
{% else %}
<span class="badge badge-warning badge-xs">Not billed</span>
{% endif %}
</td>
<td class="text-right tabular-nums"> <td class="text-right tabular-nums">
{% if not club.dues_owed %} <div class="flex flex-row gap-2 items-center justify-end">
{% if club.tier_name %}<span class="badge badge-success badge-xs">Paid</span>{% endif %} {% if not club.admin_count %}
{% else %} <span class="text-error">{% lucide "triangle-alert" size=16 %}</span>
<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>
{% elif club.dues_period_end < today %}
<span class="badge badge-warning badge-xs">Grace</span>
{% endif %} {% endif %}
{% endif %} <span class="{% if not club.admin_count %}font-bold text-error{% endif %}">{{ club.admin_count }}</span>
</td> </div>
<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> </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 tabular-nums">{{ club.upcoming_events }}</td>
<td class="text-right tabular-nums {% if not club.admin_count %}text-error{% endif %}">{{ club.admin_count }}</td>
<td class="text-right">
{% if club.tier_name %}
<span class="badge badge-accent">{{ club.tier_name|lower }}</span>
{% else %}
<span class="badge badge-ghost badge-outline">n/a</span>
{% endif %}
</td>
<td class="text-right">
<div class="flex flex-row gap-2 items-center justify-end">
{% if not club.dues_owed %}
{% if club.tier_name %}
{% comment %} paid_until is the current period's end — the day grace would start if nothing renews. On its own row under the badge. {% endcomment %}
<div class="flex flex-col items-end gap-1">
<span class="badge badge-success">paid</span>
{% if club.paid_until %}
<span class="whitespace-nowrap text-xs opacity-60">until {{ club.paid_until|date:"j M Y" }}</span>
{% endif %}
</div>
{% 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">overdue</span>
{% elif club.dues_period_end < today %}
<span class="badge badge-warning">grace</span>
{% endif %}
{% endif %}
</div>
</td>
<td>
<a class="btn btn-sm gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
</td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>

View File

@@ -0,0 +1,37 @@
{% load lucide %}
{% comment %}
The panel's navigation, in one place: the sidebar renders it on a wide screen and the
collapsed menu renders it on a narrow one. Two copies of a link list is how a new section
ends up reachable on a desktop and invisible on a phone.
`menu-active` is daisyUI 5's active state; hover and focus come with `.menu` itself.
{% endcomment %}
<li>
<a class="{% if nav == 'dashboard' %}menu-active{% endif %}" href="{% url 'controlpanel:dashboard' %}">
{% lucide "layout-dashboard" size=16 %} Dashboard
</a>
</li>
<li>
<a class="{% if nav == 'clubs' %}menu-active{% endif %}" href="{% url 'controlpanel:club_list' %}">
{% lucide "building-2" size=16 %} Clubs
</a>
</li>
<li>
<a class="{% if nav == 'billing' %}menu-active{% endif %}" href="{% url 'controlpanel:billing' %}">
{% lucide "receipt-euro" size=16 %} Billing
</a>
</li>
<li>
<a class="{% if nav == 'features' %}menu-active{% endif %}" href="{% url 'controlpanel:features' %}">
{% lucide "toggle-right" size=16 %} Features
</a>
</li>
{% if user.is_superuser %}
{# Superusers only, exactly as the view is gated: a link staff cannot follow is a lie. #}
<li>
<a class="{% if nav == 'admins' %}menu-active{% endif %}" href="{% url 'controlpanel:admins' %}">
{% lucide "user-cog" size=16 %} Admins
</a>
</li>
{% endif %}

View File

@@ -5,15 +5,27 @@
{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief {% block panel_title %}Control panel{% endblock panel_title %} · RosterChief
{% endblock title %} {% endblock title %}
{% block menu %}
{% comment %}
Outside <main>, so it never scrolls with the content. Its own overflow-y-auto is for
the day the menu itself grows taller than the screen.
{% endcomment %}
<aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-base-300 bg-base-100 lg:block">
<ul class="menu w-full gap-1 p-3 mt-4">
{% include "controlpanel/_nav_items.html" %}
</ul>
</aside>
{% endblock menu %}
{% block main %} {% block main %}
{% if maintenance_on %} {% if maintenance_on %}
<div class="alert alert-error mb-6"> <div class="alert alert-error mb-6">
{% lucide "wrench" size=20 %} {% lucide "wrench" size=20 %}
<span> <span>
<strong>The platform is closed for maintenance.</strong> <strong>The platform is currently closed for maintenance.</strong>
Clubs see a maintenance page and the scheduled jobs are standing down. Clubs see a maintenance page and the scheduled jobs are standing down.
</span> </span>
<a class="btn btn-sm" href="{% url 'controlpanel:features' %}">Reopen</a> <a class="btn btn-sm gap-2 btn-error btn-soft" href="{% url 'controlpanel:features' %}">{% lucide "unlock" size=16 %} Reopen platform</a>
</div> </div>
{% endif %} {% endif %}
<div class="mb-6 flex flex-wrap items-center justify-between gap-3"> <div class="mb-6 flex flex-wrap items-center justify-between gap-3">
@@ -28,15 +40,11 @@
{% block actions %}{% endblock actions %} {% block actions %}{% endblock actions %}
</div> </div>
</div> </div>
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit"> {# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #}
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab gap-2 {% if nav == 'dashboard' %}tab-active{% endif %}">{% lucide "layout-dashboard" size=16 %} Dashboard</a> <ul class="menu menu-horizontal mb-6 w-full gap-1 overflow-x-auto rounded-box bg-base-100 lg:hidden">
<a role="tab" href="{% url 'controlpanel:club_list' %}" class="tab gap-2 {% if nav == 'clubs' %}tab-active{% endif %}">{% lucide "building-2" size=16 %} Clubs</a> {% include "controlpanel/_nav_items.html" %}
<a role="tab" href="{% url 'controlpanel:billing' %}" class="tab gap-2 {% if nav == 'billing' %}tab-active{% endif %}">{% lucide "receipt-euro" size=16 %} Billing</a> </ul>
<a role="tab" href="{% url 'controlpanel:features' %}" class="tab gap-2 {% if nav == 'features' %}tab-active{% endif %}">{% lucide "toggle-right" size=16 %} Features</a>
{% if user.is_superuser %}
<a role="tab" href="{% url 'controlpanel:admins' %}" class="tab gap-2 {% if nav == 'admins' %}tab-active{% endif %}">{% lucide "user-cog" size=16 %} Admins</a>
{% endif %}
</div>
{% block panel %}{% endblock panel %} {% block panel %}{% endblock panel %}
{% endblock main %} {% endblock main %}

View File

@@ -234,6 +234,11 @@
{% else %} {% else %}
<p class="text-sm opacity-70"> <p class="text-sm opacity-70">
On <strong>{{ subscription.tier.name }}</strong>. On <strong>{{ subscription.tier.name }}</strong>.
{% if subscription.auto_renew %}
Renews automatically 30 days before the period ends.
{% else %}
<span class="badge badge-warning badge-sm">Auto-renew off</span> — you must open each period by hand, or this club uses the platform for free.
{% endif %}
{% if subscription.auto_archive %} {% if subscription.auto_archive %}
Archived automatically when a period goes unpaid past its grace period. Archived automatically when a period goes unpaid past its grace period.
{% else %} {% else %}

View File

@@ -2,52 +2,70 @@
{% load static lucide %} {% load static lucide %}
{% block heading %}RosterChief Platform Dashboard{% endblock heading %} {% 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 %} {% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} Create new club</a> <a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} Create new club</a>
{% endblock actions %} {% endblock actions %}
{% block panel %} {% block panel %}
{% comment %} <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
Needs attention first: these are the numbers that are supposed to be zero. A club with <div class="card bg-base-100 shadow border-l-4 border-info">
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="card-body p-4"> <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="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "building-2" size=16 %} Clubs</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.clubs_without_season }}</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 class="text-xs opacity-60">Clubs that cannot take signups</div>
</div> </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="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "moon-star" size=16 %} Dormant</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-3xl font-bold tabular-nums">{{ attention.dormant_clubs }}</div> <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.dormant_clubs }}</div>
<div class="text-xs opacity-60">Nothing scheduled in 30 days</div> <div class="text-xs opacity-60">No events scheduled next 30 days</div>
</div> </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="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="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "shield-alert" size=16 %} MFA pending</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.admins_pending_mfa }}</div> <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.admins_pending_mfa }}</div>
<div class="text-xs opacity-60">Admins locked out until they enrol</div> <div class="text-xs opacity-60">Admins without MFA configured</div>
</div> </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"> <div class="card-body p-4">
{% comment %} <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "receipt-euro" size=16 %} Payment pending</div>
What the CLUBS owe US. Not to be confused with the club-shop money below, <div class="text-4xl font-bold tabular-nums font-mono">&euro;{{ attention.dues_owed|floatformat:2 }}</div>
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="text-xs opacity-60"> <div class="text-xs opacity-60">
{{ attention.dues_in_grace }} in grace · {{ attention.dues_in_grace }} in grace ·
<span class="{% if attention.dues_overdue %}font-semibold text-error{% endif %}">{{ attention.dues_overdue }} overdue</span> <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> </div>
</div> </div>
@@ -63,22 +81,11 @@
</div> </div>
</div> </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 bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-base">{% lucide "milestone" size=18 %} Onboarding</h2> <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"> <div class="mt-2 space-y-3">
{% for step in funnel %} {% for step in funnel %}
<div> <div>
@@ -86,76 +93,18 @@
<span class="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span> <span class="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span>
<span class="font-semibold tabular-nums">{{ step.count }}</span> <span class="font-semibold tabular-nums">{{ step.count }}</span>
</div> </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> </div>
{% endfor %} {% endfor %}
</div> </div>
</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>
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <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" %} {% include "controlpanel/_club_health_table.html" %}
</div> </div>
</div> </div>
@@ -219,22 +168,22 @@
data: { data: {
labels: data.signups.map((point) => point.month), labels: data.signups.map((point) => point.month),
datasets: [ datasets: [
{ label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5") }, {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: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
], ],
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { legend: { position: "bottom", labels: { color: ink } } }, plugins: {legend: {position: "bottom", labels: {color: ink}}},
scales: { scales: {
x: { stacked: true, ticks: { color: ink }, 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 } }, 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(); let charts = render();

View File

@@ -751,8 +751,8 @@ class DashboardMetricsTests(ControlPanelTestBase):
self.assertContains(response, "No current season") self.assertContains(response, "No current season")
self.assertContains(response, "MFA pending") self.assertContains(response, "MFA pending")
self.assertContains(response, "Payment pending")
self.assertContains(response, 'id="signups-chart"') self.assertContains(response, 'id="signups-chart"')
self.assertContains(response, 'id="revenue-chart"')
self.assertContains(response, "js/chart.js") self.assertContains(response, "js/chart.js")
self.assertIn("signups", response.context["charts"]) self.assertIn("signups", response.context["charts"])
@@ -1014,18 +1014,17 @@ class ClubHealthTableTests(TestCase):
response = self.client.get(reverse("controlpanel:dashboard")) response = self.client.get(reverse("controlpanel:dashboard"))
self.assertContains(response, "Owed") # Health, not vanity: Plan and Dues each name something to act on, next to the counts.
self.assertContains(response, "Upcoming") for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"):
self.assertContains(response, "Unpaid") self.assertContains(response, f">{column}</th>")
class ClubListHealthTests(ControlPanelTestBase): class ClubListHealthTests(ControlPanelTestBase):
def test_the_list_shows_the_same_health_columns_as_the_dashboard(self): def test_the_list_shows_the_same_health_columns_as_the_dashboard(self):
response = self.client.get(reverse("controlpanel:club_list")) response = self.client.get(reverse("controlpanel:club_list"))
self.assertContains(response, "Owed") for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"):
self.assertContains(response, "Upcoming") self.assertContains(response, f">{column}</th>")
self.assertContains(response, "Unpaid")
self.assertTemplateUsed(response, "controlpanel/_club_health_table.html") self.assertTemplateUsed(response, "controlpanel/_club_health_table.html")
def test_an_archived_club_is_badged_archived_rather_than_dormant(self): 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) 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): def test_platform_dues_and_club_shop_money_are_different_charts(self):
subscribe(self.club, self.tier) subscribe(self.club, self.tier)
record_payment(self.club.dues.first(), Decimal("500.00")) record_payment(self.club.dues.first(), Decimal("500.00"))
@@ -1114,6 +1131,21 @@ class PlatformDuesMetricTests(TestCase):
self.assertEqual(club.tier_name, "Standard") self.assertEqual(club.tier_name, "Standard")
self.assertEqual(club.dues_owed, Decimal("500.00")) self.assertEqual(club.dues_owed, Decimal("500.00"))
def test_a_fully_paid_club_shows_when_its_cover_ends(self):
# The end of the current paid period is the day grace would start if nothing renews.
subscribe(self.club, self.tier)
due = self.club.dues.first()
record_payment(due, due.amount)
club = clubs_with_health().get(pk=self.club.pk)
self.assertEqual(club.paid_until, due.period_end)
def test_a_club_that_owes_has_no_paid_until(self):
subscribe(self.club, self.tier) # unpaid
self.assertIsNone(clubs_with_health().get(pk=self.club.pk).paid_until)
def test_the_health_table_still_costs_one_query_with_billing_on_it(self): def test_the_health_table_still_costs_one_query_with_billing_on_it(self):
subscribe(self.club, self.tier) subscribe(self.club, self.tier)
subscribe(Club.objects.create(name="Feyenoord"), self.tier) subscribe(Club.objects.create(name="Feyenoord"), self.tier)

View File

@@ -414,7 +414,7 @@ class SubscribeClubView(PlatformStaffRequiredMixin, FormView):
subscription.save() subscription.save()
messages.success(self.request, f"{club} is now on {subscription.tier}. The current period keeps the amount it was billed at.") messages.success(self.request, f"{club} is now on {subscription.tier}. The current period keeps the amount it was billed at.")
else: else:
subscribe(club, form.cleaned_data["tier"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"]) subscribe(club, form.cleaned_data["tier"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"], auto_renew=form.cleaned_data["auto_renew"])
messages.success(self.request, f"{club} is on {form.cleaned_data['tier']}. Its first period is open.") messages.success(self.request, f"{club} is on {form.cleaned_data['tier']}. Its first period is open.")
except BillingError as error: except BillingError as error:
messages.error(self.request, str(error)) messages.error(self.request, str(error))

File diff suppressed because one or more lines are too long

View File

@@ -33,8 +33,14 @@
{% block extra %}{% endblock extra %} {% block extra %}{% endblock extra %}
</head> </head>
<body class="min-h-screen bg-base-200"> {% comment %}
<div class="navbar mb-4 border-b border-base-300 bg-base-100 px-6 shadow-sm"> An app shell: the viewport is the frame, and exactly one region scrolls. The body is a
flex column pinned to the screen height with overflow hidden, so the navbar and the
sidebar cannot be scrolled away — only <main> moves. Leave the body scrollable instead
and a "sticky" sidebar still drifts on a long page, which is the thing this is for.
{% endcomment %}
<body class="flex h-screen flex-col overflow-hidden bg-base-200">
<div class="navbar shrink-0 border-b border-base-300 bg-base-100 px-6 shadow-sm">
<div class="my-4 flex-1"> <div class="my-4 flex-1">
{% block brand %}{% endblock brand %} {% block brand %}{% endblock brand %}
</div> </div>
@@ -65,25 +71,36 @@
{% endif %} {% endif %}
</div> </div>
{% if messages %} <div class="flex flex-1 overflow-hidden">
<div class="mx-auto mt-4 w-full space-y-2 px-4"> {% comment %}
{% for message in messages %} The sidebar is a sibling of <main>, not inside it, so it sits outside the one
{% with alert=message|as_alert %} scrolling region and stays put by construction. Pages with no menu (the auth
<div class="alert alert-soft {{ alert.css }}" role="alert"> screens) leave the block empty and <main> simply takes the full width.
{% lucide alert.icon size=20 %} {% endcomment %}
<div> {% block menu %}{% endblock menu %}
<div class="font-bold">{{ alert.title }}</div>
<div class="text-sm">{{ alert.body }}</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% endif %}
<main class="mx-auto w-full py-4 px-8"> <main class="flex-1 overflow-y-auto">
{% block main %}{% endblock main %} <div class="mx-auto w-full px-8 py-6">
</main> {% if messages %}
<div class="mb-6 w-full space-y-2">
{% for message in messages %}
{% with alert=message|as_alert %}
<div class="alert alert-soft {{ alert.css }}" role="alert">
{% lucide alert.icon size=20 %}
<div>
<div class="font-bold">{{ alert.title }}</div>
<div class="text-sm">{{ alert.body }}</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% endif %}
{% block main %}{% endblock main %}
</div>
</main>
</div>
<script> <script>
// The button cycles light -> dark -> auto. "auto" removes the attribute and the // The button cycles light -> dark -> auto. "auto" removes the attribute and the