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,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import ngettext
|
||||
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
|
||||
|
||||
from billing.models import RENEWAL_LEAD_DAYS, Due
|
||||
from billing.models import Due
|
||||
from club.mixins import (
|
||||
ClubAdminRequiredMixin,
|
||||
ClubStaffRequiredMixin,
|
||||
@@ -86,10 +86,13 @@ class HomeView(ClubStaffRequiredMixin, TemplateView):
|
||||
club, user = self.request.club, self.request.user
|
||||
subscription = getattr(club, "subscription", None)
|
||||
|
||||
# "Your period ends soon" is a different question from "you owe us money", and only
|
||||
# worth raising when nothing is owed -- an unpaid club gets the billing notice from
|
||||
# the context processor instead, which is louder and more urgent.
|
||||
billing_ends_at = None
|
||||
if subscription is not None:
|
||||
if subscription is not None and not club.dues.filter(status__in=Due.OWING).exists():
|
||||
latest_due = club.dues.exclude(status=Due.Status.CANCELLED).order_by("-period_end").first()
|
||||
if latest_due is not None and 0 <= (latest_due.period_end - timezone.localdate()).days <= RENEWAL_LEAD_DAYS:
|
||||
if latest_due is not None and 0 <= (latest_due.period_end - timezone.localdate()).days <= subscription.plan.renewal_lead_days:
|
||||
billing_ends_at = latest_due.period_end
|
||||
|
||||
upcoming_events = scoped_to_managed_teams(Event.objects.filter(club=club, start__gte=timezone.now()), user, club).order_by("start").prefetch_related("teams")[:5]
|
||||
|
||||
Reference in New Issue
Block a user