From 94d6cbd4e96133b0aa69fe7dc66bfe98eb040641 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Thu, 20 Aug 2026 21:23:06 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9 --- club/admin.py | 11 +- club/migrations/0029_duesinvoice.py | 39 +++ club/models.py | 80 +++++- club/services/invoicing.py | 162 ++++++++++++ club/templates/club/dues_invoice_pdf.html | 96 +++++++ club/templates/club/email/dues_invoice.html | 72 +++++ club/templates/club/email/dues_invoice.txt | 11 + .../club/email/dues_invoice_reminder.html | 73 ++++++ .../club/email/dues_invoice_reminder.txt | 10 + .../email/dues_invoice_reminder_subject.txt | 1 + .../club/email/dues_invoice_subject.txt | 1 + club/tests.py | 120 ++++++++- management/forms.py | 13 + .../management/dues_invoice_detail.html | 64 +++++ .../templates/management/membership_list.html | 46 +++- management/tests.py | 192 +++++++++++++- management/urls.py | 4 + management/views.py | 102 +++++++- static/css/app.css | 245 ++++++++++++++++++ 19 files changed, 1334 insertions(+), 8 deletions(-) create mode 100644 club/migrations/0029_duesinvoice.py create mode 100644 club/services/invoicing.py create mode 100644 club/templates/club/dues_invoice_pdf.html create mode 100644 club/templates/club/email/dues_invoice.html create mode 100644 club/templates/club/email/dues_invoice.txt create mode 100644 club/templates/club/email/dues_invoice_reminder.html create mode 100644 club/templates/club/email/dues_invoice_reminder.txt create mode 100644 club/templates/club/email/dues_invoice_reminder_subject.txt create mode 100644 club/templates/club/email/dues_invoice_subject.txt create mode 100644 management/templates/management/dues_invoice_detail.html diff --git a/club/admin.py b/club/admin.py index 16926fc..d6f1948 100644 --- a/club/admin.py +++ b/club/admin.py @@ -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"] diff --git a/club/migrations/0029_duesinvoice.py b/club/migrations/0029_duesinvoice.py new file mode 100644 index 0000000..37050eb --- /dev/null +++ b/club/migrations/0029_duesinvoice.py @@ -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')], + }, + ), + ] diff --git a/club/models.py b/club/models.py index c6894ca..f46f6d2 100644 --- a/club/models.py +++ b/club/models.py @@ -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--``. + 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}" diff --git a/club/services/invoicing.py b/club/services/invoicing.py new file mode 100644 index 0000000..87a5fe1 --- /dev/null +++ b/club/services/invoicing.py @@ -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) diff --git a/club/templates/club/dues_invoice_pdf.html b/club/templates/club/dues_invoice_pdf.html new file mode 100644 index 0000000..e845522 --- /dev/null +++ b/club/templates/club/dues_invoice_pdf.html @@ -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 %} + + + + + {{ invoice.number }} + + + +
+
+

{{ club.name }}

+ {% if club.contact_email %}
{{ club.contact_email }}
{% endif %} +
+
+

{% trans "Invoice" %}

+
{{ invoice.number }}
+ {% if invoice.sent_at %}
{% blocktrans with date=invoice.sent_at|date:"j F Y" %}Issued {{ date }}{% endblocktrans %}
{% endif %} +
+
+ +
+
+

{% trans "Billed to" %}

+
{{ member }}
+ {% if invoice.sent_to_email %}
{{ invoice.sent_to_email }}{% if invoice.sent_to_guardian %} ({% trans "parent/guardian" %}){% endif %}
{% endif %} +
+
+

{% trans "Season" %}

+
{{ membership.season }}
+
{% blocktrans with date=invoice.due_date|date:"j F Y" %}Due {{ date }}{% endblocktrans %}
+
+
+ + + + + + + + + + + + + + + + + + +
{% trans "Description" %}{% trans "Amount" %}
+ {% trans "Membership fee" %} +
{{ club.name }} — {{ membership.season }}
+
€{{ invoice.amount }}
{% trans "Balance due" %}€{{ invoice.amount }}
+ + {% if invoice.is_paid %} + + {% else %} +

