Rework platform billing: per-plan clocks, grace from period start

Implements BILLING.md. The architecture was sound -- snapshot-on-Due,
dated prices, asymmetric dry-run commands are all kept -- so this
fixes the three hardcoded assumptions rather than rewriting.

The real defect: grace ran from period_END, so an annual club used
the whole unpaid year plus 45 days (~410 days) before anything
switched it off. Grace now runs from the period START, and every
clock is per-plan.

- Tier -> Plan (+ TierPrice -> PlanPrice, and every FK). Migration
  0004 is hand-written: run non-interactively, makemigrations emits
  DeleteModel+CreateModel and drops every price, subscription and
  due. Its two RemoveConstraints must come first, or SQLite's
  table-rebuild tries to render a constraint over a just-renamed
  column. Verified by round-tripping real rows through it.
- Plan gains duration_months / renewal_lead_days / grace_days /
  is_trial, with CheckConstraints and a matching clean() so the form
  reports an impossible plan instead of 500ing on IntegrityError.
- Existing dues keep their stored grace_until. Re-deriving it would
  put the date in the past for every open annual period and archive
  the entire paying customer base on the next --commit run.
- Trials take their length from the trial plan's own duration_months;
  start_trial() loses its trial_months argument.
- New BillingNotice service drives a club-facing warning: every level
  on the dashboard, and on every management page once urgent.
- send_billing_reminders emails club admins, once per escalation
  level so a daily cron is not a daily email. SMTP settings are
  env-driven and provider-agnostic; the backend defaults to console.
- Paying does not auto-restore an archived club -- the control panel
  surfaces a Reactivate prompt instead, since a club can also be
  archived by hand.
This commit is contained in:
2026-08-08 18:49:52 +02:00
parent ae93406853
commit fc6488ce55
36 changed files with 1342 additions and 386 deletions

View File

