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

@@ -1,7 +1,7 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Club, ClubMembership, ClubRole, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from .models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
@admin.register(Club)
@@ -57,6 +57,15 @@ class FeePaymentAdmin(admin.ModelAdmin):
search_fields = ["membership__club__name", "membership__member__last_name", "reference"]
@admin.register(DuesInvoice)
class DuesInvoiceAdmin(admin.ModelAdmin):
list_display = ["number", "membership", "amount", "due_date", "sent_at", "reminder_count"]
list_filter = ["club"]
search_fields = ["number", "membership__member__last_name", "membership__member__first_name"]
raw_id_fields = ["membership"]
readonly_fields = ["number"]
@admin.register(ClubRole)
class ClubRoleAdmin(admin.ModelAdmin):
list_display = ["club__name", "member__last_name", "member__first_name", "role"]

View File

@@ -0,0 +1,39 @@
# Generated by Django 6.0.6 on 2026-08-20 07:46
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0028_alter_onboardingrequirement_options_and_more'),
]
operations = [
migrations.CreateModel(
name='DuesInvoice',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('number', models.CharField(blank=True, max_length=255, verbose_name='number')),
('amount', models.DecimalField(decimal_places=2, help_text='The outstanding balance at the time this was sent — not re-read from the membership afterwards.', max_digits=10, verbose_name='amount')),
('due_date', models.DateField(verbose_name='due date')),
('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='sent at')),
('sent_to_email', models.EmailField(blank=True, max_length=254, verbose_name='sent to')),
('sent_to_guardian', models.BooleanField(default=False, help_text="The member had no email on file, so a parent/guardian's was used instead.", verbose_name='sent to a parent/guardian')),
('last_reminder_sent_at', models.DateTimeField(blank=True, null=True, verbose_name='last reminder sent at')),
('reminder_count', models.PositiveIntegerField(default=0, verbose_name='reminders sent')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
('membership', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='dues_invoice', to='club.clubmembership', verbose_name='membership')),
],
options={
'verbose_name': 'dues invoice',
'verbose_name_plural': 'dues invoices',
'ordering': ['-sent_at'],
'constraints': [models.UniqueConstraint(fields=('club', 'number'), name='unique_dues_invoice_number_per_club')],
},
),
]

View File

@@ -4,8 +4,8 @@ from decimal import Decimal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.db.models import Q
from django.db import IntegrityError, models, transaction
from django.db.models import Q, UniqueConstraint
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -382,6 +382,82 @@ class FeePayment(UUIDModel):
return f"{self.membership}{self.amount}"
class DuesInvoice(ClubScopedModel):
"""A record of asking one membership's fee to be paid — not itself the source of
truth for what's owed or settled (that's still ``ClubMembership.fee_amount``/
``amount_paid``/``fee_status``, via ``club.services.fees``). Sending one snapshots
the outstanding balance and a due date so a later fee change or reminder never
silently rewrites a bill someone already received; whether it still needs chasing
is read live off the membership's own ``fee_status``, since a payment recorded
through any route settles the same balance this invoice asked for.
One per membership (see ``club.services.invoicing``): "send" creates it if
missing, "resend" re-snapshots the balance and pushes the due date out again on
the existing row, so a membership never accumulates a history of stale invoices.
"""
membership = models.OneToOneField(ClubMembership, on_delete=models.CASCADE, related_name="dues_invoice", verbose_name=_("membership"))
number = models.CharField(_("number"), max_length=255, blank=True)
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, help_text=_("The outstanding balance at the time this was sent — not re-read from the membership afterwards."))
due_date = models.DateField(_("due date"))
sent_at = models.DateTimeField(_("sent at"), null=True, blank=True)
sent_to_email = models.EmailField(_("sent to"), blank=True)
sent_to_guardian = models.BooleanField(_("sent to a parent/guardian"), default=False, help_text=_("The member had no email on file, so a parent/guardian's was used instead."))
last_reminder_sent_at = models.DateTimeField(_("last reminder sent at"), null=True, blank=True)
reminder_count = models.PositiveIntegerField(_("reminders sent"), default=0)
class Meta:
verbose_name = _("dues invoice")
verbose_name_plural = _("dues invoices")
ordering = ["-sent_at"]
constraints = [
UniqueConstraint(fields=["club", "number"], name="unique_dues_invoice_number_per_club"),
]
def __str__(self):
return self.number or _("Unsent invoice for %(member)s") % {"member": self.membership.member}
def clean(self):
validate_club_scope(self, self.club_id, same_club_fields=("membership",))
@property
def is_paid(self) -> bool:
return self.membership.fee_status == ClubMembership.FeeStatus.PAID
@property
def is_overdue(self) -> bool:
return bool(self.sent_at) and not self.is_paid and self.due_date < timezone.now().date()
def generate_number(self) -> str:
"""Next per-club invoice number for the current year: ``DUE-<year>-<seq>``.
Same shape as shop.models.Invoice's numbering, duplicated rather than shared
across the two apps — see that module's own numbering helpers."""
prefix = f"DUE-{timezone.now().year}-"
sequences = [int(suffix) for existing in DuesInvoice.objects.filter(club=self.club, number__startswith=prefix).values_list("number", flat=True) if (suffix := existing.removeprefix(prefix)).isdigit()]
return f"{prefix}{max(sequences, default=0) + 1:05d}"
def save(self, *args, **kwargs):
if self.number:
return super().save(*args, **kwargs)
# Retrying on a numbering collision (two invoices allocated the same
# sequence in the same instant) rather than locking: this only ever
# fires once, on first send, so a rare retry is cheaper than a lock
# held around every save.
for attempt in range(5):
self.number = self.generate_number()
try:
with transaction.atomic():
return super().save(*args, **kwargs)
except IntegrityError:
self.number = ""
if attempt == 4:
raise
def onboarding_document_path(instance: MemberRequirementStatus, filename: str) -> str:
return f"clubs/{instance.membership.club.slug}/onboarding/{instance.membership_id}/{filename}"

