Add plan deletion, with a confirmation screen listing affected clubs

Due.plan is PROTECT -- a plan that has ever billed anyone can never
truly be removed, on purpose: amount/period_end/grace_until are
frozen on a Due precisely so a later change can't rewrite what was
actually charged, and losing the plan link off an old Due would do
exactly that to every historical invoice.

"Delete" therefore means one of two things, chosen automatically
(billing/services/plans.py):
- never billed anyone -> the row is removed outright.
- has billing history -> soft-deleted (Plan.deleted_at, is_active
  off): hidden from every picker/listing via the new opt-in
  Plan.objects.visible(), but the row survives so old invoices still
  show what they were billed under.

Either way, every club currently on the plan is unsubscribed outright
-- its Subscription row deleted, not just its plan field cleared.
"No plan" was already a fully-understood state everywhere else in the
app, so this reuses it instead of inventing a new one.

Also handles the easy-to-miss second group: a club on a DIFFERENT
plan, mid-trial, configured to convert to the plan being deleted
(Subscription.post_trial_plan). Left alone that would try to convert
onto a hidden/gone plan later; instead that club's trial is ended now
(both trial fields cleared, per the CheckConstraint requiring them
together) so it needs a new plan picked by hand.

The confirmation screen is a real page, not a modal like every other
billing action -- naming exactly which clubs are affected, in both
groups, and that list can be long.
This commit is contained in:
2026-08-08 20:01:30 +02:00
parent 617271f0d0
commit ae31c1d544
12 changed files with 540 additions and 7 deletions

View File

@@ -126,7 +126,7 @@ class SubscriptionForm(forms.ModelForm):
# An inactive plan still bills its existing subscriptions, but must not be picked up
# by a new one — which is the whole point of retiring a plan. Trial plans are excluded
# too: they are reached through the trial form, which converts them properly.
self.fields["plan"].queryset = Plan.objects.filter(is_active=True, is_trial=False)
self.fields["plan"].queryset = Plan.objects.visible().filter(is_active=True, is_trial=False)
class TrialForm(forms.Form):
@@ -150,8 +150,8 @@ class TrialForm(forms.Form):
super().__init__(*args, **kwargs)
# Same reasoning as SubscriptionForm: a retired plan keeps billing whoever is
# already on it, but must not be offered for a new trial or a new plan either.
self.fields["trial_plan"].queryset = Plan.objects.filter(is_active=True, is_trial=True)
self.fields["post_trial_plan"].queryset = Plan.objects.filter(is_active=True, is_trial=False)
self.fields["trial_plan"].queryset = Plan.objects.visible().filter(is_active=True, is_trial=True)
self.fields["post_trial_plan"].queryset = Plan.objects.visible().filter(is_active=True, is_trial=False)
class DuePaymentForm(forms.Form):

View File

@@ -65,6 +65,7 @@
<td class="flex flex-row gap-2 justify-end">
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ plan.pk|dom_id:"plan_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ plan.pk|dom_id:"plan_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
<a class="btn btn-error btn-outline btn-sm gap-1" href="{% url 'controlpanel:plan_delete' plan.pk %}">{% lucide "trash-2" size=14 %} Delete</a>
</td>
</tr>
{% empty %}

View File

