Add a club legal address, used for invoices/referee forms ahead of the home ground

New Club.legal_address/legal_zip_code/legal_city, editable from the
identity page (management:club_settings). Official document headers
(the dues invoice, the referee payment form) previously borrowed the
club's home Location for this -- conflating "where we play" with "our
registered address", which aren't always the same place. New
club.services.invoicing.resolve_document_address(club) picks the club's
own legal address when set, falling back to the home Location exactly as
before when it isn't, so nothing breaks for a club that hasn't set one
yet. Location.is_home now means only what it always should have: telling
a home game from an away one.

Renamed the shared "home_location" template/context variable to
"document_address" on both PDFs to match -- it was never accurate once a
legal address could win instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-21 17:50:08 +02:00
parent 80fda6a4a4
commit c7887637ff
12 changed files with 159 additions and 27 deletions

View File

@@ -0,0 +1,28 @@
# Generated by Django 6.0.6 on 2026-08-21 15:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0029_duesinvoice'),
]
operations = [
migrations.AddField(
model_name='club',
name='legal_address',
field=models.CharField(blank=True, help_text="Street address for official documents (invoices, the referee payment form). Falls back to the home location's address when left blank -- set this when the club's registered address isn't where it actually plays.", max_length=255, verbose_name='legal address'),
),
migrations.AddField(
model_name='club',
name='legal_city',
field=models.CharField(blank=True, max_length=255, verbose_name='legal city'),
),
migrations.AddField(
model_name='club',
name='legal_zip_code',
field=models.CharField(blank=True, max_length=255, verbose_name='legal zip code'),
),
]

View File

