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:
@@ -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):
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user