@@ -0,0 +1,82 @@
{% extends "controlpanel/base.html" %}
{% load i18n lucide %}
{% comment %}
Confirm-then-delete for a plan. A real page rather than a modal (unlike every other
billing action) because the whole point is naming exactly which clubs are affected, and
that list can be long. See billing.services.plans for what "delete" actually does.
{% endcomment %}
{% block heading %}{% blocktrans with plan=plan.name %}Delete “{{ plan }}”{% endblocktrans %}{% endblock heading %}
{% block actions %}
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:billing' %}">{% lucide "arrow-left" size=16 %} {% trans "Back to billing" %}</a>
{% endblock actions %}
{% block panel %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "triangle-alert" size=18 %} {% trans "This can't be undone" %}</h2>
{% if impact.will_hard_delete %}
<p class="text-sm opacity-70">{% blocktrans with plan=plan.name %}“{{ plan }}” has never billed anyone, so it will be removed completely.{% endblocktrans %}</p>
{% else %}
<p class="text-sm opacity-70">{% blocktrans with plan=plan.name %}“{{ plan }}” has billing history, so it will be hidden rather than removed — past invoices will still show what they were billed under.{% endblocktrans %}</p>
{% endif %}
</div>
</div>
{% if impact.unsubscribed_clubs %}
<div class="card mb-6 bg-base-100 shadow border-l-4 border-error">
<div class="card-body">
<h2 class="card-title text-base">
{% lucide "building-2" size=18 %}
{% blocktrans count counter=impact.unsubscribed_clubs|length %}{{ counter }} club is currently on this plan{% plural %}{{ counter }} clubs are currently on this plan{% endblocktrans %}
</h2>
<p class="text-sm opacity-70">{% trans "Deleting this plan removes their subscription outright — each shows as not billed for anything afterwards, the same as a club that was never put on a plan." %}</p>
<ul class="mt-2 divide-y divide-base-200">
{% for club in impact.unsubscribed_clubs %}
<li class="py-2 flex items-center justify-between">
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
<span class="badge badge-error badge-outline gap-1">{% lucide "circle-x" size=12 %} {% trans "Loses this plan" %}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% if impact.broken_trial_clubs %}
<div class="card mb-6 bg-base-100 shadow border-l-4 border-warning">
<div class="card-body">
<h2 class="card-title text-base">
{% lucide "hourglass" size=18 %}
{% blocktrans count counter=impact.broken_trial_clubs|length %}{{ counter }} club's trial is scheduled to switch to this plan{% plural %}{{ counter }} clubs' trials are scheduled to switch to this plan{% endblocktrans %}
</h2>
<p class="text-sm opacity-70">{% trans "They stay on their current trial plan, but the scheduled switch is cancelled — pick a new plan for them before the trial ends." %}</p>
<ul class="mt-2 divide-y divide-base-200">
{% for club in impact.broken_trial_clubs %}
<li class="py-2 flex items-center justify-between">
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
<span class="badge badge-warning badge-outline gap-1">{% lucide "octagon-alert" size=12 %} {% trans "Trial needs a new plan" %}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% if not impact.has_impact %}
<div class="alert alert-info mb-6">
{% lucide "info" size=20 %}
<span>{% trans "No club is currently on this plan, or has a trial scheduled to switch to it." %}</span>
</div>
{% endif %}
<form method="post" action="{% url 'controlpanel:plan_delete' plan.pk %}">
{% csrf_token %}
<div class="flex flex-row justify-end gap-2">
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:billing' %}">{% lucide "x" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-error gap-2" type="submit">{% lucide "trash-2" size=16 %} {% trans "Delete plan" %}</button>
</div>
</form>
{% endblock panel %}

View File