@@ -50,6 +50,15 @@ class Club(UUIDModel):
) )
website = models.URLField(_("website"), blank=True, help_text=_("The club's own site, if it has one -- shown alongside its RosterChief pages, not used for anything else yet.")) website = models.URLField(_("website"), blank=True, help_text=_("The club's own site, if it has one -- shown alongside its RosterChief pages, not used for anything else yet."))
legal_address = models.CharField(
_("legal address"),
max_length=255,
blank=True,
help_text=_("Street address for official documents (invoices, the referee payment form). Falls back to the home location's address when left blank -- set this when the club's registered address isn't where it actually plays."),
)
legal_zip_code = models.CharField(_("legal zip code"), max_length=255, blank=True)
legal_city = models.CharField(_("legal city"), max_length=255, blank=True)
logo = models.FileField( logo = models.FileField(
_("logo"), _("logo"),
upload_to=club_logo_path, upload_to=club_logo_path,

View File

@@ -8,6 +8,7 @@ membership.fee_status -- never a flag duplicated here that could drift out of st
""" """
from datetime import timedelta from datetime import timedelta
from types import SimpleNamespace
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
@@ -158,11 +159,30 @@ def render_pdf(html: str) -> bytes:
return HTML(string=html).write_pdf() return HTML(string=html).write_pdf()
def resolve_document_address(club):
"""The address to print on an official document header (a dues invoice,
the referee payment form) -- the club's own ``legal_address`` when set,
else its home ground (``events.models.Location``, ``is_home=True``), so
a club that hasn't set a legal address yet still gets *something* rather
than a blank header.
``Location.is_home`` itself is purely about telling a home game from an
away one -- this is the one place its address doubles as a stand-in for
an actual registered/mailing address, and only when the club hasn't set
one of its own. Returns an object exposing ``.address``/``.zip_code``/
``.city`` either way (a plain namespace for the legal-address branch, the
real ``Location`` for the fallback), or ``None`` when neither is set.
"""
if club.legal_address:
return SimpleNamespace(address=club.legal_address, zip_code=club.legal_zip_code, city=club.legal_city)
return Location.objects.filter(club=club, is_home=True).first()
def invoice_pdf(invoice: DuesInvoice) -> bytes: def invoice_pdf(invoice: DuesInvoice) -> bytes:
# Same header convention as management/event_referee_form_pdf.html: the club's # Same header convention as management/event_referee_form_pdf.html: the club's
# legal name (official_name falls back to the everyday name when unset) and its # legal name (official_name falls back to the everyday name when unset) and its
# home location -- never an event-specific location, since a dues invoice isn't # document address -- never an event-specific location, since a dues invoice
# tied to any one event. # isn't tied to any one event.
home_location = Location.objects.filter(club=invoice.club, is_home=True).first() document_address = resolve_document_address(invoice.club)
html = render_to_string("club/dues_invoice_pdf.html", {"club": invoice.club, "invoice": invoice, "membership": invoice.membership, "member": invoice.membership.member, "home_location": home_location}) html = render_to_string("club/dues_invoice_pdf.html", {"club": invoice.club, "invoice": invoice, "membership": invoice.membership, "member": invoice.membership.member, "document_address": document_address})
return render_pdf(html) return render_pdf(html)

View File

@@ -43,9 +43,9 @@
<div class="header"> <div class="header">
<div> <div>
<h1>{{ club.official_name }}</h1> <h1>{{ club.official_name }}</h1>
{% if home_location %} {% if document_address %}
<div class="muted">{{ home_location.address }}</div> <div class="muted">{{ document_address.address }}</div>
<div class="muted">{{ home_location.zip_code }} {{ home_location.city }}</div> <div class="muted">{{ document_address.zip_code }} {{ document_address.city }}</div>
{% endif %} {% endif %}
{% if club.contact_email %}<div class="muted">{{ club.contact_email }}</div>{% endif %} {% if club.contact_email %}<div class="muted">{{ club.contact_email }}</div>{% endif %}
</div> </div>

View File

@@ -40,7 +40,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, invoice_pdf, invoices_due_for_reminder, recipient_for from .services.invoicing import create_or_resend_invoice, invoice_pdf, invoices_due_for_reminder, recipient_for, resolve_document_address
from .services.onboarding import ( from .services.onboarding import (
annotate_onboarding_status, annotate_onboarding_status,
approve_all_clean, approve_all_clean,
@@ -1683,6 +1683,48 @@ class InvoicePdfTests(TestCase):
self.assertIn(self.invoice.number, html) self.assertIn(self.invoice.number, html)
def test_the_legal_address_takes_precedence_over_the_home_location(self):
Location.objects.create(club=self.club, name="Sports Hall", address="Sportlaan 1", zip_code="1000", city="Brussels", is_home=True)
self.club.legal_address = "Registered Office 5"
self.club.legal_zip_code = "9000"
self.club.legal_city = "Ghent"
self.club.save(update_fields=["legal_address", "legal_zip_code", "legal_city"])
html = self.render()
self.assertIn("Registered Office 5", html)
self.assertNotIn("Sportlaan 1", html)
class ResolveDocumentAddressTests(TestCase):
"""club.services.invoicing.resolve_document_address -- the club's own
legal_address when set, else its home Location, else None."""
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_returns_none_with_neither_set(self):
self.assertIsNone(resolve_document_address(self.club))
def test_falls_back_to_the_home_location(self):
home = Location.objects.create(club=self.club, name="Sports Hall", address="Sportlaan 1", zip_code="1000", city="Brussels", is_home=True)
self.assertEqual(resolve_document_address(self.club), home)
def test_legal_address_wins_over_the_home_location(self):
Location.objects.create(club=self.club, name="Sports Hall", address="Sportlaan 1", zip_code="1000", city="Brussels", is_home=True)
self.club.legal_address = "Registered Office 5"
self.club.legal_zip_code = "9000"
self.club.legal_city = "Ghent"
self.club.save(update_fields=["legal_address", "legal_zip_code", "legal_city"])
address = resolve_document_address(self.club)
self.assertEqual(address.address, "Registered Office 5")
self.assertEqual(address.zip_code, "9000")
self.assertEqual(address.city, "Ghent")
class InvoicesDueForReminderTests(TestCase): class InvoicesDueForReminderTests(TestCase):
"""club.services.invoicing.invoices_due_for_reminder -- sent, unpaid, past """club.services.invoicing.invoices_due_for_reminder -- sent, unpaid, past

View File

@@ -905,7 +905,7 @@ class ClubSettingsForm(forms.ModelForm):
class Meta: class Meta:
model = Club model = Club
fields = ["name", "legal_name", "contact_email", "website", "logo", "primary_color", "secondary_color"] fields = ["name", "legal_name", "legal_address", "legal_zip_code", "legal_city", "contact_email", "website", "logo", "primary_color", "secondary_color"]
widgets = { widgets = {
"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}), "primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}),
"secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}), "secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}),

View File

@@ -58,9 +58,9 @@ def _referee_form_pdf_context(club, request):
opponent="Leuven", opponent="Leuven",
external_game_id="BE-2026-00417", external_game_id="BE-2026-00417",
) )
home_location = SimpleNamespace(address="Sportlaan 1", zip_code="1000", city="Brussels") document_address = SimpleNamespace(address="Sportlaan 1", zip_code="1000", city="Brussels")
grand_total = sum((referee.total_payable for referee in referees), Decimal("0")) grand_total = sum((referee.total_payable for referee in referees), Decimal("0"))
return {"club": club, "event": event, "referees": referees, "home_location": home_location, "grand_total": grand_total} | referee_form_colors(club) return {"club": club, "event": event, "referees": referees, "document_address": document_address, "grand_total": grand_total} | referee_form_colors(club)
PDF_PREVIEWS = [ PDF_PREVIEWS = [

View File

@@ -64,9 +64,13 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.name %} {% form_field form.name %}
{% form_field form.legal_name %} {% form_field form.legal_name %}
<div class="md:col-span-2">{% form_field form.legal_address %}</div>
{% form_field form.legal_zip_code %}
{% form_field form.legal_city %}
{% form_field form.contact_email %} {% form_field form.contact_email %}
{% form_field form.website %} {% form_field form.website %}
</div> </div>
<p class="mt-1 text-xs text-dim">{% trans "Used on official documents (invoices, the referee payment form). Leave blank to use the club's home location instead." %}</p>
</div> </div>
</div> </div>

View File

@@ -69,9 +69,9 @@
<div class="header"> <div class="header">
<div> <div>
<div class="club-name">{{ club.official_name }}</div> <div class="club-name">{{ club.official_name }}</div>
{% if home_location %} {% if document_address %}
<div class="club-address">{{ home_location.address }}</div> <div class="club-address">{{ document_address.address }}</div>
<div class="club-address">{{ home_location.zip_code }} {{ home_location.city }}</div> <div class="club-address">{{ document_address.zip_code }} {{ document_address.city }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="doc-title"> <div class="doc-title">

View File

@@ -4863,6 +4863,28 @@ class ClubSettingsPreviewTests(ManagementTestBase):
self.assertEqual(self.club.secondary_color, "#654321") self.assertEqual(self.club.secondary_color, "#654321")
self.assertEqual(self.club.website, "https://ajax-united.example") self.assertEqual(self.club.website, "https://ajax-united.example")
def test_the_legal_address_can_be_set(self):
response = self.club_post(
"club_settings",
{
"name": "Ajax United",
"legal_name": "",
"legal_address": "Registered Office 5",
"legal_zip_code": "9000",
"legal_city": "Ghent",
"contact_email": "",
"website": "",
"primary_color": "",
"secondary_color": "",
},
)
self.assertRedirects(response, reverse("management:club_settings"))
self.club.refresh_from_db()
self.assertEqual(self.club.legal_address, "Registered Office 5")
self.assertEqual(self.club.legal_zip_code, "9000")
self.assertEqual(self.club.legal_city, "Ghent")
class ClubSettingsDocumentTabTests(ManagementTestBase): class ClubSettingsDocumentTabTests(ManagementTestBase):
"""The Email and PDF tabs on the Club identity page -- every branded """The Email and PDF tabs on the Club identity page -- every branded
@@ -6197,7 +6219,7 @@ class EventRefereeFormPdfTests(ManagementTestBase):
context = renderer.call_args[0][0] context = renderer.call_args[0][0]
self.assertEqual(context["club"].official_name, "Ajax United VZW") self.assertEqual(context["club"].official_name, "Ajax United VZW")
self.assertEqual(context["home_location"], self.home_ground) self.assertEqual(context["document_address"], self.home_ground)
self.assertEqual(list(context["referees"]), [EventReferee.objects.get(event=game)]) self.assertEqual(list(context["referees"]), [EventReferee.objects.get(event=game)])
def test_the_grand_total_sums_every_referees_total_payable(self): def test_the_grand_total_sums_every_referees_total_payable(self):
@@ -6217,7 +6239,7 @@ class EventRefereeFormPdfTests(ManagementTestBase):
game = self.make_game() game = self.make_game()
EventReferee.objects.create(event=game, external_name="Guest Referee", assigned_by=self.admin_member) EventReferee.objects.create(event=game, external_name="Guest Referee", assigned_by=self.admin_member)
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": list(game.referees.all()), "home_location": self.home_ground, "grand_total": Decimal("0")}) html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": list(game.referees.all()), "document_address": self.home_ground, "grand_total": Decimal("0")})
self.assertIn("Guest Referee", html) self.assertIn("Guest Referee", html)
self.assertNotIn("External", html) self.assertNotIn("External", html)
@@ -6226,7 +6248,7 @@ class EventRefereeFormPdfTests(ManagementTestBase):
game = self.make_game() game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"), km=Decimal("40"), km_rate=Decimal("0.083")) EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"), km=Decimal("40"), km_rate=Decimal("0.083"))
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": list(game.referees.all()), "home_location": self.home_ground, "grand_total": Decimal("28.320")}) html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": list(game.referees.all()), "document_address": self.home_ground, "grand_total": Decimal("28.320")})
self.assertIn("€28.32<", html) self.assertIn("€28.32<", html)
self.assertNotIn("28.320", html) self.assertNotIn("28.320", html)
@@ -6237,14 +6259,14 @@ class EventRefereeFormPdfTests(ManagementTestBase):
self.club.save(update_fields=["primary_color", "secondary_color"]) self.club.save(update_fields=["primary_color", "secondary_color"])
game = self.make_game() game = self.make_game()
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "home_location": self.home_ground, "grand_total": Decimal("0"), **referee_form_colors(self.club)}) html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "document_address": self.home_ground, "grand_total": Decimal("0"), **referee_form_colors(self.club)})
self.assertIn("--accent: #0f766e", html) self.assertIn("--accent: #0f766e", html)
def test_the_pdf_falls_back_to_default_colours_when_unset(self): def test_the_pdf_falls_back_to_default_colours_when_unset(self):
game = self.make_game() game = self.make_game()
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "home_location": self.home_ground, "grand_total": Decimal("0"), **referee_form_colors(self.club)}) html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "document_address": self.home_ground, "grand_total": Decimal("0"), **referee_form_colors(self.club)})
self.assertIn("--accent: #3730a3", html) self.assertIn("--accent: #3730a3", html)
@@ -6254,7 +6276,7 @@ class EventRefereeFormPdfTests(ManagementTestBase):
# silently renders with no background at all. # silently renders with no background at all.
game = self.make_game() game = self.make_game()
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "home_location": self.home_ground, "grand_total": Decimal("0"), **referee_form_colors(self.club)}) html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "document_address": self.home_ground, "grand_total": Decimal("0"), **referee_form_colors(self.club)})
self.assertNotIn("color-mix(", html) self.assertNotIn("color-mix(", html)
self.assertIn("--info-card-bg: #", html) self.assertIn("--info-card-bg: #", html)

