Add a Payments & dues section to the Me page, matching the design's M5 row

Home already had the M1 dues card; Me's own "Payments & dues" row was
explicitly stubbed out with nowhere to lead. Give it a real destination:
a Payments screen (open balances for everyone managed, reusing Home's
dues-card layout via a shared _dues_row.html partial) and a "N OPEN"
pill on the Me row itself, only shown once something is actually owed.
club.services.fees.open_dues_rows is factored out so Home and Payments
can never drift apart on what counts as "still open".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-21 21:48:50 +02:00
parent 4b5a4a81ec
commit 0ce1593af1
9 changed files with 282 additions and 38 deletions

View File

@@ -16,6 +16,23 @@ def remaining_balance(membership):
return max(membership.fee_amount - membership.amount_paid, Decimal("0.00")) return max(membership.fee_amount - membership.amount_paid, Decimal("0.00"))
def open_dues_rows(club, people, season):
"""Every season-dues row still owed by ``people`` in ``season`` -- shared by
mobile's Home dues card and its Payments & dues screen so the two never
drift out of sync on what counts as "still open". WAIVED memberships and
fully-paid balances are excluded."""
if season is None or not people:
return []
memberships = ClubMembership.objects.filter(club=club, member__in=people, season=season).exclude(fee_status=ClubMembership.FeeStatus.WAIVED).select_related("dues_invoice", "member")
rows = []
for membership in memberships:
balance = remaining_balance(membership)
if balance > 0:
rows.append({"membership": membership, "balance": balance, "invoice": getattr(membership, "dues_invoice", None)})
return rows
def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None): def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None):
"""Record money received against one membership's fee. Several payments may """Record money received against one membership's fee. Several payments may
land on one membership -- a family paying in two installments must not read as land on one membership -- a family paying in two installments must not read as

View File

@@ -39,7 +39,7 @@ from .services.access import (
teams_managed_by, teams_managed_by,
teams_staffed_by, teams_staffed_by,
) )
from .services.fees import mark_as_paid, record_payment, remaining_balance from .services.fees import mark_as_paid, open_dues_rows, record_payment, remaining_balance
from .services.invoicing import create_or_resend_invoice, invoice_pdf, invoices_due_for_reminder, recipient_for, resolve_document_address from .services.invoicing import create_or_resend_invoice, invoice_pdf, invoices_due_for_reminder, recipient_for, resolve_document_address
from .services.onboarding import ( from .services.onboarding import (
annotate_onboarding_status, annotate_onboarding_status,
@@ -1562,6 +1562,69 @@ class FeeServiceTests(TestCase):
self.assertEqual(payment.recorded_by, user) self.assertEqual(payment.recorded_by, user)
class OpenDuesRowsTests(TestCase):
"""club.services.fees.open_dues_rows -- the shared source behind mobile's
Home dues card and its Payments & dues screen (mobile/views.py's HomeView
and PaymentsView)."""
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
cls.season = make_season(cls.club)
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
def test_returns_nothing_without_a_season(self):
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
self.assertEqual(open_dues_rows(self.club, [self.member], None), [])
def test_returns_nothing_without_any_people(self):
self.assertEqual(open_dues_rows(self.club, [], self.season), [])
def test_a_membership_with_a_remaining_balance_is_included(self):
membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
rows = open_dues_rows(self.club, [self.member], self.season)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["membership"], membership)
self.assertEqual(rows[0]["balance"], Decimal("150.00"))
self.assertIsNone(rows[0]["invoice"])
def test_a_fully_paid_membership_is_excluded(self):
membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
record_payment(membership, amount=Decimal("150.00"))
self.assertEqual(open_dues_rows(self.club, [self.member], self.season), [])
def test_a_waived_membership_is_excluded_even_with_an_unpaid_balance(self):
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"), fee_status=ClubMembership.FeeStatus.WAIVED)
self.assertEqual(open_dues_rows(self.club, [self.member], self.season), [])
def test_a_membership_with_no_fee_priced_is_excluded(self):
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season)
self.assertEqual(open_dues_rows(self.club, [self.member], self.season), [])
def test_the_linked_invoice_is_included_when_one_exists(self):
membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
invoice = DuesInvoice.objects.create(club=self.club, membership=membership, amount=Decimal("150.00"), due_date=timezone.now().date(), sent_at=timezone.now())
rows = open_dues_rows(self.club, [self.member], self.season)
self.assertEqual(rows[0]["invoice"], invoice)
def test_only_includes_the_given_people(self):
other_member = Member.objects.create(first_name="Tom", last_name="Roe")
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, fee_amount=Decimal("150.00"))
rows = open_dues_rows(self.club, [self.member], self.season)
self.assertEqual([row["membership"].member for row in rows], [self.member])
class RecipientForTests(TestCase): class RecipientForTests(TestCase):
"""club.services.invoicing.recipient_for -- the member's own email, else the """club.services.invoicing.recipient_for -- the member's own email, else the
first parent/guardian who has one, else nobody reachable at all.""" first parent/guardian who has one, else nobody reachable at all."""