@@ -16,9 +16,9 @@ from django.urls import reverse
from django.utils import timezone
from waffle import get_waffle_flag_model, get_waffle_switch_model
from billing.models import DEFAULT_GRACE_DAYS, Due, Plan, PlanPrice
from billing.models import DEFAULT_GRACE_DAYS, Due, Plan, PlanPrice, Subscription
from billing.services import BillingError
from billing.services.dues import record_payment, subscribe, waive
from billing.services.dues import record_payment, start_trial, subscribe, waive
from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Attendance, Event, Location
from features.models import Maintenance
@@ -1662,6 +1662,121 @@ class BillingFormRenderTests(ControlPanelTestBase):
self.assertContains(response, "remove them before waiving")
class PlanDeleteTests(ControlPanelTestBase):
"""billing.services.plans and controlpanel.views.PlanDeleteView."""
def setUp(self):
super().setUp()
self.today = timezone.localdate()
self.plan = Plan.objects.create(name="Standard")
PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
def test_a_never_used_plan_is_removed_completely(self):
unused = Plan.objects.create(name="Unused")
response = self.client.get(reverse("controlpanel:plan_delete", args=[unused.pk]))
self.assertTrue(response.context["impact"].will_hard_delete)
self.client.post(reverse("controlpanel:plan_delete", args=[unused.pk]))
self.assertFalse(Plan.objects.filter(pk=unused.pk).exists())
def test_a_plan_with_billing_history_is_hidden_not_removed(self):
subscribe(self.club, self.plan)
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
self.plan.refresh_from_db()
self.assertTrue(Plan.objects.filter(pk=self.plan.pk).exists())
self.assertTrue(self.plan.is_deleted)
self.assertFalse(self.plan.is_active)
self.assertIsNotNone(self.plan.deleted_at)
def test_deleting_unsubscribes_every_club_currently_on_it(self):
other = Club.objects.create(name="Feyenoord")
subscribe(self.club, self.plan)
subscribe(other, self.plan)
response = self.client.get(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
self.assertCountEqual([c.pk for c in response.context["impact"].unsubscribed_clubs], [self.club.pk, other.pk])
self.assertContains(response, "Ajax United")
self.assertContains(response, "Feyenoord")
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
self.assertFalse(hasattr(self.club, "subscription") and Subscription.objects.filter(club=self.club).exists())
self.assertFalse(Subscription.objects.filter(club=other).exists())
# The club itself, and its billing history, are untouched.
self.assertTrue(Club.objects.filter(pk=self.club.pk).exists())
self.assertEqual(self.club.dues.first().plan, self.plan)
def test_the_amount_and_dates_on_a_deleted_plans_dues_are_unchanged(self):
# The whole reason a plan with history can't be hard-deleted: Due.plan, .amount,
# .period_end and .grace_until are frozen snapshots, and deleting the plan must not
# touch any of them.
subscribe(self.club, self.plan)
due = self.club.dues.first()
amount, period_end, grace_until = due.amount, due.period_end, due.grace_until
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
due.refresh_from_db()
self.assertEqual(due.amount, amount)
self.assertEqual(due.period_end, period_end)
self.assertEqual(due.grace_until, grace_until)
self.assertEqual(due.plan_id, self.plan.pk)
def test_a_club_mid_trial_scheduled_to_convert_to_the_deleted_plan_is_flagged(self):
trial_plan = Plan.objects.create(name="Trial", is_trial=True, duration_months=2, renewal_lead_days=7, grace_days=14)
PlanPrice.objects.create(plan=trial_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("0.00"))
start_trial(self.club, trial_plan, post_trial_plan=self.plan)
response = self.client.get(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
self.assertEqual([c.pk for c in response.context["impact"].broken_trial_clubs], [self.club.pk])
# Not double-counted as "currently on this plan" -- it's still on the trial plan.
self.assertEqual(response.context["impact"].unsubscribed_clubs, [])
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
self.club.refresh_from_db()
subscription = self.club.subscription
self.assertEqual(subscription.plan, trial_plan)
self.assertIsNone(subscription.trial_ends_at)
self.assertIsNone(subscription.post_trial_plan)
def test_a_deleted_plan_is_removed_from_the_billing_page(self):
subscribe(self.club, self.plan)
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
response = self.client.get(reverse("controlpanel:billing"))
self.assertNotIn(self.plan, response.context["plans"])
def test_a_deleted_plan_cannot_be_picked_for_a_new_subscription(self):
other = Club.objects.create(name="Feyenoord")
subscribe(self.club, self.plan)
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
response = self.client.get(reverse("controlpanel:club_detail", args=[other.pk]))
self.assertNotIn(self.plan, response.context["subscription_form"].fields["plan"].queryset)
def test_visiting_an_already_deleted_plan_redirects_with_a_message(self):
subscribe(self.club, self.plan)
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
response = self.client.get(reverse("controlpanel:plan_delete", args=[self.plan.pk]), follow=True)
self.assertRedirects(response, reverse("controlpanel:billing"))
self.assertContains(response, "already been deleted")
def test_a_plan_with_no_clubs_reports_no_impact(self):
response = self.client.get(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
self.assertFalse(response.context["impact"].has_impact)
self.assertContains(response, "No club is currently on this plan")
class MaintenancePanelTests(ControlPanelTestBase):
def setUp(self):
super().setUp()

View File

@@ -27,6 +27,7 @@ urlpatterns = [
path("billing/", views.BillingView.as_view(), name="billing"),
path("billing/plans/new/", views.PlanCreateView.as_view(), name="plan_create"),
path("billing/plans/<uuid:pk>/edit/", views.PlanUpdateView.as_view(), name="plan_update"),
path("billing/plans/<uuid:pk>/delete/", views.PlanDeleteView.as_view(), name="plan_delete"),
path("billing/plans/<uuid:pk>/prices/new/", views.PlanPriceCreateView.as_view(), name="plan_price_create"),
path("billing/dues/<uuid:pk>/pay/", views.RecordPaymentView.as_view(), name="due_pay"),
path("billing/dues/<uuid:pk>/waive/", views.WaiveDueView.as_view(), name="due_waive"),

View File

@@ -7,6 +7,8 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.utils.formats import date_format
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 waffle import get_waffle_flag_model, get_waffle_switch_model
@@ -14,6 +16,7 @@ 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
from billing.services.plans import delete_plan, plan_deletion_impact
from club.models import Club, ClubRole
from events.models import Location
from features.models import Maintenance
@@ -401,7 +404,7 @@ class BillingView(PlatformStaffRequiredMixin, TemplateView):
# Bound per-row so each "Edit" / "New price" modal can render its own form: the
# template can't call PlanForm(instance=plan) itself, so the form rides along on
# the object it belongs to.
plans = list(Plan.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")))
plans = list(Plan.objects.visible().prefetch_related("prices").annotate(club_count=Count("subscriptions")))
for plan in plans:
plan.edit_form = PlanForm(instance=plan)
plan.price_form = PlanPriceForm()
@@ -448,6 +451,53 @@ class PlanUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateV
return reverse("controlpanel:billing")
class PlanDeleteView(PlatformStaffRequiredMixin, TemplateView):
"""Confirm-then-delete for a plan. Unlike every other billing action, this is a real
page rather than a modal: the whole point is naming exactly which clubs lose their plan
(or lose their trial's scheduled landing plan), and that list can be long -- see
billing.services.plans for what "delete" actually does to a plan with billing history.
"""
template_name = "controlpanel/plan_delete.html"
@property
def plan(self):
return get_object_or_404(Plan, pk=self.kwargs["pk"])
def get(self, request, *args, **kwargs):
plan = self.plan
if plan.is_deleted:
notify(request, f"i|{_('Already deleted')}|" + _("%(plan)s” has already been deleted.") % {"plan": plan.name})
return redirect("controlpanel:billing")
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
return super().get_context_data(nav="billing", plan=self.plan, impact=plan_deletion_impact(self.plan), **kwargs)
def post(self, request, *args, **kwargs):
plan = self.plan
plan_name = plan.name
impact = delete_plan(plan)
if impact.will_hard_delete:
body = _("%(plan)s” has been deleted.") % {"plan": plan_name}
else:
body = _("%(plan)s” has been deleted. It has billing history, so it's hidden rather than removed — past invoices still show it.") % {"plan": plan_name}
notify(request, f"w|{_('Plan deleted')}|{body}")
if impact.unsubscribed_clubs:
count = len(impact.unsubscribed_clubs)
club_body = ngettext("%(count)d club now has no plan.", "%(count)d clubs now have no plan.", count) % {"count": count}
notify(request, f"w|{_('Clubs affected')}|{club_body}")
if impact.broken_trial_clubs:
count = len(impact.broken_trial_clubs)
trial_body = ngettext("%(count)d club's trial lost its scheduled plan and needs a new one picked.", "%(count)d clubs' trials lost their scheduled plan and need a new one picked.", count) % {"count": count}
notify(request, f"w|{_('Trials affected')}|{trial_body}")
return redirect("controlpanel:billing")
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.