Due.plan is PROTECT -- a plan that has ever billed anyone can never
truly be removed, on purpose: amount/period_end/grace_until are
frozen on a Due precisely so a later change can't rewrite what was
actually charged, and losing the plan link off an old Due would do
exactly that to every historical invoice.
"Delete" therefore means one of two things, chosen automatically
(billing/services/plans.py):
- never billed anyone -> the row is removed outright.
- has billing history -> soft-deleted (Plan.deleted_at, is_active
off): hidden from every picker/listing via the new opt-in
Plan.objects.visible(), but the row survives so old invoices still
show what they were billed under.
Either way, every club currently on the plan is unsubscribed outright
-- its Subscription row deleted, not just its plan field cleared.
"No plan" was already a fully-understood state everywhere else in the
app, so this reuses it instead of inventing a new one.
Also handles the easy-to-miss second group: a club on a DIFFERENT
plan, mid-trial, configured to convert to the plan being deleted
(Subscription.post_trial_plan). Left alone that would try to convert
onto a hidden/gone plan later; instead that club's trial is ended now
(both trial fields cleared, per the CheckConstraint requiring them
together) so it needs a new plan picked by hand.
The confirmation screen is a real page, not a modal like every other
billing action -- naming exactly which clubs are affected, in both
groups, and that list can be long.
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.
Documents a revision, not a rewrite. The snapshot-on-Due pattern,
dated prices and the asymmetric dry-run commands are sound and are
kept; what's wrong is three hardcoded assumptions in models.py:
annual-only periods, a global GRACE_DAYS, and grace measured from
period_end -- which gives a club ~410 days of unpaid use before
archive_overdue_clubs will touch it.
Proposes per-plan duration/lead/grace, grace measured from
period_start, a Tier -> Plan rename, and a club-facing unpaid-dues
warning with a countdown to archiving.
Flags the migration hazard prominently: re-deriving grace_until on
existing dues under the new rule puts the date in the past for every
open annual period, which would archive the whole paying customer
base on the next --commit run.