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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
77
billing/services/notices.py
Normal file
77
billing/services/notices.py
Normal 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,
|
||||
)
|
||||
98
billing/services/reminders.py
Normal file
98
billing/services/reminders.py
Normal 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
|
||||
Reference in New Issue
Block a user