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:

View File

@@ -1,6 +1,6 @@
"""Invoice PDFs.
The PDF is rendered on demand from the Due's frozen snapshot (tier, amount, dates), so it
The PDF is rendered on demand from the Due's frozen snapshot (plan, amount, dates), so it
carries no state of its own beyond the number. Only the number is stored — an accountant
reconciles against it, so it is allocated once, never recomputed.
"""

View File

@@ -0,0 +1,77 @@
"""What a club's own admins are told about money they owe the platform.
Separate from dues.py because the audience is different: everything in dues.py is read by
platform staff in the control panel, and this is the one piece of billing a *club* sees. It
returns data, never rendered text — the wording lives in the template so it can be translated,
and the same notice feeds both the on-screen banner and the reminder email.
"""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from django.utils import timezone
from billing.models import Due
#: Inside this many days of being archived, the notice stops being a warning and becomes a
#: final one — which is also the point at which it follows the admin onto every page.
URGENT_DAYS = 7
INFO = "info"
WARNING = "warning"
ERROR = "error"
@dataclass(frozen=True)
class BillingNotice:
"""The single most pressing thing a club owes, and how alarmed to be about it."""
level: str
due: Due
amount_outstanding: Decimal
period_start: date
grace_until: date
days_until_archive: int
#: False when the subscription has auto_archive off. Money is still owed and still worth
#: saying so, but the countdown must not claim an archiving that will never happen.
will_archive: bool
@property
def is_urgent(self) -> bool:
return self.level == ERROR
def club_billing_notice(club, today: date | None = None) -> BillingNotice | None:
"""The notice for ``club``, or None when it owes nothing.
Picks the due with the earliest ``grace_until`` when several are owing: that is the one
that will archive the club first, so it is the one worth shouting about.
"""
today = today or timezone.localdate()
due = club.dues.filter(status__in=Due.OWING).select_related("plan").order_by("grace_until").first()
if due is None:
return None
subscription = getattr(club, "subscription", None)
will_archive = subscription.auto_archive if subscription is not None else False
days_left = due.days_until_archive(today)
if due.is_overdue(today):
level = ERROR
elif due.is_in_grace(today):
level = ERROR if days_left <= URGENT_DAYS else WARNING
else:
# Issued during the plan's renewal lead window: billed, but nothing is late yet.
level = INFO
return BillingNotice(
level=level,
due=due,
amount_outstanding=due.balance,
period_start=due.period_start,
grace_until=due.grace_until,
days_until_archive=days_left,
will_archive=will_archive,
)

View File

@@ -0,0 +1,98 @@
"""Emailing a club's admins about money it owes the platform.
Built on the same BillingNotice the on-screen banner uses (notices.py), so the email and the
banner can never disagree about how much is owed or how long is left.
**Sent once per escalation level, not once per run.** The command is on a daily cron; a club
that owes money for a month must not receive thirty identical emails. ``Due.last_reminder_level``
records the level last mailed, so an escalation (info -> warning -> error) always gets through
and a repeat of the same level never does.
"""
from dataclasses import dataclass
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext as _
from billing.services.notices import BillingNotice, club_billing_notice
from club.models import ClubRole
@dataclass(frozen=True)
class ReminderResult:
club: object
notice: BillingNotice
recipients: list[str]
sent: bool
skipped_reason: str = ""
def admin_emails(club) -> list[str]:
"""Every club admin we can actually reach, de-duplicated and order-stable.
A club with admins but no email addresses returns empty — the caller reports that rather
than silently counting it as reminded.
"""
roles = ClubRole.objects.filter(club=club, role=ClubRole.Roles.ADMIN).select_related("member", "member__user").order_by("member__last_name", "member__first_name")
seen, emails = set(), []
for role in roles:
email = role.member.contact_email
if email and email not in seen:
seen.add(email)
emails.append(email)
return emails
def needs_reminder(due, notice: BillingNotice) -> bool:
"""True when this due has not yet been mailed at its current level."""
return due.last_reminder_level != notice.level
def send_reminder(club, notice: BillingNotice, *, recipients: list[str]) -> None:
"""Render and send one reminder, then record the level so it is not repeated."""
context = {
"club": club,
"notice": notice,
"due": notice.due,
"billing_contact": settings.BILLING_CONTACT_EMAIL,
}
subject = render_to_string("billing/email/reminder_subject.txt", context).strip()
text_body = render_to_string("billing/email/reminder.txt", context)
message = EmailMultiAlternatives(subject=subject, body=text_body, from_email=settings.DEFAULT_FROM_EMAIL, to=recipients)
message.send(fail_silently=False)
notice.due.last_reminder_level = notice.level
notice.due.last_reminder_sent_at = timezone.now()
notice.due.save(update_fields=["last_reminder_level", "last_reminder_sent_at", "modified"])
def reminders_to_send(clubs, today=None, *, force: bool = False) -> list[ReminderResult]:
"""Work out who would be reminded, without sending anything.
Returned whether or not each one is actually sendable, so the command can report a club
with no reachable admin instead of skipping it in silence — an unreachable club is exactly
the one that gets archived without ever having been told.
"""
results = []
for club in clubs:
notice = club_billing_notice(club, today)
if notice is None:
continue
recipients = admin_emails(club)
if not recipients:
results.append(ReminderResult(club=club, notice=notice, recipients=[], sent=False, skipped_reason=_("no club admin with an email address")))
continue
if not force and not needs_reminder(notice.due, notice):
results.append(ReminderResult(club=club, notice=notice, recipients=recipients, sent=False, skipped_reason=_("already reminded at this level")))
continue
results.append(ReminderResult(club=club, notice=notice, recipients=recipients, sent=True))
return results