Rework platform billing: per-plan clocks, grace from period start

Implements BILLING.md. The architecture was sound -- snapshot-on-Due,
dated prices, asymmetric dry-run commands are all kept -- so this
fixes the three hardcoded assumptions rather than rewriting.

The real defect: grace ran from period_END, so an annual club used
the whole unpaid year plus 45 days (~410 days) before anything
switched it off. Grace now runs from the period START, and every
clock is per-plan.

- Tier -> Plan (+ TierPrice -> PlanPrice, and every FK). Migration
  0004 is hand-written: run non-interactively, makemigrations emits
  DeleteModel+CreateModel and drops every price, subscription and
  due. Its two RemoveConstraints must come first, or SQLite's
  table-rebuild tries to render a constraint over a just-renamed
  column. Verified by round-tripping real rows through it.
- Plan gains duration_months / renewal_lead_days / grace_days /
  is_trial, with CheckConstraints and a matching clean() so the form
  reports an impossible plan instead of 500ing on IntegrityError.
- Existing dues keep their stored grace_until. Re-deriving it would
  put the date in the past for every open annual period and archive
  the entire paying customer base on the next --commit run.
- Trials take their length from the trial plan's own duration_months;
  start_trial() loses its trial_months argument.
- New BillingNotice service drives a club-facing warning: every level
  on the dashboard, and on every management page once urgent.
- send_billing_reminders emails club admins, once per escalation
  level so a daily cron is not a daily email. SMTP settings are
  env-driven and provider-agnostic; the backend defaults to console.
- Paying does not auto-restore an archived club -- the control panel
  surfaces a Reactivate prompt instead, since a club can also be
  archived by hand.
This commit is contained in:
2026-08-08 18:49:52 +02:00
parent ae93406853
commit fc6488ce55
36 changed files with 1342 additions and 386 deletions

View File

@@ -4,7 +4,7 @@ from django import forms
from django.utils.translation import gettext_lazy as _
from waffle import get_waffle_flag_model
from billing.models import DuePayment, Subscription, Tier, TierPrice
from billing.models import DuePayment, Plan, PlanPrice, Subscription
from club.models import Club
from events.models import Location
@@ -82,51 +82,61 @@ class FlagForm(forms.ModelForm):
}
class TierForm(forms.ModelForm):
class PlanForm(forms.ModelForm):
class Meta:
model = Tier
fields = ["name", "description", "is_active"]
model = Plan
fields = ["name", "description", "duration_months", "renewal_lead_days", "grace_days", "is_trial", "is_active"]
class TierPriceForm(forms.ModelForm):
class PlanPriceForm(forms.ModelForm):
class Meta:
model = TierPrice
model = PlanPrice
fields = ["active_from", "amount"]
widgets = {"active_from": forms.DateInput(attrs={"type": "date"})}
help_texts = {"active_from": _("Periods opening on or after this date are billed at this amount. Existing periods keep the amount they were billed at.")}
help_texts = {
"active_from": _(
"Periods opening on or after this date are billed at this amount. Existing periods keep the amount "
"they were billed at — including any already issued during a plan's renewal lead window, so enter a "
"price change before that window opens."
)
}
class SubscriptionForm(forms.ModelForm):
"""Put a club on a tier. The first period opens when the subscription is created."""
"""Put a club on a plan. The first period opens when the subscription is created."""
start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("First period starts"), help_text=_("Left blank, the period starts today."))
class Meta:
model = Subscription
fields = ["tier", "auto_renew", "auto_archive", "notes"]
fields = ["plan", "auto_renew", "auto_archive", "notes"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# An inactive tier still bills its existing subscriptions, but must not be picked up
# by a new one — which is the whole point of retiring a tier.
self.fields["tier"].queryset = Tier.objects.filter(is_active=True)
# An inactive plan still bills its existing subscriptions, but must not be picked up
# by a new one — which is the whole point of retiring a plan. Trial plans are excluded
# too: they are reached through the trial form, which converts them properly.
self.fields["plan"].queryset = Plan.objects.filter(is_active=True, is_trial=False)
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."""
"""Put a club with no subscription yet on a trial that switches itself to
``post_trial_plan`` 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)"))
There is no length field: a trial's length is its plan's own ``duration_months``, so a
1-month and a 3-month trial are two plans rather than one plan plus a number typed here.
"""
trial_plan = forms.ModelChoiceField(queryset=Plan.objects.none(), label=_("Trial plan"), help_text=_("What this club is billed on during the trial. Its length is the plan's own duration."))
post_trial_plan = forms.ModelChoiceField(queryset=Plan.objects.none(), label=_("Then switch to"), help_text=_("The plan it lands on automatically once the trial ends."))
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
# Same reasoning as SubscriptionForm: a retired plan 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)
self.fields["trial_plan"].queryset = Plan.objects.filter(is_active=True, is_trial=True)
self.fields["post_trial_plan"].queryset = Plan.objects.filter(is_active=True, is_trial=False)
class DuePaymentForm(forms.Form):

View File

@@ -84,7 +84,7 @@ def clubs_with_health(queryset=None, today=None, now=None):
team_count=_subquery(Team.objects.all(), Count("pk"), IntegerField()),
teams_managed=_subquery(Team.objects.filter(managed_this_season), Count("pk", distinct=True), IntegerField()),
admin_count=_subquery(ClubRole.objects.filter(role=ClubRole.Roles.ADMIN), Count("pk"), IntegerField()),
tier_name=Subquery(Subscription.objects.filter(club=OuterRef("pk")).values("tier__name")[:1]),
plan_name=Subquery(Subscription.objects.filter(club=OuterRef("pk")).values("plan__name")[:1]),
dues_owed=_subquery(Due.objects.filter(status__in=Due.OWING), Sum(F("amount") - F("amount_paid")), DecimalField(max_digits=10, decimal_places=2)),
dues_grace_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("grace_until").values("grace_until")[:1]),
dues_period_end=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("period_end").values("period_end")[:1]),

View File

@@ -27,17 +27,31 @@
</div>
</div>
{% comment %}
Paying up does not un-archive a club on its own -- restoring is a deliberate act,
because a club can also be archived by hand for reasons that have nothing to do
with money. This is the prompt that makes the deliberate act one click away
instead of something you have to remember to go and check.
{% endcomment %}
{% if club.is_archived and dues_settled %}
<div class="alert alert-success alert-sm mb-2">
{% lucide "circle-check" size=16 %}
<span>This club is archived but owes nothing. Reactivating will restore access and open its next period.</span>
</div>
{% endif %}
{% if not subscription %}
<p class="text-sm opacity-70">This club is not billed for anything. Put it on a tier to start.</p>
<p class="text-sm opacity-70">This club is not billed for anything. Put it on a plan to start.</p>
{% else %}
<p class="text-sm opacity-70">
On plan <strong>{{ subscription.tier.name }}</strong>.
On plan <strong>{{ subscription.plan.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>.
On trial until {{ subscription.trial_ends_at|date:"j M Y" }}, then switches to <strong>{{ subscription.post_trial_plan.name }}</strong>.
{% endif %}
{{ subscription.plan.duration_months }}-month periods, archived {{ subscription.plan.grace_days }} days after a period starts if unpaid.
{% if subscription.auto_renew %}
Renews automatically 30 days before the period ends.
Renews automatically {{ subscription.plan.renewal_lead_days }} days before the period ends.
{% else %}
<span class="badge badge-warning badge-sm">Auto-renew off</span> — you must open each period by hand, or this club uses the platform for free.
{% endif %}
@@ -64,7 +78,7 @@
<tr>
<td>
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
<div class="text-xs opacity-60">{{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}</div>
<div class="text-xs opacity-60">{{ due.plan.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}</div>
</td>
<td class="text-right tabular-nums">€{{ due.amount|floatformat:2 }}</td>
<td class="text-right tabular-nums">€{{ due.amount_paid|floatformat:2 }}</td>
@@ -125,11 +139,11 @@
</div>
{% 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." %}
{% 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 plan 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." %}
{% 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 plan 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 %}

View File

@@ -77,8 +77,8 @@
<td class="text-right tabular-nums">{{ club.upcoming_events }}</td>
<td class="text-right">
{% if club.tier_name %}
<span class="badge badge-accent">{{ club.tier_name|lower }}</span>
{% if club.plan_name %}
<span class="badge badge-accent">{{ club.plan_name|lower }}</span>
{% else %}
-
{% endif %}
@@ -87,7 +87,7 @@
<td class="text-right">
<div class="flex flex-row gap-2 items-center justify-end">
{% if not club.dues_owed %}
{% if club.tier_name %}
{% if club.plan_name %}
{% comment %}
Not owing and on a plan. covered_until is the settled period's end — the day
grace would start if nothing renews — shown on its own row under the badge,

View File

@@ -4,12 +4,12 @@
{% block heading %}Billing{% endblock heading %}
{% block actions %}
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('tier_create_modal').showModal()">{% lucide "plus" size=16 %} New plan</button>
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('plan_create_modal').showModal()">{% lucide "plus" size=16 %} New plan</button>
{% endblock actions %}
{% block panel %}
{% url 'controlpanel:tier_create' as tier_create_url %}
{% include "controlpanel/_modal_form.html" with modal_id="tier_create_modal" title="New plan" form=tier_form action_url=tier_create_url submit_label="Create plan" submit_icon="plus" %}
{% url 'controlpanel:plan_create' as plan_create_url %}
{% include "controlpanel/_modal_form.html" with modal_id="plan_create_modal" title="New plan" form=plan_form action_url=plan_create_url submit_label="Create plan" submit_icon="plus" %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
@@ -25,23 +25,34 @@
<thead>
<tr>
<th>Plan</th>
<th>Clocks</th>
<th class="text-right">Clubs</th>
<th>Prices</th>
<th></th>
</tr>
</thead>
<tbody>
{% for tier in tiers %}
{% for plan in plans %}
<tr>
<td>
<div class="font-medium">{{ tier.name }}</div>
{% if not tier.is_active %}<span class="badge badge-ghost badge-xs">Retired</span>{% endif %}
{% if tier.description %}
<div class="text-xs opacity-60">{{ tier.description }}</div>{% endif %}
<div class="font-medium">{{ plan.name }}</div>
{% if plan.is_trial %}<span class="badge badge-info badge-xs">Trial</span>{% endif %}
{% if not plan.is_active %}<span class="badge badge-ghost badge-xs">Retired</span>{% endif %}
{% if plan.description %}
<div class="text-xs opacity-60">{{ plan.description }}</div>{% endif %}
</td>
<td class="text-right tabular-nums">{{ tier.club_count }}</td>
{% comment %}
Named for what each measures from, because that is the easy thing to
get wrong: grace runs from the period START, not its end.
{% endcomment %}
<td class="text-xs opacity-70 whitespace-nowrap">
<div>{{ plan.duration_months }} month{{ plan.duration_months|pluralize }} long</div>
<div>billed {{ plan.renewal_lead_days }}d before it starts</div>
<div>archived {{ plan.grace_days }}d after it starts</div>
</td>
<td class="text-right tabular-nums">{{ plan.club_count }}</td>
<td>
{% for price in tier.prices.all %}
{% for price in plan.prices.all %}
<div class="text-sm tabular-nums">
€{{ price.amount|floatformat:2 }}
<span class="opacity-60">from {{ price.active_from|date:"j M Y" }}</span>
@@ -52,13 +63,13 @@
{% endfor %}
</td>
<td class="flex flex-row gap-2 justify-end">
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ plan.pk|dom_id:"plan_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ plan.pk|dom_id:"plan_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center opacity-60">No plans yet.</td>
<td colspan="5" class="text-center opacity-60">No plans yet.</td>
</tr>
{% endfor %}
</tbody>
@@ -68,12 +79,12 @@
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for tier in tiers %}
{% url 'controlpanel:tier_price_create' tier.pk as tier_price_url %}
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_price_modal" title="New price — "|add:tier.name form=tier.price_form action_url=tier_price_url submit_label="Add price" submit_icon="euro" %}
{% for plan in plans %}
{% url 'controlpanel:plan_price_create' plan.pk as plan_price_url %}
{% include "controlpanel/_modal_form.html" with modal_id=plan.pk|dom_id:"plan_price_modal" title="New price — "|add:plan.name form=plan.price_form action_url=plan_price_url submit_label="Add price" submit_icon="euro" %}
{% url 'controlpanel:tier_update' tier.pk as tier_update_url %}
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_edit_modal" title="Edit "|add:tier.name form=tier.edit_form action_url=tier_update_url submit_label="Save" submit_icon="check" %}
{% url 'controlpanel:plan_update' plan.pk as plan_update_url %}
{% include "controlpanel/_modal_form.html" with modal_id=plan.pk|dom_id:"plan_edit_modal" title="Edit "|add:plan.name form=plan.edit_form action_url=plan_update_url submit_label="Save" submit_icon="check" %}
{% endfor %}
<div class="card bg-base-100 shadow">
@@ -95,7 +106,7 @@
<tr>
<td>
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' due.club.pk %}">{{ due.club.name }}</a>
<div class="text-xs opacity-60">{{ due.tier.name }}</div>
<div class="text-xs opacity-60">{{ due.plan.name }}</div>
</td>
<td class="text-sm">
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}

View File

@@ -16,7 +16,7 @@ from django.urls import reverse
from django.utils import timezone
from waffle import get_waffle_flag_model, get_waffle_switch_model
from billing.models import GRACE_DAYS, Due, Tier, TierPrice
from billing.models import DEFAULT_GRACE_DAYS, Due, Plan, PlanPrice
from billing.services import BillingError
from billing.services.dues import record_payment, subscribe, waive
from club.models import Club, ClubMembership, ClubRole, Season
@@ -278,7 +278,7 @@ class ClubHomeLocationTests(ControlPanelTestBase):
self.assertNotContains(response, 'type="lazyselect"')
self.assertContains(response, "Belgium")
self.assertContains(response, '<select')
self.assertContains(response, "<select")
def test_the_club_detail_page_shows_the_home_location(self):
self.set_home_location(name="Home Ground")
@@ -604,7 +604,7 @@ class NotifyTests(TestCase):
self.assertEqual(LEVELS, {"s": messages.SUCCESS, "i": messages.INFO, "w": messages.WARNING, "e": messages.ERROR, "d": messages.DEBUG})
def test_a_pipe_inside_the_body_is_preserved_intact(self):
# maxsplit=2 stops after the level and the title, so a "|" a club/tier/flag name
# maxsplit=2 stops after the level and the title, so a "|" a club/plan/flag name
# might contain stays part of the body rather than truncating it.
request, storage = self.request()
@@ -1265,11 +1265,11 @@ class PlatformDuesMetricTests(TestCase):
def setUp(self):
self.today = timezone.localdate()
self.club = Club.objects.create(name="Ajax United")
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"))
self.plan = Plan.objects.create(name="Standard")
PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
def test_dues_owed_is_the_unpaid_balance_across_every_club(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
record_payment(self.club.dues.first(), Decimal("200.00"))
self.assertEqual(platform_attention()["dues_owed"], Decimal("300.00"))
@@ -1277,18 +1277,20 @@ class PlatformDuesMetricTests(TestCase):
def test_grace_and_overdue_are_counted_separately(self):
in_grace = Club.objects.create(name="Grace FC")
overdue = Club.objects.create(name="Overdue FC")
subscribe(in_grace, self.tier, start=self.today - datetime.timedelta(days=370))
subscribe(overdue, self.tier, start=self.today - datetime.timedelta(days=365 + GRACE_DAYS + 10))
# Grace runs from the period START now: a club a few days into an unpaid period is
# in grace, where under the old rule this meant one whose period had already ended.
subscribe(in_grace, self.plan, start=self.today - datetime.timedelta(days=5))
subscribe(overdue, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 10))
attention = platform_attention()
self.assertEqual(attention["dues_in_grace"], 1)
self.assertEqual(attention["dues_overdue"], 1)
def test_clubs_on_no_tier_are_flagged(self):
def test_clubs_on_no_plan_are_flagged(self):
self.assertEqual(platform_attention()["clubs_unbilled"], 1)
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
self.assertEqual(platform_attention()["clubs_unbilled"], 0)
@@ -1296,14 +1298,14 @@ class PlatformDuesMetricTests(TestCase):
# ~0 in normal running; a number here means the renewal cron has stopped.
self.assertEqual(platform_attention()["renewals_pending"], 0)
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=350)) # ends in 15 days
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=350)) # ends in 15 days
self.assertEqual(platform_attention()["renewals_pending"], 1)
def test_the_dashboard_surfaces_pending_renewals(self):
# The whole point of the KPI: a club about to go free is visible, though nothing is
# owed yet, so no other figure on the page would show it.
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=350))
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=350))
staff = User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True)
enrol_mfa(staff)
self.client.force_login(staff)
@@ -1311,7 +1313,7 @@ class PlatformDuesMetricTests(TestCase):
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "awaiting renewal")
def test_platform_dues_and_club_shop_money_are_different_charts(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
record_payment(self.club.dues.first(), Decimal("500.00"))
charts = platform_charts()
@@ -1320,16 +1322,16 @@ class PlatformDuesMetricTests(TestCase):
self.assertEqual(charts["club_revenue"][-1]["value"], 0.0) # never ours
def test_the_health_table_carries_the_plan_and_what_is_owed(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
club = clubs_with_health().get(pk=self.club.pk)
self.assertEqual(club.tier_name, "Standard")
self.assertEqual(club.plan_name, "Standard")
self.assertEqual(club.dues_owed, Decimal("500.00"))
def test_a_fully_paid_club_shows_when_its_cover_ends(self):
# The end of the current paid period is the day grace would start if nothing renews.
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
record_payment(due, due.amount)
@@ -1341,7 +1343,7 @@ class PlatformDuesMetricTests(TestCase):
def test_a_waived_period_also_shows_its_cover_end(self):
# Waived is settled too — the club is covered for that time, so its end date shows,
# badged "waived" rather than "paid".
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
waive(due)
@@ -1351,58 +1353,58 @@ class PlatformDuesMetricTests(TestCase):
self.assertEqual(club.covered_status, Due.Status.WAIVED)
def test_a_club_that_owes_has_no_cover(self):
subscribe(self.club, self.tier) # unpaid
subscribe(self.club, self.plan) # unpaid
club = clubs_with_health().get(pk=self.club.pk)
self.assertIsNone(club.covered_until)
self.assertIsNone(club.covered_status)
def test_the_health_table_still_costs_one_query_with_billing_on_it(self):
subscribe(self.club, self.tier)
subscribe(Club.objects.create(name="Feyenoord"), self.tier)
subscribe(self.club, self.plan)
subscribe(Club.objects.create(name="Feyenoord"), self.plan)
with self.assertNumQueries(1):
[(club.tier_name, club.dues_owed, club.outstanding) for club in clubs_with_health()]
[(club.plan_name, club.dues_owed, club.outstanding) for club in clubs_with_health()]
class BillingPanelTests(ControlPanelTestBase):
def setUp(self):
super().setUp()
self.today = timezone.localdate()
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"))
self.plan = Plan.objects.create(name="Standard")
PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
def test_the_billing_page_lists_tiers_and_what_is_owed(self):
subscribe(self.club, self.tier)
def test_the_billing_page_lists_plans_and_what_is_owed(self):
subscribe(self.club, self.plan)
response = self.client.get(reverse("controlpanel:billing"))
self.assertContains(response, "Standard")
self.assertContains(response, "500.00")
def test_a_tier_can_be_created_and_priced(self):
self.client.post(reverse("controlpanel:tier_create"), {"name": "Large", "description": "", "is_active": "on"})
tier = Tier.objects.get(name="Large")
def test_a_plan_can_be_created_and_priced(self):
self.client.post(reverse("controlpanel:plan_create"), {"name": "Large", "description": "", "is_active": "on", "duration_months": 12, "renewal_lead_days": 30, "grace_days": 30})
plan = Plan.objects.get(name="Large")
self.client.post(reverse("controlpanel:tier_price_create", args=[tier.pk]), {"active_from": self.today.isoformat(), "amount": "900.00"})
self.client.post(reverse("controlpanel:plan_price_create", args=[plan.pk]), {"active_from": self.today.isoformat(), "amount": "900.00"})
self.assertEqual(tier.price_on(self.today), Decimal("900.00"))
self.assertEqual(plan.price_on(self.today), Decimal("900.00"))
def test_a_rate_change_does_not_rewrite_an_open_period(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
self.client.post(reverse("controlpanel:tier_price_create", args=[self.tier.pk]), {"active_from": self.today.isoformat(), "amount": "900.00"})
self.client.post(reverse("controlpanel:plan_price_create", args=[self.plan.pk]), {"active_from": self.today.isoformat(), "amount": "900.00"})
self.assertEqual(self.club.dues.first().amount, Decimal("500.00"))
def test_subscribing_a_club_opens_its_first_period(self):
self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": self.tier.pk, "auto_archive": "on", "notes": ""})
self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"plan": self.plan.pk, "auto_archive": "on", "notes": ""})
self.assertEqual(self.club.dues.count(), 1)
self.assertEqual(self.club.subscription.tier, self.tier)
self.assertEqual(self.club.subscription.plan, self.plan)
def test_a_payment_can_be_recorded_and_settles_the_due(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "500.00", "method": "bank_transfer", "reference": "TRX-1", "paid_at": "", "note": ""})
@@ -1412,7 +1414,7 @@ class BillingPanelTests(ControlPanelTestBase):
self.assertEqual(due.payments.first().recorded_by, self.staff)
def test_a_part_payment_leaves_a_balance(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "200.00", "method": "bank_transfer", "reference": "", "paid_at": "", "note": ""})
@@ -1422,7 +1424,7 @@ class BillingPanelTests(ControlPanelTestBase):
def test_a_billing_error_is_shown_rather_than_raised(self):
# A waived period cannot take a payment; the panel must say so, not 500.
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
self.client.post(reverse("controlpanel:due_waive", args=[due.pk]))
@@ -1431,7 +1433,7 @@ class BillingPanelTests(ControlPanelTestBase):
self.assertContains(response, "cannot take a payment")
def test_a_period_can_be_waived(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
self.client.post(reverse("controlpanel:due_waive", args=[due.pk]))
@@ -1440,7 +1442,7 @@ class BillingPanelTests(ControlPanelTestBase):
self.assertEqual(due.status, Due.Status.WAIVED)
def test_opening_a_period_continues_from_the_last_one(self):
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=400))
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=400))
first = self.club.dues.first()
self.client.post(reverse("controlpanel:club_open_period", args=[self.club.pk]), {"start": ""})
@@ -1449,7 +1451,7 @@ class BillingPanelTests(ControlPanelTestBase):
self.assertEqual(latest.period_start, first.period_end + datetime.timedelta(days=1))
def test_reactivating_an_archived_club_restores_it(self):
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=400))
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=400))
self.club.archive()
self.client.post(reverse("controlpanel:club_open_period", args=[self.club.pk]), {"start": self.today.isoformat()})
@@ -1458,7 +1460,7 @@ class BillingPanelTests(ControlPanelTestBase):
self.assertFalse(self.club.is_archived)
def test_the_club_page_shows_the_plan_and_its_periods(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
@@ -1466,7 +1468,7 @@ class BillingPanelTests(ControlPanelTestBase):
self.assertContains(response, "INV-")
def test_an_invoice_downloads_as_a_pdf(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
with mock.patch("controlpanel.views.invoice_pdf", return_value=b"%PDF-1.7 fake"):
@@ -1477,7 +1479,7 @@ class BillingPanelTests(ControlPanelTestBase):
def test_a_missing_pdf_library_is_reported_rather_than_a_500(self):
# WeasyPrint needs native libs. Without them the button must explain itself.
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
with mock.patch("controlpanel.views.invoice_pdf", side_effect=BillingError("PDF rendering needs the native pango/cairo libraries.")):
@@ -1493,39 +1495,40 @@ class TrialPanelTests(ControlPanelTestBase):
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"))
# A trial plan carries its own length; the form only offers plans flagged is_trial.
self.trial_plan = Plan.objects.create(name="Trial", is_trial=True, duration_months=2)
PlanPrice.objects.create(plan=self.trial_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("50.00"))
self.plan = Plan.objects.create(name="Standard")
PlanPrice.objects.create(plan=self.plan, 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
data = {"trial_plan": self.trial_plan.pk, "post_trial_plan": self.plan.pk, "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)
self.assertEqual(self.club.subscription.plan, self.trial_plan)
self.assertEqual(self.club.subscription.post_trial_plan, self.plan)
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)
subscribe(self.club, self.plan)
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": ""},
{"trial_plan": self.trial_plan.pk, "post_trial_plan": self.plan.pk, "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)
def test_a_non_trial_plan_is_not_offered_as_the_trial(self):
response = self.client.post(reverse("controlpanel:club_trial_start", args=[self.club.pk]), {"trial_plan": self.plan.pk, "post_trial_plan": self.plan.pk, "start": ""}, follow=True)
self.assertFalse(hasattr(self.club, "subscription"))
self.assertTrue(response.context["messages"])
@@ -1538,36 +1541,36 @@ class TrialPanelTests(ControlPanelTestBase):
self.assertContains(response, "On trial")
self.assertContains(response, "Standard")
def test_manually_changing_tier_mid_trial_clears_the_trial(self):
def test_manually_changing_plan_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"))
other = Plan.objects.create(name="Other")
PlanPrice.objects.create(plan=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.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"plan": other.pk, "auto_archive": "on", "notes": ""})
self.club.refresh_from_db()
self.assertEqual(self.club.subscription.tier, other)
self.assertEqual(self.club.subscription.plan, other)
self.assertIsNone(self.club.subscription.trial_ends_at)
self.assertIsNone(self.club.subscription.post_trial_tier)
self.assertIsNone(self.club.subscription.post_trial_plan)
class BillingFormRenderTests(ControlPanelTestBase):
def setUp(self):
super().setUp()
self.today = timezone.localdate()
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"))
self.plan = Plan.objects.create(name="Standard")
PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
def test_the_billing_forms_are_post_only(self):
# Every one of these is reachable only through a modal on the billing or club
# detail page: there is no standalone template to render on a GET.
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
for url in (
reverse("controlpanel:tier_create"),
reverse("controlpanel:tier_update", args=[self.tier.pk]),
reverse("controlpanel:tier_price_create", args=[self.tier.pk]),
reverse("controlpanel:plan_create"),
reverse("controlpanel:plan_update", args=[self.plan.pk]),
reverse("controlpanel:plan_price_create", args=[self.plan.pk]),
reverse("controlpanel:club_subscribe", args=[self.club.pk]),
reverse("controlpanel:club_open_period", args=[self.club.pk]),
reverse("controlpanel:due_pay", args=[due.pk]),
@@ -1578,10 +1581,10 @@ class BillingFormRenderTests(ControlPanelTestBase):
# Rejected input has nowhere to re-render — the modal that submitted it is on a
# page this view no longer serves — so it must bounce back with an error message
# rather than 500 or silently drop the submission.
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
response = self.client.post(reverse("controlpanel:tier_create"), {"name": "", "description": "", "is_active": "on"}, follow=True)
response = self.client.post(reverse("controlpanel:plan_create"), {"name": "", "description": "", "is_active": "on"}, follow=True)
self.assertRedirects(response, reverse("controlpanel:billing"))
self.assertContains(response, "This field is required")
@@ -1590,7 +1593,7 @@ class BillingFormRenderTests(ControlPanelTestBase):
self.assertContains(response, "Enter a number")
def test_the_payment_modal_defaults_to_the_outstanding_balance(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
record_payment(due, Decimal("200.00"))
due.refresh_from_db()
@@ -1600,34 +1603,34 @@ class BillingFormRenderTests(ControlPanelTestBase):
rendered_due = next(rendered for rendered in response.context["dues"] if rendered.pk == due.pk)
self.assertEqual(rendered_due.payment_form.initial["amount"], Decimal("300.00"))
def test_a_tier_can_be_renamed(self):
self.client.post(reverse("controlpanel:tier_update", args=[self.tier.pk]), {"name": "Standard plus", "description": "", "is_active": "on"})
def test_a_plan_can_be_renamed(self):
self.client.post(reverse("controlpanel:plan_update", args=[self.plan.pk]), {"name": "Standard plus", "description": "", "is_active": "on", "duration_months": 12, "renewal_lead_days": 30, "grace_days": 30})
self.tier.refresh_from_db()
self.assertEqual(self.tier.name, "Standard plus")
self.plan.refresh_from_db()
self.assertEqual(self.plan.name, "Standard plus")
def test_changing_tier_leaves_the_open_period_alone(self):
def test_changing_plan_leaves_the_open_period_alone(self):
# The current period keeps the amount it was issued at; the new rate bites next time.
subscribe(self.club, self.tier)
premium = Tier.objects.create(name="Premium")
TierPrice.objects.create(tier=premium, active_from=self.today, amount=Decimal("900.00"))
subscribe(self.club, self.plan)
premium = Plan.objects.create(name="Premium")
PlanPrice.objects.create(plan=premium, active_from=self.today, amount=Decimal("900.00"))
response = self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": premium.pk, "auto_archive": "on", "notes": ""}, follow=True)
response = self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"plan": premium.pk, "auto_archive": "on", "notes": ""}, follow=True)
self.club.refresh_from_db()
self.assertEqual(self.club.subscription.tier, premium)
self.assertEqual(self.club.subscription.plan, premium)
self.assertEqual(self.club.dues.first().amount, Decimal("500.00"))
self.assertContains(response, "keeps the amount it was billed at")
def test_subscribing_to_an_unpriced_tier_reports_itself(self):
unpriced = Tier.objects.create(name="Enterprise")
def test_subscribing_to_an_unpriced_plan_reports_itself(self):
unpriced = Plan.objects.create(name="Enterprise")
response = self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": unpriced.pk, "auto_archive": "on", "notes": ""}, follow=True)
response = self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"plan": unpriced.pk, "auto_archive": "on", "notes": ""}, follow=True)
self.assertContains(response, "no price in force")
def test_billing_a_period_twice_reports_itself(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
start = self.club.dues.first().period_start
response = self.client.post(reverse("controlpanel:club_open_period", args=[self.club.pk]), {"start": start.isoformat()}, follow=True)
@@ -1635,7 +1638,7 @@ class BillingFormRenderTests(ControlPanelTestBase):
self.assertContains(response, "already billed")
def test_waiving_a_paid_period_reports_itself(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
record_payment(due, Decimal("500.00"))

View File

@@ -25,9 +25,9 @@ urlpatterns = [
path("features/switches/<int:pk>/toggle/", views.SwitchToggleView.as_view(), name="switch_toggle"),
# Billing (platform charging the clubs)
path("billing/", views.BillingView.as_view(), name="billing"),
path("billing/tiers/new/", views.TierCreateView.as_view(), name="tier_create"),
path("billing/tiers/<uuid:pk>/edit/", views.TierUpdateView.as_view(), name="tier_update"),
path("billing/tiers/<uuid:pk>/prices/new/", views.TierPriceCreateView.as_view(), name="tier_price_create"),
path("billing/plans/new/", views.PlanCreateView.as_view(), name="plan_create"),
path("billing/plans/<uuid:pk>/edit/", views.PlanUpdateView.as_view(), name="plan_update"),
path("billing/plans/<uuid:pk>/prices/new/", views.PlanPriceCreateView.as_view(), name="plan_price_create"),
path("billing/dues/<uuid:pk>/pay/", views.RecordPaymentView.as_view(), name="due_pay"),
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"),

View File

@@ -10,7 +10,7 @@ from django.utils.formats import date_format
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
from waffle import get_waffle_flag_model, get_waffle_switch_model
from billing.models import Due, Tier, TierPrice
from billing.models import Due, Plan, PlanPrice
from billing.services import BillingError
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
@@ -18,7 +18,7 @@ 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, TrialForm
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, HomeLocationForm, MaintenanceForm, OpenPeriodForm, PlanForm, PlanPriceForm, PlatformAdminForm, SubscriptionForm, TrialForm
from .messages import notify
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
from .services.admins import grant_club_admin, revoke_club_admin
@@ -130,7 +130,7 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
# Bound per-row so each due's "Add payment" modal can render its own form without
# the template calling DuePaymentForm(initial=...) itself.
dues = list(self.object.dues.select_related("tier", "invoice").prefetch_related("payments"))
dues = list(self.object.dues.select_related("plan", "invoice").prefetch_related("payments"))
for due in dues:
if due.is_owing:
due.payment_form = DuePaymentForm(initial={"amount": due.balance})
@@ -138,6 +138,9 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
home_location = Location.objects.filter(club=self.object, is_home=True).first()
return super().get_context_data(
# Drives the "archived but owes nothing -- reactivate?" prompt. Computed from the
# dues already fetched above rather than re-querying.
dues_settled=not any(due.is_owing for due in dues),
home_location=home_location,
home_location_form=HomeLocationForm(instance=home_location),
nav="clubs",
@@ -388,7 +391,7 @@ class PlatformAdminRevokeView(PlatformSuperuserRequiredMixin, View):
class BillingView(PlatformStaffRequiredMixin, TemplateView):
"""Tiers and their prices, plus every period we are owed money for."""
"""Plans and their prices, plus every period we are owed money for."""
template_name = "controlpanel/billing.html"
@@ -396,76 +399,76 @@ class BillingView(PlatformStaffRequiredMixin, TemplateView):
today = timezone.localdate()
# Bound per-row so each "Edit" / "New price" modal can render its own form: the
# template can't call TierForm(instance=tier) itself, so the form rides along on
# template can't call PlanForm(instance=plan) itself, so the form rides along on
# the object it belongs to.
tiers = list(Tier.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")))
for tier in tiers:
tier.edit_form = TierForm(instance=tier)
tier.price_form = TierPriceForm()
plans = list(Plan.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")))
for plan in plans:
plan.edit_form = PlanForm(instance=plan)
plan.price_form = PlanPriceForm()
owing = list(Due.objects.filter(status__in=Due.OWING).select_related("club", "tier").order_by("grace_until"))
owing = list(Due.objects.filter(status__in=Due.OWING).select_related("club", "plan").order_by("grace_until"))
for due in owing:
due.payment_form = DuePaymentForm(initial={"amount": due.balance})
return super().get_context_data(
nav="billing",
tiers=tiers,
tier_form=TierForm(),
plans=plans,
plan_form=PlanForm(),
owing=owing,
today=today,
**kwargs,
)
class TierCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
class PlanCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
"""Reachable only via the "New plan" modal on the billing page — POST-only, and there
is no standalone template to render on GET or on a rejected submission."""
model = Tier
form_class = TierForm
model = Plan
form_class = PlanForm
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:billing"
def get_success_url(self):
notify(self.request, f"s|Plan created|Tier{self.object}” created. Give it a price before billing anyone.")
notify(self.request, f"s|Plan created|Plan{self.object}” created. Give it a price before billing anyone.")
return reverse("controlpanel:billing")
class TierUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateView):
"""Reachable only via a tier's "Edit" modal on the billing page — POST-only, and there
class PlanUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateView):
"""Reachable only via a plan's "Edit" modal on the billing page — POST-only, and there
is no standalone template to render on GET or on a rejected submission."""
model = Tier
form_class = TierForm
model = Plan
form_class = PlanForm
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:billing"
def get_success_url(self):
notify(self.request, f"s|Plan updated|Tier{self.object}” updated.")
notify(self.request, f"s|Plan updated|Plan{self.object}” updated.")
return reverse("controlpanel:billing")
class TierPriceCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
class PlanPriceCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
"""A rate change is a new dated price, never an edit of the old one — periods already
billed keep the amount they were billed at.
Reachable only via a tier's "New price" modal on the billing page — POST-only, and
Reachable only via a plan's "New price" modal on the billing page — POST-only, and
there is no standalone template to render on GET or on a rejected submission.
"""
model = TierPrice
form_class = TierPriceForm
model = PlanPrice
form_class = PlanPriceForm
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:billing"
@property
def tier(self):
return get_object_or_404(Tier, pk=self.kwargs["pk"])
def plan(self):
return get_object_or_404(Plan, pk=self.kwargs["pk"])
def form_valid(self, form):
form.instance.tier = self.tier
form.instance.plan = self.plan
response = super().form_valid(form)
notify(self.request, f"s|Price added|{self.tier} is €{self.object.amount} for periods opening from {self.object.active_from}.")
notify(self.request, f"s|Price added|{self.plan} is €{self.object.amount} for periods opening from {self.object.active_from}.")
return response
def get_success_url(self):
@@ -473,7 +476,7 @@ class TierPriceCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, Cr
class SubscribeClubView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Put a club on a tier, which opens its first period.
"""Put a club on a plan, which opens its first period.
Reachable only via the "Change plan" modal on the club detail page — POST-only, and
there is no standalone template to render on GET or on a rejected submission.
@@ -502,22 +505,22 @@ class SubscribeClubView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, Form
existing = getattr(club, "subscription", None)
with suppress_billing_errors(self.request, title="Couldn't change plan"):
if existing:
# Changing tier does not re-bill: the current period keeps the amount it was
# Changing plan 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
# A manual plan change while on a trial is a deliberate override --
# left in place, the trial fields would silently swap the plan again
# later, onto a plan the admin didn't just choose.
subscription.trial_ends_at = None
subscription.post_trial_tier = None
subscription.post_trial_plan = 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.")
notify(self.request, f"s|Plan changed|{club} is now on {subscription.plan}. The current period keeps the amount it was billed at.")
else:
subscribe(club, form.cleaned_data["tier"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"], auto_renew=form.cleaned_data["auto_renew"])
notify(self.request, f"s|Billing started|{club} is on {form.cleaned_data['tier']}. Its first period is open.")
subscribe(club, form.cleaned_data["plan"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"], auto_renew=form.cleaned_data["auto_renew"])
notify(self.request, f"s|Billing started|{club} is on {form.cleaned_data['plan']}. Its first period is open.")
return redirect("controlpanel:club_detail", pk=club.pk)
@@ -541,14 +544,14 @@ class ClubStartTrialView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, For
def form_valid(self, form):
club = self.club
with suppress_billing_errors(self.request, title="Couldn't start trial"):
trial_plan = form.cleaned_data["trial_plan"]
start_trial(
club,
form.cleaned_data["trial_tier"],
post_trial_tier=form.cleaned_data["post_trial_tier"],
trial_months=form.cleaned_data["trial_months"],
trial_plan,
post_trial_plan=form.cleaned_data["post_trial_plan"],
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']}.")
notify(self.request, f"s|Trial started|{club} is on a {trial_plan.duration_months}-month trial of {trial_plan}, then switches to {form.cleaned_data['post_trial_plan']}.")
return redirect("controlpanel:club_detail", pk=club.pk)
@@ -564,7 +567,7 @@ class RecordPaymentView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, Form
@property
def due(self):
return get_object_or_404(Due.objects.select_related("club", "tier"), pk=self.kwargs["pk"])
return get_object_or_404(Due.objects.select_related("club", "plan"), pk=self.kwargs["pk"])
def get_invalid_redirect_kwargs(self):
return {"pk": self.due.club_id}
@@ -627,7 +630,7 @@ class OpenPeriodView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormVie
class InvoicePdfView(PlatformStaffRequiredMixin, View):
def get(self, request, pk):
due = get_object_or_404(Due.objects.select_related("club", "tier", "invoice"), pk=pk)
due = get_object_or_404(Due.objects.select_related("club", "plan", "invoice"), pk=pk)
invoice = issue_invoice(due)
try:
pdf = invoice_pdf(invoice)