162
club/services/invoicing.py Normal file
View File

@@ -0,0 +1,162 @@
"""Dues invoices: asking a member (or their parent/guardian) to pay an outstanding
membership fee, and chasing it if the due date passes unpaid.
Kept separate from club.services.fees on purpose: fees.py owns what's actually owed
and settled (fee_amount/amount_paid/fee_status), this module only owns the paper
trail of having asked for it. A DuesInvoice's own "paid" reading is always the live
membership.fee_status -- never a flag duplicated here that could drift out of step.
"""
from datetime import timedelta
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership, DuesInvoice
from club.services.fees import remaining_balance
class DuesInvoicePDFError(Exception):
"""Raised when WeasyPrint's native libraries aren't available."""
def recipient_for(member) -> tuple[str, bool]:
"""Best email to invoice ``member`` at: their own, else the first parent/guardian
who has one. Empty string means nobody reachable at all -- the caller must not
create or send an invoice in that case."""
if member.contact_email:
return member.contact_email, False
for guardian in member.guardians.order_by("last_name", "first_name"):
if guardian.contact_email:
return guardian.contact_email, True
return "", False
def create_or_resend_invoice(membership: ClubMembership, *, due_in_days: int, recipient_email: str, sent_to_guardian: bool) -> DuesInvoice:
"""Create the membership's one invoice, or re-snapshot it if it already has one.
Never touches reminder_count/last_reminder_sent_at -- a fresh send earns a fresh
reminder clock, but that's set by the reminder path itself, not reset here, since
a resend before any reminder went out has nothing to reset."""
invoice, _created = DuesInvoice.objects.get_or_create(
club=membership.club,
membership=membership,
defaults={"amount": remaining_balance(membership), "due_date": timezone.now().date() + timedelta(days=due_in_days)},
)
invoice.amount = remaining_balance(membership)
invoice.due_date = timezone.now().date() + timedelta(days=due_in_days)
invoice.sent_at = timezone.now()
invoice.sent_to_email = recipient_email
invoice.sent_to_guardian = sent_to_guardian
# get_or_create's own save (for a new row) already assigned invoice.number,
# so it's always set by this point -- update_fields never needs to include it.
invoice.save(update_fields=["amount", "due_date", "sent_at", "sent_to_email", "sent_to_guardian", "modified"])
return invoice
def _email_context(invoice: DuesInvoice, *, request=None) -> dict:
return {"club": invoice.club, "invoice": invoice, "membership": invoice.membership, "member": invoice.membership.member, "request": request}
def _attach_pdf(message: EmailMultiAlternatives, invoice: DuesInvoice) -> None:
"""Best-effort: a club running without WeasyPrint's native libraries still gets
the invoice email itself, just without the PDF -- everything the PDF shows is
already in the email body."""
try:
pdf_bytes = invoice_pdf(invoice)
except DuesInvoicePDFError:
return
message.attach(f"{invoice.number}.pdf", pdf_bytes, "application/pdf")
def send_invoice_email(invoice: DuesInvoice, *, request=None) -> bool:
"""Mail the branded invoice to invoice.sent_to_email. Never fatal: the invoice
row (and its sent_at stamp) exists whether or not the mail leaves the building --
see members.services.claims.send_claim_approved_email for the same reasoning."""
if not invoice.sent_to_email:
return False
context = _email_context(invoice, request=request)
subject = " ".join(render_to_string("club/email/dues_invoice_subject.txt", context).split())
text_body = render_to_string("club/email/dues_invoice.txt", context).strip() + "\n"
html_body = render_to_string("club/email/dues_invoice.html", context)
message = EmailMultiAlternatives(subject, text_body, settings.DEFAULT_FROM_EMAIL, [invoice.sent_to_email])
message.attach_alternative(html_body, "text/html")
_attach_pdf(message, invoice)
try:
message.send(fail_silently=False)
except OSError:
return False
return True
def invoices_due_for_reminder(club, today=None):
"""Sent, unpaid (and not waived -- nothing's owed there), past their own due
date. Reminders are opt-in per club-wide button push, not a cron job, so there's
no "already reminded today" guard here -- see MembershipSendInvoiceRemindersView."""
today = today or timezone.now().date()
return (
DuesInvoice.objects.filter(club=club, sent_at__isnull=False, due_date__lt=today)
.exclude(membership__fee_status__in=[ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED])
.select_related("membership__member")
)
def send_reminder_email(invoice: DuesInvoice, *, request=None) -> bool:
if not invoice.sent_to_email:
return False
context = _email_context(invoice, request=request)
subject = " ".join(render_to_string("club/email/dues_invoice_reminder_subject.txt", context).split())
text_body = render_to_string("club/email/dues_invoice_reminder.txt", context).strip() + "\n"
html_body = render_to_string("club/email/dues_invoice_reminder.html", context)
message = EmailMultiAlternatives(subject, text_body, settings.DEFAULT_FROM_EMAIL, [invoice.sent_to_email])
message.attach_alternative(html_body, "text/html")
_attach_pdf(message, invoice)
try:
message.send(fail_silently=False)
except OSError:
return False
invoice.last_reminder_sent_at = timezone.now()
invoice.reminder_count += 1
invoice.save(update_fields=["last_reminder_sent_at", "reminder_count", "modified"])
return True
def send_reminders(club, *, request=None) -> tuple[int, int]:
"""Push-button "remind everyone past due" -- returns (sent, failed)."""
sent = failed = 0
for invoice in invoices_due_for_reminder(club):
if send_reminder_email(invoice, request=request):
sent += 1
else:
failed += 1
return sent, failed
def render_pdf(html: str) -> bytes:
"""Same lazy-import shape as management.pdf.render_pdf/billing.services.invoices.render_pdf
-- WeasyPrint binds to native pango/cairo libraries, and a machine without them
must still be able to run the app; this only fails when someone actually asks
for a PDF. Not shared with either of those: an app depending on another app's
PDF error type for a two-line function isn't worth the coupling."""
try:
from weasyprint import HTML
except (ImportError, OSError) as error:
raise DuesInvoicePDFError(_("PDF rendering needs the native pango/cairo libraries (on macOS: brew install pango).")) from error
return HTML(string=html).write_pdf()
def invoice_pdf(invoice: DuesInvoice) -> bytes:
html = render_to_string("club/dues_invoice_pdf.html", {"club": invoice.club, "invoice": invoice, "membership": invoice.membership, "member": invoice.membership.member})
return render_pdf(html)

