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

@@ -11,9 +11,10 @@ from decimal import Decimal
from dateutil import relativedelta
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q
from django.db.models import F, Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -21,39 +22,89 @@ from rosterchief.base import UUIDModel, unique_slugify
ZERO = Decimal("0.00")
# A club stays live for six weeks past the end of an unpaid period before it is archived.
GRACE_DAYS = 45
#: Conservative lower bound on the number of days in a month, used to express the plan's
#: clock invariants as CheckConstraints — month arithmetic is not available in SQL, and
#: under-counting is the safe direction for a guard rail.
DAYS_PER_MONTH_FLOOR = 28
# The next period is issued this long before the current one ends, so the invoice reaches the
# club — and can be paid — before the old period lapses. Grace then only matters for genuine
# non-payers, rather than for everyone who takes a fortnight to pay a bank transfer.
RENEWAL_LEAD_DAYS = 30
def add_one_year(day: date) -> date:
return day + relativedelta.relativedelta(years=1)
# Defaults for a new plan, chosen to reproduce the annual billing the platform started with.
DEFAULT_DURATION_MONTHS = 12
DEFAULT_RENEWAL_LEAD_DAYS = 30
DEFAULT_GRACE_DAYS = 30
def add_months(day: date, months: int) -> date:
return day + relativedelta.relativedelta(months=months)
class Tier(UUIDModel):
"""A price band. The price itself lives in TierPrice, which is dated."""
class Plan(UUIDModel):
"""What a club is billed on: a duration, a set of clocks, and a dated price.
The price itself lives in PlanPrice, which is dated. The three day/month numbers here
are the plan's *clocks*, and they are named for what they measure from — see BILLING.md
§3, because confusing them is the easy mistake:
* ``duration_months`` — how long a period runs, from its start.
* ``renewal_lead_days`` — how far BEFORE a period starts its invoice is raised.
* ``grace_days`` — how long AFTER a period starts it may remain unpaid.
"""
name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True)
description = models.TextField(_("description"), blank=True)
is_active = models.BooleanField(_("active"), default=True, help_text=_("Inactive tiers keep billing existing subscriptions but cannot be chosen for new ones."))
is_active = models.BooleanField(_("active"), default=True, help_text=_("Inactive plans keep billing existing subscriptions but cannot be chosen for new ones."))
duration_months = models.PositiveSmallIntegerField(_("duration (months)"), default=DEFAULT_DURATION_MONTHS, validators=[MinValueValidator(1)], help_text=_("How long one billing period runs."))
renewal_lead_days = models.PositiveSmallIntegerField(_("renewal lead (days)"), default=DEFAULT_RENEWAL_LEAD_DAYS, help_text=_("Raise the next period's invoice this many days before that period starts."))
grace_days = models.PositiveSmallIntegerField(_("grace (days)"), default=DEFAULT_GRACE_DAYS, help_text=_("Days after a period starts before an unpaid club is archived."))
is_trial = models.BooleanField(
_("trial plan"),
default=False,
help_text=_("Offered as a trial rather than as a paid plan. A trial converts to the plan chosen on the subscription once it runs out."),
)
class Meta:
verbose_name = _("tier")
verbose_name_plural = _("tiers")
verbose_name = _("plan")
verbose_name_plural = _("plans")
ordering = ["name"]
constraints = [
# Lead longer than the period itself would raise the next invoice before the
# current period had even started, and periods would run away from the calendar.
models.CheckConstraint(
condition=Q(renewal_lead_days__lt=F("duration_months") * DAYS_PER_MONTH_FLOOR),
name="renewal_lead_shorter_than_duration",
),
# Grace longer than the period means the next period is issued while this one is
# still in grace: unpaid periods stack and the club is never archived.
models.CheckConstraint(
condition=Q(grace_days__lte=F("duration_months") * DAYS_PER_MONTH_FLOOR),
name="grace_no_longer_than_duration",
),
]
def __str__(self):
return self.name
def clean(self):
"""The same two invariants the CheckConstraints enforce, as form errors.
Without this a form would hand the database an impossible plan and get back an
IntegrityError -- a 500 rather than "that lead is longer than the period".
"""
if not self.duration_months:
return
period_days = self.duration_months * DAYS_PER_MONTH_FLOOR
errors = {}
if self.renewal_lead_days is not None and self.renewal_lead_days >= period_days:
errors["renewal_lead_days"] = _("Must be shorter than the period itself (under %(days)s days for this duration), or the next invoice would be raised before the current period starts.") % {"days": period_days}
if self.grace_days is not None and self.grace_days > period_days:
errors["grace_days"] = _("Must not be longer than the period itself (at most %(days)s days for this duration), or unpaid periods stack up and the club is never archived.") % {"days": period_days}
if errors:
raise ValidationError(errors)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = unique_slugify(self, self.name)
@@ -62,7 +113,7 @@ class Tier(UUIDModel):
def price_on(self, day: date | None = None) -> Decimal | None:
"""The price in force on ``day`` — the latest one that had started by then.
None means the tier had no price yet on that date. Callers must treat that as
None means the plan had no price yet on that date. Callers must treat that as
"cannot bill", never as free.
"""
day = day or timezone.localdate()
@@ -71,40 +122,40 @@ class Tier(UUIDModel):
return price.amount if price else None
class TierPrice(UUIDModel):
"""A dated price for a tier.
class PlanPrice(UUIDModel):
"""A dated price for a plan.
Dated rather than keyed by year: a rate change is one new row with a future
``active_from``, and every period already opened keeps the amount it was billed at.
"""
tier = models.ForeignKey(Tier, on_delete=models.CASCADE, related_name="prices", verbose_name=_("tier"))
plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name="prices", verbose_name=_("plan"))
active_from = models.DateField(_("active from"), help_text=_("Periods opening on or after this date are billed at this amount."))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(ZERO)])
class Meta:
verbose_name = _("tier price")
verbose_name_plural = _("tier prices")
ordering = ["tier__name", "-active_from"]
verbose_name = _("plan price")
verbose_name_plural = _("plan prices")
ordering = ["plan__name", "-active_from"]
constraints = [
models.UniqueConstraint(fields=["tier", "active_from"], name="unique_tier_price_per_start_date"),
models.UniqueConstraint(fields=["plan", "active_from"], name="unique_plan_price_per_start_date"),
]
def __str__(self):
return f"{self.tier}{self.amount} from {self.active_from}"
return f"{self.plan}{self.amount} from {self.active_from}"
class Subscription(UUIDModel):
"""A club's current plan. The periods it is billed for are Dues."""
club = models.OneToOneField("club.Club", on_delete=models.CASCADE, related_name="subscription", verbose_name=_("club"))
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("tier"))
plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("plan"))
auto_renew = models.BooleanField(_("auto renew"), default=True, help_text=_("Issue the next period automatically before this one ends. Off means you invoice this club by hand."))
auto_archive = models.BooleanField(_("auto archive"), default=True, help_text=_("Archive this club when a period goes unpaid past its grace period."))
notes = models.TextField(_("notes"), blank=True)
trial_ends_at = models.DateField(_("trial ends at"), null=True, blank=True, help_text=_("Set while this club is on a trial. The tier switches to post_trial_tier the next time a period is opened after this date."))
post_trial_tier = models.ForeignKey(Tier, on_delete=models.PROTECT, null=True, blank=True, related_name="+", verbose_name=_("post-trial tier"), help_text=_("The plan this club switches to automatically once its trial ends."))
trial_ends_at = models.DateField(_("trial ends at"), null=True, blank=True, help_text=_("Set while this club is on a trial. The plan switches to the post-trial plan the next time a period is opened after this date."))
post_trial_plan = models.ForeignKey(Plan, on_delete=models.PROTECT, null=True, blank=True, related_name="+", verbose_name=_("post-trial plan"), help_text=_("The plan this club switches to automatically once its trial ends."))
class Meta:
verbose_name = _("subscription")
@@ -114,21 +165,25 @@ class Subscription(UUIDModel):
# Both set together or neither -- a trial with no target plan (or a target
# plan with no trial end date) is a half-configured state nothing should read.
models.CheckConstraint(
condition=Q(trial_ends_at__isnull=True, post_trial_tier__isnull=True) | Q(trial_ends_at__isnull=False, post_trial_tier__isnull=False),
condition=Q(trial_ends_at__isnull=True, post_trial_plan__isnull=True) | Q(trial_ends_at__isnull=False, post_trial_plan__isnull=False),
name="trial_fields_set_together",
),
]
def __str__(self):
return f"{self.club}{self.tier}"
return f"{self.club}{self.plan}"
class Due(UUIDModel):
"""One billing period for one club.
``tier`` and ``amount`` are snapshots taken when the period opens, never read back
through the tier at display time: raise the price and last year's period must still say
``plan`` and ``amount`` are snapshots taken when the period opens, never read back
through the plan at display time: raise the price and last year's period must still say
what was actually charged. A live lookup would rewrite financial history.
``period_end`` and ``grace_until`` are snapshots for the same reason. They are stored as
*dates* rather than as the plan's duration/grace *numbers*, which is what makes editing a
plan afterwards leave every period already running exactly where it was.
"""
class Status(models.TextChoices):
@@ -142,20 +197,27 @@ class Due(UUIDModel):
OWING = (Status.UNPAID, Status.PARTIAL)
club = models.ForeignKey("club.Club", on_delete=models.CASCADE, related_name="dues", verbose_name=_("club"))
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="dues", verbose_name=_("tier"))
plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name="dues", verbose_name=_("plan"))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(ZERO)])
amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=ZERO, help_text=_("Kept in step with the payments by the billing service."))
period_start = models.DateField(_("period start"))
period_end = models.DateField(_("period end"), blank=True)
grace_until = models.DateField(_("grace until"), blank=True, help_text=_("Past this date an unpaid club is archived."))
grace_until = models.DateField(_("grace until"), blank=True, help_text=_("Past this date an unpaid club is archived. Measured from the period start, not its end."))
status = models.CharField(_("status"), max_length=20, choices=Status.choices, default=Status.UNPAID)
paid_at = models.DateTimeField(_("paid at"), null=True, blank=True)
is_trial = models.BooleanField(_("trial period"), default=False, help_text=_("This period was opened as a trial. A durable marker on the row itself -- the subscription's own trial fields are cleared once it converts."))
# Reminders are sent once per escalation level, not once per run: the cron job runs daily,
# and a club that owes money for a month must not get thirty identical emails. Storing the
# level last sent (rather than a date) means an escalation always gets through, and nothing
# else does. See billing/services/reminders.py.
last_reminder_level = models.CharField(_("last reminder level"), max_length=20, blank=True, editable=False)
last_reminder_sent_at = models.DateTimeField(_("last reminder sent at"), null=True, blank=True, editable=False)
class Meta:
verbose_name = _("due")
verbose_name_plural = _("dues")
@@ -168,12 +230,14 @@ class Due(UUIDModel):
return f"{self.club}{self.period_start} to {self.period_end}"
def save(self, *args, **kwargs):
# A period runs a rolling year from its start and the grace hangs off its end.
# Derived here so no caller can open a period without them.
# A period runs for the plan's duration from its start, and the grace runs from that
# same start -- NOT from the period end. Measured from the end, a club would get the
# whole unpaid period plus the grace on top (~410 days on an annual plan) before
# anything switched it off. Derived here so no caller can open a period without them.
if not self.period_end:
self.period_end = add_one_year(self.period_start) - timedelta(days=1)
self.period_end = add_months(self.period_start, self.plan.duration_months) - timedelta(days=1)
if not self.grace_until:
self.grace_until = self.period_end + timedelta(days=GRACE_DAYS)
self.grace_until = self.period_start + timedelta(days=self.plan.grace_days)
super().save(*args, **kwargs)
@property
@@ -184,11 +248,21 @@ class Due(UUIDModel):
def is_owing(self) -> bool:
return self.status in self.OWING
def is_in_grace(self, today: date | None = None) -> bool:
"""The period has ended unpaid, but the club is not archivable yet."""
def is_issued_ahead(self, today: date | None = None) -> bool:
"""Billed and owing, but the period it covers has not started yet.
The gentlest of the three owing states: the invoice was raised during the plan's
renewal lead window, and nothing is late yet.
"""
today = today or timezone.localdate()
return self.is_owing and self.period_end < today <= self.grace_until
return self.is_owing and today < self.period_start
def is_in_grace(self, today: date | None = None) -> bool:
"""The period has started and is still unpaid, but is not archivable yet."""
today = today or timezone.localdate()
return self.is_owing and self.period_start <= today <= self.grace_until
def is_overdue(self, today: date | None = None) -> bool:
"""Unpaid past grace — this is what makes a club archivable."""
@@ -196,6 +270,12 @@ class Due(UUIDModel):
return self.is_owing and self.grace_until < today
def days_until_archive(self, today: date | None = None) -> int:
"""Days left before this period makes the club archivable. Negative once past."""
today = today or timezone.localdate()
return (self.grace_until - today).days
class DuePayment(UUIDModel):
"""Money received against a due.
@@ -230,7 +310,7 @@ class DuePayment(UUIDModel):
class Invoice(UUIDModel):
"""The bill for one period.
Only the number and the issue date are stored: the money, the tier and the dates are
Only the number and the issue date are stored: the money, the plan and the dates are
already frozen on the Due, so the PDF is rendered from those snapshots on demand. The
number, though, must be stable and gapless — it is the thing an accountant reconciles
against, so it is allocated once and never recomputed.