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

@@ -10,7 +10,7 @@ from django.utils.formats import date_format
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
from waffle import get_waffle_flag_model, get_waffle_switch_model
from billing.models import Due, Tier, TierPrice
from billing.models import Due, Plan, PlanPrice
from billing.services import BillingError
from billing.services.dues import next_period_start, open_period, reactivate, record_payment, start_trial, subscribe, waive
from billing.services.invoices import invoice_pdf, issue_invoice
@@ -18,7 +18,7 @@ from club.models import Club, ClubRole
from events.models import Location
from features.models import Maintenance
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, HomeLocationForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm, TrialForm
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, HomeLocationForm, MaintenanceForm, OpenPeriodForm, PlanForm, PlanPriceForm, PlatformAdminForm, SubscriptionForm, TrialForm
from .messages import notify
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
from .services.admins import grant_club_admin, revoke_club_admin
@@ -130,7 +130,7 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
# Bound per-row so each due's "Add payment" modal can render its own form without
# the template calling DuePaymentForm(initial=...) itself.
dues = list(self.object.dues.select_related("tier", "invoice").prefetch_related("payments"))
dues = list(self.object.dues.select_related("plan", "invoice").prefetch_related("payments"))
for due in dues:
if due.is_owing:
due.payment_form = DuePaymentForm(initial={"amount": due.balance})
@@ -138,6 +138,9 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
home_location = Location.objects.filter(club=self.object, is_home=True).first()
return super().get_context_data(
# Drives the "archived but owes nothing -- reactivate?" prompt. Computed from the
# dues already fetched above rather than re-querying.
dues_settled=not any(due.is_owing for due in dues),
home_location=home_location,
home_location_form=HomeLocationForm(instance=home_location),
nav="clubs",
@@ -388,7 +391,7 @@ class PlatformAdminRevokeView(PlatformSuperuserRequiredMixin, View):
class BillingView(PlatformStaffRequiredMixin, TemplateView):
"""Tiers and their prices, plus every period we are owed money for."""
"""Plans and their prices, plus every period we are owed money for."""
template_name = "controlpanel/billing.html"
@@ -396,76 +399,76 @@ class BillingView(PlatformStaffRequiredMixin, TemplateView):
today = timezone.localdate()
# Bound per-row so each "Edit" / "New price" modal can render its own form: the
# template can't call TierForm(instance=tier) itself, so the form rides along on
# template can't call PlanForm(instance=plan) itself, so the form rides along on
# the object it belongs to.
tiers = list(Tier.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")))
for tier in tiers:
tier.edit_form = TierForm(instance=tier)
tier.price_form = TierPriceForm()
plans = list(Plan.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")))
for plan in plans:
plan.edit_form = PlanForm(instance=plan)
plan.price_form = PlanPriceForm()
owing = list(Due.objects.filter(status__in=Due.OWING).select_related("club", "tier").order_by("grace_until"))
owing = list(Due.objects.filter(status__in=Due.OWING).select_related("club", "plan").order_by("grace_until"))
for due in owing:
due.payment_form = DuePaymentForm(initial={"amount": due.balance})
return super().get_context_data(
nav="billing",
tiers=tiers,
tier_form=TierForm(),
plans=plans,
plan_form=PlanForm(),
owing=owing,
today=today,
**kwargs,
)
class TierCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
class PlanCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
"""Reachable only via the "New plan" modal on the billing page — POST-only, and there
is no standalone template to render on GET or on a rejected submission."""
model = Tier
form_class = TierForm
model = Plan
form_class = PlanForm
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:billing"
def get_success_url(self):
notify(self.request, f"s|Plan created|Tier{self.object}” created. Give it a price before billing anyone.")
notify(self.request, f"s|Plan created|Plan{self.object}” created. Give it a price before billing anyone.")
return reverse("controlpanel:billing")
class TierUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateView):
"""Reachable only via a tier's "Edit" modal on the billing page — POST-only, and there
class PlanUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateView):
"""Reachable only via a plan's "Edit" modal on the billing page — POST-only, and there
is no standalone template to render on GET or on a rejected submission."""
model = Tier
form_class = TierForm
model = Plan
form_class = PlanForm
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:billing"
def get_success_url(self):
notify(self.request, f"s|Plan updated|Tier{self.object}” updated.")
notify(self.request, f"s|Plan updated|Plan{self.object}” updated.")
return reverse("controlpanel:billing")
class TierPriceCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
class PlanPriceCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
"""A rate change is a new dated price, never an edit of the old one — periods already
billed keep the amount they were billed at.
Reachable only via a tier's "New price" modal on the billing page — POST-only, and
Reachable only via a plan's "New price" modal on the billing page — POST-only, and
there is no standalone template to render on GET or on a rejected submission.
"""
model = TierPrice
form_class = TierPriceForm
model = PlanPrice
form_class = PlanPriceForm
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:billing"
@property
def tier(self):
return get_object_or_404(Tier, pk=self.kwargs["pk"])
def plan(self):
return get_object_or_404(Plan, pk=self.kwargs["pk"])
def form_valid(self, form):
form.instance.tier = self.tier
form.instance.plan = self.plan
response = super().form_valid(form)
notify(self.request, f"s|Price added|{self.tier} is €{self.object.amount} for periods opening from {self.object.active_from}.")
notify(self.request, f"s|Price added|{self.plan} is €{self.object.amount} for periods opening from {self.object.active_from}.")
return response
def get_success_url(self):
@@ -473,7 +476,7 @@ class TierPriceCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, Cr
class SubscribeClubView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Put a club on a tier, which opens its first period.
"""Put a club on a plan, which opens its first period.
Reachable only via the "Change plan" modal on the club detail page — POST-only, and
there is no standalone template to render on GET or on a rejected submission.
@@ -502,22 +505,22 @@ class SubscribeClubView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, Form
existing = getattr(club, "subscription", None)
with suppress_billing_errors(self.request, title="Couldn't change plan"):
if existing:
# Changing tier does not re-bill: the current period keeps the amount it was
# Changing plan does not re-bill: the current period keeps the amount it was
# issued at, and the new rate applies from the next one.
was_on_trial = existing.trial_ends_at is not None
subscription = form.save(commit=False)
subscription.club = club
if was_on_trial:
# A manual tier change while on a trial is a deliberate override --
# left in place, the trial fields would silently swap the tier again
# A manual plan change while on a trial is a deliberate override --
# left in place, the trial fields would silently swap the plan again
# later, onto a plan the admin didn't just choose.
subscription.trial_ends_at = None
subscription.post_trial_tier = None
subscription.post_trial_plan = None
subscription.save()
notify(self.request, f"s|Plan changed|{club} is now on {subscription.tier}. The current period keeps the amount it was billed at.")
notify(self.request, f"s|Plan changed|{club} is now on {subscription.plan}. The current period keeps the amount it was billed at.")
else:
subscribe(club, form.cleaned_data["tier"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"], auto_renew=form.cleaned_data["auto_renew"])
notify(self.request, f"s|Billing started|{club} is on {form.cleaned_data['tier']}. Its first period is open.")
subscribe(club, form.cleaned_data["plan"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"], auto_renew=form.cleaned_data["auto_renew"])
notify(self.request, f"s|Billing started|{club} is on {form.cleaned_data['plan']}. Its first period is open.")
return redirect("controlpanel:club_detail", pk=club.pk)
@@ -541,14 +544,14 @@ class ClubStartTrialView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, For
def form_valid(self, form):
club = self.club
with suppress_billing_errors(self.request, title="Couldn't start trial"):
trial_plan = form.cleaned_data["trial_plan"]
start_trial(
club,
form.cleaned_data["trial_tier"],
post_trial_tier=form.cleaned_data["post_trial_tier"],
trial_months=form.cleaned_data["trial_months"],
trial_plan,
post_trial_plan=form.cleaned_data["post_trial_plan"],
start=form.cleaned_data.get("start"),
)
notify(self.request, f"s|Trial started|{club} is on a {form.cleaned_data['trial_months']}-month trial of {form.cleaned_data['trial_tier']}, then switches to {form.cleaned_data['post_trial_tier']}.")
notify(self.request, f"s|Trial started|{club} is on a {trial_plan.duration_months}-month trial of {trial_plan}, then switches to {form.cleaned_data['post_trial_plan']}.")
return redirect("controlpanel:club_detail", pk=club.pk)
@@ -564,7 +567,7 @@ class RecordPaymentView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, Form
@property
def due(self):
return get_object_or_404(Due.objects.select_related("club", "tier"), pk=self.kwargs["pk"])
return get_object_or_404(Due.objects.select_related("club", "plan"), pk=self.kwargs["pk"])
def get_invalid_redirect_kwargs(self):
return {"pk": self.due.club_id}
@@ -627,7 +630,7 @@ class OpenPeriodView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormVie
class InvoicePdfView(PlatformStaffRequiredMixin, View):
def get(self, request, pk):
due = get_object_or_404(Due.objects.select_related("club", "tier", "invoice"), pk=pk)
due = get_object_or_404(Due.objects.select_related("club", "plan", "invoice"), pk=pk)
invoice = issue_invoice(due)
try:
pdf = invoice_pdf(invoice)