@@ -9,6 +9,7 @@ action rather than the whole section (``NewsAuthorRequiredMixin``/``can_add_news
from waffle import flag_is_active
from billing.services.notices import club_billing_notice
from club.services.access import can_add_news, has_management_access, is_club_admin, is_coach_manager
#: Every management URL name, mapped to the nav item it should light up --
@@ -123,6 +124,23 @@ def is_admin(request):
return {"is_club_admin": is_club_admin(request.user, club)}
def billing_notice(request):
"""What this club owes the platform, for the club's own admins.
A context processor rather than view context because the notice has to be able to follow
an admin onto every management page once it turns urgent -- billing/base.html renders it
at error level only, and the home page renders it at every level.
Admins only: platform billing is none of an ordinary member's business, and the query is
skipped entirely for everyone else rather than fetched and hidden in the template.
"""
club = getattr(request, "club", None)
if club is None or not request.user.is_authenticated or not is_club_admin(request.user, club):
return {"billing_notice": None}
return {"billing_notice": club_billing_notice(club)}
def management_position(request):
"""Whether the signed-in user holds a management position (or is ADMIN) --
gates the nav's Locations/Opponents links, which ``ManagementPositionRequiredMixin``

View File

@@ -0,0 +1,35 @@
{% comment %}
What this club owes the platform, for its own admins.
`billing_notice` comes from management.context_processors.billing_notice, which returns
None for anyone who is not a club admin -- so this partial never needs to check that
itself. Included unconditionally by home.html, and by base.html only when the notice has
reached error level, so a final notice follows an admin onto every management page while
an early one stays on the dashboard.
{% endcomment %}
{% load i18n lucide %}
{% if billing_notice %}
<div class="alert {% if billing_notice.level == 'error' %}alert-error{% elif billing_notice.level == 'warning' %}alert-warning{% else %}alert-info{% endif %} mb-6">
{% if billing_notice.level == 'error' %}
{% lucide "octagon-alert" size=20 %}
{% elif billing_notice.level == 'warning' %}
{% lucide "triangle-alert" size=20 %}
{% else %}
{% lucide "receipt-euro" size=20 %}
{% endif %}
<span>
{% blocktrans with amount=billing_notice.amount_outstanding %}Platform fees of €{{ amount }} are outstanding.{% endblocktrans %}
{% if billing_notice.will_archive %}
{% if billing_notice.days_until_archive < 0 %}
{% trans "This club is now due to be archived. Pay to keep access." %}
{% else %}
{% blocktrans count days=billing_notice.days_until_archive %}This club will be archived in {{ days }} day unless payment is received.{% plural %}This club will be archived in {{ days }} days unless payment is received.{% endblocktrans %}
{% endif %}
{% else %}
{% trans "Please settle it to keep your account in good standing." %}
{% endif %}
</span>
</div>
{% endif %}

View File

@@ -38,5 +38,16 @@
{% include "management/_nav_items.html" %}
</ul>
{% comment %}
A final billing notice follows the admin onto every management page -- but only at
error level (inside 7 days of archiving, or already past it). Shown from the moment
anything is owed it would sit on every screen for weeks and train people to ignore
the one week it matters. home.html includes the same partial at every level, hence
the guard here rather than inside the partial.
{% endcomment %}
{% if billing_notice.is_urgent and nav != "home" %}
{% include "management/_billing_notice.html" %}
{% endif %}
{% block panel %}{% endblock panel %}
{% endblock main %}

View File

@@ -5,6 +5,9 @@
{% block subheading %}{% trans "Management" %}{% endblock subheading %}
{% block panel %}
{# Every level here; base.html repeats it on other pages only once it turns urgent. #}
{% include "management/_billing_notice.html" %}
{% if attention.no_season %}
<div class="alert alert-warning mb-6">
{% lucide "calendar-x" size=20 %}

View File

@@ -15,8 +15,8 @@ from django.urls import NoReverseMatch, reverse
from django.utils import timezone
from waffle import get_waffle_flag_model
from billing.models import Tier, TierPrice
from billing.services.dues import subscribe
from billing.models import Plan, PlanPrice
from billing.services.dues import record_payment, subscribe
from club.models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor
from events.models import Attendance, Competition, Event, EventSeries, Location, Opponent
from events.services.rbihf_import import RBIHFImportError
@@ -1407,9 +1407,7 @@ class FamilyMembershipRoleUpdateTests(ManagementTestBase):
self.assertRedirects(response, next_url)
def test_ignores_an_unsafe_next_url(self):
response = self.club_post(
"family_membership_role_update", {"role": FamilyMembership.FamilyRole.GUARDIAN, "next": "https://evil.example.com/steal"}, self.family.pk, self.member.pk
)
response = self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.GUARDIAN, "next": "https://evil.example.com/steal"}, self.family.pk, self.member.pk)
self.assertRedirects(response, reverse("management:family_detail", args=[self.family.pk]))
@@ -1682,9 +1680,7 @@ class MembershipRecordPaymentTests(ManagementTestBase):
super().setUp()
self.client.force_login(self.admin_user)
self.member = Member.objects.create(first_name="Owed", last_name="Fee")
self.membership = ClubMembership.objects.create(
club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID, fee_amount=Decimal("150.00")
)
self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID, fee_amount=Decimal("150.00"))
def test_recording_a_partial_payment(self):
response = self.club_post("membership_record_payment", {"amount": "50.00", "method": FeePayment.Method.CASH, "reference": "R1"}, self.membership.pk)
@@ -1731,9 +1727,7 @@ class MembershipMarkFullyPaidTests(ManagementTestBase):
super().setUp()
self.client.force_login(self.admin_user)
self.member = Member.objects.create(first_name="Owed", last_name="Fee")
self.membership = ClubMembership.objects.create(
club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID, fee_amount=Decimal("150.00")
)
self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID, fee_amount=Decimal("150.00"))
def test_settles_the_remaining_balance_in_one_click(self):
response = self.club_post("membership_mark_fully_paid", {}, self.membership.pk)
@@ -2563,7 +2557,7 @@ class LocationOpponentManagementTests(ManagementTestBase):
self.assertNotContains(response, 'type="lazyselect"')
self.assertContains(response, "Belgium")
self.assertContains(response, '<select')
self.assertContains(response, "<select")
def test_plain_staff_cannot_view_the_location_list(self):
self.client.force_login(self.make_plain_staff())
@@ -2790,15 +2784,21 @@ class SponsorManagementTests(ManagementTestBase):
class BillingEndingBannerTests(ManagementTestBase):
"""The club dashboard's "billing is about to stop" warning -- see
management.views.HomeView and management/templates/management/home.html.
Admin-only, and only within RENEWAL_LEAD_DAYS of the current period ending."""
Admin-only, only within the plan's own renewal lead of the period ending, and only when
nothing is owed -- an unpaid club gets the louder billing notice instead."""
def setUp(self):
super().setUp()
self.tier = Tier.objects.create(name="Standard")
TierPrice.objects.create(tier=self.tier, active_from=self.season.start_date - datetime.timedelta(days=1200), amount=Decimal("500.00"))
self.plan = Plan.objects.create(name="Standard")
PlanPrice.objects.create(plan=self.plan, active_from=self.season.start_date - datetime.timedelta(days=1200), amount=Decimal("500.00"))
def settle(self):
"""The "period ends soon" notice only shows when nothing is owed."""
record_payment(self.club.dues.first(), Decimal("500.00"))
def test_admin_sees_the_banner_when_the_period_ends_soon(self):
subscribe(self.club, self.tier, start=timezone.localdate() - datetime.timedelta(days=350), auto_renew=False)
subscribe(self.club, self.plan, start=timezone.localdate() - datetime.timedelta(days=350), auto_renew=False)
self.settle()
self.client.force_login(self.admin_user)
response = self.club_get("home")
@@ -2806,7 +2806,8 @@ class BillingEndingBannerTests(ManagementTestBase):
self.assertContains(response, "billing is about to stop")
def test_admin_does_not_see_the_banner_when_the_period_is_not_ending_soon(self):
subscribe(self.club, self.tier, start=timezone.localdate())
subscribe(self.club, self.plan, start=timezone.localdate())
self.settle()
self.client.force_login(self.admin_user)
response = self.club_get("home")
@@ -2814,12 +2815,13 @@ class BillingEndingBannerTests(ManagementTestBase):
self.assertNotContains(response, "billing is about to stop")
def test_a_non_admin_manager_never_sees_the_banner(self):
subscribe(self.club, self.tier, start=timezone.localdate() - datetime.timedelta(days=350), auto_renew=False)
subscribe(self.club, self.plan, start=timezone.localdate() - datetime.timedelta(days=350), auto_renew=False)
team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
position = Position.objects.create(club=self.club, name="Coach", short_name="C", staff_position=True, management_position=True)
coach_user = User.objects.create_user(email="coach-banner@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.settle()
self.client.force_login(coach_user)
response = self.club_get("home")
@@ -2835,7 +2837,8 @@ class BillingEndingBannerTests(ManagementTestBase):
self.assertNotContains(response, "will renew automatically")
def test_an_auto_renewing_club_gets_a_reassuring_banner_instead(self):
subscribe(self.club, self.tier, start=timezone.localdate() - datetime.timedelta(days=350), auto_renew=True)
subscribe(self.club, self.plan, start=timezone.localdate() - datetime.timedelta(days=350), auto_renew=True)
self.settle()
self.client.force_login(self.admin_user)
response = self.club_get("home")

View File

@@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
from billing.models import RENEWAL_LEAD_DAYS, Due
from billing.models import Due
from club.mixins import (
ClubAdminRequiredMixin,
ClubStaffRequiredMixin,
@@ -86,10 +86,13 @@ class HomeView(ClubStaffRequiredMixin, TemplateView):
club, user = self.request.club, self.request.user
subscription = getattr(club, "subscription", None)
# "Your period ends soon" is a different question from "you owe us money", and only
# worth raising when nothing is owed -- an unpaid club gets the billing notice from
# the context processor instead, which is louder and more urgent.
billing_ends_at = None
if subscription is not None:
if subscription is not None and not club.dues.filter(status__in=Due.OWING).exists():
latest_due = club.dues.exclude(status=Due.Status.CANCELLED).order_by("-period_end").first()
if latest_due is not None and 0 <= (latest_due.period_end - timezone.localdate()).days <= RENEWAL_LEAD_DAYS:
if latest_due is not None and 0 <= (latest_due.period_end - timezone.localdate()).days <= subscription.plan.renewal_lead_days:
billing_ends_at = latest_due.period_end
upcoming_events = scoped_to_managed_teams(Event.objects.filter(club=club, start__gte=timezone.now()), user, club).order_by("start").prefetch_related("teams")[:5]