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.
99 lines
3.8 KiB
Python
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
|