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)])