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

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