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

@@ -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}"