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:
96
club/templates/club/dues_invoice_pdf.html
Normal file
96
club/templates/club/dues_invoice_pdf.html
Normal file
@@ -0,0 +1,96 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% comment %}
|
||||
Rendered by WeasyPrint, not a browser -- same convention as billing/templates/billing/invoice.html
|
||||
and management/templates/management/membership_list_pdf.html: a standalone document with its
|
||||
own print stylesheet, no app.css. Branded off the club's own colours (falling back to the same
|
||||
shades club.templatetags.club_email's HTML emails use) rather than a fixed accent, since this
|
||||
is the club's invoice to its own member, not RosterChief's to the club.
|
||||
{% endcomment %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ invoice.number }}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm;
|
||||
@bottom-center {
|
||||
content: "{{ club.name }} — {% trans "invoice" %} {{ invoice.number }} — " counter(page) " / " counter(pages);
|
||||
font-size: 8pt;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
body { font-family: sans-serif; font-size: 10pt; color: #111; }
|
||||
h1 { font-size: 20pt; margin: 0 0 2mm; }
|
||||
.muted { color: #666; }
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12mm; }
|
||||
.accent { color: {{ club.primary_color|default:"#4f46e5" }}; }
|
||||
.parties { display: flex; justify-content: space-between; margin-bottom: 10mm; }
|
||||
.parties h2 { font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; margin: 0 0 2mm; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
|
||||
th { text-align: left; font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; border-bottom: 1px solid #ccc; padding: 2mm 0; }
|
||||
td { padding: 2mm 0; border-bottom: 1px solid #eee; }
|
||||
.right { text-align: right; }
|
||||
.total td { font-weight: bold; border-bottom: 2px solid #111; border-top: 1px solid #111; }
|
||||
.balance { font-size: 12pt; font-weight: bold; }
|
||||
.paid { color: #15803d; }
|
||||
.owed { color: #b91c1c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>{{ club.name }}</h1>
|
||||
{% if club.contact_email %}<div class="muted">{{ club.contact_email }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="right">
|
||||
<h1 class="accent">{% trans "Invoice" %}</h1>
|
||||
<div><strong>{{ invoice.number }}</strong></div>
|
||||
{% if invoice.sent_at %}<div class="muted">{% blocktrans with date=invoice.sent_at|date:"j F Y" %}Issued {{ date }}{% endblocktrans %}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="parties">
|
||||
<div>
|
||||
<h2>{% trans "Billed to" %}</h2>
|
||||
<div><strong>{{ member }}</strong></div>
|
||||
{% if invoice.sent_to_email %}<div class="muted">{{ invoice.sent_to_email }}{% if invoice.sent_to_guardian %} ({% trans "parent/guardian" %}){% endif %}</div>{% endif %}
|
||||
</div>
|
||||
<div class="right">
|
||||
<h2>{% trans "Season" %}</h2>
|
||||
<div>{{ membership.season }}</div>
|
||||
<div class="muted">{% blocktrans with date=invoice.due_date|date:"j F Y" %}Due {{ date }}{% endblocktrans %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Description" %}</th>
|
||||
<th class="right">{% trans "Amount" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{% trans "Membership fee" %}</strong>
|
||||
<div class="muted">{{ club.name }} — {{ membership.season }}</div>
|
||||
</td>
|
||||
<td class="right">€{{ invoice.amount }}</td>
|
||||
</tr>
|
||||
<tr class="total">
|
||||
<td>{% trans "Balance due" %}</td>
|
||||
<td class="right balance {% if invoice.is_paid %}paid{% else %}owed{% endif %}">€{{ invoice.amount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if invoice.is_paid %}
|
||||
<p class="paid"><strong>{% trans "Paid in full." %}</strong> {% trans "Thank you." %}</p>
|
||||
{% else %}
|
||||
<p class="muted">{% blocktrans with date=invoice.due_date|date:"j F Y" %}Payable by {{ date }}.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
72
club/templates/club/email/dues_invoice.html
Normal file
72
club/templates/club/email/dues_invoice.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{% extends "email/_base.html" %}
|
||||
{% load i18n club_email %}
|
||||
|
||||
{% comment %}
|
||||
HTML sibling of dues_invoice.txt -- same content, same context (club, membership,
|
||||
member, invoice, request), laid out for an inbox. Kept in lockstep with the .txt
|
||||
version by hand, same as members/templates/members/email/claim_approved.html.
|
||||
{% endcomment %}
|
||||
|
||||
{% block title %}{% blocktrans with club=club.name number=invoice.number %}{{ club }} — invoice {{ number }}{% endblocktrans %}{% endblock title %}
|
||||
|
||||
{% block preheader %}{% blocktrans with club=club.name %}{{ club }} has sent you an invoice for a membership fee.{% endblocktrans %}{% endblock preheader %}
|
||||
|
||||
{% block header %}
|
||||
{% absolute_media_url club.logo as logo_url %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td valign="middle">
|
||||
{% if club.logo %}
|
||||
<img src="{{ logo_url }}" alt="{{ club.name }}" width="48" height="48" style="display:block; width:48px; height:48px; border-radius:24px; object-fit:contain; background-color:#f3f4f6;">
|
||||
{% else %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="48" style="width:48px;">
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_color|default:"#ec4899"|contrast_color }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
|
||||
{{ club.initials }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding-left:14px;" valign="middle">
|
||||
<span style="font-family: Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; color:#111827;">{{ club.name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock header %}
|
||||
|
||||
{% block content %}
|
||||
<p style="margin:0 0 16px 0;">{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}</p>
|
||||
|
||||
<p style="margin:0 0 20px 0;">{% blocktrans with club=club.name season=membership.season %}{{ club }} has sent you an invoice for your {{ season }} membership fee.{% endblocktrans %}</p>
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0; border:1px solid #e5e7eb; border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:16px 20px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size:13px; color:#6b7280;">{% trans "Amount due" %}</td>
|
||||
<td align="right" style="font-size:18px; font-weight:bold; color:#111827;">€{{ invoice.amount }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px; color:#6b7280; padding-top:6px;">{% trans "Due date" %}</td>
|
||||
<td align="right" style="font-size:13px; color:#111827; padding-top:6px;">{{ invoice.due_date|date:"j F Y" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px; color:#6b7280; padding-top:6px;">{% trans "Invoice number" %}</td>
|
||||
<td align="right" style="font-size:13px; color:#111827; padding-top:6px;">{{ invoice.number }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0;">{% trans "A PDF copy of this invoice is attached." %}</p>
|
||||
{% endblock content %}
|
||||
|
||||
{% block footer %}
|
||||
{% if club.contact_email %}
|
||||
<p style="margin:0 0 8px 0;">{% blocktrans with email=club.contact_email %}Questions about this invoice? Reply to this note or write to {{ email }}.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<p style="margin:0;">{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}</p>
|
||||
{% endblock footer %}
|
||||
11
club/templates/club/email/dues_invoice.txt
Normal file
11
club/templates/club/email/dues_invoice.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
{% load i18n %}{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}
|
||||
|
||||
{% blocktrans with club=club.name season=membership.season %}{{ club }} has sent you an invoice for your {{ season }} membership fee.{% endblocktrans %}
|
||||
|
||||
{% trans "Amount due:" %} €{{ invoice.amount }}
|
||||
{% trans "Due date:" %} {{ invoice.due_date|date:"j F Y" }}
|
||||
{% trans "Invoice number:" %} {{ invoice.number }}
|
||||
{% if club.contact_email %}
|
||||
{% blocktrans with email=club.contact_email %}Questions about this invoice? Reply to this note or write to {{ email }}.{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}
|
||||
73
club/templates/club/email/dues_invoice_reminder.html
Normal file
73
club/templates/club/email/dues_invoice_reminder.html
Normal file
@@ -0,0 +1,73 @@
|
||||
{% extends "email/_base.html" %}
|
||||
{% load i18n club_email %}
|
||||
|
||||
{% comment %}
|
||||
HTML sibling of dues_invoice_reminder.txt -- same content, same context (club,
|
||||
membership, member, invoice, request). Same shell/branding as dues_invoice.html,
|
||||
just a different message and no "PDF attached" line (the reminder re-attaches
|
||||
the same PDF the original invoice did, but leads with the overdue note instead).
|
||||
{% endcomment %}
|
||||
|
||||
{% block title %}{% blocktrans with club=club.name number=invoice.number %}Reminder: {{ club }} invoice {{ number }} is overdue{% endblocktrans %}{% endblock title %}
|
||||
|
||||
{% block preheader %}{% blocktrans with club=club.name %}A membership fee invoice from {{ club }} is still unpaid.{% endblocktrans %}{% endblock preheader %}
|
||||
|
||||
{% block header %}
|
||||
{% absolute_media_url club.logo as logo_url %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td valign="middle">
|
||||
{% if club.logo %}
|
||||
<img src="{{ logo_url }}" alt="{{ club.name }}" width="48" height="48" style="display:block; width:48px; height:48px; border-radius:24px; object-fit:contain; background-color:#f3f4f6;">
|
||||
{% else %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="48" style="width:48px;">
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_color|default:"#ec4899"|contrast_color }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
|
||||
{{ club.initials }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding-left:14px;" valign="middle">
|
||||
<span style="font-family: Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; color:#111827;">{{ club.name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock header %}
|
||||
|
||||
{% block content %}
|
||||
<p style="margin:0 0 16px 0;">{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}</p>
|
||||
|
||||
<p style="margin:0 0 20px 0;">{% blocktrans with club=club.name date=invoice.due_date|date:"j F Y" %}This is a reminder that {{ club }}'s invoice for your membership fee was due on {{ date }} and is still unpaid.{% endblocktrans %}</p>
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0; border:1px solid #fca5a5; background-color:#fef2f2; border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:16px 20px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size:13px; color:#991b1b;">{% trans "Amount due" %}</td>
|
||||
<td align="right" style="font-size:18px; font-weight:bold; color:#991b1b;">€{{ invoice.amount }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px; color:#991b1b; padding-top:6px;">{% trans "Was due" %}</td>
|
||||
<td align="right" style="font-size:13px; color:#991b1b; padding-top:6px;">{{ invoice.due_date|date:"j F Y" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px; color:#991b1b; padding-top:6px;">{% trans "Invoice number" %}</td>
|
||||
<td align="right" style="font-size:13px; color:#991b1b; padding-top:6px;">{{ invoice.number }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0;">{% trans "A PDF copy of this invoice is attached." %}</p>
|
||||
{% endblock content %}
|
||||
|
||||
{% block footer %}
|
||||
{% if club.contact_email %}
|
||||
<p style="margin:0 0 8px 0;">{% blocktrans with email=club.contact_email %}Already paid? Let us know at {{ email }} so we can update our records.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<p style="margin:0;">{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}</p>
|
||||
{% endblock footer %}
|
||||
10
club/templates/club/email/dues_invoice_reminder.txt
Normal file
10
club/templates/club/email/dues_invoice_reminder.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
{% load i18n %}{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}
|
||||
|
||||
{% blocktrans with club=club.name date=invoice.due_date|date:"j F Y" %}This is a reminder that {{ club }}'s invoice for your membership fee was due on {{ date }} and is still unpaid.{% endblocktrans %}
|
||||
|
||||
{% trans "Amount due:" %} €{{ invoice.amount }}
|
||||
{% trans "Invoice number:" %} {{ invoice.number }}
|
||||
{% if club.contact_email %}
|
||||
{% blocktrans with email=club.contact_email %}Already paid? Let us know at {{ email }} so we can update our records.{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}
|
||||
@@ -0,0 +1 @@
|
||||
{% load i18n %}{% blocktrans with club=club.name number=invoice.number %}Reminder: {{ club }} invoice {{ number }} is overdue{% endblocktrans %}
|
||||
1
club/templates/club/email/dues_invoice_subject.txt
Normal file
1
club/templates/club/email/dues_invoice_subject.txt
Normal file
@@ -0,0 +1 @@
|
||||
{% load i18n %}{% blocktrans with club=club.name number=invoice.number %}{{ club }} — invoice {{ number }}{% endblocktrans %}
|
||||
Reference in New Issue
Block a user