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

@@ -0,0 +1,34 @@
# Generated by Django 6.0.6 on 2026-08-04 10:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0002_subscription_auto_renew'),
('club', '0017_club_season_duration_months_club_season_start'),
]
operations = [
migrations.AddField(
model_name='due',
name='is_trial',
field=models.BooleanField(default=False, help_text="This period was opened as a trial. A durable marker on the row itself -- the subscription's own trial fields are cleared once it converts.", verbose_name='trial period'),
),
migrations.AddField(
model_name='subscription',
name='post_trial_tier',
field=models.ForeignKey(blank=True, help_text='The plan this club switches to automatically once its trial ends.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='billing.tier', verbose_name='post-trial tier'),
),
migrations.AddField(
model_name='subscription',
name='trial_ends_at',
field=models.DateField(blank=True, help_text='Set while this club is on a trial. The tier switches to post_trial_tier the next time a period is opened after this date.', null=True, verbose_name='trial ends at'),
),
migrations.AddConstraint(
model_name='subscription',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('post_trial_tier__isnull', True), ('trial_ends_at__isnull', True)), models.Q(('post_trial_tier__isnull', False), ('trial_ends_at__isnull', False)), _connector='OR'), name='trial_fields_set_together'),
),
]

View File

@@ -13,6 +13,7 @@ from dateutil import relativedelta
from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -33,6 +34,10 @@ def add_one_year(day: date) -> date:
return day + relativedelta.relativedelta(years=1)
def add_months(day: date, months: int) -> date:
return day + relativedelta.relativedelta(months=months)
class Tier(UUIDModel):
"""A price band. The price itself lives in TierPrice, which is dated."""
@@ -98,10 +103,21 @@ class Subscription(UUIDModel):
auto_archive = models.BooleanField(_("auto archive"), default=True, help_text=_("Archive this club when a period goes unpaid past its grace period."))
notes = models.TextField(_("notes"), blank=True)
trial_ends_at = models.DateField(_("trial ends at"), null=True, blank=True, help_text=_("Set while this club is on a trial. The tier switches to post_trial_tier the next time a period is opened after this date."))
post_trial_tier = models.ForeignKey(Tier, on_delete=models.PROTECT, null=True, blank=True, related_name="+", verbose_name=_("post-trial tier"), help_text=_("The plan this club switches to automatically once its trial ends."))
class Meta:
verbose_name = _("subscription")
verbose_name_plural = _("subscriptions")
ordering = ["club__name"]
constraints = [
# Both set together or neither -- a trial with no target plan (or a target
# plan with no trial end date) is a half-configured state nothing should read.
models.CheckConstraint(
condition=Q(trial_ends_at__isnull=True, post_trial_tier__isnull=True) | Q(trial_ends_at__isnull=False, post_trial_tier__isnull=False),
name="trial_fields_set_together",
),
]
def __str__(self):
return f"{self.club}{self.tier}"
@@ -138,6 +154,8 @@ class Due(UUIDModel):
status = models.CharField(_("status"), max_length=20, choices=Status.choices, default=Status.UNPAID)
paid_at = models.DateTimeField(_("paid at"), null=True, blank=True)
is_trial = models.BooleanField(_("trial period"), default=False, help_text=_("This period was opened as a trial. A durable marker on the row itself -- the subscription's own trial fields are cleared once it converts."))
class Meta:
verbose_name = _("due")
verbose_name_plural = _("dues")

View File

