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:
@@ -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
|
||||
|
||||
64
management/templates/management/dues_invoice_detail.html
Normal file
64
management/templates/management/dues_invoice_detail.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user