diff --git a/club/services/fees.py b/club/services/fees.py index 758a942..a38f549 100644 --- a/club/services/fees.py +++ b/club/services/fees.py @@ -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 diff --git a/club/tests.py b/club/tests.py index 0e8ca2f..2f47d5b 100644 --- a/club/tests.py +++ b/club/tests.py @@ -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.""" diff --git a/mobile/templates/mobile/_dues_row.html b/mobile/templates/mobile/_dues_row.html new file mode 100644 index 0000000..1af0c61 --- /dev/null +++ b/mobile/templates/mobile/_dues_row.html @@ -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 %} +
+
+ +
+
+
{% blocktrans with name=row.membership.member.first_name %}Season dues — {{ name }}{% endblocktrans %}
+
+ {% if row.invoice %} + {% blocktrans with amount=row.balance|floatformat:2 due=row.invoice.due_date|date:"d M" %}€ {{ amount }} · due {{ due }}{% endblocktrans %} + {% else %} + {% blocktrans with amount=row.balance|floatformat:2 %}€ {{ amount }}{% endblocktrans %} + {% endif %} +
+
+ {% trans "Pay" %} +
diff --git a/mobile/templates/mobile/home.html b/mobile/templates/mobile/home.html index 05383f5..cdcd012 100644 --- a/mobile/templates/mobile/home.html +++ b/mobile/templates/mobile/home.html @@ -112,22 +112,7 @@ {% if dues_rows %}
{% for row in dues_rows %} -
-
- -
-
-
{% blocktrans with name=row.membership.member.first_name %}Season dues — {{ name }}{% endblocktrans %}
-
- {% if row.invoice %} - {% blocktrans with amount=row.balance|floatformat:2 due=row.invoice.due_date|date:"d M" %}€ {{ amount }} · due {{ due }}{% endblocktrans %} - {% else %} - {% blocktrans with amount=row.balance|floatformat:2 %}€ {{ amount }}{% endblocktrans %} - {% endif %} -
-
- {% trans "Pay" %} -
+ {% include "mobile/_dues_row.html" %} {% endfor %}
{% endif %} diff --git a/mobile/templates/mobile/me.html b/mobile/templates/mobile/me.html index 3eff06e..3882ee5 100644 --- a/mobile/templates/mobile/me.html +++ b/mobile/templates/mobile/me.html @@ -5,10 +5,12 @@ M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me & 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 - meta line is real roster data instead; "Household & contacts" and - "Payments & dues" have no screen to lead to and are omitted, same for the - mockup's "Coach mode" promo (base.html's own precedent -- no Coach mode - screens exist yet, so it's never rendered, not even as a dead/inert link). + meta line is real roster data instead; "Household & contacts" has no + screen to lead to and is omitted, same for the mockup's "Coach mode" + promo (base.html's own precedent -- no Coach mode screens exist yet, so + 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 shared navy app-header (base.html) rather than a separately-coloured block of its own, matching the design canvas's own M5 markup. @@ -70,6 +72,16 @@
+ + {% trans "Payments & dues" %} + {% if open_dues_count %} + + {% blocktrans count counter=open_dues_count %}{{ counter }} OPEN{% plural %}{{ counter }} OPEN{% endblocktrans %} + + {% endif %} + + +
{% trans "Notifications" %} diff --git a/mobile/templates/mobile/payments.html b/mobile/templates/mobile/payments.html new file mode 100644 index 0000000..d5d38a4 --- /dev/null +++ b/mobile/templates/mobile/payments.html @@ -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 %} +
+ + + + {% trans "Payments & dues" %} +
+ + {% if dues_rows %} +
+ {% for row in dues_rows %} + {% include "mobile/_dues_row.html" %} + {% endfor %} +
+ {% else %} +
+

{% trans "All settled up" %}

+

{% trans "There's nothing open right now." %}

+
+ {% endif %} +{% endblock content %} diff --git a/mobile/tests.py b/mobile/tests.py index 7f99110..260f873 100644 --- a/mobile/tests.py +++ b/mobile/tests.py @@ -1238,8 +1238,9 @@ class MeViewTests(TestCase): """M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me & my people". See MeView's own docstring for the judgment calls: no licence/eligibility field backing "licence OK" (real roster data used - instead), "Household & contacts"/"Payments & dues"/"Coach mode" all - omitted since none has anywhere to lead in this build. + instead), "Household & contacts"/"Coach mode" omitted since neither has + anywhere to lead in this build; "Payments & dues" does (PaymentsView, + tested separately below). """ @classmethod @@ -1333,6 +1334,101 @@ class MeViewTests(TestCase): self.assertContains(response, "No one to show yet") 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"]) class EditProfileViewTests(TestCase): diff --git a/mobile/urls.py b/mobile/urls.py index fb2b21b..9854062 100644 --- a/mobile/urls.py +++ b/mobile/urls.py @@ -18,6 +18,7 @@ urlpatterns = [ path("news/", views.NewsListView.as_view(), name="news_list"), path("news//", views.NewsDetailView.as_view(), name="news_detail"), 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//edit/", views.EditProfileView.as_view(), name="edit_profile"), path("notifications/", views.NotificationsView.as_view(), name="notifications"), diff --git a/mobile/views.py b/mobile/views.py index cd3df5b..44345ea 100644 --- a/mobile/views.py +++ b/mobile/views.py @@ -23,7 +23,7 @@ from django.views.generic import TemplateView from club.models import ClubMembership 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.sponsors import active_sponsors from controlpanel.messages import notify @@ -229,16 +229,7 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView): season = current_season(self.request.club) if season is not None: - memberships = ( - 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)}) - + dues_rows = open_dues_rows(self.request.club, people, season) team_ids = list(TeamMembership.objects.filter(member__in=people, season=season).values_list("team_id", flat=True)) else: team_ids = [] @@ -538,10 +529,12 @@ class MeView(PersonScopeMixin, LoginRequiredMixin, TemplateView): roster), and a settings-ish card linking into M6 (edit_profile) for ``self.me`` and into M7 (notifications). - The mockup's "Household & contacts" and "Payments & dues" rows, and its - "Coach mode" promo card, have nowhere to lead in this build (no dedicated - screen, no Coach mode screens at all yet -- see base.html's own comment) - and are deliberately omitted rather than built as dead or inert links. + The mockup's "Household & contacts" row and its "Coach mode" promo card + have nowhere to lead in this build (no dedicated screen, no Coach mode + screens at all yet -- see base.html's own comment) and are deliberately + 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 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] + open_dues_count = len(open_dues_rows(club, self.managed_people, season)) + return super().get_context_data( member_since=member_since, team_manager_label=team_manager_label, people_rows=people_rows, + open_dues_count=open_dues_count, **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): """M6 -- design_handoff_rosterchief_platform/README.md's M6 section, "Edit personal info". The design mock also shows a "National register