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"))
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):
"""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

View File

@@ -39,7 +39,7 @@ from .services.access import (
teams_managed_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.onboarding import (
annotate_onboarding_status,
@@ -1562,6 +1562,69 @@ class FeeServiceTests(TestCase):
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):
"""club.services.invoicing.recipient_for -- the member's own email, else the
first parent/guardian who has one, else nobody reachable at all."""