diff --git a/billing/migrations/0003_due_is_trial_subscription_post_trial_tier_and_more.py b/billing/migrations/0003_due_is_trial_subscription_post_trial_tier_and_more.py new file mode 100644 index 0000000..5871aba --- /dev/null +++ b/billing/migrations/0003_due_is_trial_subscription_post_trial_tier_and_more.py @@ -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'), + ), + ] diff --git a/billing/models.py b/billing/models.py index 884b9d7..b00866d 100644 --- a/billing/models.py +++ b/billing/models.py @@ -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") diff --git a/billing/services/dues.py b/billing/services/dues.py index 17ebe4a..d60367e 100644 --- a/billing/services/dues.py +++ b/billing/services/dues.py @@ -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 diff --git a/billing/tests.py b/billing/tests.py index 8280987..b00d53f 100644 --- a/billing/tests.py +++ b/billing/tests.py @@ -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)]) diff --git a/controlpanel/forms.py b/controlpanel/forms.py index 1cd850a..6495ad3 100644 --- a/controlpanel/forms.py +++ b/controlpanel/forms.py @@ -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")) diff --git a/controlpanel/templates/controlpanel/_club_billing_card.html b/controlpanel/templates/controlpanel/_club_billing_card.html index 52243c5..e02c8af 100644 --- a/controlpanel/templates/controlpanel/_club_billing_card.html +++ b/controlpanel/templates/controlpanel/_club_billing_card.html @@ -14,6 +14,11 @@ + {% if not subscription %} + + {% endif %} {% if subscription %}