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:
162
club/services/invoicing.py
Normal file
162
club/services/invoicing.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user