Show the cover-end date for waived periods too, not just paid

The "until <date>" only appeared for a PAID due, because the annotation filtered
status=PAID. A WAIVED period is settled just the same — the club is covered for
that time, and its end is still when grace would start — so it belongs there too.

The annotation is now `covered_until` (furthest-out period end where status is PAID
or WAIVED) plus `covered_status`, read from the same ordered row so the table can
badge "paid" vs "waived" and still show the date for both. Rides the same single
query — assertNumQueries(1) still holds.

Full matrix now: no plan -> n/a; paid -> "paid, until X"; waived -> "waived, until
X"; unpaid/partial -> the amount owed (no date); a paid-then-owing club shows the
amount, not the stale cover date. A subscribed club with no covering period at all
(only cancelled dues) shows a dash rather than a bare "paid".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 18:16:56 +02:00
parent 10b113f244
commit 19108407c6
3 changed files with 44 additions and 12 deletions

View File

@@ -11,7 +11,7 @@ from decimal import Decimal
from allauth.mfa.models import Authenticator from allauth.mfa.models import Authenticator
from django.contrib.auth import get_user_model 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.db.models.functions import Coalesce, TruncMonth
from django.utils import timezone from django.utils import timezone
from waffle import get_waffle_flag_model 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 clubs = Club.objects.active() if queryset is None else queryset
in_season = Q(season__start_date__lte=today, season__end_date__gte=today) 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( managed_this_season = Q(
staff_assignments__season__start_date__lte=today, staff_assignments__season__start_date__lte=today,
staff_assignments__season__end_date__gte=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_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 # How far the club is covered: the furthest-out period that is settled. PAID and
# grace would start if nothing is renewed. Null when the club owes, or was never billed. # WAIVED both mean nothing is owed for that period, and its end is the day grace
paid_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status=Due.Status.PAID).order_by("-period_end").values("period_end")[:1]), # 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")) .annotate(teams_without_coach=F("team_count") - F("teams_managed"))
.order_by("name") .order_by("name")

View File

@@ -87,11 +87,22 @@
<div class="flex flex-row gap-2 items-center justify-end"> <div class="flex flex-row gap-2 items-center justify-end">
{% if not club.dues_owed %} {% if not club.dues_owed %}
{% if club.tier_name %} {% 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 %}
<div class="flex flex-col items-end gap-1"> <div class="flex flex-col items-end gap-1">
{% if club.covered_status == "waived" %}
<span class="badge badge-ghost">waived</span>
{% elif club.covered_until %}
<span class="badge badge-success">paid</span> <span class="badge badge-success">paid</span>
{% if club.paid_until %} {% else %}
<span class="whitespace-nowrap text-xs opacity-60">until {{ club.paid_until|date:"j M Y" }}</span> <span class="badge badge-ghost badge-outline">&mdash;</span>
{% endif %}
{% if club.covered_until %}
<span class="whitespace-nowrap text-xs opacity-60">until {{ club.covered_until|date:"j M Y" }}</span>
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}

View File

@@ -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.models import GRACE_DAYS, Due, Tier, TierPrice
from billing.services import BillingError 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 club.models import Club, ClubMembership, ClubRole, Season
from events.models import Attendance, Event from events.models import Attendance, Event
from features.models import Maintenance from features.models import Maintenance
@@ -1139,12 +1139,27 @@ class PlatformDuesMetricTests(TestCase):
club = clubs_with_health().get(pk=self.club.pk) 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 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): def test_the_health_table_still_costs_one_query_with_billing_on_it(self):
subscribe(self.club, self.tier) subscribe(self.club, self.tier)