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

@@ -9,36 +9,37 @@ from django.db import transaction
from django.db.models import DateField, OuterRef, Subquery, Sum
from django.utils import timezone
from billing.models import RENEWAL_LEAD_DAYS, ZERO, Due, DuePayment, Subscription, Tier, add_months
from billing.models import ZERO, Due, DuePayment, Plan, Subscription, add_months
from billing.services import BillingError
from billing.services.invoices import issue_invoice
def subscribe(club, tier: Tier, *, start: date | None = None, auto_archive: bool = True, auto_renew: bool = True) -> Subscription:
"""Put a club on a tier and open its first period."""
subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"tier": tier, "auto_archive": auto_archive, "auto_renew": auto_renew})
def subscribe(club, plan: Plan, *, start: date | None = None, auto_archive: bool = True, auto_renew: bool = True) -> Subscription:
"""Put a club on a plan and open its first period."""
subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"plan": plan, "auto_archive": auto_archive, "auto_renew": auto_renew})
open_period(club, start=start)
return subscription
@transaction.atomic
def start_trial(club, trial_tier: Tier, *, post_trial_tier: Tier, trial_months: int, start: date | None = None, auto_renew: bool = True, auto_archive: bool = True) -> Due:
"""Put a club on a short trial that switches itself to ``post_trial_tier`` the moment
the trial period is renewed -- see open_period()'s trial-conversion check.
def start_trial(club, trial_plan: Plan, *, post_trial_plan: Plan, start: date | None = None, auto_renew: bool = True, auto_archive: bool = True) -> Due:
"""Put a club on a trial that switches itself to ``post_trial_plan`` the moment the trial
period is renewed -- see open_period()'s trial-conversion check.
The trial's length is the trial plan's own ``duration_months``: a 1-month and a 3-month
trial are two plans, not one plan plus a number passed at the call site.
Only for a club with no subscription yet -- converting an existing paying subscription
into a trial is a different, deliberately unsupported operation for now.
"""
if trial_months <= 0:
raise BillingError("Trial length must be at least 1 month.")
if getattr(club, "subscription", None) is not None:
raise BillingError(f"{club} is already subscribed -- use Change plan instead.")
start = start or next_period_start(club)
trial_end = add_months(start, trial_months) - timedelta(days=1)
trial_end = add_months(start, trial_plan.duration_months) - timedelta(days=1)
Subscription.objects.create(club=club, tier=trial_tier, trial_ends_at=trial_end, post_trial_tier=post_trial_tier, auto_renew=auto_renew, auto_archive=auto_archive)
Subscription.objects.create(club=club, plan=trial_plan, trial_ends_at=trial_end, post_trial_plan=post_trial_plan, auto_renew=auto_renew, auto_archive=auto_archive)
return open_period(club, start=start, period_end=trial_end, is_trial=True)
@@ -57,34 +58,34 @@ def next_period_start(club, today: date | None = None) -> date:
@transaction.atomic
def open_period(club, *, start: date | None = None, tier: Tier | None = None, period_end: date | None = None, is_trial: bool = False) -> Due:
"""Issue the next due for a club, snapshotting the tier and the price of the day."""
def open_period(club, *, start: date | None = None, plan: Plan | None = None, period_end: date | None = None, is_trial: bool = False) -> Due:
"""Issue the next due for a club, snapshotting the plan and the price of the day."""
subscription = getattr(club, "subscription", None)
if tier is None:
if plan is None:
if subscription is None:
raise BillingError(f"{club} has no tier: put it on a subscription before billing it.")
raise BillingError(f"{club} has no plan: put it on a subscription before billing it.")
# A trial that has run its course: swap onto the pre-selected plan before billing
# the next period, rather than silently renewing the trial tier forever. Checked
# the next period, rather than silently renewing the trial plan forever. Checked
# here (not in renew()) so it fires whether this period was opened by the renewal
# command or by a platform admin clicking "Open period"/"Reactivate" by hand --
# both call open_period() directly.
if subscription.trial_ends_at is not None and (start or next_period_start(club)) > subscription.trial_ends_at:
subscription.tier = subscription.post_trial_tier
subscription.plan = subscription.post_trial_plan
subscription.trial_ends_at = None
subscription.post_trial_tier = None
subscription.save(update_fields=["tier", "trial_ends_at", "post_trial_tier"])
tier = subscription.tier
subscription.post_trial_plan = None
subscription.save(update_fields=["plan", "trial_ends_at", "post_trial_plan"])
plan = subscription.plan
start = start or next_period_start(club)
amount = tier.price_on(start)
amount = plan.price_on(start)
if amount is None:
raise BillingError(f"{tier} has no price in force on {start:%d %b %Y}. Add one before opening the period.")
raise BillingError(f"{plan} has no price in force on {start:%d %b %Y}. Add one before opening the period.")
if club.dues.filter(period_start=start).exists():
raise BillingError(f"{club} is already billed for a period starting {start:%d %b %Y}.")
due = Due.objects.create(club=club, tier=tier, amount=amount, period_start=start, period_end=period_end, is_trial=is_trial)
due = Due.objects.create(club=club, plan=plan, amount=amount, period_start=start, period_end=period_end, is_trial=is_trial)
if amount == ZERO:
# Nothing is actually owed -- left at the default UNPAID, this would eventually
# trip is_overdue() and get a free club archived for non-payment of nothing.
@@ -157,10 +158,14 @@ def owing_dues():
def dues_in_grace(today: date | None = None):
"""Period over, unpaid, not yet archivable."""
"""Period started, unpaid, not yet archivable.
Bounded below by ``period_start``, not ``period_end``: grace now runs from the start of
the period, so a due is in grace *during* the period it covers, not after it.
"""
today = today or timezone.localdate()
return owing_dues().filter(period_end__lt=today, grace_until__gte=today)
return owing_dues().filter(period_start__lte=today, grace_until__gte=today)
def dues_overdue(today: date | None = None):
@@ -176,7 +181,7 @@ def archivable_clubs(today: date | None = None):
A club with auto_archive off is deliberately spared — that flag is how you keep a club
you are negotiating with from being switched off overnight.
"""
return dues_overdue(today).filter(club__archived_at__isnull=True, club__subscription__auto_archive=True).select_related("club", "tier").order_by("club__name")
return dues_overdue(today).filter(club__archived_at__isnull=True, club__subscription__auto_archive=True).select_related("club", "plan").order_by("club__name")
@transaction.atomic
@@ -191,27 +196,43 @@ def reactivate(club, *, start: date | None = None) -> Due:
return open_period(club, start=start)
def subscriptions_due_for_renewal(today: date | None = None, lead_days: int = RENEWAL_LEAD_DAYS):
def subscriptions_due_for_renewal(today: date | None = None, lead_days: int | None = None):
"""Clubs whose next period should be issued now.
Each plan sets its own ``renewal_lead_days``: a single global lead is silently annual-only,
and on a 1-month plan a 30-day lead would issue the next period before the current one had
started. ``lead_days`` overrides every plan's own value — that is what makes a rehearsal or
a backfill possible, and it is what the command's --lead-days flag passes.
The per-plan comparison is done in Python rather than SQL. The function already
materialised its result as a list, and date arithmetic against a field value is not
portably expressible across SQLite and Postgres; at platform scale (tens to low hundreds of
clubs) this is one query plus a list walk.
Idempotent by construction: a club that has just been renewed has a latest period ending a
year out, which is past the horizon, so it cannot be picked up twice. Running the job twice
a day is harmless.
full duration out, which is past its horizon, so it cannot be picked up twice. Running the
job twice a day is harmless.
A subscription with no period at all (its only due was cancelled) counts too — a club on a
plan and billed for nothing is the leak this whole job exists to close.
"""
today = today or timezone.localdate()
horizon = today + timedelta(days=lead_days)
latest_period_end = Subquery(
Due.objects.filter(club=OuterRef("club")).exclude(status=Due.Status.CANCELLED).order_by("-period_end").values("period_end")[:1],
output_field=DateField(),
)
subscriptions = Subscription.objects.filter(auto_renew=True, club__archived_at__isnull=True).select_related("club", "tier").annotate(latest_period_end=latest_period_end).order_by("club__name")
subscriptions = Subscription.objects.filter(auto_renew=True, club__archived_at__isnull=True).select_related("club", "plan").annotate(latest_period_end=latest_period_end).order_by("club__name")
return [subscription for subscription in subscriptions if subscription.latest_period_end is None or subscription.latest_period_end <= horizon]
def is_due(subscription) -> bool:
if subscription.latest_period_end is None:
return True
lead = subscription.plan.renewal_lead_days if lead_days is None else lead_days
return subscription.latest_period_end <= today + timedelta(days=lead)
return [subscription for subscription in subscriptions if is_due(subscription)]
def renew(subscription: Subscription) -> Due: