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

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