View File

@@ -0,0 +1,23 @@
{% load i18n %}
{% comment %}
One open season-dues card. Expects ``row`` ({membership, balance, invoice})
in scope -- included from both home.html's dues card and payments.html so
the two surfaces render identically (see club.services.fees.open_dues_rows,
their shared data source).
{% endcomment %}
<div class="m-card flex items-center gap-3 p-4">
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-danger-bg">
<span class="font-display text-lg font-extrabold text-club">&euro;</span>
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{% blocktrans with name=row.membership.member.first_name %}Season dues &mdash; {{ name }}{% endblocktrans %}</div>
<div class="text-xs text-muted">
{% if row.invoice %}
{% blocktrans with amount=row.balance|floatformat:2 due=row.invoice.due_date|date:"d M" %}&euro; {{ amount }} &middot; due {{ due }}{% endblocktrans %}
{% else %}
{% blocktrans with amount=row.balance|floatformat:2 %}&euro; {{ amount }}{% endblocktrans %}
{% endif %}
</div>
</div>
<span class="btn btn-dark shrink-0">{% trans "Pay" %}</span>
</div>

View File

@@ -112,22 +112,7 @@
{% if dues_rows %} {% if dues_rows %}
<div class="flex flex-col gap-2.5"> <div class="flex flex-col gap-2.5">
{% for row in dues_rows %} {% for row in dues_rows %}
<div class="m-card flex items-center gap-3 p-4"> {% include "mobile/_dues_row.html" %}
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-danger-bg">
<span class="font-display text-lg font-extrabold text-club">&euro;</span>
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{% blocktrans with name=row.membership.member.first_name %}Season dues &mdash; {{ name }}{% endblocktrans %}</div>
<div class="text-xs text-muted">
{% if row.invoice %}
{% blocktrans with amount=row.balance|floatformat:2 due=row.invoice.due_date|date:"d M" %}&euro; {{ amount }} &middot; due {{ due }}{% endblocktrans %}
{% else %}
{% blocktrans with amount=row.balance|floatformat:2 %}&euro; {{ amount }}{% endblocktrans %}
{% endif %}
</div>
</div>
<span class="btn btn-dark shrink-0">{% trans "Pay" %}</span>
</div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -5,10 +5,12 @@
M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me & M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me &
my people". See MeView's own docstring (mobile/views.py) for the judgment my people". See MeView's own docstring (mobile/views.py) for the judgment
calls: no license/eligibility field backing "licence OK", so each row's calls: no license/eligibility field backing "licence OK", so each row's
meta line is real roster data instead; "Household & contacts" and meta line is real roster data instead; "Household & contacts" has no
"Payments & dues" have no screen to lead to and are omitted, same for the screen to lead to and is omitted, same for the mockup's "Coach mode"
mockup's "Coach mode" promo (base.html's own precedent -- no Coach mode promo (base.html's own precedent -- no Coach mode screens exist yet, so
screens exist yet, so it's never rendered, not even as a dead/inert link). it's never rendered, not even as a dead/inert link). "Payments & dues"
does lead somewhere (mobile:payments) and carries its "N OPEN" pill only
once open_dues_count is actually > 0.
The avatar/name/subtitle row lives in header_extra -- merged into the The avatar/name/subtitle row lives in header_extra -- merged into the
shared navy app-header (base.html) rather than a separately-coloured shared navy app-header (base.html) rather than a separately-coloured
block of its own, matching the design canvas's own M5 markup. block of its own, matching the design canvas's own M5 markup.
@@ -70,6 +72,16 @@
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
</a> </a>
<div class="h-px bg-rule"></div> <div class="h-px bg-rule"></div>
<a class="flex items-center gap-3 p-3.5" href="{% url "mobile:payments" %}">
<span class="flex-1 text-[15px] font-semibold text-ink">{% trans "Payments & dues" %}</span>
{% if open_dues_count %}
<span class="pill pill-danger shrink-0">
{% blocktrans count counter=open_dues_count %}{{ counter }} OPEN{% plural %}{{ counter }} OPEN{% endblocktrans %}
</span>
{% endif %}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
</a>
<div class="h-px bg-rule"></div>
<a class="flex items-center gap-3 p-3.5" href="{% url "mobile:notifications" %}"> <a class="flex items-center gap-3 p-3.5" href="{% url "mobile:notifications" %}">
<span class="flex-1 text-[15px] font-semibold text-ink">{% trans "Notifications" %}</span> <span class="flex-1 text-[15px] font-semibold text-ink">{% trans "Notifications" %}</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>

