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

@@ -0,0 +1,64 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% blocktrans with number=invoice.number %}Invoice {{ number }}{% endblocktrans %}{% endblock heading %}
{% block topbar_context %}<span class="text-sm text-muted">{{ member }}</span>{% endblock topbar_context %}
{% block actions %}
<a class="btn btn-outline gap-2" href="{% url 'management:membership_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Dues & billing" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:membership_invoice_pdf' membership.pk %}">{% lucide "file-down" size=16 %} {% trans "Download PDF" %}</a>
{% endblock actions %}
{% block panel %}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="card p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Amount due" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums {% if invoice.is_paid %}text-ok{% else %}text-ink{% endif %}">€{{ invoice.amount }}</div>
<div class="mt-1 text-[13px] text-muted">
{% if invoice.is_paid %}{% trans "Paid" %}{% elif invoice.is_overdue %}<span class="text-club">{% trans "Overdue" %}</span>{% else %}{% blocktrans with date=invoice.due_date|date:"j F Y" %}Due {{ date }}{% endblocktrans %}{% endif %}
</div>
</div>
<div class="card p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Sent" %}</div>
<div class="mt-1 font-display text-[22px] leading-none font-extrabold text-ink">{{ invoice.sent_at|date:"j M Y" }}</div>
<div class="mt-1 text-[13px] text-muted">{{ invoice.sent_to_email }}{% if invoice.sent_to_guardian %} <span class="text-dim">({% trans "parent/guardian" %})</span>{% endif %}</div>
</div>
<div class="card p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Reminders" %}</div>
<div class="mt-1 font-display text-[22px] leading-none font-extrabold text-ink tabular-nums">{{ invoice.reminder_count }}</div>
<div class="mt-1 text-[13px] text-muted">
{% if invoice.last_reminder_sent_at %}{% blocktrans with date=invoice.last_reminder_sent_at|date:"j M Y" %}Last sent {{ date }}{% endblocktrans %}{% else %}{% trans "None sent yet" %}{% endif %}
</div>
</div>
</div>
<div class="card card-body gap-3">
<h2 class="card-title">{% lucide "receipt" size=18 %} {% trans "Details" %}</h2>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<div class="text-xs text-muted">{% trans "Member" %}</div>
<div class="font-semibold text-ink"><a class="link link-hover" href="{% url 'management:member_detail' member.pk %}">{{ member }}</a></div>
</div>
<div>
<div class="text-xs text-muted">{% trans "Season" %}</div>
<div>{{ membership.season }}</div>
</div>
<div>
<div class="text-xs text-muted">{% trans "Fee status" %}</div>
<div>
<span class="badge badge-sm
{% if membership.fee_status == "paid" %}badge-success
{% elif membership.fee_status == "partially_paid" %}badge-warning
{% elif membership.fee_status == "unpaid" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ membership.get_fee_status_display }}
</span>
</div>
</div>
<div>
<div class="text-xs text-muted">{% trans "Invoice number" %}</div>
<div class="font-mono">{{ invoice.number }}</div>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -10,6 +10,14 @@
{% endblock topbar_context %}
{% block actions %}
<form method="post" action="{% url 'management:membership_send_invoice_reminders' %}" class="inline-flex">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button class="btn btn-outline gap-2" type="submit">
{% lucide "bell" size=16 %} {% trans "Send reminders" %}
{% if kpi_overdue_invoices %}<span class="badge badge-error badge-xs">{{ kpi_overdue_invoices }}</span>{% endif %}
</button>
</form>
<a class="btn btn-outline gap-2" href="{% url 'management:membership_export_pdf' %}?{{ request.GET.urlencode }}">{% lucide "file-down" size=16 %} {% trans "Export to PDF" %}</a>
{% endblock actions %}
@@ -108,7 +116,10 @@
<span class="font-display text-sm font-bold tracking-wide text-muted uppercase">
{% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %}
</span>
<button class="btn btn-success btn-sm gap-2" type="submit">{% lucide "circle-check" size=14 %} {% trans "Mark selected as paid" %}</button>
<div class="flex gap-2">
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('send_invoices_modal').showModal()">{% lucide "send" size=14 %} {% trans "Send invoice" %}</button>
<button class="btn btn-success btn-sm gap-2" type="submit">{% lucide "circle-check" size=14 %} {% trans "Mark selected as paid" %}</button>
</div>
</div>
<div class="overflow-x-auto">
@@ -124,6 +135,7 @@
<th>{% trans "Owed" %}</th>
<th>{% trans "Paid" %}</th>
<th>{% trans "License" %}</th>
<th>{% trans "Invoice" %}</th>
<th></th>
</tr>
</thead>
@@ -168,6 +180,18 @@
{% endif %}
</td>
<td>{{ membership.license|default:"-" }}</td>
<td>
{% if membership.dues_invoice %}
<a class="link link-hover" href="{% url 'management:membership_invoice_detail' membership.pk %}">
<span class="badge badge-sm {% if membership.dues_invoice.is_paid %}badge-success{% elif membership.dues_invoice.is_overdue %}badge-error{% else %}badge-neutral{% endif %}">
{% if membership.dues_invoice.is_paid %}{% trans "Paid" %}{% elif membership.dues_invoice.is_overdue %}{% trans "Overdue" %}{% else %}{% trans "Sent" %}{% endif %}
</span>
</a>
<div class="text-xs text-muted">{{ membership.dues_invoice.sent_at|date:"j M" }}{% if membership.dues_invoice.reminder_count %}, {% blocktrans count counter=membership.dues_invoice.reminder_count %}{{ counter }} reminder{% plural %}{{ counter }} reminders{% endblocktrans %}{% endif %}</div>
{% else %}
<span class="text-xs text-dim">{% trans "Not sent" %}</span>
{% endif %}
</td>
<td class="text-right">
{% if membership.record_payment_form %}
<div class="flex flex-wrap justify-end gap-1">
@@ -183,7 +207,7 @@
</tr>
{% empty %}
<tr>
<td colspan="10" class="text-center text-muted">{% trans "Nobody matches these filters." %}</td>
<td colspan="11" class="text-center text-muted">{% trans "Nobody matches these filters." %}</td>
</tr>
{% endfor %}
</tbody>
@@ -192,6 +216,24 @@
</div>
</form>
{# due_in_days's widget carries form="membership-form" (see SendDuesInvoicesForm), so it and the submit button below (via its own form=) post through the *same* form as the row checkboxes, just to a different action, overridden with formaction. #}
<dialog id="send_invoices_modal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">{% trans "Send invoice" %}</h3>
<p class="py-2 text-sm opacity-70">{% trans "Emails each selected member (or a parent/guardian, if they have no email on file) an invoice for their outstanding balance." %}</p>
<div class="form-control w-full">
{% form_field send_invoice_form.due_in_days size="small" %}
</div>
</div>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
</form>
<button class="btn btn-primary gap-2" type="submit" form="membership-form" formaction="{% url 'management:membership_send_invoices' %}">{% lucide "send" size=16 %} {% trans "Send" %}</button>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
{% trans "Record payment" as record_payment_title %}
{% trans "Record" as record_payment_submit_label %}
{% for membership in memberships %}