View File

@@ -0,0 +1,96 @@
{% load i18n %}
{% comment %}
Rendered by WeasyPrint, not a browser -- same convention as billing/templates/billing/invoice.html
and management/templates/management/membership_list_pdf.html: a standalone document with its
own print stylesheet, no app.css. Branded off the club's own colours (falling back to the same
shades club.templatetags.club_email's HTML emails use) rather than a fixed accent, since this
is the club's invoice to its own member, not RosterChief's to the club.
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ invoice.number }}</title>
<style>
@page {
size: A4;
margin: 20mm;
@bottom-center {
content: "{{ club.name }} — {% trans "invoice" %} {{ invoice.number }} — " counter(page) " / " counter(pages);
font-size: 8pt;
color: #666;
}
}
body { font-family: sans-serif; font-size: 10pt; color: #111; }
h1 { font-size: 20pt; margin: 0 0 2mm; }
.muted { color: #666; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12mm; }
.accent { color: {{ club.primary_color|default:"#4f46e5" }}; }
.parties { display: flex; justify-content: space-between; margin-bottom: 10mm; }
.parties h2 { font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; margin: 0 0 2mm; }
table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
th { text-align: left; font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; border-bottom: 1px solid #ccc; padding: 2mm 0; }
td { padding: 2mm 0; border-bottom: 1px solid #eee; }
.right { text-align: right; }
.total td { font-weight: bold; border-bottom: 2px solid #111; border-top: 1px solid #111; }
.balance { font-size: 12pt; font-weight: bold; }
.paid { color: #15803d; }
.owed { color: #b91c1c; }
</style>
</head>
<body>
<div class="header">
<div>
<h1>{{ club.name }}</h1>
{% if club.contact_email %}<div class="muted">{{ club.contact_email }}</div>{% endif %}
</div>
<div class="right">
<h1 class="accent">{% trans "Invoice" %}</h1>
<div><strong>{{ invoice.number }}</strong></div>
{% if invoice.sent_at %}<div class="muted">{% blocktrans with date=invoice.sent_at|date:"j F Y" %}Issued {{ date }}{% endblocktrans %}</div>{% endif %}
</div>
</div>
<div class="parties">
<div>
<h2>{% trans "Billed to" %}</h2>
<div><strong>{{ member }}</strong></div>
{% if invoice.sent_to_email %}<div class="muted">{{ invoice.sent_to_email }}{% if invoice.sent_to_guardian %} ({% trans "parent/guardian" %}){% endif %}</div>{% endif %}
</div>
<div class="right">
<h2>{% trans "Season" %}</h2>
<div>{{ membership.season }}</div>
<div class="muted">{% blocktrans with date=invoice.due_date|date:"j F Y" %}Due {{ date }}{% endblocktrans %}</div>
</div>
</div>
<table>
<thead>
<tr>
<th>{% trans "Description" %}</th>
<th class="right">{% trans "Amount" %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<strong>{% trans "Membership fee" %}</strong>
<div class="muted">{{ club.name }} — {{ membership.season }}</div>
</td>
<td class="right">€{{ invoice.amount }}</td>
</tr>
<tr class="total">
<td>{% trans "Balance due" %}</td>
<td class="right balance {% if invoice.is_paid %}paid{% else %}owed{% endif %}">€{{ invoice.amount }}</td>
</tr>
</tbody>
</table>
{% if invoice.is_paid %}
<p class="paid"><strong>{% trans "Paid in full." %}</strong> {% trans "Thank you." %}</p>
{% else %}
<p class="muted">{% blocktrans with date=invoice.due_date|date:"j F Y" %}Payable by {{ date }}.{% endblocktrans %}</p>
{% endif %}
</body>
</html>

View File

@@ -0,0 +1,72 @@
{% extends "email/_base.html" %}
{% load i18n club_email %}
{% comment %}
HTML sibling of dues_invoice.txt -- same content, same context (club, membership,
member, invoice, request), laid out for an inbox. Kept in lockstep with the .txt
version by hand, same as members/templates/members/email/claim_approved.html.
{% endcomment %}
{% block title %}{% blocktrans with club=club.name number=invoice.number %}{{ club }} — invoice {{ number }}{% endblocktrans %}{% endblock title %}
{% block preheader %}{% blocktrans with club=club.name %}{{ club }} has sent you an invoice for a membership fee.{% endblocktrans %}{% endblock preheader %}
{% block header %}
{% absolute_media_url club.logo as logo_url %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="middle">
{% if club.logo %}
<img src="{{ logo_url }}" alt="{{ club.name }}" width="48" height="48" style="display:block; width:48px; height:48px; border-radius:24px; object-fit:contain; background-color:#f3f4f6;">
{% else %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="48" style="width:48px;">
<tr>
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_color|default:"#ec4899"|contrast_color }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
{{ club.initials }}
</td>
</tr>
</table>
{% endif %}
</td>
<td style="padding-left:14px;" valign="middle">
<span style="font-family: Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; color:#111827;">{{ club.name }}</span>
</td>
</tr>
</table>
{% endblock header %}
{% block content %}
<p style="margin:0 0 16px 0;">{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}</p>
<p style="margin:0 0 20px 0;">{% blocktrans with club=club.name season=membership.season %}{{ club }} has sent you an invoice for your {{ season }} membership fee.{% endblocktrans %}</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0; border:1px solid #e5e7eb; border-radius:8px;">
<tr>
<td style="padding:16px 20px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="font-size:13px; color:#6b7280;">{% trans "Amount due" %}</td>
<td align="right" style="font-size:18px; font-weight:bold; color:#111827;">€{{ invoice.amount }}</td>
</tr>
<tr>
<td style="font-size:13px; color:#6b7280; padding-top:6px;">{% trans "Due date" %}</td>
<td align="right" style="font-size:13px; color:#111827; padding-top:6px;">{{ invoice.due_date|date:"j F Y" }}</td>
</tr>
<tr>
<td style="font-size:13px; color:#6b7280; padding-top:6px;">{% trans "Invoice number" %}</td>
<td align="right" style="font-size:13px; color:#111827; padding-top:6px;">{{ invoice.number }}</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="margin:0;">{% trans "A PDF copy of this invoice is attached." %}</p>
{% endblock content %}
{% block footer %}
{% if club.contact_email %}
<p style="margin:0 0 8px 0;">{% blocktrans with email=club.contact_email %}Questions about this invoice? Reply to this note or write to {{ email }}.{% endblocktrans %}</p>
{% endif %}
<p style="margin:0;">{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}</p>
{% endblock footer %}

View File

@@ -0,0 +1,11 @@
{% load i18n %}{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}
{% blocktrans with club=club.name season=membership.season %}{{ club }} has sent you an invoice for your {{ season }} membership fee.{% endblocktrans %}
{% trans "Amount due:" %} €{{ invoice.amount }}
{% trans "Due date:" %} {{ invoice.due_date|date:"j F Y" }}
{% trans "Invoice number:" %} {{ invoice.number }}
{% if club.contact_email %}
{% blocktrans with email=club.contact_email %}Questions about this invoice? Reply to this note or write to {{ email }}.{% endblocktrans %}
{% endif %}
{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}

View File

@@ -0,0 +1,73 @@
{% extends "email/_base.html" %}
{% load i18n club_email %}
{% comment %}
HTML sibling of dues_invoice_reminder.txt -- same content, same context (club,
membership, member, invoice, request). Same shell/branding as dues_invoice.html,
just a different message and no "PDF attached" line (the reminder re-attaches
the same PDF the original invoice did, but leads with the overdue note instead).
{% endcomment %}
{% block title %}{% blocktrans with club=club.name number=invoice.number %}Reminder: {{ club }} invoice {{ number }} is overdue{% endblocktrans %}{% endblock title %}
{% block preheader %}{% blocktrans with club=club.name %}A membership fee invoice from {{ club }} is still unpaid.{% endblocktrans %}{% endblock preheader %}
{% block header %}
{% absolute_media_url club.logo as logo_url %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="middle">
{% if club.logo %}
<img src="{{ logo_url }}" alt="{{ club.name }}" width="48" height="48" style="display:block; width:48px; height:48px; border-radius:24px; object-fit:contain; background-color:#f3f4f6;">
{% else %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="48" style="width:48px;">
<tr>
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_color|default:"#ec4899"|contrast_color }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
{{ club.initials }}
</td>
</tr>
</table>
{% endif %}
</td>
<td style="padding-left:14px;" valign="middle">
<span style="font-family: Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; color:#111827;">{{ club.name }}</span>
</td>
</tr>
</table>
{% endblock header %}
{% block content %}
<p style="margin:0 0 16px 0;">{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}</p>
<p style="margin:0 0 20px 0;">{% blocktrans with club=club.name date=invoice.due_date|date:"j F Y" %}This is a reminder that {{ club }}'s invoice for your membership fee was due on {{ date }} and is still unpaid.{% endblocktrans %}</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0; border:1px solid #fca5a5; background-color:#fef2f2; border-radius:8px;">
<tr>
<td style="padding:16px 20px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="font-size:13px; color:#991b1b;">{% trans "Amount due" %}</td>
<td align="right" style="font-size:18px; font-weight:bold; color:#991b1b;">€{{ invoice.amount }}</td>
</tr>
<tr>
<td style="font-size:13px; color:#991b1b; padding-top:6px;">{% trans "Was due" %}</td>
<td align="right" style="font-size:13px; color:#991b1b; padding-top:6px;">{{ invoice.due_date|date:"j F Y" }}</td>
</tr>
<tr>
<td style="font-size:13px; color:#991b1b; padding-top:6px;">{% trans "Invoice number" %}</td>
<td align="right" style="font-size:13px; color:#991b1b; padding-top:6px;">{{ invoice.number }}</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="margin:0;">{% trans "A PDF copy of this invoice is attached." %}</p>
{% endblock content %}
{% block footer %}
{% if club.contact_email %}
<p style="margin:0 0 8px 0;">{% blocktrans with email=club.contact_email %}Already paid? Let us know at {{ email }} so we can update our records.{% endblocktrans %}</p>
{% endif %}
<p style="margin:0;">{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}</p>
{% endblock footer %}

View File

@@ -0,0 +1,10 @@
{% load i18n %}{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}
{% blocktrans with club=club.name date=invoice.due_date|date:"j F Y" %}This is a reminder that {{ club }}'s invoice for your membership fee was due on {{ date }} and is still unpaid.{% endblocktrans %}
{% trans "Amount due:" %} €{{ invoice.amount }}
{% trans "Invoice number:" %} {{ invoice.number }}
{% if club.contact_email %}
{% blocktrans with email=club.contact_email %}Already paid? Let us know at {{ email }} so we can update our records.{% endblocktrans %}
{% endif %}
{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}

View File

@@ -0,0 +1 @@
{% load i18n %}{% blocktrans with club=club.name number=invoice.number %}Reminder: {{ club }} invoice {{ number }} is overdue{% endblocktrans %}

View File

@@ -0,0 +1 @@
{% load i18n %}{% blocktrans with club=club.name number=invoice.number %}{{ club }} — invoice {{ number }}{% endblocktrans %}

View File

@@ -22,7 +22,7 @@ from members.models import Family, FamilyMembership, Member
from teams.models import Position, StaffAssignment, Team, TeamMembership
from teams.services import eligible_roster_members
from .models import Club, ClubMembership, ClubRole, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor, club_logo_path
from .models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor, club_logo_path
from .services.access import (
COACH_MANAGER,
can_edit_event,
@@ -39,6 +39,7 @@ from .services.access import (
teams_staffed_by,
)
from .services.fees import mark_as_paid, record_payment, remaining_balance
from .services.invoicing import create_or_resend_invoice, invoices_due_for_reminder, recipient_for
from .services.onboarding import (
annotate_onboarding_status,
approve_all_clean,
@@ -1560,6 +1561,123 @@ class FeeServiceTests(TestCase):
self.assertEqual(payment.recorded_by, user)
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."""
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
cls.season = make_season(cls.club)
def test_the_members_own_email_wins(self):
member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com")
email, used_guardian = recipient_for(member)
self.assertEqual(email, "jane@example.com")
self.assertFalse(used_guardian)
def test_falls_back_to_a_guardians_email_when_the_member_has_none(self):
family = Family.objects.create()
member = Member.objects.create(first_name="Jane", last_name="Doe")
parent = Member.objects.create(first_name="Pat", last_name="Doe", email="pat@example.com")
FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
email, used_guardian = recipient_for(member)
self.assertEqual(email, "pat@example.com")
self.assertTrue(used_guardian)
def test_empty_when_nobody_is_reachable(self):
family = Family.objects.create()
member = Member.objects.create(first_name="Jane", last_name="Doe")
parent = Member.objects.create(first_name="Pat", last_name="Doe")
FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
email, used_guardian = recipient_for(member)
self.assertEqual(email, "")
self.assertFalse(used_guardian)
class CreateOrResendInvoiceTests(TestCase):
"""club.services.invoicing.create_or_resend_invoice -- one invoice per
membership, numbered once, re-snapshotted on every send."""
@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", email="jane@example.com")
cls.membership = ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("150.00"))
def test_amount_is_the_remaining_balance_not_the_full_fee(self):
record_payment(self.membership, amount=Decimal("50.00"))
invoice = create_or_resend_invoice(self.membership, due_in_days=14, recipient_email="jane@example.com", sent_to_guardian=False)
self.assertEqual(invoice.amount, Decimal("100.00"))
def test_due_date_is_today_plus_due_in_days(self):
invoice = create_or_resend_invoice(self.membership, due_in_days=10, recipient_email="jane@example.com", sent_to_guardian=False)
self.assertEqual(invoice.due_date, timezone.now().date() + datetime.timedelta(days=10))
def test_a_number_is_allocated_once(self):
invoice = create_or_resend_invoice(self.membership, due_in_days=14, recipient_email="jane@example.com", sent_to_guardian=False)
first_number = invoice.number
resent = create_or_resend_invoice(self.membership, due_in_days=30, recipient_email="jane@example.com", sent_to_guardian=False)
self.assertEqual(resent.pk, invoice.pk)
self.assertEqual(resent.number, first_number)
self.assertEqual(resent.due_date, timezone.now().date() + datetime.timedelta(days=30))
def test_a_membership_can_only_ever_have_one_invoice_row(self):
create_or_resend_invoice(self.membership, due_in_days=14, recipient_email="jane@example.com", sent_to_guardian=False)
create_or_resend_invoice(self.membership, due_in_days=14, recipient_email="jane@example.com", sent_to_guardian=False)
self.assertEqual(DuesInvoice.objects.filter(membership=self.membership).count(), 1)
class InvoicesDueForReminderTests(TestCase):
"""club.services.invoicing.invoices_due_for_reminder -- sent, unpaid, past
their own due date; never paid, waived, or not yet due."""
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
cls.season = make_season(cls.club)
def make_invoice(self, *, fee_status, due_date, sent_at=None):
member = Member.objects.create(first_name="Member", last_name=fee_status)
membership = ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("100.00"), fee_status=fee_status)
return DuesInvoice.objects.create(club=self.club, membership=membership, amount=Decimal("100.00"), due_date=due_date, sent_at=sent_at or timezone.now())
def test_includes_an_overdue_unpaid_invoice(self):
overdue = self.make_invoice(fee_status=ClubMembership.FeeStatus.UNPAID, due_date=timezone.now().date() - datetime.timedelta(days=1))
self.assertIn(overdue, invoices_due_for_reminder(self.club))
def test_excludes_a_paid_invoice(self):
paid = self.make_invoice(fee_status=ClubMembership.FeeStatus.PAID, due_date=timezone.now().date() - datetime.timedelta(days=1))
self.assertNotIn(paid, invoices_due_for_reminder(self.club))
def test_excludes_a_waived_invoice(self):
waived = self.make_invoice(fee_status=ClubMembership.FeeStatus.WAIVED, due_date=timezone.now().date() - datetime.timedelta(days=1))
self.assertNotIn(waived, invoices_due_for_reminder(self.club))
def test_excludes_one_not_yet_due(self):
not_due = self.make_invoice(fee_status=ClubMembership.FeeStatus.UNPAID, due_date=timezone.now().date() + datetime.timedelta(days=5))
self.assertNotIn(not_due, invoices_due_for_reminder(self.club))
class SeasonStartEndTests(TestCase):
"""club.services.seasons._initial_season_start / _season_end -- the
per-club rules generate_seasons chains off, now that a club's own

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,8 +116,11 @@
<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>
<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">
<table class="table">
@@ -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."""

View File

@@ -785,6 +785,26 @@
}
}
}
.collapse-plus {
@layer daisyui.l1.l2 {
> .collapse-title:after {
position: absolute;
display: block;
height: 0.5rem;
width: 0.5rem;
@media (prefers-reduced-motion: no-preference) {
transition-property: all;
transition-duration: 300ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
top: 0.9rem;
inset-inline-end: 1.4rem;
--tw-content: "+";
content: var(--tw-content);
pointer-events: none;
}
}
}
.dropdown {
@layer daisyui.l1.l2.l3 {
position: relative;
@@ -1330,6 +1350,13 @@
}
}
}
.validator-hint {
@layer daisyui.l1.l2.l3 {
visibility: hidden;
margin-top: calc(0.25rem * 2);
font-size: 0.75rem;
}
}
.validator {
@layer daisyui.l1.l2.l3 {
&:user-valid, &:has(:user-valid) {
@@ -1353,6 +1380,21 @@
}
}
}
.collapse-open {
@layer daisyui.l1.l2 {
grid-template-rows: max-content 1fr;
> .collapse-content {
--overflow-delay: 0.2s;
overflow: revert-layer;
content-visibility: visible;
min-height: fit-content;
padding-bottom: 1rem;
@supports not (content-visibility: visible) {
visibility: visible;
}
}
}
}
.collapse {
visibility: collapse;
}
@@ -1421,6 +1463,27 @@
}
}
}
.toast {
@layer daisyui.l1.l2.l3 {
position: fixed;
inset-inline-start: auto;
inset-inline-end: calc(0.25rem * 4);
top: auto;
bottom: calc(0.25rem * 4);
display: flex;
flex-direction: column;
gap: calc(0.25rem * 2);
background-color: transparent;
translate: var(--toast-x, 0) var(--toast-y, 0);
width: max-content;
max-width: calc(100vw - 2rem);
& > * {
@media (prefers-reduced-motion: no-preference) {
animation: toast 0.25s ease-out;
}
}
}
}
.toggle {
@layer daisyui.l1.l2.l3 {
border: var(--border) solid currentColor;
@@ -1873,6 +1936,51 @@
}
}
}
.aura {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-block;
--aura-padding: 0.125rem;
padding: var(--aura-padding);
border-radius: calc(var(--aura-padding) + var(--aura-radius, var(--radius-box)));
animation: aura var(--tw-duration, 6s) linear infinite;
background-image: conic-gradient(from var(--aura-angle), transparent 225deg, currentColor);
&:has( > .card, > .alert) {
--aura-radius: var(--radius-box);
}
&:has( > .btn, > .input, > .select) {
--aura-radius: var(--radius-field);
}
&:has( > .checkbox, > .toggle, > .badge) {
--aura-radius: var(--radius-selector);
}
&:before, &:after {
animation: inherit;
background-color: inherit;
background-image: inherit;
border-radius: inherit;
position: absolute;
top: calc(1 / 2 * 100%);
left: calc(1 / 2 * 100%);
z-index: 0;
display: block;
opacity: 70%;
filter: blur(0.25rem);
translate: -50% -50%;
width: 100%;
height: 100%;
content: "";
}
&:after {
opacity: 30%;
filter: blur(1rem);
}
& > * {
position: relative;
z-index: 1;
}
}
}
.steps {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
@@ -2395,6 +2503,48 @@
}
}
}
.rating {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-flex;
vertical-align: middle;
--size: var(--size-selector, 0.25rem) * 6;
input {
cursor: pointer;
appearance: none;
}
* {
border-radius: 0;
background-color: var(--color-base-content);
opacity: 20%;
width: calc(var(--size) * 1);
height: calc(var(--size));
@media (prefers-reduced-motion: no-preference) {
animation: rating 0.25s ease-out;
}
}
.rating-hidden {
width: calc(0.25rem * 2);
background-color: transparent;
}
:checked, [aria-checked="true"], [aria-current="true"], :has( ~ :checked, ~ [aria-checked="true"], ~ [aria-current="true"]) {
opacity: 100%;
}
:focus-visible {
scale: 1.1;
@media (prefers-reduced-motion: no-preference) {
transition: scale 0.2s ease-out;
}
}
:active:focus {
animation: none;
scale: 1.1;
}
}
@layer daisyui.l1.l2 {
--size: var(--size-selector, 0.25rem) * 6;
}
}
.navbar {
@layer daisyui.l1.l2.l3 {
display: flex;
@@ -2545,6 +2695,30 @@
.inset-0 {
inset: 0;
}
.dropdown-right {
@layer daisyui.l1.l2 {
--anchor-h: right;
--anchor-v: span-bottom;
.dropdown-content {
inset-inline-start: 100%;
top: 0;
bottom: auto;
transform-origin: 0;
}
}
}
.dropdown-left {
@layer daisyui.l1.l2 {
--anchor-h: left;
--anchor-v: span-bottom;
.dropdown-content {
inset-inline-end: 100%;
top: 0;
bottom: auto;
transform-origin: 100%;
}
}
}
.dropdown-end {
@layer daisyui.l1.l2 {
--anchor-h: span-left;
@@ -3041,6 +3215,26 @@
.z-50 {
z-index: 50;
}
.tab-content {
@layer daisyui.l1.l2.l3 {
order: var(--tabcontent-order);
display: none;
border-color: transparent;
--tabcontent-radius-ss: var(--radius-box);
--tabcontent-radius-se: var(--radius-box);
--tabcontent-radius-es: var(--radius-box);
--tabcontent-radius-ee: var(--radius-box);
--tabcontent-order: 1;
width: 100%;
height: calc(100% - var(--tab-height) + var(--border));
margin: var(--tabcontent-margin);
border-width: var(--border);
border-start-start-radius: var(--tabcontent-radius-ss);
border-start-end-radius: var(--tabcontent-radius-se);
border-end-start-radius: var(--tabcontent-radius-es);
border-end-end-radius: var(--tabcontent-radius-ee);
}
}
.col-span-2 {
grid-column: span 2 / span 2;
}
@@ -3443,6 +3637,20 @@
}
}
}
.fieldset-label {
@layer daisyui.l1.l2.l3 {
display: flex;
align-items: center;
gap: calc(0.25rem * 1.5);
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
}
&:has(input) {
cursor: pointer;
}
}
}
.alert {
border-width: var(--border);
border-color: var(--alert-border-color, var(--color-base-200));
@@ -3590,6 +3798,9 @@
.inline-flex {
display: inline-flex;
}
.inline-grid {
display: inline-grid;
}
.table {
display: table;
}
@@ -3867,6 +4078,9 @@
.shrink-0 {
flex-shrink: 0;
}
.flex-grow {
flex-grow: 1;
}
.grow {
flex-grow: 1;
}
@@ -3900,6 +4114,18 @@
}
}
}
.aura-glow {
@layer daisyui.l1.l2 {
animation: none;
background-image: radial-gradient(closest-corner at center, currentColor 0%, transparent 90%);
&:before {
animation: aura-glow var(--tw-duration, 6s) ease-out infinite;
}
&:after {
animation: aura-glow-after var(--tw-duration, 6s) ease-out infinite;
}
}
}
.animate-pulse {
animation: var(--animate-pulse);
}
@@ -4288,6 +4514,9 @@
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
}
}
.mask-repeat {
mask-repeat: repeat;
}
.stroke-2 {
stroke-width: 2;
}
@@ -4788,6 +5017,9 @@
text-decoration-line: none;
}
}
.underline {
text-decoration-line: underline;
}
.antialiased {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -4886,6 +5118,14 @@
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.ease-in-out {
--tw-ease: var(--ease-in-out);
transition-timing-function: var(--ease-in-out);
}
.ease-out {
--tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out);
}
.input-lg {
@layer daisyui.l1.l2 {
--in-size-mul: 12;
@@ -5938,6 +6178,10 @@
syntax: "*";
inherits: false;
}
@property --tw-ease {
syntax: "*";
inherits: false;
}
@keyframes pulse {
50% {
opacity: 0.5;
@@ -6002,6 +6246,7 @@
--tw-backdrop-opacity: initial;
--tw-backdrop-saturate: initial;
--tw-backdrop-sepia: initial;
--tw-ease: initial;
}
}
}