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