Add a dues-invoicing feature: send, track and remind on membership fees

New DuesInvoice model (one per membership, resendable) plus
club.services.invoicing: resolves the best email to invoice (the
member's own, else a parent/guardian's), snapshots the outstanding
balance and a due date on send, and mails a branded HTML invoice
(same club-colour email shell as the parent-claim email) with a
WeasyPrint PDF attached when the native libs are available.

Dues & billing gains a bulk "Send invoice" action (checkbox selection
+ a shared due-in-days prompt), a per-row invoice status column, a
staff-facing invoice detail/PDF page, and a push-button "Send
reminders" action for every sent, unpaid invoice past its own due
date.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-20 21:23:06 +02:00
parent 44ddf7b6eb
commit 94d6cbd4e9
19 changed files with 1334 additions and 8 deletions

View File

@@ -872,6 +872,19 @@ class RecordFeePaymentForm(forms.Form):
note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea(attrs={"rows": 2}))
class SendDuesInvoicesForm(forms.Form):
"""The one shared setting for a batch of invoices sent from the Dues & billing
page -- see club.services.invoicing.create_or_resend_invoice. Applies to every
membership selected in the same submit, not chosen per row.
The widget's own `form` attr (rendered as-is by templatetags/field.html's attrs
passthrough) is what lets this field live inside the confirm dialog while still
posting through the row-checkboxes' #membership-form -- see membership_list.html's
send_invoices_modal."""
due_in_days = forms.IntegerField(label=_("Due in"), min_value=1, max_value=365, initial=14, help_text=_("Days from today."), widget=forms.NumberInput(attrs={"form": "membership-form"}))
class ClubSettingsForm(forms.ModelForm):
"""A club's own self-service identity/branding editor (management:club_settings) --
the club-facing equivalent of controlpanel's ClubForm, minus everything only

View File

@@ -0,0 +1,64 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% blocktrans with number=invoice.number %}Invoice {{ number }}{% endblocktrans %}{% endblock heading %}
{% block topbar_context %}<span class="text-sm text-muted">{{ member }}</span>{% endblock topbar_context %}
{% block actions %}
<a class="btn btn-outline gap-2" href="{% url 'management:membership_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Dues & billing" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:membership_invoice_pdf' membership.pk %}">{% lucide "file-down" size=16 %} {% trans "Download PDF" %}</a>
{% endblock actions %}
{% block panel %}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="card p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Amount due" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums {% if invoice.is_paid %}text-ok{% else %}text-ink{% endif %}">€{{ invoice.amount }}</div>
<div class="mt-1 text-[13px] text-muted">
{% if invoice.is_paid %}{% trans "Paid" %}{% elif invoice.is_overdue %}<span class="text-club">{% trans "Overdue" %}</span>{% else %}{% blocktrans with date=invoice.due_date|date:"j F Y" %}Due {{ date }}{% endblocktrans %}{% endif %}
</div>
</div>
<div class="card p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Sent" %}</div>
<div class="mt-1 font-display text-[22px] leading-none font-extrabold text-ink">{{ invoice.sent_at|date:"j M Y" }}</div>
<div class="mt-1 text-[13px] text-muted">{{ invoice.sent_to_email }}{% if invoice.sent_to_guardian %} <span class="text-dim">({% trans "parent/guardian" %})</span>{% endif %}</div>
</div>
<div class="card p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Reminders" %}</div>
<div class="mt-1 font-display text-[22px] leading-none font-extrabold text-ink tabular-nums">{{ invoice.reminder_count }}</div>
<div class="mt-1 text-[13px] text-muted">
{% if invoice.last_reminder_sent_at %}{% blocktrans with date=invoice.last_reminder_sent_at|date:"j M Y" %}Last sent {{ date }}{% endblocktrans %}{% else %}{% trans "None sent yet" %}{% endif %}
</div>
</div>
</div>
<div class="card card-body gap-3">
<h2 class="card-title">{% lucide "receipt" size=18 %} {% trans "Details" %}</h2>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<div class="text-xs text-muted">{% trans "Member" %}</div>
<div class="font-semibold text-ink"><a class="link link-hover" href="{% url 'management:member_detail' member.pk %}">{{ member }}</a></div>
</div>
<div>
<div class="text-xs text-muted">{% trans "Season" %}</div>
<div>{{ membership.season }}</div>
</div>
<div>
<div class="text-xs text-muted">{% trans "Fee status" %}</div>
<div>
<span class="badge badge-sm
{% if membership.fee_status == "paid" %}badge-success
{% elif membership.fee_status == "partially_paid" %}badge-warning
{% elif membership.fee_status == "unpaid" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ membership.get_fee_status_display }}
</span>
</div>
</div>
<div>
<div class="text-xs text-muted">{% trans "Invoice number" %}</div>
<div class="font-mono">{{ invoice.number }}</div>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -10,6 +10,14 @@
{% endblock topbar_context %}
{% block actions %}
<form method="post" action="{% url 'management:membership_send_invoice_reminders' %}" class="inline-flex">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button class="btn btn-outline gap-2" type="submit">
{% lucide "bell" size=16 %} {% trans "Send reminders" %}
{% if kpi_overdue_invoices %}<span class="badge badge-error badge-xs">{{ kpi_overdue_invoices }}</span>{% endif %}
</button>
</form>
<a class="btn btn-outline gap-2" href="{% url 'management:membership_export_pdf' %}?{{ request.GET.urlencode }}">{% lucide "file-down" size=16 %} {% trans "Export to PDF" %}</a>
{% endblock actions %}
@@ -108,7 +116,10 @@
<span class="font-display text-sm font-bold tracking-wide text-muted uppercase">
{% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %}
</span>
<button class="btn btn-success btn-sm gap-2" type="submit">{% lucide "circle-check" size=14 %} {% trans "Mark selected as paid" %}</button>
<div class="flex gap-2">
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('send_invoices_modal').showModal()">{% lucide "send" size=14 %} {% trans "Send invoice" %}</button>
<button class="btn btn-success btn-sm gap-2" type="submit">{% lucide "circle-check" size=14 %} {% trans "Mark selected as paid" %}</button>
</div>
</div>
<div class="overflow-x-auto">
@@ -124,6 +135,7 @@
<th>{% trans "Owed" %}</th>
<th>{% trans "Paid" %}</th>
<th>{% trans "License" %}</th>
<th>{% trans "Invoice" %}</th>
<th></th>
</tr>
</thead>
@@ -168,6 +180,18 @@
{% endif %}
</td>
<td>{{ membership.license|default:"-" }}</td>
<td>
{% if membership.dues_invoice %}
<a class="link link-hover" href="{% url 'management:membership_invoice_detail' membership.pk %}">
<span class="badge badge-sm {% if membership.dues_invoice.is_paid %}badge-success{% elif membership.dues_invoice.is_overdue %}badge-error{% else %}badge-neutral{% endif %}">
{% if membership.dues_invoice.is_paid %}{% trans "Paid" %}{% elif membership.dues_invoice.is_overdue %}{% trans "Overdue" %}{% else %}{% trans "Sent" %}{% endif %}
</span>
</a>
<div class="text-xs text-muted">{{ membership.dues_invoice.sent_at|date:"j M" }}{% if membership.dues_invoice.reminder_count %}, {% blocktrans count counter=membership.dues_invoice.reminder_count %}{{ counter }} reminder{% plural %}{{ counter }} reminders{% endblocktrans %}{% endif %}</div>
{% else %}
<span class="text-xs text-dim">{% trans "Not sent" %}</span>
{% endif %}
</td>
<td class="text-right">
{% if membership.record_payment_form %}
<div class="flex flex-wrap justify-end gap-1">
@@ -183,7 +207,7 @@
</tr>
{% empty %}
<tr>
<td colspan="10" class="text-center text-muted">{% trans "Nobody matches these filters." %}</td>
<td colspan="11" class="text-center text-muted">{% trans "Nobody matches these filters." %}</td>
</tr>
{% endfor %}
</tbody>
@@ -192,6 +216,24 @@
</div>
</form>
{# due_in_days's widget carries form="membership-form" (see SendDuesInvoicesForm), so it and the submit button below (via its own form=) post through the *same* form as the row checkboxes, just to a different action, overridden with formaction. #}
<dialog id="send_invoices_modal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">{% trans "Send invoice" %}</h3>
<p class="py-2 text-sm opacity-70">{% trans "Emails each selected member (or a parent/guardian, if they have no email on file) an invoice for their outstanding balance." %}</p>
<div class="form-control w-full">
{% form_field send_invoice_form.due_in_days size="small" %}
</div>
</div>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
</form>
<button class="btn btn-primary gap-2" type="submit" form="membership-form" formaction="{% url 'management:membership_send_invoices' %}">{% lucide "send" size=16 %} {% trans "Send" %}</button>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
{% trans "Record payment" as record_payment_title %}
{% trans "Record" as record_payment_submit_label %}
{% for membership in memberships %}

View File

@@ -19,7 +19,8 @@ from waffle import get_waffle_flag_model
from billing.models import Plan, PlanPrice
from billing.services.dues import record_payment, subscribe
from club.models import Club, ClubMembership, ClubRole, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from club.models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from club.services.invoicing import DuesInvoicePDFError
from club.services.onboarding import mark_complete
from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent
from events.services.rbihf_import import RBIHFImportError
@@ -3258,6 +3259,195 @@ class MembershipExportPdfTests(ManagementTestBase):
self.assertEqual(response.status_code, 403)
class MembershipSendInvoicesTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com")
self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_amount=Decimal("150.00"))
def test_sending_creates_and_emails_an_invoice(self):
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
invoice = DuesInvoice.objects.get(membership=self.membership)
self.assertIsNotNone(invoice.sent_at)
self.assertEqual(invoice.sent_to_email, "jane@example.com")
self.assertEqual(invoice.amount, Decimal("150.00"))
self.assertEqual(invoice.due_date, timezone.now().date() + datetime.timedelta(days=14))
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["jane@example.com"])
def test_the_email_carries_an_html_alternative(self):
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
[(html_body, mimetype)] = mail.outbox[0].alternatives
self.assertEqual(mimetype, "text/html")
self.assertIn(self.club.name, html_body)
def test_falls_back_to_a_guardians_email(self):
self.member.email = ""
self.member.save(update_fields=["email"])
family = Family.objects.create()
parent = Member.objects.create(first_name="Pat", last_name="Doe", email="pat@example.com")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
invoice = DuesInvoice.objects.get(membership=self.membership)
self.assertEqual(invoice.sent_to_email, "pat@example.com")
self.assertTrue(invoice.sent_to_guardian)
def test_a_member_with_no_reachable_email_is_skipped(self):
self.member.email = ""
self.member.save(update_fields=["email"])
response = self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
self.assertFalse(DuesInvoice.objects.filter(membership=self.membership).exists())
self.assertEqual(len(mail.outbox), 0)
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "no email on file")
def test_resending_updates_the_same_invoice(self):
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
first_number = DuesInvoice.objects.get(membership=self.membership).number
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "30"})
self.assertEqual(DuesInvoice.objects.filter(membership=self.membership).count(), 1)
invoice = DuesInvoice.objects.get(membership=self.membership)
self.assertEqual(invoice.number, first_number)
self.assertEqual(invoice.due_date, timezone.now().date() + datetime.timedelta(days=30))
self.assertEqual(len(mail.outbox), 2)
def test_no_selection_shows_an_error(self):
response = self.club_post("membership_send_invoices", {"due_in_days": "14"})
self.assertFalse(DuesInvoice.objects.exists())
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "Select at least one member")
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-invoice@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U15", short_name="U15")
position = Position.objects.create(club=self.club, name="Coach11", short_name="C11", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
self.assertEqual(response.status_code, 403)
class MembershipSendInvoiceRemindersTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def make_invoice(self, *, due_date, fee_status=ClubMembership.FeeStatus.UNPAID, email="jane@example.com"):
member = Member.objects.create(first_name="Jane", last_name="Doe", email=email)
membership = ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_amount=Decimal("100.00"), fee_status=fee_status)
return DuesInvoice.objects.create(club=self.club, membership=membership, number="DUE-2026-00001", amount=Decimal("100.00"), due_date=due_date, sent_at=timezone.now(), sent_to_email=email)
def test_reminds_an_overdue_unpaid_invoice(self):
invoice = self.make_invoice(due_date=timezone.now().date() - datetime.timedelta(days=1))
self.club_post("membership_send_invoice_reminders", {})
invoice.refresh_from_db()
self.assertEqual(invoice.reminder_count, 1)
self.assertIsNotNone(invoice.last_reminder_sent_at)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["jane@example.com"])
def test_does_not_remind_one_not_yet_due(self):
invoice = self.make_invoice(due_date=timezone.now().date() + datetime.timedelta(days=5))
self.club_post("membership_send_invoice_reminders", {})
invoice.refresh_from_db()
self.assertEqual(invoice.reminder_count, 0)
self.assertEqual(len(mail.outbox), 0)
def test_does_not_remind_a_paid_invoice(self):
invoice = self.make_invoice(due_date=timezone.now().date() - datetime.timedelta(days=1), fee_status=ClubMembership.FeeStatus.PAID)
self.club_post("membership_send_invoice_reminders", {})
invoice.refresh_from_db()
self.assertEqual(invoice.reminder_count, 0)
def test_nothing_to_remind_notifies_gracefully(self):
response = self.club_post("membership_send_invoice_reminders", {})
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "Nothing to remind")
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-reminder@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
position = Position.objects.create(club=self.club, name="Coach12", short_name="C12", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_post("membership_send_invoice_reminders", {})
self.assertEqual(response.status_code, 403)
class DuesInvoiceDetailViewTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com")
self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_amount=Decimal("150.00"))
self.invoice = DuesInvoice.objects.create(club=self.club, membership=self.membership, number="DUE-2026-00001", amount=Decimal("150.00"), due_date=timezone.now().date(), sent_at=timezone.now(), sent_to_email="jane@example.com")
def test_shows_the_invoice(self):
response = self.club_get("membership_invoice_detail", self.membership.pk)
self.assertContains(response, "DUE-2026-00001")
self.assertContains(response, "jane@example.com")
def test_404_when_the_membership_has_no_invoice(self):
other_member = Member.objects.create(first_name="No", last_name="Invoice")
other_membership = ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
response = self.club_get("membership_invoice_detail", other_membership.pk)
self.assertEqual(response.status_code, 404)
def test_downloads_as_a_pdf(self):
with mock.patch("management.views.invoice_pdf", return_value=b"%PDF-fake") as renderer:
response = self.club_get("membership_invoice_pdf", self.membership.pk)
self.assertEqual(response["Content-Type"], "application/pdf")
self.assertEqual(response.content, b"%PDF-fake")
renderer.assert_called_once()
def test_a_missing_pdf_library_is_reported_rather_than_a_500(self):
with mock.patch("management.views.invoice_pdf", side_effect=DuesInvoicePDFError("PDF rendering needs the native pango/cairo libraries.")):
response = self.club_get("membership_invoice_pdf", self.membership.pk)
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "pango")
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-invoice-detail@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U17", short_name="U17")
position = Position.objects.create(club=self.club, name="Coach13", short_name="C13", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_get("membership_invoice_detail", self.membership.pk)
self.assertEqual(response.status_code, 403)
class MemberListRowActionsTests(ManagementTestBase):
@classmethod
def setUpTestData(cls):

View File

@@ -13,6 +13,10 @@ urlpatterns = [
path("memberships/export/", views.MembershipExportPdfView.as_view(), name="membership_export_pdf"),
path("memberships/<uuid:pk>/mark-fully-paid/", views.MembershipMarkFullyPaidView.as_view(), name="membership_mark_fully_paid"),
path("memberships/<uuid:pk>/record-payment/", views.MembershipRecordPaymentView.as_view(), name="membership_record_payment"),
path("memberships/send-invoices/", views.MembershipSendInvoicesView.as_view(), name="membership_send_invoices"),
path("memberships/send-invoice-reminders/", views.MembershipSendInvoiceRemindersView.as_view(), name="membership_send_invoice_reminders"),
path("memberships/<uuid:pk>/invoice/", views.DuesInvoiceDetailView.as_view(), name="membership_invoice_detail"),
path("memberships/<uuid:pk>/invoice/pdf/", views.DuesInvoicePdfView.as_view(), name="membership_invoice_pdf"),
path("members/new/", views.MemberCreateView.as_view(), name="member_create"),
path("members/import/template/", views.MemberImportTemplateView.as_view(), name="member_import_template"),
path("members/import/", views.MemberImportView.as_view(), name="member_import"),

View File

@@ -25,9 +25,10 @@ from club.mixins import (
NewsPublisherRequiredMixin,
TeamManagerRequiredMixin,
)
from club.models import ClubMembership, ClubRole, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from club.models import ClubMembership, ClubRole, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from club.services.access import _guardians_only, can_edit_news, can_publish_news, current_season, groups_manageable_by, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
from club.services.fees import mark_as_paid, record_payment, remaining_balance
from club.services.invoicing import DuesInvoicePDFError, create_or_resend_invoice, invoice_pdf, invoices_due_for_reminder, recipient_for, send_invoice_email, send_reminders
from club.services.onboarding import annotate_onboarding_status, approve_all_clean, approve_one, blocking_event_kinds, checklist_for, is_signup_clean, mark_bypassed, mark_complete, mark_incomplete, members_with_open_requirements
from controlpanel.messages import notify
from controlpanel.mixins import RedirectOnInvalidMixin
@@ -81,6 +82,7 @@ from .forms import (
RefereeLevelForm,
RequirementBypassForm,
RequirementCompletionForm,
SendDuesInvoicesForm,
SignupTeamPlacementForm,
SponsorForm,
StaffAssignmentForm,
@@ -386,6 +388,8 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
waived = counts.get(ClubMembership.FeeStatus.WAIVED, 0)
total = paid + partial + unpaid + waived
overdue_count = invoices_due_for_reminder(club).count() if current is not None else 0
context = super().get_context_data(
current_season=current,
selected_season=self.get_selected_season(),
@@ -403,6 +407,8 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
kpi_unpaid=unpaid,
kpi_waived=waived,
kpi_paid_rate=round(100 * paid / total) if total else None,
kpi_overdue_invoices=overdue_count,
send_invoice_form=SendDuesInvoicesForm(),
**kwargs,
)
@@ -415,9 +421,11 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
family_memberships_by_member_id = {}
for fm in family_memberships:
family_memberships_by_member_id.setdefault(fm.member_id, []).append(fm)
invoices_by_membership_id = {invoice.membership_id: invoice for invoice in DuesInvoice.objects.filter(membership__in=memberships)}
for membership in memberships:
membership.member.family_memberships_display = family_memberships_by_member_id.get(membership.member_id, [])
membership.remaining_balance_display = remaining_balance(membership)
membership.dues_invoice = invoices_by_membership_id.get(membership.pk)
# Nothing to collect on an already-settled or deliberately-exempted row.
if membership.fee_status in (ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED):
membership.record_payment_form = None
@@ -526,6 +534,98 @@ class MembershipExportPdfView(MembershipListView):
return response
class MembershipSendInvoicesView(ClubAdminRequiredMixin, View):
"""The bulk "Send invoice" action on Dues & billing -- one invoice per selected
membership, mailed to the member's own email or a parent/guardian's when they
have none (see club.services.invoicing.recipient_for). Every membership in the
batch shares the one due-in-days setting from the form; per-membership tracking
(sent_at, who it went to, reminders) still lives on each invoice individually."""
def post(self, request):
next_url = request.POST.get("next")
redirect_url = next_url if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()) else reverse("management:membership_list")
ids = request.POST.getlist("membership_ids")
if not ids:
notify(request, f"e|{_('No members selected')}|{_('Select at least one member to invoice.')}")
return redirect(redirect_url)
form = SendDuesInvoicesForm(request.POST)
if not form.is_valid():
notify(request, f"e|{_('Could not send invoices')}|{_('Enter a valid number of days until due.')}")
return redirect(redirect_url)
memberships = ClubMembership.objects.filter(pk__in=ids, club=request.club).select_related("member")
sent = failed = unreachable = 0
for membership in memberships:
email, sent_to_guardian = recipient_for(membership.member)
if not email:
unreachable += 1
continue
invoice = create_or_resend_invoice(membership, due_in_days=form.cleaned_data["due_in_days"], recipient_email=email, sent_to_guardian=sent_to_guardian)
if send_invoice_email(invoice, request=request):
sent += 1
else:
failed += 1
if sent:
notify(request, f"s|{_('Invoices sent')}|{_('%(count)d invoice(s) sent.') % {'count': sent}}")
if failed:
notify(request, f"w|{_('Some invoices could not be emailed')}|{_('%(count)d invoice(s) were recorded but the email could not be sent.') % {'count': failed}}")
if unreachable:
notify(request, f"w|{_('Some members have no email on file')}|{_('%(count)d member(s) have no email on file, on themselves or a parent/guardian, so no invoice was sent.') % {'count': unreachable}}")
return redirect(redirect_url)
class MembershipSendInvoiceRemindersView(ClubAdminRequiredMixin, View):
"""The push-button "remind everyone past due" action -- every sent, unpaid
invoice whose due date has passed, club-wide, regardless of the current list's
filters or page. See club.services.invoicing.invoices_due_for_reminder."""
def post(self, request):
next_url = request.POST.get("next")
redirect_url = next_url if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()) else reverse("management:membership_list")
sent, failed = send_reminders(request.club, request=request)
if not sent and not failed:
notify(request, f"s|{_('Nothing to remind')}|{_('No overdue, unpaid invoices right now.')}")
else:
if sent:
notify(request, f"s|{_('Reminders sent')}|{_('%(count)d reminder(s) sent.') % {'count': sent}}")
if failed:
notify(request, f"w|{_('Some reminders could not be emailed')}|{_('%(count)d reminder(s) failed to send.') % {'count': failed}}")
return redirect(redirect_url)
class DuesInvoiceDetailView(ClubAdminRequiredMixin, DetailView):
"""A staff-facing view of one membership's invoice -- the same document the
member/guardian received, viewable here for reference without re-sending it."""
template_name = "management/dues_invoice_detail.html"
context_object_name = "invoice"
def get_object(self, queryset=None):
return get_object_or_404(DuesInvoice, membership__pk=self.kwargs["pk"], club=self.request.club)
def get_context_data(self, **kwargs):
return super().get_context_data(membership=self.object.membership, member=self.object.membership.member, **kwargs)
class DuesInvoicePdfView(ClubAdminRequiredMixin, View):
def get(self, request, pk):
invoice = get_object_or_404(DuesInvoice, membership__pk=pk, club=request.club)
try:
pdf = invoice_pdf(invoice)
except DuesInvoicePDFError as error:
notify(request, f"e|{_('PDF unavailable')}|{error}")
return redirect("management:membership_invoice_detail", pk=pk)
response = HttpResponse(pdf, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{invoice.number}.pdf"'
return response
class MemberImportTemplateView(ClubStaffRequiredMixin, View):
"""Anyone with management access can download the template -- filling it in
doesn't grant any authority, only the upload step (admin-only) does."""