View File

@@ -0,0 +1,32 @@
{% extends "mobile/base.html" %}
{% load i18n %}
{% comment %}
M5's "Payments & dues" row -- every open season-dues balance across every
managed person, one card per open row via mobile/_dues_row.html (the same
partial Home's dues card uses, so the two never drift apart visually).
White sticky back-header, same pattern as calendar_feed_settings.html --
not a tab of its own, active_tab stays "me" (PaymentsView's own docstring).
{% endcomment %}
{% block content %}
<div class="-mx-4 -mt-4 flex items-center gap-1 border-b border-line bg-white px-4 py-3">
<a class="-ml-2.5 flex h-11 w-11 shrink-0 items-center justify-center" href="{% url "mobile:me" %}" aria-label="{% trans "Back" %}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#0b1220" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>
</a>
<span class="min-w-0 flex-1 truncate font-display text-xl leading-none font-extrabold text-ink uppercase">{% trans "Payments & dues" %}</span>
</div>
{% if dues_rows %}
<div class="flex flex-col gap-2.5">
{% for row in dues_rows %}
{% include "mobile/_dues_row.html" %}
{% endfor %}
</div>
{% else %}
<div class="m-card p-6 text-center">
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "All settled up" %}</p>
<p class="mt-1 text-sm text-muted">{% trans "There's nothing open right now." %}</p>
</div>
{% endif %}
{% endblock content %}

View File

@@ -1238,8 +1238,9 @@ class MeViewTests(TestCase):
"""M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me """M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me
& my people". See MeView's own docstring for the judgment calls: no & my people". See MeView's own docstring for the judgment calls: no
licence/eligibility field backing "licence OK" (real roster data used licence/eligibility field backing "licence OK" (real roster data used
instead), "Household & contacts"/"Payments & dues"/"Coach mode" all instead), "Household & contacts"/"Coach mode" omitted since neither has
omitted since none has anywhere to lead in this build. anywhere to lead in this build; "Payments & dues" does (PaymentsView,
tested separately below).
""" """
@classmethod @classmethod
@@ -1333,6 +1334,101 @@ class MeViewTests(TestCase):
self.assertContains(response, "No one to show yet") self.assertContains(response, "No one to show yet")
self.assertNotContains(response, "Coach mode") self.assertNotContains(response, "Coach mode")
def test_payments_row_links_to_the_payments_screen(self):
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, reverse("mobile:payments"))
def test_open_dues_pill_is_hidden_with_nothing_owed(self):
self.client.force_login(self.user)
response = self._get()
self.assertNotContains(response, "OPEN")
def test_open_dues_pill_shows_the_open_count(self):
membership = ClubMembership.objects.get(club=self.club, member=self.child, season=self.season)
membership.fee_amount = Decimal("150.00")
membership.save()
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["open_dues_count"], 1)
self.assertContains(response, "1 OPEN")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class PaymentsViewTests(TestCase):
"""M5's "Payments & dues" row -- every open season-dues balance across
every managed person, reusing club.services.fees.open_dues_rows (see
club.tests.OpenDuesRowsTests for the exclusion rules themselves)."""
@classmethod
def setUpTestData(cls):
cls.club = make_club()
today = timezone.localdate()
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123")
cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", email="parent@example.com", user=cls.user)
cls.membership = ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
def _get(self):
return self.client.get(reverse("mobile:payments"), HTTP_HOST="ajax-united.rosterchief.app")
def test_requires_login(self):
response = self._get()
self.assertEqual(response.status_code, 302)
def test_back_link_returns_to_me(self):
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, reverse("mobile:me"))
def test_nothing_owed_shows_a_settled_up_empty_state(self):
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "All settled up")
def test_an_open_balance_shows_the_amount_and_a_pay_button(self):
self.membership.fee_amount = Decimal("150.00")
self.membership.save()
self.client.force_login(self.user)
response = self._get()
self.assertEqual(len(response.context["dues_rows"]), 1)
self.assertContains(response, "150.00")
self.assertContains(response, "Pay")
self.assertNotContains(response, "All settled up")
def test_a_fully_paid_balance_does_not_show(self):
self.membership.fee_amount = Decimal("150.00")
self.membership.fee_status = ClubMembership.FeeStatus.PAID
self.membership.amount_paid = Decimal("150.00")
self.membership.save()
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["dues_rows"], [])
def test_only_shows_balances_for_managed_people(self):
other_member = Member.objects.create(first_name="Tom", last_name="Roe")
ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, fee_amount=Decimal("200.00"))
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["dues_rows"], [])
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class EditProfileViewTests(TestCase): class EditProfileViewTests(TestCase):

