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