{% blocktrans with date=invoice.due_date|date:"j F Y" %}Payable by {{ date }}.{% endblocktrans %}

+ {% endif %} + + diff --git a/club/templates/club/email/dues_invoice.html b/club/templates/club/email/dues_invoice.html new file mode 100644 index 0000000..8f7950c --- /dev/null +++ b/club/templates/club/email/dues_invoice.html @@ -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 %} + + + + + +
+ {% if club.logo %} + {{ club.name }} + {% else %} + + + + +
+ {{ club.initials }} +
+ {% endif %} +
+ {{ club.name }} +
+{% endblock header %} + +{% block content %} +

{% 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 }}
+
+ +

{% trans "A PDF copy of this invoice is attached." %}

+{% endblock content %} + +{% block footer %} + {% 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 %}

+{% endblock footer %} diff --git a/club/templates/club/email/dues_invoice.txt b/club/templates/club/email/dues_invoice.txt new file mode 100644 index 0000000..3490fcd --- /dev/null +++ b/club/templates/club/email/dues_invoice.txt @@ -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 %} diff --git a/club/templates/club/email/dues_invoice_reminder.html b/club/templates/club/email/dues_invoice_reminder.html new file mode 100644 index 0000000..623a6ea --- /dev/null +++ b/club/templates/club/email/dues_invoice_reminder.html @@ -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 %} + + + + + +
+ {% if club.logo %} + {{ club.name }} + {% else %} + + + + +
+ {{ club.initials }} +
+ {% endif %} +
+ {{ club.name }} +
+{% endblock header %} + +{% block content %} +

{% 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 "Was due" %}{{ invoice.due_date|date:"j F Y" }}
{% trans "Invoice number" %}{{ invoice.number }}
+
+ +

{% trans "A PDF copy of this invoice is attached." %}

+{% endblock content %} + +{% block footer %} + {% 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 %}

+{% endblock footer %} diff --git a/club/templates/club/email/dues_invoice_reminder.txt b/club/templates/club/email/dues_invoice_reminder.txt new file mode 100644 index 0000000..96ae146 --- /dev/null +++ b/club/templates/club/email/dues_invoice_reminder.txt @@ -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 %} diff --git a/club/templates/club/email/dues_invoice_reminder_subject.txt b/club/templates/club/email/dues_invoice_reminder_subject.txt new file mode 100644 index 0000000..d4d9f3f --- /dev/null +++ b/club/templates/club/email/dues_invoice_reminder_subject.txt @@ -0,0 +1 @@ +{% load i18n %}{% blocktrans with club=club.name number=invoice.number %}Reminder: {{ club }} invoice {{ number }} is overdue{% endblocktrans %} diff --git a/club/templates/club/email/dues_invoice_subject.txt b/club/templates/club/email/dues_invoice_subject.txt new file mode 100644 index 0000000..9bf40a2 --- /dev/null +++ b/club/templates/club/email/dues_invoice_subject.txt @@ -0,0 +1 @@ +{% load i18n %}{% blocktrans with club=club.name number=invoice.number %}{{ club }} — invoice {{ number }}{% endblocktrans %} diff --git a/club/tests.py b/club/tests.py index 86891aa..8872e55 100644 --- a/club/tests.py +++ b/club/tests.py @@ -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 diff --git a/management/forms.py b/management/forms.py index 59e1808..62930e8 100644 --- a/management/forms.py +++ b/management/forms.py @@ -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 diff --git a/management/templates/management/dues_invoice_detail.html b/management/templates/management/dues_invoice_detail.html new file mode 100644 index 0000000..cdb3f43 --- /dev/null +++ b/management/templates/management/dues_invoice_detail.html @@ -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 %}{{ member }}{% endblock topbar_context %} + +{% block actions %} + {% lucide "arrow-left" size=16 %} {% trans "Dues & billing" %} + {% lucide "file-down" size=16 %} {% trans "Download PDF" %} +{% endblock actions %} + +{% block panel %} +
+
+
{% trans "Amount due" %}
+
€{{ invoice.amount }}
+
+ {% if invoice.is_paid %}{% trans "Paid" %}{% elif invoice.is_overdue %}{% trans "Overdue" %}{% else %}{% blocktrans with date=invoice.due_date|date:"j F Y" %}Due {{ date }}{% endblocktrans %}{% endif %} +
+
+
+
{% trans "Sent" %}
+
{{ invoice.sent_at|date:"j M Y" }}
+
{{ invoice.sent_to_email }}{% if invoice.sent_to_guardian %} ({% trans "parent/guardian" %}){% endif %}
+
+
+
{% trans "Reminders" %}
+
{{ invoice.reminder_count }}
+
+ {% 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 %} +
+
+
+ +
+

