diff --git a/controlpanel/services/statistics.py b/controlpanel/services/statistics.py index d1c61c2..724eb3f 100644 --- a/controlpanel/services/statistics.py +++ b/controlpanel/services/statistics.py @@ -11,7 +11,7 @@ from decimal import Decimal from allauth.mfa.models import Authenticator from django.contrib.auth import get_user_model -from django.db.models import Count, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value +from django.db.models import Count, DateField, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value from django.db.models.functions import Coalesce, TruncMonth from django.utils import timezone from waffle import get_waffle_flag_model @@ -64,6 +64,8 @@ def clubs_with_health(queryset=None, today=None, now=None): clubs = Club.objects.active() if queryset is None else queryset in_season = Q(season__start_date__lte=today, season__end_date__gte=today) + # A period the club is covered for, most recent first — paid or waived, both settled. + _covered = Due.objects.filter(club=OuterRef("pk"), status__in=(Due.Status.PAID, Due.Status.WAIVED)).order_by("-period_end") managed_this_season = Q( staff_assignments__season__start_date__lte=today, staff_assignments__season__end_date__gte=today, @@ -84,9 +86,13 @@ 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_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]), - # 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]), + # How far the club is covered: the furthest-out period that is settled. PAID and + # WAIVED both mean nothing is owed for that period, and its end is the day grace + # would start if nothing renews — so both count. `covered_status` is read from the + # same top row, so the table can badge "paid" vs "waived". Null when the club owes + # or was never billed. + covered_until=Subquery(_covered.values("period_end")[:1], output_field=DateField()), + covered_status=Subquery(_covered.values("status")[:1]), ) .annotate(teams_without_coach=F("team_count") - F("teams_managed")) .order_by("name") diff --git a/controlpanel/templates/controlpanel/_club_health_table.html b/controlpanel/templates/controlpanel/_club_health_table.html index 8a55308..afc0b08 100644 --- a/controlpanel/templates/controlpanel/_club_health_table.html +++ b/controlpanel/templates/controlpanel/_club_health_table.html @@ -87,11 +87,22 @@
{% 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 %} + {% comment %} + Not owing and on a plan. covered_until is the settled period's end — the day + grace would start if nothing renews — shown on its own row under the badge, + for a paid period AND a waived one (both cover the club, they just differ in + how). No covered period at all (only cancelled dues, say) shows a dash. + {% endcomment %}
- paid - {% if club.paid_until %} - until {{ club.paid_until|date:"j M Y" }} + {% if club.covered_status == "waived" %} + waived + {% elif club.covered_until %} + paid + {% else %} + + {% endif %} + {% if club.covered_until %} + until {{ club.covered_until|date:"j M Y" }} {% endif %}
{% else %} diff --git a/controlpanel/tests.py b/controlpanel/tests.py index 32749b7..e129590 100644 --- a/controlpanel/tests.py +++ b/controlpanel/tests.py @@ -17,7 +17,7 @@ from waffle import get_waffle_flag_model, get_waffle_switch_model from billing.models import GRACE_DAYS, Due, Tier, TierPrice from billing.services import BillingError -from billing.services.dues import record_payment, subscribe +from billing.services.dues import record_payment, subscribe, waive from club.models import Club, ClubMembership, ClubRole, Season from events.models import Attendance, Event from features.models import Maintenance @@ -1139,12 +1139,27 @@ class PlatformDuesMetricTests(TestCase): club = clubs_with_health().get(pk=self.club.pk) - self.assertEqual(club.paid_until, due.period_end) + self.assertEqual(club.covered_until, due.period_end) + self.assertEqual(club.covered_status, Due.Status.PAID) - def test_a_club_that_owes_has_no_paid_until(self): + def test_a_waived_period_also_shows_its_cover_end(self): + # Waived is settled too — the club is covered for that time, so its end date shows, + # badged "waived" rather than "paid". + subscribe(self.club, self.tier) + due = self.club.dues.first() + waive(due) + + club = clubs_with_health().get(pk=self.club.pk) + + self.assertEqual(club.covered_until, due.period_end) + self.assertEqual(club.covered_status, Due.Status.WAIVED) + + def test_a_club_that_owes_has_no_cover(self): subscribe(self.club, self.tier) # unpaid - self.assertIsNone(clubs_with_health().get(pk=self.club.pk).paid_until) + club = clubs_with_health().get(pk=self.club.pk) + self.assertIsNone(club.covered_until) + self.assertIsNone(club.covered_status) def test_the_health_table_still_costs_one_query_with_billing_on_it(self): subscribe(self.club, self.tier)