diff --git a/club/migrations/0030_club_legal_address_club_legal_city_and_more.py b/club/migrations/0030_club_legal_address_club_legal_city_and_more.py new file mode 100644 index 0000000..45257c8 --- /dev/null +++ b/club/migrations/0030_club_legal_address_club_legal_city_and_more.py @@ -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'), + ), + ] diff --git a/club/models.py b/club/models.py index f46f6d2..eb9071c 100644 --- a/club/models.py +++ b/club/models.py @@ -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.")) + 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"), upload_to=club_logo_path, diff --git a/club/services/invoicing.py b/club/services/invoicing.py index a29b145..1264658 100644 --- a/club/services/invoicing.py +++ b/club/services/invoicing.py @@ -8,6 +8,7 @@ membership.fee_status -- never a flag duplicated here that could drift out of st """ from datetime import timedelta +from types import SimpleNamespace from django.conf import settings from django.core.mail import EmailMultiAlternatives @@ -158,11 +159,30 @@ def render_pdf(html: str) -> bytes: 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: # 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 - # home location -- never an event-specific location, since a dues invoice isn't - # tied to any one event. - home_location = Location.objects.filter(club=invoice.club, is_home=True).first() - 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}) + # document address -- never an event-specific location, since a dues invoice + # isn't tied to any one event. + 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, "document_address": document_address}) return render_pdf(html) diff --git a/club/templates/club/dues_invoice_pdf.html b/club/templates/club/dues_invoice_pdf.html index 4f14988..75b315e 100644 --- a/club/templates/club/dues_invoice_pdf.html +++ b/club/templates/club/dues_invoice_pdf.html @@ -43,9 +43,9 @@

{{ club.official_name }}

- {% if home_location %} -
{{ home_location.address }}
-
{{ home_location.zip_code }} {{ home_location.city }}
+ {% if document_address %} +
{{ document_address.address }}
+
{{ document_address.zip_code }} {{ document_address.city }}
{% endif %} {% if club.contact_email %}
{{ club.contact_email }}
{% endif %}
diff --git a/club/tests.py b/club/tests.py index 0e18f14..0e8ca2f 100644 --- a/club/tests.py +++ b/club/tests.py @@ -40,7 +40,7 @@ from .services.access import ( teams_staffed_by, ) 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 ( annotate_onboarding_status, approve_all_clean, @@ -1683,6 +1683,48 @@ class InvoicePdfTests(TestCase): 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): """club.services.invoicing.invoices_due_for_reminder -- sent, unpaid, past diff --git a/management/forms.py b/management/forms.py index 4c6f2e9..7a90868 100644 --- a/management/forms.py +++ b/management/forms.py @@ -905,7 +905,7 @@ class ClubSettingsForm(forms.ModelForm): class Meta: 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 = { "primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}), "secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}), diff --git a/management/pdf_previews.py b/management/pdf_previews.py index 959e033..0d3f4ee 100644 --- a/management/pdf_previews.py +++ b/management/pdf_previews.py @@ -58,9 +58,9 @@ def _referee_form_pdf_context(club, request): opponent="Leuven", 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")) - 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 = [ diff --git a/management/templates/management/club_settings.html b/management/templates/management/club_settings.html index a2be9b8..514b010 100644 --- a/management/templates/management/club_settings.html +++ b/management/templates/management/club_settings.html @@ -64,9 +64,13 @@
{% form_field form.name %} {% form_field form.legal_name %} +
{% form_field form.legal_address %}
+ {% form_field form.legal_zip_code %} + {% form_field form.legal_city %} {% form_field form.contact_email %} {% form_field form.website %}
+

{% trans "Used on official documents (invoices, the referee payment form). Leave blank to use the club's home location instead." %}

diff --git a/management/templates/management/event_referee_form_pdf.html b/management/templates/management/event_referee_form_pdf.html index 0ff38da..a35cd7e 100644 --- a/management/templates/management/event_referee_form_pdf.html +++ b/management/templates/management/event_referee_form_pdf.html @@ -69,9 +69,9 @@
{{ club.official_name }}
- {% if home_location %} -
{{ home_location.address }}
-
{{ home_location.zip_code }} {{ home_location.city }}
+ {% if document_address %} +
{{ document_address.address }}
+
{{ document_address.zip_code }} {{ document_address.city }}
{% endif %}
diff --git a/management/tests.py b/management/tests.py index 0db5e68..f753d09 100644 --- a/management/tests.py +++ b/management/tests.py @@ -4863,6 +4863,28 @@ class ClubSettingsPreviewTests(ManagementTestBase): self.assertEqual(self.club.secondary_color, "#654321") 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): """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] 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)]) def test_the_grand_total_sums_every_referees_total_payable(self): @@ -6217,7 +6239,7 @@ class EventRefereeFormPdfTests(ManagementTestBase): game = self.make_game() 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.assertNotIn("External", html) @@ -6226,7 +6248,7 @@ class EventRefereeFormPdfTests(ManagementTestBase): 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")) - 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.assertNotIn("28.320", html) @@ -6237,14 +6259,14 @@ class EventRefereeFormPdfTests(ManagementTestBase): self.club.save(update_fields=["primary_color", "secondary_color"]) 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) def test_the_pdf_falls_back_to_default_colours_when_unset(self): 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) @@ -6254,7 +6276,7 @@ class EventRefereeFormPdfTests(ManagementTestBase): # silently renders with no background at all. 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.assertIn("--info-card-bg: #", html) diff --git a/management/views.py b/management/views.py index b597c89..149aee1 100644 --- a/management/views.py +++ b/management/views.py @@ -30,7 +30,7 @@ from club.mixins import ( 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.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 controlpanel.messages import notify from controlpanel.mixins import RedirectOnInvalidMixin @@ -2642,16 +2642,18 @@ class EventRefereeFeeUpdateView(ClubAdminRequiredMixin, FormView): class EventRefereeFormPdfView(ClubAdminRequiredMixin, View): """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 - plain name; address from the club's home Location, not this specific - event's, so the form 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.""" + plain name; address from the club's own legal address, or its home + Location when that's blank -- see club.services.invoicing. + resolve_document_address -- never this specific event's, so the form + 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): 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()) - 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: pdf = event_referee_form_pdf(context) diff --git a/static/css/app.css b/static/css/app.css index 7330367..89b8ceb 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -5584,6 +5584,11 @@ 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 { @media (width >= 48rem) { grid-template-columns: repeat(2, minmax(0, 1fr));