@@ -9,7 +9,7 @@ from django.db import transaction
from django.db.models import DateField, OuterRef, Subquery, Sum
from django.utils import timezone
from billing.models import RENEWAL_LEAD_DAYS, ZERO, Due, DuePayment, Subscription, Tier
from billing.models import RENEWAL_LEAD_DAYS, ZERO, Due, DuePayment, Subscription, Tier, add_months
from billing.services import BillingError
from billing.services.invoices import issue_invoice
@@ -22,6 +22,27 @@ def subscribe(club, tier: Tier, *, start: date | None = None, auto_archive: bool
return subscription
@transaction.atomic
def start_trial(club, trial_tier: Tier, *, post_trial_tier: Tier, trial_months: int, start: date | None = None, auto_renew: bool = True, auto_archive: bool = True) -> Due:
"""Put a club on a short trial that switches itself to ``post_trial_tier`` the moment
the trial period is renewed -- see open_period()'s trial-conversion check.
Only for a club with no subscription yet -- converting an existing paying subscription
into a trial is a different, deliberately unsupported operation for now.
"""
if trial_months <= 0:
raise BillingError("Trial length must be at least 1 month.")
if getattr(club, "subscription", None) is not None:
raise BillingError(f"{club} is already subscribed -- use Change plan instead.")
start = start or next_period_start(club)
trial_end = add_months(start, trial_months) - timedelta(days=1)
Subscription.objects.create(club=club, tier=trial_tier, trial_ends_at=trial_end, post_trial_tier=post_trial_tier, auto_renew=auto_renew, auto_archive=auto_archive)
return open_period(club, start=start, period_end=trial_end, is_trial=True)
def next_period_start(club, today: date | None = None) -> date:
"""Where the club's next period begins.
@@ -36,12 +57,23 @@ def next_period_start(club, today: date | None = None) -> date:
@transaction.atomic
def open_period(club, *, start: date | None = None, tier: Tier | None = None) -> Due:
def open_period(club, *, start: date | None = None, tier: Tier | None = None, period_end: date | None = None, is_trial: bool = False) -> Due:
"""Issue the next due for a club, snapshotting the tier and the price of the day."""
subscription = getattr(club, "subscription", None)
tier = tier or (subscription.tier if subscription else None)
if tier is None:
raise BillingError(f"{club} has no tier: put it on a subscription before billing it.")
if subscription is None:
raise BillingError(f"{club} has no tier: put it on a subscription before billing it.")
# A trial that has run its course: swap onto the pre-selected plan before billing
# the next period, rather than silently renewing the trial tier forever. Checked
# here (not in renew()) so it fires whether this period was opened by the renewal
# command or by a platform admin clicking "Open period"/"Reactivate" by hand --
# both call open_period() directly.
if subscription.trial_ends_at is not None and (start or next_period_start(club)) > subscription.trial_ends_at:
subscription.tier = subscription.post_trial_tier
subscription.trial_ends_at = None
subscription.post_trial_tier = None
subscription.save(update_fields=["tier", "trial_ends_at", "post_trial_tier"])
tier = subscription.tier
start = start or next_period_start(club)
@@ -52,7 +84,13 @@ def open_period(club, *, start: date | None = None, tier: Tier | None = None) ->
if club.dues.filter(period_start=start).exists():
raise BillingError(f"{club} is already billed for a period starting {start:%d %b %Y}.")
due = Due.objects.create(club=club, tier=tier, amount=amount, period_start=start)
due = Due.objects.create(club=club, tier=tier, amount=amount, period_start=start, period_end=period_end, is_trial=is_trial)
if amount == ZERO:
# Nothing is actually owed -- left at the default UNPAID, this would eventually
# trip is_overdue() and get a free club archived for non-payment of nothing.
due.status = Due.Status.PAID
due.paid_at = timezone.now()
due.save(update_fields=["status", "paid_at"])
issue_invoice(due) # every period is billable the moment it opens
return due

View File

@@ -13,7 +13,7 @@ from club.models import Club
from .models import GRACE_DAYS, Due, Invoice, Subscription, Tier, TierPrice, add_one_year
from .services import BillingError
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, renew, subscribe, subscriptions_due_for_renewal, waive
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, renew, start_trial, subscribe, subscriptions_due_for_renewal, waive
from .services.invoices import invoice_pdf, issue_invoice, render_pdf
@@ -488,3 +488,76 @@ class RenewedButUnpaidTests(BillingTestBase):
record_payment(renewed, renewed.amount)
self.assertNotIn(club, [d.club for d in archivable_clubs(self.today)])
class TrialTests(BillingTestBase):
"""A club with no subscription yet can be started on a short trial that switches
itself to a pre-selected plan automatically once the trial period is renewed --
see billing.services.dues.start_trial and the trial-conversion check in
open_period()."""
def setUp(self):
super().setUp()
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"))
def test_start_trial_creates_a_short_trial_period(self):
due = start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
subscription = self.club.subscription
self.assertEqual(subscription.tier, self.trial_tier)
self.assertEqual(subscription.post_trial_tier, self.tier)
self.assertEqual(subscription.trial_ends_at, due.period_end)
self.assertTrue(due.is_trial)
# Roughly 2 months, nowhere near the standard ~1-year period.
self.assertLess((due.period_end - due.period_start).days, 65)
def test_start_trial_refuses_if_already_subscribed(self):
subscribe(self.club, self.tier)
with self.assertRaises(BillingError):
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
def test_start_trial_refuses_a_non_positive_length(self):
with self.assertRaises(BillingError):
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=0)
def test_renewing_after_the_trial_switches_to_the_post_trial_tier(self):
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
due = renew(self.club.subscription)
self.club.refresh_from_db()
self.assertEqual(self.club.subscription.tier, self.tier)
self.assertIsNone(self.club.subscription.trial_ends_at)
self.assertIsNone(self.club.subscription.post_trial_tier)
self.assertEqual(due.tier, self.tier)
self.assertFalse(due.is_trial)
self.assertEqual(due.amount, Decimal("500.00"))
def test_manually_opening_the_next_period_also_switches_tier(self):
# Same conversion must fire via the control panel's "Open period" button, which
# calls open_period() directly rather than renew().
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
open_period(self.club)
self.club.refresh_from_db()
self.assertEqual(self.club.subscription.tier, self.tier)
def test_a_trial_nearing_its_end_is_picked_up_for_renewal(self):
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2, start=self.today - datetime.timedelta(days=50))
self.assertIn(self.club, [s.club for s in subscriptions_due_for_renewal()])
def test_a_zero_amount_trial_is_created_already_paid(self):
free_tier = Tier.objects.create(name="Free Trial")
TierPrice.objects.create(tier=free_tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("0.00"))
due = start_trial(self.club, free_tier, post_trial_tier=self.tier, trial_months=2)
self.assertEqual(due.status, Due.Status.PAID)
self.assertIsNotNone(due.paid_at)
far_future = due.grace_until + datetime.timedelta(days=100)
self.assertNotIn(due, dues_overdue(far_future))
self.assertNotIn(self.club, [d.club for d in archivable_clubs(far_future)])

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