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