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:
@@ -1,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .models import Club, ClubMembership, ClubRole, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
from .models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Club)
|
@admin.register(Club)
|
||||||
@@ -57,6 +57,15 @@ class FeePaymentAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ["membership__club__name", "membership__member__last_name", "reference"]
|
search_fields = ["membership__club__name", "membership__member__last_name", "reference"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DuesInvoice)
|
||||||
|
class DuesInvoiceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["number", "membership", "amount", "due_date", "sent_at", "reminder_count"]
|
||||||
|
list_filter = ["club"]
|
||||||
|
search_fields = ["number", "membership__member__last_name", "membership__member__first_name"]
|
||||||
|
raw_id_fields = ["membership"]
|
||||||
|
readonly_fields = ["number"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ClubRole)
|
@admin.register(ClubRole)
|
||||||
class ClubRoleAdmin(admin.ModelAdmin):
|
class ClubRoleAdmin(admin.ModelAdmin):
|
||||||
list_display = ["club__name", "member__last_name", "member__first_name", "role"]
|
list_display = ["club__name", "member__last_name", "member__first_name", "role"]
|
||||||
|
|||||||
39
club/migrations/0029_duesinvoice.py
Normal file
39
club/migrations/0029_duesinvoice.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-20 07:46
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0028_alter_onboardingrequirement_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DuesInvoice',
|
||||||
|
fields=[
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('number', models.CharField(blank=True, max_length=255, verbose_name='number')),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, help_text='The outstanding balance at the time this was sent — not re-read from the membership afterwards.', max_digits=10, verbose_name='amount')),
|
||||||
|
('due_date', models.DateField(verbose_name='due date')),
|
||||||
|
('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='sent at')),
|
||||||
|
('sent_to_email', models.EmailField(blank=True, max_length=254, verbose_name='sent to')),
|
||||||
|
('sent_to_guardian', models.BooleanField(default=False, help_text="The member had no email on file, so a parent/guardian's was used instead.", verbose_name='sent to a parent/guardian')),
|
||||||
|
('last_reminder_sent_at', models.DateTimeField(blank=True, null=True, verbose_name='last reminder sent at')),
|
||||||
|
('reminder_count', models.PositiveIntegerField(default=0, verbose_name='reminders sent')),
|
||||||
|
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||||
|
('membership', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='dues_invoice', to='club.clubmembership', verbose_name='membership')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'dues invoice',
|
||||||
|
'verbose_name_plural': 'dues invoices',
|
||||||
|
'ordering': ['-sent_at'],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('club', 'number'), name='unique_dues_invoice_number_per_club')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -4,8 +4,8 @@ from decimal import Decimal
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
|
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
|
||||||
from django.db import models
|
from django.db import IntegrityError, models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q, UniqueConstraint
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@@ -382,6 +382,82 @@ class FeePayment(UUIDModel):
|
|||||||
return f"{self.membership} — {self.amount}"
|
return f"{self.membership} — {self.amount}"
|
||||||
|
|
||||||
|
|
||||||
|
class DuesInvoice(ClubScopedModel):
|
||||||
|
"""A record of asking one membership's fee to be paid — not itself the source of
|
||||||
|
truth for what's owed or settled (that's still ``ClubMembership.fee_amount``/
|
||||||
|
``amount_paid``/``fee_status``, via ``club.services.fees``). Sending one snapshots
|
||||||
|
the outstanding balance and a due date so a later fee change or reminder never
|
||||||
|
silently rewrites a bill someone already received; whether it still needs chasing
|
||||||
|
is read live off the membership's own ``fee_status``, since a payment recorded
|
||||||
|
through any route settles the same balance this invoice asked for.
|
||||||
|
|
||||||
|
One per membership (see ``club.services.invoicing``): "send" creates it if
|
||||||
|
missing, "resend" re-snapshots the balance and pushes the due date out again on
|
||||||
|
the existing row, so a membership never accumulates a history of stale invoices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
membership = models.OneToOneField(ClubMembership, on_delete=models.CASCADE, related_name="dues_invoice", verbose_name=_("membership"))
|
||||||
|
number = models.CharField(_("number"), max_length=255, blank=True)
|
||||||
|
|
||||||
|
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, help_text=_("The outstanding balance at the time this was sent — not re-read from the membership afterwards."))
|
||||||
|
due_date = models.DateField(_("due date"))
|
||||||
|
|
||||||
|
sent_at = models.DateTimeField(_("sent at"), null=True, blank=True)
|
||||||
|
sent_to_email = models.EmailField(_("sent to"), blank=True)
|
||||||
|
sent_to_guardian = models.BooleanField(_("sent to a parent/guardian"), default=False, help_text=_("The member had no email on file, so a parent/guardian's was used instead."))
|
||||||
|
|
||||||
|
last_reminder_sent_at = models.DateTimeField(_("last reminder sent at"), null=True, blank=True)
|
||||||
|
reminder_count = models.PositiveIntegerField(_("reminders sent"), default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("dues invoice")
|
||||||
|
verbose_name_plural = _("dues invoices")
|
||||||
|
ordering = ["-sent_at"]
|
||||||
|
constraints = [
|
||||||
|
UniqueConstraint(fields=["club", "number"], name="unique_dues_invoice_number_per_club"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.number or _("Unsent invoice for %(member)s") % {"member": self.membership.member}
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
validate_club_scope(self, self.club_id, same_club_fields=("membership",))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_paid(self) -> bool:
|
||||||
|
return self.membership.fee_status == ClubMembership.FeeStatus.PAID
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_overdue(self) -> bool:
|
||||||
|
return bool(self.sent_at) and not self.is_paid and self.due_date < timezone.now().date()
|
||||||
|
|
||||||
|
def generate_number(self) -> str:
|
||||||
|
"""Next per-club invoice number for the current year: ``DUE-<year>-<seq>``.
|
||||||
|
Same shape as shop.models.Invoice's numbering, duplicated rather than shared
|
||||||
|
across the two apps — see that module's own numbering helpers."""
|
||||||
|
prefix = f"DUE-{timezone.now().year}-"
|
||||||
|
sequences = [int(suffix) for existing in DuesInvoice.objects.filter(club=self.club, number__startswith=prefix).values_list("number", flat=True) if (suffix := existing.removeprefix(prefix)).isdigit()]
|
||||||
|
return f"{prefix}{max(sequences, default=0) + 1:05d}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.number:
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Retrying on a numbering collision (two invoices allocated the same
|
||||||
|
# sequence in the same instant) rather than locking: this only ever
|
||||||
|
# fires once, on first send, so a rare retry is cheaper than a lock
|
||||||
|
# held around every save.
|
||||||
|
for attempt in range(5):
|
||||||
|
self.number = self.generate_number()
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
except IntegrityError:
|
||||||
|
self.number = ""
|
||||||
|
if attempt == 4:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def onboarding_document_path(instance: MemberRequirementStatus, filename: str) -> str:
|
def onboarding_document_path(instance: MemberRequirementStatus, filename: str) -> str:
|
||||||
return f"clubs/{instance.membership.club.slug}/onboarding/{instance.membership_id}/{filename}"
|
return f"clubs/{instance.membership.club.slug}/onboarding/{instance.membership_id}/{filename}"
|
||||||
|
|
||||||
|
|||||||
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)
|
||||||
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 %}
|
||||||
120
club/tests.py
120
club/tests.py
@@ -22,7 +22,7 @@ from members.models import Family, FamilyMembership, Member
|
|||||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||||
from teams.services import eligible_roster_members
|
from teams.services import eligible_roster_members
|
||||||
|
|
||||||
from .models import Club, ClubMembership, ClubRole, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor, club_logo_path
|
from .models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor, club_logo_path
|
||||||
from .services.access import (
|
from .services.access import (
|
||||||
COACH_MANAGER,
|
COACH_MANAGER,
|
||||||
can_edit_event,
|
can_edit_event,
|
||||||
@@ -39,6 +39,7 @@ from .services.access import (
|
|||||||
teams_staffed_by,
|
teams_staffed_by,
|
||||||
)
|
)
|
||||||
from .services.fees import mark_as_paid, record_payment, remaining_balance
|
from .services.fees import mark_as_paid, record_payment, remaining_balance
|
||||||
|
from .services.invoicing import create_or_resend_invoice, invoices_due_for_reminder, recipient_for
|
||||||
from .services.onboarding import (
|
from .services.onboarding import (
|
||||||
annotate_onboarding_status,
|
annotate_onboarding_status,
|
||||||
approve_all_clean,
|
approve_all_clean,
|
||||||
@@ -1560,6 +1561,123 @@ class FeeServiceTests(TestCase):
|
|||||||
self.assertEqual(payment.recorded_by, user)
|
self.assertEqual(payment.recorded_by, user)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipientForTests(TestCase):
|
||||||
|
"""club.services.invoicing.recipient_for -- the member's own email, else the
|
||||||
|
first parent/guardian who has one, else nobody reachable at all."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
cls.season = make_season(cls.club)
|
||||||
|
|
||||||
|
def test_the_members_own_email_wins(self):
|
||||||
|
member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com")
|
||||||
|
|
||||||
|
email, used_guardian = recipient_for(member)
|
||||||
|
|
||||||
|
self.assertEqual(email, "jane@example.com")
|
||||||
|
self.assertFalse(used_guardian)
|
||||||
|
|
||||||
|
def test_falls_back_to_a_guardians_email_when_the_member_has_none(self):
|
||||||
|
family = Family.objects.create()
|
||||||
|
member = Member.objects.create(first_name="Jane", last_name="Doe")
|
||||||
|
parent = Member.objects.create(first_name="Pat", last_name="Doe", email="pat@example.com")
|
||||||
|
FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.CHILD)
|
||||||
|
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
|
||||||
|
|
||||||
|
email, used_guardian = recipient_for(member)
|
||||||
|
|
||||||
|
self.assertEqual(email, "pat@example.com")
|
||||||
|
self.assertTrue(used_guardian)
|
||||||
|
|
||||||
|
def test_empty_when_nobody_is_reachable(self):
|
||||||
|
family = Family.objects.create()
|
||||||
|
member = Member.objects.create(first_name="Jane", last_name="Doe")
|
||||||
|
parent = Member.objects.create(first_name="Pat", last_name="Doe")
|
||||||
|
FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.CHILD)
|
||||||
|
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
|
||||||
|
|
||||||
|
email, used_guardian = recipient_for(member)
|
||||||
|
|
||||||
|
self.assertEqual(email, "")
|
||||||
|
self.assertFalse(used_guardian)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateOrResendInvoiceTests(TestCase):
|
||||||
|
"""club.services.invoicing.create_or_resend_invoice -- one invoice per
|
||||||
|
membership, numbered once, re-snapshotted on every send."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
cls.season = make_season(cls.club)
|
||||||
|
cls.member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com")
|
||||||
|
cls.membership = ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("150.00"))
|
||||||
|
|
||||||
|
def test_amount_is_the_remaining_balance_not_the_full_fee(self):
|
||||||
|
record_payment(self.membership, amount=Decimal("50.00"))
|
||||||
|
|
||||||
|
invoice = create_or_resend_invoice(self.membership, due_in_days=14, recipient_email="jane@example.com", sent_to_guardian=False)
|
||||||
|
|
||||||
|
self.assertEqual(invoice.amount, Decimal("100.00"))
|
||||||
|
|
||||||
|
def test_due_date_is_today_plus_due_in_days(self):
|
||||||
|
invoice = create_or_resend_invoice(self.membership, due_in_days=10, recipient_email="jane@example.com", sent_to_guardian=False)
|
||||||
|
|
||||||
|
self.assertEqual(invoice.due_date, timezone.now().date() + datetime.timedelta(days=10))
|
||||||
|
|
||||||
|
def test_a_number_is_allocated_once(self):
|
||||||
|
invoice = create_or_resend_invoice(self.membership, due_in_days=14, recipient_email="jane@example.com", sent_to_guardian=False)
|
||||||
|
first_number = invoice.number
|
||||||
|
|
||||||
|
resent = create_or_resend_invoice(self.membership, due_in_days=30, recipient_email="jane@example.com", sent_to_guardian=False)
|
||||||
|
|
||||||
|
self.assertEqual(resent.pk, invoice.pk)
|
||||||
|
self.assertEqual(resent.number, first_number)
|
||||||
|
self.assertEqual(resent.due_date, timezone.now().date() + datetime.timedelta(days=30))
|
||||||
|
|
||||||
|
def test_a_membership_can_only_ever_have_one_invoice_row(self):
|
||||||
|
create_or_resend_invoice(self.membership, due_in_days=14, recipient_email="jane@example.com", sent_to_guardian=False)
|
||||||
|
create_or_resend_invoice(self.membership, due_in_days=14, recipient_email="jane@example.com", sent_to_guardian=False)
|
||||||
|
|
||||||
|
self.assertEqual(DuesInvoice.objects.filter(membership=self.membership).count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoicesDueForReminderTests(TestCase):
|
||||||
|
"""club.services.invoicing.invoices_due_for_reminder -- sent, unpaid, past
|
||||||
|
their own due date; never paid, waived, or not yet due."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
cls.season = make_season(cls.club)
|
||||||
|
|
||||||
|
def make_invoice(self, *, fee_status, due_date, sent_at=None):
|
||||||
|
member = Member.objects.create(first_name="Member", last_name=fee_status)
|
||||||
|
membership = ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("100.00"), fee_status=fee_status)
|
||||||
|
return DuesInvoice.objects.create(club=self.club, membership=membership, amount=Decimal("100.00"), due_date=due_date, sent_at=sent_at or timezone.now())
|
||||||
|
|
||||||
|
def test_includes_an_overdue_unpaid_invoice(self):
|
||||||
|
overdue = self.make_invoice(fee_status=ClubMembership.FeeStatus.UNPAID, due_date=timezone.now().date() - datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
self.assertIn(overdue, invoices_due_for_reminder(self.club))
|
||||||
|
|
||||||
|
def test_excludes_a_paid_invoice(self):
|
||||||
|
paid = self.make_invoice(fee_status=ClubMembership.FeeStatus.PAID, due_date=timezone.now().date() - datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
self.assertNotIn(paid, invoices_due_for_reminder(self.club))
|
||||||
|
|
||||||
|
def test_excludes_a_waived_invoice(self):
|
||||||
|
waived = self.make_invoice(fee_status=ClubMembership.FeeStatus.WAIVED, due_date=timezone.now().date() - datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
self.assertNotIn(waived, invoices_due_for_reminder(self.club))
|
||||||
|
|
||||||
|
def test_excludes_one_not_yet_due(self):
|
||||||
|
not_due = self.make_invoice(fee_status=ClubMembership.FeeStatus.UNPAID, due_date=timezone.now().date() + datetime.timedelta(days=5))
|
||||||
|
|
||||||
|
self.assertNotIn(not_due, invoices_due_for_reminder(self.club))
|
||||||
|
|
||||||
|
|
||||||
class SeasonStartEndTests(TestCase):
|
class SeasonStartEndTests(TestCase):
|
||||||
"""club.services.seasons._initial_season_start / _season_end -- the
|
"""club.services.seasons._initial_season_start / _season_end -- the
|
||||||
per-club rules generate_seasons chains off, now that a club's own
|
per-club rules generate_seasons chains off, now that a club's own
|
||||||
|
|||||||
@@ -872,6 +872,19 @@ class RecordFeePaymentForm(forms.Form):
|
|||||||
note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea(attrs={"rows": 2}))
|
note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea(attrs={"rows": 2}))
|
||||||
|
|
||||||
|
|
||||||
|
class SendDuesInvoicesForm(forms.Form):
|
||||||
|
"""The one shared setting for a batch of invoices sent from the Dues & billing
|
||||||
|
page -- see club.services.invoicing.create_or_resend_invoice. Applies to every
|
||||||
|
membership selected in the same submit, not chosen per row.
|
||||||
|
|
||||||
|
The widget's own `form` attr (rendered as-is by templatetags/field.html's attrs
|
||||||
|
passthrough) is what lets this field live inside the confirm dialog while still
|
||||||
|
posting through the row-checkboxes' #membership-form -- see membership_list.html's
|
||||||
|
send_invoices_modal."""
|
||||||
|
|
||||||
|
due_in_days = forms.IntegerField(label=_("Due in"), min_value=1, max_value=365, initial=14, help_text=_("Days from today."), widget=forms.NumberInput(attrs={"form": "membership-form"}))
|
||||||
|
|
||||||
|
|
||||||
class ClubSettingsForm(forms.ModelForm):
|
class ClubSettingsForm(forms.ModelForm):
|
||||||
"""A club's own self-service identity/branding editor (management:club_settings) --
|
"""A club's own self-service identity/branding editor (management:club_settings) --
|
||||||
the club-facing equivalent of controlpanel's ClubForm, minus everything only
|
the club-facing equivalent of controlpanel's ClubForm, minus everything only
|
||||||
|
|||||||
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 %}
|
{% endblock topbar_context %}
|
||||||
|
|
||||||
{% block actions %}
|
{% 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>
|
<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 %}
|
{% endblock actions %}
|
||||||
|
|
||||||
@@ -108,8 +116,11 @@
|
|||||||
<span class="font-display text-sm font-bold tracking-wide text-muted uppercase">
|
<span class="font-display text-sm font-bold tracking-wide text-muted uppercase">
|
||||||
{% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %}
|
{% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %}
|
||||||
</span>
|
</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>
|
<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>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
@@ -124,6 +135,7 @@
|
|||||||
<th>{% trans "Owed" %}</th>
|
<th>{% trans "Owed" %}</th>
|
||||||
<th>{% trans "Paid" %}</th>
|
<th>{% trans "Paid" %}</th>
|
||||||
<th>{% trans "License" %}</th>
|
<th>{% trans "License" %}</th>
|
||||||
|
<th>{% trans "Invoice" %}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -168,6 +180,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ membership.license|default:"-" }}</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">
|
<td class="text-right">
|
||||||
{% if membership.record_payment_form %}
|
{% if membership.record_payment_form %}
|
||||||
<div class="flex flex-wrap justify-end gap-1">
|
<div class="flex flex-wrap justify-end gap-1">
|
||||||
@@ -183,7 +207,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -192,6 +216,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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 payment" as record_payment_title %}
|
||||||
{% trans "Record" as record_payment_submit_label %}
|
{% trans "Record" as record_payment_submit_label %}
|
||||||
{% for membership in memberships %}
|
{% for membership in memberships %}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ from waffle import get_waffle_flag_model
|
|||||||
|
|
||||||
from billing.models import Plan, PlanPrice
|
from billing.models import Plan, PlanPrice
|
||||||
from billing.services.dues import record_payment, subscribe
|
from billing.services.dues import record_payment, subscribe
|
||||||
from club.models import Club, ClubMembership, ClubRole, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
from club.models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
||||||
|
from club.services.invoicing import DuesInvoicePDFError
|
||||||
from club.services.onboarding import mark_complete
|
from club.services.onboarding import mark_complete
|
||||||
from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent
|
from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent
|
||||||
from events.services.rbihf_import import RBIHFImportError
|
from events.services.rbihf_import import RBIHFImportError
|
||||||
@@ -3258,6 +3259,195 @@ class MembershipExportPdfTests(ManagementTestBase):
|
|||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipSendInvoicesTests(ManagementTestBase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.client.force_login(self.admin_user)
|
||||||
|
self.member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com")
|
||||||
|
self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_amount=Decimal("150.00"))
|
||||||
|
|
||||||
|
def test_sending_creates_and_emails_an_invoice(self):
|
||||||
|
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
|
||||||
|
|
||||||
|
invoice = DuesInvoice.objects.get(membership=self.membership)
|
||||||
|
self.assertIsNotNone(invoice.sent_at)
|
||||||
|
self.assertEqual(invoice.sent_to_email, "jane@example.com")
|
||||||
|
self.assertEqual(invoice.amount, Decimal("150.00"))
|
||||||
|
self.assertEqual(invoice.due_date, timezone.now().date() + datetime.timedelta(days=14))
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertEqual(mail.outbox[0].to, ["jane@example.com"])
|
||||||
|
|
||||||
|
def test_the_email_carries_an_html_alternative(self):
|
||||||
|
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
|
||||||
|
|
||||||
|
[(html_body, mimetype)] = mail.outbox[0].alternatives
|
||||||
|
self.assertEqual(mimetype, "text/html")
|
||||||
|
self.assertIn(self.club.name, html_body)
|
||||||
|
|
||||||
|
def test_falls_back_to_a_guardians_email(self):
|
||||||
|
self.member.email = ""
|
||||||
|
self.member.save(update_fields=["email"])
|
||||||
|
family = Family.objects.create()
|
||||||
|
parent = Member.objects.create(first_name="Pat", last_name="Doe", email="pat@example.com")
|
||||||
|
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.CHILD)
|
||||||
|
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
|
||||||
|
|
||||||
|
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
|
||||||
|
|
||||||
|
invoice = DuesInvoice.objects.get(membership=self.membership)
|
||||||
|
self.assertEqual(invoice.sent_to_email, "pat@example.com")
|
||||||
|
self.assertTrue(invoice.sent_to_guardian)
|
||||||
|
|
||||||
|
def test_a_member_with_no_reachable_email_is_skipped(self):
|
||||||
|
self.member.email = ""
|
||||||
|
self.member.save(update_fields=["email"])
|
||||||
|
|
||||||
|
response = self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
|
||||||
|
|
||||||
|
self.assertFalse(DuesInvoice.objects.filter(membership=self.membership).exists())
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
self.assertContains(response, "no email on file")
|
||||||
|
|
||||||
|
def test_resending_updates_the_same_invoice(self):
|
||||||
|
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
|
||||||
|
first_number = DuesInvoice.objects.get(membership=self.membership).number
|
||||||
|
|
||||||
|
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "30"})
|
||||||
|
|
||||||
|
self.assertEqual(DuesInvoice.objects.filter(membership=self.membership).count(), 1)
|
||||||
|
invoice = DuesInvoice.objects.get(membership=self.membership)
|
||||||
|
self.assertEqual(invoice.number, first_number)
|
||||||
|
self.assertEqual(invoice.due_date, timezone.now().date() + datetime.timedelta(days=30))
|
||||||
|
self.assertEqual(len(mail.outbox), 2)
|
||||||
|
|
||||||
|
def test_no_selection_shows_an_error(self):
|
||||||
|
response = self.club_post("membership_send_invoices", {"due_in_days": "14"})
|
||||||
|
|
||||||
|
self.assertFalse(DuesInvoice.objects.exists())
|
||||||
|
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
self.assertContains(response, "Select at least one member")
|
||||||
|
|
||||||
|
def test_non_admin_gets_403(self):
|
||||||
|
coach_user = User.objects.create_user(email="coach-invoice@example.com", password="pw-secret-123")
|
||||||
|
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
|
||||||
|
team = Team.objects.create(club=self.club, name="U15", short_name="U15")
|
||||||
|
position = Position.objects.create(club=self.club, name="Coach11", short_name="C11", staff_position=True)
|
||||||
|
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
|
||||||
|
self.client.force_login(coach_user)
|
||||||
|
|
||||||
|
response = self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipSendInvoiceRemindersTests(ManagementTestBase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.client.force_login(self.admin_user)
|
||||||
|
|
||||||
|
def make_invoice(self, *, due_date, fee_status=ClubMembership.FeeStatus.UNPAID, email="jane@example.com"):
|
||||||
|
member = Member.objects.create(first_name="Jane", last_name="Doe", email=email)
|
||||||
|
membership = ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_amount=Decimal("100.00"), fee_status=fee_status)
|
||||||
|
return DuesInvoice.objects.create(club=self.club, membership=membership, number="DUE-2026-00001", amount=Decimal("100.00"), due_date=due_date, sent_at=timezone.now(), sent_to_email=email)
|
||||||
|
|
||||||
|
def test_reminds_an_overdue_unpaid_invoice(self):
|
||||||
|
invoice = self.make_invoice(due_date=timezone.now().date() - datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
self.club_post("membership_send_invoice_reminders", {})
|
||||||
|
|
||||||
|
invoice.refresh_from_db()
|
||||||
|
self.assertEqual(invoice.reminder_count, 1)
|
||||||
|
self.assertIsNotNone(invoice.last_reminder_sent_at)
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertEqual(mail.outbox[0].to, ["jane@example.com"])
|
||||||
|
|
||||||
|
def test_does_not_remind_one_not_yet_due(self):
|
||||||
|
invoice = self.make_invoice(due_date=timezone.now().date() + datetime.timedelta(days=5))
|
||||||
|
|
||||||
|
self.club_post("membership_send_invoice_reminders", {})
|
||||||
|
|
||||||
|
invoice.refresh_from_db()
|
||||||
|
self.assertEqual(invoice.reminder_count, 0)
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
|
def test_does_not_remind_a_paid_invoice(self):
|
||||||
|
invoice = self.make_invoice(due_date=timezone.now().date() - datetime.timedelta(days=1), fee_status=ClubMembership.FeeStatus.PAID)
|
||||||
|
|
||||||
|
self.club_post("membership_send_invoice_reminders", {})
|
||||||
|
|
||||||
|
invoice.refresh_from_db()
|
||||||
|
self.assertEqual(invoice.reminder_count, 0)
|
||||||
|
|
||||||
|
def test_nothing_to_remind_notifies_gracefully(self):
|
||||||
|
response = self.club_post("membership_send_invoice_reminders", {})
|
||||||
|
|
||||||
|
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
self.assertContains(response, "Nothing to remind")
|
||||||
|
|
||||||
|
def test_non_admin_gets_403(self):
|
||||||
|
coach_user = User.objects.create_user(email="coach-reminder@example.com", password="pw-secret-123")
|
||||||
|
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
|
||||||
|
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
|
||||||
|
position = Position.objects.create(club=self.club, name="Coach12", short_name="C12", staff_position=True)
|
||||||
|
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
|
||||||
|
self.client.force_login(coach_user)
|
||||||
|
|
||||||
|
response = self.club_post("membership_send_invoice_reminders", {})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class DuesInvoiceDetailViewTests(ManagementTestBase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.client.force_login(self.admin_user)
|
||||||
|
self.member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com")
|
||||||
|
self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_amount=Decimal("150.00"))
|
||||||
|
self.invoice = DuesInvoice.objects.create(club=self.club, membership=self.membership, number="DUE-2026-00001", amount=Decimal("150.00"), due_date=timezone.now().date(), sent_at=timezone.now(), sent_to_email="jane@example.com")
|
||||||
|
|
||||||
|
def test_shows_the_invoice(self):
|
||||||
|
response = self.club_get("membership_invoice_detail", self.membership.pk)
|
||||||
|
|
||||||
|
self.assertContains(response, "DUE-2026-00001")
|
||||||
|
self.assertContains(response, "jane@example.com")
|
||||||
|
|
||||||
|
def test_404_when_the_membership_has_no_invoice(self):
|
||||||
|
other_member = Member.objects.create(first_name="No", last_name="Invoice")
|
||||||
|
other_membership = ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
||||||
|
|
||||||
|
response = self.club_get("membership_invoice_detail", other_membership.pk)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_downloads_as_a_pdf(self):
|
||||||
|
with mock.patch("management.views.invoice_pdf", return_value=b"%PDF-fake") as renderer:
|
||||||
|
response = self.club_get("membership_invoice_pdf", self.membership.pk)
|
||||||
|
|
||||||
|
self.assertEqual(response["Content-Type"], "application/pdf")
|
||||||
|
self.assertEqual(response.content, b"%PDF-fake")
|
||||||
|
renderer.assert_called_once()
|
||||||
|
|
||||||
|
def test_a_missing_pdf_library_is_reported_rather_than_a_500(self):
|
||||||
|
with mock.patch("management.views.invoice_pdf", side_effect=DuesInvoicePDFError("PDF rendering needs the native pango/cairo libraries.")):
|
||||||
|
response = self.club_get("membership_invoice_pdf", self.membership.pk)
|
||||||
|
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertContains(response, "pango")
|
||||||
|
|
||||||
|
def test_non_admin_gets_403(self):
|
||||||
|
coach_user = User.objects.create_user(email="coach-invoice-detail@example.com", password="pw-secret-123")
|
||||||
|
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
|
||||||
|
team = Team.objects.create(club=self.club, name="U17", short_name="U17")
|
||||||
|
position = Position.objects.create(club=self.club, name="Coach13", short_name="C13", staff_position=True)
|
||||||
|
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
|
||||||
|
self.client.force_login(coach_user)
|
||||||
|
|
||||||
|
response = self.club_get("membership_invoice_detail", self.membership.pk)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
class MemberListRowActionsTests(ManagementTestBase):
|
class MemberListRowActionsTests(ManagementTestBase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ urlpatterns = [
|
|||||||
path("memberships/export/", views.MembershipExportPdfView.as_view(), name="membership_export_pdf"),
|
path("memberships/export/", views.MembershipExportPdfView.as_view(), name="membership_export_pdf"),
|
||||||
path("memberships/<uuid:pk>/mark-fully-paid/", views.MembershipMarkFullyPaidView.as_view(), name="membership_mark_fully_paid"),
|
path("memberships/<uuid:pk>/mark-fully-paid/", views.MembershipMarkFullyPaidView.as_view(), name="membership_mark_fully_paid"),
|
||||||
path("memberships/<uuid:pk>/record-payment/", views.MembershipRecordPaymentView.as_view(), name="membership_record_payment"),
|
path("memberships/<uuid:pk>/record-payment/", views.MembershipRecordPaymentView.as_view(), name="membership_record_payment"),
|
||||||
|
path("memberships/send-invoices/", views.MembershipSendInvoicesView.as_view(), name="membership_send_invoices"),
|
||||||
|
path("memberships/send-invoice-reminders/", views.MembershipSendInvoiceRemindersView.as_view(), name="membership_send_invoice_reminders"),
|
||||||
|
path("memberships/<uuid:pk>/invoice/", views.DuesInvoiceDetailView.as_view(), name="membership_invoice_detail"),
|
||||||
|
path("memberships/<uuid:pk>/invoice/pdf/", views.DuesInvoicePdfView.as_view(), name="membership_invoice_pdf"),
|
||||||
path("members/new/", views.MemberCreateView.as_view(), name="member_create"),
|
path("members/new/", views.MemberCreateView.as_view(), name="member_create"),
|
||||||
path("members/import/template/", views.MemberImportTemplateView.as_view(), name="member_import_template"),
|
path("members/import/template/", views.MemberImportTemplateView.as_view(), name="member_import_template"),
|
||||||
path("members/import/", views.MemberImportView.as_view(), name="member_import"),
|
path("members/import/", views.MemberImportView.as_view(), name="member_import"),
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ from club.mixins import (
|
|||||||
NewsPublisherRequiredMixin,
|
NewsPublisherRequiredMixin,
|
||||||
TeamManagerRequiredMixin,
|
TeamManagerRequiredMixin,
|
||||||
)
|
)
|
||||||
from club.models import ClubMembership, ClubRole, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
from club.models import ClubMembership, ClubRole, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
||||||
from club.services.access import _guardians_only, can_edit_news, can_publish_news, current_season, groups_manageable_by, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
|
from club.services.access import _guardians_only, can_edit_news, can_publish_news, current_season, groups_manageable_by, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
|
||||||
from club.services.fees import mark_as_paid, record_payment, remaining_balance
|
from club.services.fees import mark_as_paid, record_payment, remaining_balance
|
||||||
|
from club.services.invoicing import DuesInvoicePDFError, create_or_resend_invoice, invoice_pdf, invoices_due_for_reminder, recipient_for, send_invoice_email, send_reminders
|
||||||
from club.services.onboarding import annotate_onboarding_status, approve_all_clean, approve_one, blocking_event_kinds, checklist_for, is_signup_clean, mark_bypassed, mark_complete, mark_incomplete, members_with_open_requirements
|
from club.services.onboarding import annotate_onboarding_status, approve_all_clean, approve_one, blocking_event_kinds, checklist_for, is_signup_clean, mark_bypassed, mark_complete, mark_incomplete, members_with_open_requirements
|
||||||
from controlpanel.messages import notify
|
from controlpanel.messages import notify
|
||||||
from controlpanel.mixins import RedirectOnInvalidMixin
|
from controlpanel.mixins import RedirectOnInvalidMixin
|
||||||
@@ -81,6 +82,7 @@ from .forms import (
|
|||||||
RefereeLevelForm,
|
RefereeLevelForm,
|
||||||
RequirementBypassForm,
|
RequirementBypassForm,
|
||||||
RequirementCompletionForm,
|
RequirementCompletionForm,
|
||||||
|
SendDuesInvoicesForm,
|
||||||
SignupTeamPlacementForm,
|
SignupTeamPlacementForm,
|
||||||
SponsorForm,
|
SponsorForm,
|
||||||
StaffAssignmentForm,
|
StaffAssignmentForm,
|
||||||
@@ -386,6 +388,8 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
|
|||||||
waived = counts.get(ClubMembership.FeeStatus.WAIVED, 0)
|
waived = counts.get(ClubMembership.FeeStatus.WAIVED, 0)
|
||||||
total = paid + partial + unpaid + waived
|
total = paid + partial + unpaid + waived
|
||||||
|
|
||||||
|
overdue_count = invoices_due_for_reminder(club).count() if current is not None else 0
|
||||||
|
|
||||||
context = super().get_context_data(
|
context = super().get_context_data(
|
||||||
current_season=current,
|
current_season=current,
|
||||||
selected_season=self.get_selected_season(),
|
selected_season=self.get_selected_season(),
|
||||||
@@ -403,6 +407,8 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
|
|||||||
kpi_unpaid=unpaid,
|
kpi_unpaid=unpaid,
|
||||||
kpi_waived=waived,
|
kpi_waived=waived,
|
||||||
kpi_paid_rate=round(100 * paid / total) if total else None,
|
kpi_paid_rate=round(100 * paid / total) if total else None,
|
||||||
|
kpi_overdue_invoices=overdue_count,
|
||||||
|
send_invoice_form=SendDuesInvoicesForm(),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -415,9 +421,11 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
|
|||||||
family_memberships_by_member_id = {}
|
family_memberships_by_member_id = {}
|
||||||
for fm in family_memberships:
|
for fm in family_memberships:
|
||||||
family_memberships_by_member_id.setdefault(fm.member_id, []).append(fm)
|
family_memberships_by_member_id.setdefault(fm.member_id, []).append(fm)
|
||||||
|
invoices_by_membership_id = {invoice.membership_id: invoice for invoice in DuesInvoice.objects.filter(membership__in=memberships)}
|
||||||
for membership in memberships:
|
for membership in memberships:
|
||||||
membership.member.family_memberships_display = family_memberships_by_member_id.get(membership.member_id, [])
|
membership.member.family_memberships_display = family_memberships_by_member_id.get(membership.member_id, [])
|
||||||
membership.remaining_balance_display = remaining_balance(membership)
|
membership.remaining_balance_display = remaining_balance(membership)
|
||||||
|
membership.dues_invoice = invoices_by_membership_id.get(membership.pk)
|
||||||
# Nothing to collect on an already-settled or deliberately-exempted row.
|
# Nothing to collect on an already-settled or deliberately-exempted row.
|
||||||
if membership.fee_status in (ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED):
|
if membership.fee_status in (ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED):
|
||||||
membership.record_payment_form = None
|
membership.record_payment_form = None
|
||||||
@@ -526,6 +534,98 @@ class MembershipExportPdfView(MembershipListView):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipSendInvoicesView(ClubAdminRequiredMixin, View):
|
||||||
|
"""The bulk "Send invoice" action on Dues & billing -- one invoice per selected
|
||||||
|
membership, mailed to the member's own email or a parent/guardian's when they
|
||||||
|
have none (see club.services.invoicing.recipient_for). Every membership in the
|
||||||
|
batch shares the one due-in-days setting from the form; per-membership tracking
|
||||||
|
(sent_at, who it went to, reminders) still lives on each invoice individually."""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
next_url = request.POST.get("next")
|
||||||
|
redirect_url = next_url if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()) else reverse("management:membership_list")
|
||||||
|
|
||||||
|
ids = request.POST.getlist("membership_ids")
|
||||||
|
if not ids:
|
||||||
|
notify(request, f"e|{_('No members selected')}|{_('Select at least one member to invoice.')}")
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
form = SendDuesInvoicesForm(request.POST)
|
||||||
|
if not form.is_valid():
|
||||||
|
notify(request, f"e|{_('Could not send invoices')}|{_('Enter a valid number of days until due.')}")
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
memberships = ClubMembership.objects.filter(pk__in=ids, club=request.club).select_related("member")
|
||||||
|
sent = failed = unreachable = 0
|
||||||
|
for membership in memberships:
|
||||||
|
email, sent_to_guardian = recipient_for(membership.member)
|
||||||
|
if not email:
|
||||||
|
unreachable += 1
|
||||||
|
continue
|
||||||
|
invoice = create_or_resend_invoice(membership, due_in_days=form.cleaned_data["due_in_days"], recipient_email=email, sent_to_guardian=sent_to_guardian)
|
||||||
|
if send_invoice_email(invoice, request=request):
|
||||||
|
sent += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if sent:
|
||||||
|
notify(request, f"s|{_('Invoices sent')}|{_('%(count)d invoice(s) sent.') % {'count': sent}}")
|
||||||
|
if failed:
|
||||||
|
notify(request, f"w|{_('Some invoices could not be emailed')}|{_('%(count)d invoice(s) were recorded but the email could not be sent.') % {'count': failed}}")
|
||||||
|
if unreachable:
|
||||||
|
notify(request, f"w|{_('Some members have no email on file')}|{_('%(count)d member(s) have no email on file, on themselves or a parent/guardian, so no invoice was sent.') % {'count': unreachable}}")
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipSendInvoiceRemindersView(ClubAdminRequiredMixin, View):
|
||||||
|
"""The push-button "remind everyone past due" action -- every sent, unpaid
|
||||||
|
invoice whose due date has passed, club-wide, regardless of the current list's
|
||||||
|
filters or page. See club.services.invoicing.invoices_due_for_reminder."""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
next_url = request.POST.get("next")
|
||||||
|
redirect_url = next_url if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()) else reverse("management:membership_list")
|
||||||
|
|
||||||
|
sent, failed = send_reminders(request.club, request=request)
|
||||||
|
if not sent and not failed:
|
||||||
|
notify(request, f"s|{_('Nothing to remind')}|{_('No overdue, unpaid invoices right now.')}")
|
||||||
|
else:
|
||||||
|
if sent:
|
||||||
|
notify(request, f"s|{_('Reminders sent')}|{_('%(count)d reminder(s) sent.') % {'count': sent}}")
|
||||||
|
if failed:
|
||||||
|
notify(request, f"w|{_('Some reminders could not be emailed')}|{_('%(count)d reminder(s) failed to send.') % {'count': failed}}")
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
|
||||||
|
class DuesInvoiceDetailView(ClubAdminRequiredMixin, DetailView):
|
||||||
|
"""A staff-facing view of one membership's invoice -- the same document the
|
||||||
|
member/guardian received, viewable here for reference without re-sending it."""
|
||||||
|
|
||||||
|
template_name = "management/dues_invoice_detail.html"
|
||||||
|
context_object_name = "invoice"
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
return get_object_or_404(DuesInvoice, membership__pk=self.kwargs["pk"], club=self.request.club)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(membership=self.object.membership, member=self.object.membership.member, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class DuesInvoicePdfView(ClubAdminRequiredMixin, View):
|
||||||
|
def get(self, request, pk):
|
||||||
|
invoice = get_object_or_404(DuesInvoice, membership__pk=pk, club=request.club)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf = invoice_pdf(invoice)
|
||||||
|
except DuesInvoicePDFError as error:
|
||||||
|
notify(request, f"e|{_('PDF unavailable')}|{error}")
|
||||||
|
return redirect("management:membership_invoice_detail", pk=pk)
|
||||||
|
|
||||||
|
response = HttpResponse(pdf, content_type="application/pdf")
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{invoice.number}.pdf"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class MemberImportTemplateView(ClubStaffRequiredMixin, View):
|
class MemberImportTemplateView(ClubStaffRequiredMixin, View):
|
||||||
"""Anyone with management access can download the template -- filling it in
|
"""Anyone with management access can download the template -- filling it in
|
||||||
doesn't grant any authority, only the upload step (admin-only) does."""
|
doesn't grant any authority, only the upload step (admin-only) does."""
|
||||||
|
|||||||
@@ -785,6 +785,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.collapse-plus {
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
> .collapse-title:after {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
height: 0.5rem;
|
||||||
|
width: 0.5rem;
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
transition-property: all;
|
||||||
|
transition-duration: 300ms;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
top: 0.9rem;
|
||||||
|
inset-inline-end: 1.4rem;
|
||||||
|
--tw-content: "+";
|
||||||
|
content: var(--tw-content);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.dropdown {
|
.dropdown {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1330,6 +1350,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.validator-hint {
|
||||||
|
@layer daisyui.l1.l2.l3 {
|
||||||
|
visibility: hidden;
|
||||||
|
margin-top: calc(0.25rem * 2);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
.validator {
|
.validator {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
&:user-valid, &:has(:user-valid) {
|
&:user-valid, &:has(:user-valid) {
|
||||||
@@ -1353,6 +1380,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.collapse-open {
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
grid-template-rows: max-content 1fr;
|
||||||
|
> .collapse-content {
|
||||||
|
--overflow-delay: 0.2s;
|
||||||
|
overflow: revert-layer;
|
||||||
|
content-visibility: visible;
|
||||||
|
min-height: fit-content;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
@supports not (content-visibility: visible) {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.collapse {
|
.collapse {
|
||||||
visibility: collapse;
|
visibility: collapse;
|
||||||
}
|
}
|
||||||
@@ -1421,6 +1463,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.toast {
|
||||||
|
@layer daisyui.l1.l2.l3 {
|
||||||
|
position: fixed;
|
||||||
|
inset-inline-start: auto;
|
||||||
|
inset-inline-end: calc(0.25rem * 4);
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(0.25rem * 4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(0.25rem * 2);
|
||||||
|
background-color: transparent;
|
||||||
|
translate: var(--toast-x, 0) var(--toast-y, 0);
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
& > * {
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
animation: toast 0.25s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.toggle {
|
.toggle {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
border: var(--border) solid currentColor;
|
border: var(--border) solid currentColor;
|
||||||
@@ -1873,6 +1936,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.aura {
|
||||||
|
@layer daisyui.l1.l2.l3 {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
--aura-padding: 0.125rem;
|
||||||
|
padding: var(--aura-padding);
|
||||||
|
border-radius: calc(var(--aura-padding) + var(--aura-radius, var(--radius-box)));
|
||||||
|
animation: aura var(--tw-duration, 6s) linear infinite;
|
||||||
|
background-image: conic-gradient(from var(--aura-angle), transparent 225deg, currentColor);
|
||||||
|
&:has( > .card, > .alert) {
|
||||||
|
--aura-radius: var(--radius-box);
|
||||||
|
}
|
||||||
|
&:has( > .btn, > .input, > .select) {
|
||||||
|
--aura-radius: var(--radius-field);
|
||||||
|
}
|
||||||
|
&:has( > .checkbox, > .toggle, > .badge) {
|
||||||
|
--aura-radius: var(--radius-selector);
|
||||||
|
}
|
||||||
|
&:before, &:after {
|
||||||
|
animation: inherit;
|
||||||
|
background-color: inherit;
|
||||||
|
background-image: inherit;
|
||||||
|
border-radius: inherit;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(1 / 2 * 100%);
|
||||||
|
left: calc(1 / 2 * 100%);
|
||||||
|
z-index: 0;
|
||||||
|
display: block;
|
||||||
|
opacity: 70%;
|
||||||
|
filter: blur(0.25rem);
|
||||||
|
translate: -50% -50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
&:after {
|
||||||
|
opacity: 30%;
|
||||||
|
filter: blur(1rem);
|
||||||
|
}
|
||||||
|
& > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.steps {
|
.steps {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
@@ -2395,6 +2503,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.rating {
|
||||||
|
@layer daisyui.l1.l2.l3 {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
vertical-align: middle;
|
||||||
|
--size: var(--size-selector, 0.25rem) * 6;
|
||||||
|
input {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: var(--color-base-content);
|
||||||
|
opacity: 20%;
|
||||||
|
width: calc(var(--size) * 1);
|
||||||
|
height: calc(var(--size));
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
animation: rating 0.25s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rating-hidden {
|
||||||
|
width: calc(0.25rem * 2);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
:checked, [aria-checked="true"], [aria-current="true"], :has( ~ :checked, ~ [aria-checked="true"], ~ [aria-current="true"]) {
|
||||||
|
opacity: 100%;
|
||||||
|
}
|
||||||
|
:focus-visible {
|
||||||
|
scale: 1.1;
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
transition: scale 0.2s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:active:focus {
|
||||||
|
animation: none;
|
||||||
|
scale: 1.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
--size: var(--size-selector, 0.25rem) * 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
.navbar {
|
.navbar {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2545,6 +2695,30 @@
|
|||||||
.inset-0 {
|
.inset-0 {
|
||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
.dropdown-right {
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
--anchor-h: right;
|
||||||
|
--anchor-v: span-bottom;
|
||||||
|
.dropdown-content {
|
||||||
|
inset-inline-start: 100%;
|
||||||
|
top: 0;
|
||||||
|
bottom: auto;
|
||||||
|
transform-origin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dropdown-left {
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
--anchor-h: left;
|
||||||
|
--anchor-v: span-bottom;
|
||||||
|
.dropdown-content {
|
||||||
|
inset-inline-end: 100%;
|
||||||
|
top: 0;
|
||||||
|
bottom: auto;
|
||||||
|
transform-origin: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.dropdown-end {
|
.dropdown-end {
|
||||||
@layer daisyui.l1.l2 {
|
@layer daisyui.l1.l2 {
|
||||||
--anchor-h: span-left;
|
--anchor-h: span-left;
|
||||||
@@ -3041,6 +3215,26 @@
|
|||||||
.z-50 {
|
.z-50 {
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
.tab-content {
|
||||||
|
@layer daisyui.l1.l2.l3 {
|
||||||
|
order: var(--tabcontent-order);
|
||||||
|
display: none;
|
||||||
|
border-color: transparent;
|
||||||
|
--tabcontent-radius-ss: var(--radius-box);
|
||||||
|
--tabcontent-radius-se: var(--radius-box);
|
||||||
|
--tabcontent-radius-es: var(--radius-box);
|
||||||
|
--tabcontent-radius-ee: var(--radius-box);
|
||||||
|
--tabcontent-order: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - var(--tab-height) + var(--border));
|
||||||
|
margin: var(--tabcontent-margin);
|
||||||
|
border-width: var(--border);
|
||||||
|
border-start-start-radius: var(--tabcontent-radius-ss);
|
||||||
|
border-start-end-radius: var(--tabcontent-radius-se);
|
||||||
|
border-end-start-radius: var(--tabcontent-radius-es);
|
||||||
|
border-end-end-radius: var(--tabcontent-radius-ee);
|
||||||
|
}
|
||||||
|
}
|
||||||
.col-span-2 {
|
.col-span-2 {
|
||||||
grid-column: span 2 / span 2;
|
grid-column: span 2 / span 2;
|
||||||
}
|
}
|
||||||
@@ -3443,6 +3637,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.fieldset-label {
|
||||||
|
@layer daisyui.l1.l2.l3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(0.25rem * 1.5);
|
||||||
|
color: var(--color-base-content);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
|
||||||
|
}
|
||||||
|
&:has(input) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.alert {
|
.alert {
|
||||||
border-width: var(--border);
|
border-width: var(--border);
|
||||||
border-color: var(--alert-border-color, var(--color-base-200));
|
border-color: var(--alert-border-color, var(--color-base-200));
|
||||||
@@ -3590,6 +3798,9 @@
|
|||||||
.inline-flex {
|
.inline-flex {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
.inline-grid {
|
||||||
|
display: inline-grid;
|
||||||
|
}
|
||||||
.table {
|
.table {
|
||||||
display: table;
|
display: table;
|
||||||
}
|
}
|
||||||
@@ -3867,6 +4078,9 @@
|
|||||||
.shrink-0 {
|
.shrink-0 {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.flex-grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
.grow {
|
.grow {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
@@ -3900,6 +4114,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.aura-glow {
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
animation: none;
|
||||||
|
background-image: radial-gradient(closest-corner at center, currentColor 0%, transparent 90%);
|
||||||
|
&:before {
|
||||||
|
animation: aura-glow var(--tw-duration, 6s) ease-out infinite;
|
||||||
|
}
|
||||||
|
&:after {
|
||||||
|
animation: aura-glow-after var(--tw-duration, 6s) ease-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.animate-pulse {
|
.animate-pulse {
|
||||||
animation: var(--animate-pulse);
|
animation: var(--animate-pulse);
|
||||||
}
|
}
|
||||||
@@ -4288,6 +4514,9 @@
|
|||||||
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
|
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.mask-repeat {
|
||||||
|
mask-repeat: repeat;
|
||||||
|
}
|
||||||
.stroke-2 {
|
.stroke-2 {
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
@@ -4788,6 +5017,9 @@
|
|||||||
text-decoration-line: none;
|
text-decoration-line: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.underline {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
}
|
||||||
.antialiased {
|
.antialiased {
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
@@ -4886,6 +5118,14 @@
|
|||||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
}
|
}
|
||||||
|
.ease-in-out {
|
||||||
|
--tw-ease: var(--ease-in-out);
|
||||||
|
transition-timing-function: var(--ease-in-out);
|
||||||
|
}
|
||||||
|
.ease-out {
|
||||||
|
--tw-ease: var(--ease-out);
|
||||||
|
transition-timing-function: var(--ease-out);
|
||||||
|
}
|
||||||
.input-lg {
|
.input-lg {
|
||||||
@layer daisyui.l1.l2 {
|
@layer daisyui.l1.l2 {
|
||||||
--in-size-mul: 12;
|
--in-size-mul: 12;
|
||||||
@@ -5938,6 +6178,10 @@
|
|||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
}
|
}
|
||||||
|
@property --tw-ease {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
50% {
|
50% {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -6002,6 +6246,7 @@
|
|||||||
--tw-backdrop-opacity: initial;
|
--tw-backdrop-opacity: initial;
|
||||||
--tw-backdrop-saturate: initial;
|
--tw-backdrop-saturate: initial;
|
||||||
--tw-backdrop-sepia: initial;
|
--tw-backdrop-sepia: initial;
|
||||||
|
--tw-ease: initial;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user