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:
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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