View File

@@ -30,7 +30,7 @@ from club.mixins import (
from club.models import ClubMembership, ClubRole, DuesInvoice, 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.invoicing import DuesInvoicePDFError, create_or_resend_invoice, invoice_pdf, invoices_due_for_reminder, recipient_for, resolve_document_address, 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
@@ -2642,16 +2642,18 @@ class EventRefereeFeeUpdateView(ClubAdminRequiredMixin, FormView):
class EventRefereeFormPdfView(ClubAdminRequiredMixin, View): class EventRefereeFormPdfView(ClubAdminRequiredMixin, View):
"""Downloadable PDF of the referee payment form for one game, modeled on """Downloadable PDF of the referee payment form for one game, modeled on
the club's existing paper form -- club header (legal name if set, else the club's existing paper form -- club header (legal name if set, else
plain name; address from the club's home Location, not this specific plain name; address from the club's own legal address, or its home
event's, so the form still reads right even if called from a page where Location when that's blank -- see club.services.invoicing.
the event's own location happens to be blank) plus this game's details, resolve_document_address -- never this specific event's, so the form
referees and their fee/km breakdown, and blank signature lines.""" still reads right even if called from a page where the event's own
location happens to be blank) plus this game's details, referees and
their fee/km breakdown, and blank signature lines."""
def get(self, request, pk): def get(self, request, pk):
event = get_object_or_404(Event.objects.filter(club=request.club).prefetch_related("teams", "referees__member"), pk=pk) event = get_object_or_404(Event.objects.filter(club=request.club).prefetch_related("teams", "referees__member"), pk=pk)
home_location = Location.objects.filter(club=request.club, is_home=True).first() document_address = resolve_document_address(request.club)
referees = list(event.referees.all()) referees = list(event.referees.all())
context = {"club": request.club, "event": event, "referees": referees, "home_location": home_location, "grand_total": sum((referee.total_payable for referee in referees), Decimal("0"))} | referee_form_colors(request.club) context = {"club": request.club, "event": event, "referees": referees, "document_address": document_address, "grand_total": sum((referee.total_payable for referee in referees), Decimal("0"))} | referee_form_colors(request.club)
try: try:
pdf = event_referee_form_pdf(context) pdf = event_referee_form_pdf(context)

View File

@@ -5584,6 +5584,11 @@
line-height: var(--tw-leading, var(--text-xl--line-height)); line-height: var(--tw-leading, var(--text-xl--line-height));
} }
} }
.md\:col-span-2 {
@media (width >= 48rem) {
grid-column: span 2 / span 2;
}
}
.md\:grid-cols-2 { .md\:grid-cols-2 {
@media (width >= 48rem) { @media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));