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:
64
management/templates/management/dues_invoice_detail.html
Normal file
64
management/templates/management/dues_invoice_detail.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user