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.
348 lines
16 KiB
Python
348 lines
16 KiB
Python
"""What the platform charges a club.
|
|
|
|
Deliberately NOT club-scoped. `shop` is a club charging its members — tenant data, owned by
|
|
the club. This is RosterChief charging the club: platform-owned, and no club user ever sees
|
|
it. Nothing here inherits ClubScopedModel: these rows reference a Club, they are not owned
|
|
by one, and a tenant-scoped manager would be exactly the wrong default.
|
|
"""
|
|
|
|
from datetime import date, timedelta
|
|
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 F, Q
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from rosterchief.base import UUIDModel, unique_slugify
|
|
|
|
ZERO = Decimal("0.00")
|
|
|
|
#: 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
|
|
|
|
# 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 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 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 = _("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)
|
|
super().save(*args, **kwargs)
|
|
|
|
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 plan had no price yet on that date. Callers must treat that as
|
|
"cannot bill", never as free.
|
|
"""
|
|
day = day or timezone.localdate()
|
|
price = self.prices.filter(active_from__lte=day).order_by("-active_from").first()
|
|
|
|
return price.amount if price else None
|
|
|
|
|
|
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.
|
|
"""
|
|
|
|
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 = _("plan price")
|
|
verbose_name_plural = _("plan prices")
|
|
ordering = ["plan__name", "-active_from"]
|
|
constraints = [
|
|
models.UniqueConstraint(fields=["plan", "active_from"], name="unique_plan_price_per_start_date"),
|
|
]
|
|
|
|
def __str__(self):
|
|
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"))
|
|
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 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")
|
|
verbose_name_plural = _("subscriptions")
|
|
ordering = ["club__name"]
|
|
constraints = [
|
|
# 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_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.plan}"
|
|
|
|
|
|
class Due(UUIDModel):
|
|
"""One billing period for one club.
|
|
|
|
``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):
|
|
UNPAID = "unpaid", _("unpaid")
|
|
PARTIAL = "partial", _("partially paid")
|
|
PAID = "paid", _("paid")
|
|
WAIVED = "waived", _("waived")
|
|
CANCELLED = "cancelled", _("cancelled")
|
|
|
|
#: Statuses that still owe money.
|
|
OWING = (Status.UNPAID, Status.PARTIAL)
|
|
|
|
club = models.ForeignKey("club.Club", on_delete=models.CASCADE, related_name="dues", verbose_name=_("club"))
|
|
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. 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")
|
|
ordering = ["-period_start", "club__name"]
|
|
constraints = [
|
|
models.UniqueConstraint(fields=["club", "period_start"], name="unique_due_per_club_per_period"),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.club} — {self.period_start} to {self.period_end}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
# 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_months(self.period_start, self.plan.duration_months) - timedelta(days=1)
|
|
if not self.grace_until:
|
|
self.grace_until = self.period_start + timedelta(days=self.plan.grace_days)
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def balance(self) -> Decimal:
|
|
return self.amount - self.amount_paid
|
|
|
|
@property
|
|
def is_owing(self) -> bool:
|
|
return self.status in self.OWING
|
|
|
|
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 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."""
|
|
today = today or timezone.localdate()
|
|
|
|
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.
|
|
|
|
Several may land on one due: a club that pays in two transfers must not read as unpaid,
|
|
and the half that did arrive has to be recorded somewhere.
|
|
"""
|
|
|
|
class Method(models.TextChoices):
|
|
BANK_TRANSFER = "bank_transfer", _("bank transfer")
|
|
CARD = "card", _("card")
|
|
CASH = "cash", _("cash")
|
|
OTHER = "other", _("other")
|
|
|
|
due = models.ForeignKey(Due, on_delete=models.CASCADE, related_name="payments", verbose_name=_("due"))
|
|
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))])
|
|
method = models.CharField(_("method"), max_length=20, choices=Method.choices, default=Method.BANK_TRANSFER)
|
|
reference = models.CharField(_("reference"), max_length=255, blank=True, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
|
|
paid_at = models.DateTimeField(_("paid at"), default=timezone.now)
|
|
note = models.TextField(_("note"), blank=True)
|
|
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_due_payments", verbose_name=_("recorded by"))
|
|
|
|
class Meta:
|
|
verbose_name = _("due payment")
|
|
verbose_name_plural = _("due payments")
|
|
ordering = ["-paid_at"]
|
|
|
|
def __str__(self):
|
|
return f"{self.amount} — {self.due}"
|
|
|
|
|
|
class Invoice(UUIDModel):
|
|
"""The bill for one period.
|
|
|
|
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.
|
|
"""
|
|
|
|
due = models.OneToOneField(Due, on_delete=models.CASCADE, related_name="invoice", verbose_name=_("due"))
|
|
number = models.CharField(_("number"), max_length=32, unique=True, blank=True)
|
|
issued_at = models.DateTimeField(_("issued at"), default=timezone.now)
|
|
|
|
class Meta:
|
|
verbose_name = _("invoice")
|
|
verbose_name_plural = _("invoices")
|
|
ordering = ["-issued_at"]
|
|
|
|
def __str__(self):
|
|
return self.number
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.number:
|
|
self.number = self.next_number(self.issued_at.year)
|
|
super().save(*args, **kwargs)
|
|
|
|
@classmethod
|
|
def next_number(cls, year: int) -> str:
|
|
"""INV-2026-00001, restarting each year.
|
|
|
|
Platform-wide, unlike the shop's order numbers, which are per club: these are OUR
|
|
invoices, and one sequence has to cover every club we bill.
|
|
"""
|
|
prefix = f"INV-{year}-"
|
|
last = cls.objects.filter(number__startswith=prefix).order_by("-number").first()
|
|
sequence = int(last.number.removeprefix(prefix)) + 1 if last else 1
|
|
|
|
return f"{prefix}{sequence:05d}"
|