Files
RosterChief/management/templates/management/membership_list.html
Bernard Siebens 94d6cbd4e9 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
2026-08-20 21:23:06 +02:00

266 lines
18 KiB
HTML

{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Dues & billing" %}{% endblock heading %}
{% block topbar_context %}
{% if selected_season %}
<span class="font-mono text-sm text-muted">{{ selected_season.start_date|date:"Y" }}&ndash;{{ selected_season.end_date|date:"Y" }}</span>
{% endif %}
{% 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 %}
{% block filter_strip %}
<div class="border-b border-line bg-white px-7 py-3">
<form method="get" class="flex flex-row flex-wrap items-center gap-2">
<div class="relative">
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-dim">{% lucide "search" size=15 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input w-56 pl-9">
</div>
<select name="season" class="select w-48">
{% for season in seasons %}
<option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option>
{% endfor %}
</select>
<select name="fee_status" class="select w-56">
<option value="not_paid" {% if selected_fee_status == "not_paid" %}selected{% endif %}>{% trans "Not paid (unpaid or partially)" %}</option>
{% for value, label in fee_status_choices %}
<option value="{{ value }}" {% if value == selected_fee_status %}selected{% endif %}>{{ label|capfirst }}</option>
{% endfor %}
<option value="all" {% if selected_fee_status == "all" %}selected{% endif %}>{% trans "All" %}</option>
</select>
<select name="status" class="select w-40">
<option value="all" {% if selected_status == "all" %}selected{% endif %}>{% trans "All statuses" %}</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if value == selected_status %}selected{% endif %}>{{ label|capfirst }}</option>
{% endfor %}
</select>
<select name="team" class="select w-44">
<option value="" {% if not selected_team %}selected{% endif %}>{% trans "All teams" %}</option>
{% for team in teams %}
<option value="{{ team.pk }}" {% if team.pk|stringformat:"s" == selected_team %}selected{% endif %}>{{ team }}</option>
{% endfor %}
</select>
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
<a class="btn btn-ghost gap-2" href="{% url 'management:membership_list' %}">{% lucide "x" size=16 %} {% trans "Reset" %}</a>
</form>
</div>
{% endblock filter_strip %}
{% block panel %}
{% if not current_season %}
<div class="alert alert-warning">
{% lucide "calendar-x" size=20 %}
<span>{% trans "No season covers today, so there's nothing to show fee status for yet." %}</span>
</div>
{% else %}
{# Same anatomy as the Overview dashboard's own KPI row (home.html): a plain white .card, no colour-coded border -- status lives in the number's own colour (neutral ink unless there's something to flag) plus a caption line, not a border accent. #}
<div class="flex gap-3.5">
<div class="card min-w-0 flex-1 p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Registered" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold text-ink tabular-nums">{{ kpi_total }}</div>
<div class="mt-1 text-[13px] text-muted">{% trans "This season" %}</div>
</div>
<div class="card min-w-0 flex-1 p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Paid" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums text-ok">{{ kpi_paid }}</div>
<div class="mt-1 text-[13px] text-muted">{% trans "Fully settled" %}</div>
</div>
<div class="card min-w-0 flex-1 p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Partially paid" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums {% if kpi_partial %}text-warn{% else %}text-ink{% endif %}">{{ kpi_partial }}</div>
<div class="mt-1 text-[13px] text-muted">{% trans "Still owe a balance" %}</div>
</div>
<div class="card min-w-0 flex-1 p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Unpaid" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums {% if kpi_unpaid %}text-club{% else %}text-ink{% endif %}">{{ kpi_unpaid }}</div>
<div class="mt-1 text-[13px] text-muted">{% trans "Nothing paid yet" %}</div>
</div>
<div class="card min-w-0 flex-1 p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Waived" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums text-ink">{{ kpi_waived }}</div>
<div class="mt-1 text-[13px] text-muted">{% trans "Fee waived" %}</div>
</div>
<div class="card min-w-0 flex-1 p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Paid rate" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums {% if kpi_paid_rate is None %}text-info{% elif kpi_paid_rate < 50 %}text-club{% elif kpi_paid_rate < 85 %}text-warn{% else %}text-ok{% endif %}">
{% if kpi_paid_rate is None %}{% trans "N/A" %}{% else %}{{ kpi_paid_rate }}%{% endif %}
</div>
<div class="mt-1 text-[13px] text-muted">{% trans "Of everyone registered" %}</div>
</div>
</div>
{% endif %}
<form method="post" action="{% url 'management:membership_mark_paid' %}" id="membership-form">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<div class="card overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-line px-4.5 py-3.5">
<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>
<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">
<table class="table">
<thead>
<tr>
<th><input type="checkbox" class="checkbox" id="select-all-memberships"></th>
<th>{% trans "Name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Fee status" %}</th>
<th>{% trans "Owed" %}</th>
<th>{% trans "Paid" %}</th>
<th>{% trans "License" %}</th>
<th>{% trans "Invoice" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for membership in memberships %}
<tr>
<td><input type="checkbox" class="checkbox membership-row-checkbox" name="membership_ids" value="{{ membership.pk }}"></td>
<td><a class="link link-hover font-semibold" href="{% url 'management:member_detail' membership.member.pk %}">{{ membership.member.last_name }}, {{ membership.member.first_name }}</a></td>
<td>{{ membership.member.contact_email|default:"-" }}</td>
<td>
{% if membership.member.family_memberships_display %}
{% for fm in membership.member.family_memberships_display %}
{{ fm.family }}{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}
<span class="text-dim">-</span>
{% endif %}
</td>
<td>
<span class="badge badge-sm
{% if membership.status == "active" %}badge-success
{% elif membership.status == "pending" %}badge-warning
{% elif membership.status == "cancelled" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ membership.get_status_display }}
</span>
</td>
<td>
<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>
</td>
<td class="font-mono tabular-nums">&euro; {{ membership.fee_amount }}</td>
<td class="font-mono tabular-nums">
&euro; {{ membership.amount_paid }}
{% if membership.record_payment_form and membership.fee_amount %}
<div class="font-sans text-xs text-muted">{% blocktrans with remaining=membership.remaining_balance_display %}{{ remaining }} left{% endblocktrans %}</div>
{% 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">
<button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"record_payment_modal" }}').showModal()">
{% lucide "receipt" size=12 %} {% trans "Record payment" %}
</button>
<button class="btn btn-sm btn-outline btn-success" type="submit" form="{{ membership.pk|dom_id:"mark_fully_paid_form" }}">
{% lucide "circle-check" size=12 %} {% trans "Mark fully paid" %}
</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="11" class="text-center text-muted">{% trans "Nobody matches these filters." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</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 %}
{% if membership.record_payment_form %}
{# A standalone form, not nested inside the bulk form above -- its submit button lives in the table row and links back here via the form="..." attribute. #}
<form method="post" action="{% url 'management:membership_mark_fully_paid' membership.pk %}" id="{{ membership.pk|dom_id:"mark_fully_paid_form" }}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
</form>
{% url 'management:membership_record_payment' membership.pk as record_payment_url %}
{% blocktrans with name=membership.member asvar record_payment_blurb %}For {{ name }}. Any amount is fine -- partial payments accumulate until the fee is settled.{% endblocktrans %}
{% include "controlpanel/_modal_form.html" with modal_id=membership.pk|dom_id:"record_payment_modal" title=record_payment_title form=membership.record_payment_form action_url=record_payment_url submit_label=record_payment_submit_label submit_icon="receipt" blurb=record_payment_blurb %}
{% endif %}
{% endfor %}
{% endblock panel %}
{% block extra_body %}
<script>
(() => {
const selectAll = document.getElementById("select-all-memberships");
const rowCheckboxes = document.querySelectorAll(".membership-row-checkbox");
if (!selectAll) return;
selectAll.addEventListener("change", () => {
rowCheckboxes.forEach((checkbox) => { checkbox.checked = selectAll.checked; });
});
})();
</script>
{% endblock extra_body %}