Add trial subscriptions with automatic switch to a pre-selected plan

A club with no subscription yet can be started on a short trial (e.g.
2 months) from the control panel, on a tier picked up front for what
it switches to once the trial ends -- no manual follow-up needed. The
trial is a real billed period on a dedicated trial tier, reusing the
existing invoice/grace/archive machinery unchanged; the switch happens
in open_period() itself so it fires whether reached via the scheduled
renewal command or a platform admin's manual "Open period" click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
This commit is contained in:
2026-08-04 12:28:47 +02:00
parent 68cad0c951
commit 6ad0d6658c
9 changed files with 307 additions and 8 deletions

View File

@@ -112,6 +112,23 @@ class SubscriptionForm(forms.ModelForm):
self.fields["tier"].queryset = Tier.objects.filter(is_active=True)
class TrialForm(forms.Form):
"""Put a club with no subscription yet on a short trial that switches itself to
``post_trial_tier`` automatically once it ends -- see billing.services.dues.start_trial."""
trial_tier = forms.ModelChoiceField(queryset=Tier.objects.none(), label=_("Trial tier"), help_text=_("What this club is billed on during the trial."))
post_trial_tier = forms.ModelChoiceField(queryset=Tier.objects.none(), label=_("Then switch to"), help_text=_("The plan it lands on automatically once the trial ends."))
trial_months = forms.IntegerField(min_value=1, initial=2, label=_("Trial length (months)"))
start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("Trial starts"), help_text=_("Left blank, the trial starts today."))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Same reasoning as SubscriptionForm: a retired tier keeps billing whoever is
# already on it, but must not be offered for a new trial or a new plan either.
self.fields["trial_tier"].queryset = Tier.objects.filter(is_active=True)
self.fields["post_trial_tier"].queryset = Tier.objects.filter(is_active=True)
class DuePaymentForm(forms.Form):
amount = forms.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal("0.01"), label=_("Amount"))
method = forms.ChoiceField(choices=DuePayment.Method.choices, initial=DuePayment.Method.BANK_TRANSFER, label=_("Method"))

View File

@@ -14,6 +14,11 @@
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()">
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
</button>
{% if not subscription %}
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('trial_modal').showModal()">
{% lucide "hourglass" size=14 %} Start trial
</button>
{% endif %}
{% if subscription %}
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('open_period_modal').showModal()">
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
@@ -27,6 +32,10 @@
{% else %}
<p class="text-sm opacity-70">
On plan <strong>{{ subscription.tier.name }}</strong>.
{% if subscription.trial_ends_at %}
<span class="badge badge-info badge-sm gap-1">{% lucide "hourglass" size=12 %} Trial</span>
On trial until {{ subscription.trial_ends_at|date:"j M Y" }}, then switches to <strong>{{ subscription.post_trial_tier.name }}</strong>.
{% endif %}
{% if subscription.auto_renew %}
Renews automatically 30 days before the period ends.
{% else %}
@@ -118,6 +127,11 @@
{% url 'controlpanel:club_subscribe' club.pk as subscribe_url %}
{% include "controlpanel/_modal_form.html" with modal_id="subscription_modal" title=subscription|yesno:"Change plan,Start billing" form=subscription_form action_url=subscribe_url submit_label="Save plan" submit_icon="layers" blurb="Changing tier does not re-bill: the current period keeps the amount it was issued at, and the new rate applies from the next one." %}
{% if not subscription %}
{% url 'controlpanel:club_trial_start' club.pk as trial_start_url %}
{% include "controlpanel/_modal_form.html" with modal_id="trial_modal" title="Start trial" form=trial_form action_url=trial_start_url submit_label="Start trial" submit_icon="hourglass" blurb="The trial period is billed like any other, on the trial tier you pick. It switches to the plan you pick here automatically the next time a period is opened after it ends -- no follow-up needed." %}
{% endif %}
{% if subscription %}
{% url 'controlpanel:club_open_period' club.pk as open_period_url %}
{% include "controlpanel/_modal_form.html" with modal_id="open_period_modal" title=club.is_archived|yesno:"Reactivate,Open period" form=open_period_form action_url=open_period_url submit_label="Open period" submit_icon="calendar-plus" blurb=open_period_blurb %}

View File

