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

@@ -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")