Files
RosterChief/billing/services/reminders.py
Bernard Siebens fc6488ce55 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.
2026-08-08 18:49:52 +02:00

99 lines
3.8 KiB
Python

"""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