@@ -1446,6 +1446,71 @@ class BillingPanelTests(ControlPanelTestBase):
self.assertContains(response, "pango")
class TrialPanelTests(ControlPanelTestBase):
"""The club detail page's "Start trial" modal -- see
controlpanel.views.ClubStartTrialView / billing.services.dues.start_trial."""
def setUp(self):
super().setUp()
self.today = timezone.localdate()
self.trial_tier = Tier.objects.create(name="Trial")
TierPrice.objects.create(tier=self.trial_tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("50.00"))
self.tier = Tier.objects.create(name="Standard")
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
def start_trial(self, **data):
data = {"trial_tier": self.trial_tier.pk, "post_trial_tier": self.tier.pk, "trial_months": 2, "start": ""} | data
return self.client.post(reverse("controlpanel:club_trial_start", args=[self.club.pk]), data)
def test_starting_a_trial_opens_a_short_first_period(self):
response = self.start_trial()
self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertEqual(self.club.subscription.tier, self.trial_tier)
self.assertEqual(self.club.subscription.post_trial_tier, self.tier)
due = self.club.dues.first()
self.assertTrue(due.is_trial)
self.assertLess((due.period_end - due.period_start).days, 65)
def test_a_club_already_subscribed_cannot_be_started_on_a_trial(self):
subscribe(self.club, self.tier)
response = self.client.post(
reverse("controlpanel:club_trial_start", args=[self.club.pk]),
{"trial_tier": self.trial_tier.pk, "post_trial_tier": self.tier.pk, "trial_months": 2, "start": ""},
follow=True,
)
self.assertContains(response, "already subscribed")
self.assertEqual(self.club.dues.count(), 1)
def test_an_invalid_trial_length_redirects_back_with_an_error(self):
response = self.client.post(reverse("controlpanel:club_trial_start", args=[self.club.pk]), {"trial_tier": self.trial_tier.pk, "post_trial_tier": self.tier.pk, "trial_months": 0, "start": ""}, follow=True)
self.assertFalse(hasattr(self.club, "subscription"))
self.assertTrue(response.context["messages"])
def test_the_club_page_shows_the_active_trial(self):
self.start_trial()
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, "On trial")
self.assertContains(response, "Standard")
def test_manually_changing_tier_mid_trial_clears_the_trial(self):
self.start_trial()
other = Tier.objects.create(name="Other")
TierPrice.objects.create(tier=other, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("300.00"))
self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": other.pk, "auto_archive": "on", "notes": ""})
self.club.refresh_from_db()
self.assertEqual(self.club.subscription.tier, other)
self.assertIsNone(self.club.subscription.trial_ends_at)
self.assertIsNone(self.club.subscription.post_trial_tier)
class BillingFormRenderTests(ControlPanelTestBase):
def setUp(self):
super().setUp()

View File

@@ -32,6 +32,7 @@ urlpatterns = [
path("billing/dues/<uuid:pk>/waive/", views.WaiveDueView.as_view(), name="due_waive"),
path("billing/dues/<uuid:pk>/invoice.pdf", views.InvoicePdfView.as_view(), name="due_invoice"),
path("clubs/<uuid:pk>/subscription/", views.SubscribeClubView.as_view(), name="club_subscribe"),
path("clubs/<uuid:pk>/trial/start/", views.ClubStartTrialView.as_view(), name="club_trial_start"),
path("clubs/<uuid:pk>/period/new/", views.OpenPeriodView.as_view(), name="club_open_period"),
# Platform admins (superusers only)
path("admins/", views.PlatformAdminListView.as_view(), name="admins"),

View File

@@ -12,13 +12,13 @@ from waffle import get_waffle_flag_model, get_waffle_switch_model
from billing.models import Due, Tier, TierPrice
from billing.services import BillingError
from billing.services.dues import next_period_start, open_period, reactivate, record_payment, subscribe, waive
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 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
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, HomeLocationForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm, TrialForm
from .messages import notify
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
from .services.admins import grant_club_admin, revoke_club_admin
@@ -153,6 +153,7 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
open_period_form=OpenPeriodForm(),
open_period_blurb=f"Next period starts {date_format(next_start, 'j M Y')} unless you say otherwise. By default it continues from the end of the last one, so a lapsed year is still owed — pick a start date to forgive the gap.",
subscription_form=SubscriptionForm(instance=subscription) if subscription else SubscriptionForm(),
trial_form=TrialForm(),
**kwargs,
)
@@ -503,8 +504,15 @@ class SubscribeClubView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, Form
if existing:
# Changing tier 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
# later, onto a plan the admin didn't just choose.
subscription.trial_ends_at = None
subscription.post_trial_tier = 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.")
else:
@@ -514,6 +522,37 @@ class SubscribeClubView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, Form
return redirect("controlpanel:club_detail", pk=club.pk)
class ClubStartTrialView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Put a club with no subscription yet on a short trial -- reachable only via the
"Start trial" modal on the club detail page, shown alongside "Start billing" only
while the club has no subscription. POST-only, no standalone template."""
form_class = TrialForm
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:club_detail"
@property
def club(self):
return get_object_or_404(Club, pk=self.kwargs["pk"])
def get_invalid_redirect_kwargs(self):
return {"pk": self.kwargs["pk"]}
def form_valid(self, form):
club = self.club
with suppress_billing_errors(self.request, title="Couldn't start trial"):
start_trial(
club,
form.cleaned_data["trial_tier"],
post_trial_tier=form.cleaned_data["post_trial_tier"],
trial_months=form.cleaned_data["trial_months"],
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']}.")
return redirect("controlpanel:club_detail", pk=club.pk)
class RecordPaymentView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Reachable only via a due's "Record payment" modal on the club or billing page —
POST-only, and there is no standalone template to render on GET or on a rejected