View File

@@ -18,6 +18,7 @@ urlpatterns = [
path("news/", views.NewsListView.as_view(), name="news_list"), path("news/", views.NewsListView.as_view(), name="news_list"),
path("news/<slug:slug>/", views.NewsDetailView.as_view(), name="news_detail"), path("news/<slug:slug>/", views.NewsDetailView.as_view(), name="news_detail"),
path("me/", views.MeView.as_view(), name="me"), path("me/", views.MeView.as_view(), name="me"),
path("me/payments/", views.PaymentsView.as_view(), name="payments"),
path("me/calendar-sync/", views.CalendarFeedSettingsView.as_view(), name="calendar_feed_settings"), path("me/calendar-sync/", views.CalendarFeedSettingsView.as_view(), name="calendar_feed_settings"),
path("me/<uuid:member_id>/edit/", views.EditProfileView.as_view(), name="edit_profile"), path("me/<uuid:member_id>/edit/", views.EditProfileView.as_view(), name="edit_profile"),
path("notifications/", views.NotificationsView.as_view(), name="notifications"), path("notifications/", views.NotificationsView.as_view(), name="notifications"),

View File

@@ -23,7 +23,7 @@ from django.views.generic import TemplateView
from club.models import ClubMembership from club.models import ClubMembership
from club.services.access import current_season, has_management_access, teams_managed_by from club.services.access import current_season, has_management_access, teams_managed_by
from club.services.fees import remaining_balance from club.services.fees import open_dues_rows
from club.services.onboarding import checklist_for from club.services.onboarding import checklist_for
from club.services.sponsors import active_sponsors from club.services.sponsors import active_sponsors
from controlpanel.messages import notify from controlpanel.messages import notify
@@ -229,16 +229,7 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
season = current_season(self.request.club) season = current_season(self.request.club)
if season is not None: if season is not None:
memberships = ( dues_rows = open_dues_rows(self.request.club, people, season)
ClubMembership.objects.filter(club=self.request.club, member__in=people, season=season)
.exclude(fee_status=ClubMembership.FeeStatus.WAIVED)
.select_related("dues_invoice", "member")
)
for membership in memberships:
balance = remaining_balance(membership)
if balance > 0:
dues_rows.append({"membership": membership, "balance": balance, "invoice": getattr(membership, "dues_invoice", None)})
team_ids = list(TeamMembership.objects.filter(member__in=people, season=season).values_list("team_id", flat=True)) team_ids = list(TeamMembership.objects.filter(member__in=people, season=season).values_list("team_id", flat=True))
else: else:
team_ids = [] team_ids = []
@@ -538,10 +529,12 @@ class MeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
roster), and a settings-ish card linking into M6 (edit_profile) for roster), and a settings-ish card linking into M6 (edit_profile) for
``self.me`` and into M7 (notifications). ``self.me`` and into M7 (notifications).
The mockup's "Household & contacts" and "Payments & dues" rows, and its The mockup's "Household & contacts" row and its "Coach mode" promo card
"Coach mode" promo card, have nowhere to lead in this build (no dedicated have nowhere to lead in this build (no dedicated screen, no Coach mode
screen, no Coach mode screens at all yet -- see base.html's own comment) screens at all yet -- see base.html's own comment) and are deliberately
and are deliberately omitted rather than built as dead or inert links. omitted rather than built as dead or inert links. "Payments & dues" does
lead somewhere -- PaymentsView below -- with its "N OPEN" pill only
rendered once there's actually a balance owed.
There's no license/eligibility field on Member or ClubMembership to power There's no license/eligibility field on Member or ClubMembership to power
the mockup's "licence OK" text, so each managed person's meta line is the mockup's "licence OK" text, so each managed person's meta line is
@@ -591,14 +584,36 @@ class MeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
} }
people_rows = [{"person": person, "membership": memberships_by_member.get(person.pk)} for person in self.managed_people] people_rows = [{"person": person, "membership": memberships_by_member.get(person.pk)} for person in self.managed_people]
open_dues_count = len(open_dues_rows(club, self.managed_people, season))
return super().get_context_data( return super().get_context_data(
member_since=member_since, member_since=member_since,
team_manager_label=team_manager_label, team_manager_label=team_manager_label,
people_rows=people_rows, people_rows=people_rows,
open_dues_count=open_dues_count,
**kwargs, **kwargs,
) )
class PaymentsView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""M5's "Payments & dues" row -- every open season-dues balance across
``self.managed_people``, one card per person owed, in the same €-badge /
amount+due-date / Pay layout as Home's dues card (club.services.fees.
open_dues_rows is the shared source for both). No online payment gateway
exists yet, so "Pay" is the same non-functional stub as Home's -- this
screen's job is visibility ("what do I owe, and when"), not collection.
"""
template_name = "mobile/payments.html"
screen_title = _("Payments & dues")
active_tab = "me"
def get_context_data(self, **kwargs):
season = current_season(self.request.club)
dues_rows = open_dues_rows(self.request.club, self.managed_people, season)
return super().get_context_data(dues_rows=dues_rows, **kwargs)
class EditProfileView(PersonScopeMixin, LoginRequiredMixin, TemplateView): class EditProfileView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""M6 -- design_handoff_rosterchief_platform/README.md's M6 section, """M6 -- design_handoff_rosterchief_platform/README.md's M6 section,
"Edit personal info". The design mock also shows a "National register "Edit personal info". The design mock also shows a "National register