{% lucide "receipt" size=18 %} {% trans "Details" %}

+
+
+
{% trans "Member" %}
+ +
+
+
{% trans "Season" %}
+
{{ membership.season }}
+
+
+
{% trans "Fee status" %}
+
+ + {{ membership.get_fee_status_display }} + +
+
+
+
{% trans "Invoice number" %}
+
{{ invoice.number }}
+
+
+
+{% endblock panel %} diff --git a/management/templates/management/membership_list.html b/management/templates/management/membership_list.html index 55f287a..78ad72d 100644 --- a/management/templates/management/membership_list.html +++ b/management/templates/management/membership_list.html @@ -10,6 +10,14 @@ {% endblock topbar_context %} {% block actions %} +
+ {% csrf_token %} + + +
{% lucide "file-down" size=16 %} {% trans "Export to PDF" %} {% endblock actions %} @@ -108,7 +116,10 @@ {% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %} - +
+ + +
@@ -124,6 +135,7 @@ {% trans "Owed" %} {% trans "Paid" %} {% trans "License" %} + {% trans "Invoice" %} @@ -168,6 +180,18 @@ {% endif %} {{ membership.license|default:"-" }} + + {% if membership.dues_invoice %} + + + {% if membership.dues_invoice.is_paid %}{% trans "Paid" %}{% elif membership.dues_invoice.is_overdue %}{% trans "Overdue" %}{% else %}{% trans "Sent" %}{% endif %} + + +
{{ 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 %}
+ {% else %} + {% trans "Not sent" %} + {% endif %} + {% if membership.record_payment_form %}
@@ -183,7 +207,7 @@ {% empty %} - {% trans "Nobody matches these filters." %} + {% trans "Nobody matches these filters." %} {% endfor %} @@ -192,6 +216,24 @@
+ {# 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. #} + + + + + + {% trans "Record payment" as record_payment_title %} {% trans "Record" as record_payment_submit_label %} {% for membership in memberships %} diff --git a/management/tests.py b/management/tests.py index 340e962..eb8fdac 100644 --- a/management/tests.py +++ b/management/tests.py @@ -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): diff --git a/management/urls.py b/management/urls.py index 6c02e3e..b9c9622 100644 --- a/management/urls.py +++ b/management/urls.py @@ -13,6 +13,10 @@ urlpatterns = [ path("memberships/export/", views.MembershipExportPdfView.as_view(), name="membership_export_pdf"), path("memberships//mark-fully-paid/", views.MembershipMarkFullyPaidView.as_view(), name="membership_mark_fully_paid"), path("memberships//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//invoice/", views.DuesInvoiceDetailView.as_view(), name="membership_invoice_detail"), + path("memberships//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"), diff --git a/management/views.py b/management/views.py index aa6c95b..253417e 100644 --- a/management/views.py +++ b/management/views.py @@ -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.""" diff --git a/static/css/app.css b/static/css/app.css index e8ae14c..aedd3ee 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; } } }