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:
@@ -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"))
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user