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:
2026-08-08 18:49:52 +02:00
parent ae93406853
commit fc6488ce55
36 changed files with 1342 additions and 386 deletions

View File

@@ -307,6 +307,44 @@ season-scoped** (§5.1): it gains a `season` FK and sign-up / fee-status fields,
is one member's affiliation for one season (`unique_together (club, member, season)`). This
is the record the `MEMBER` role and shop fulfilment key off of (§3.4, §5.7).
### `billing` — what the platform charges a club
**Deliberately NOT club-scoped, and the only app that isn't.** `shop` (§5.7) is a club charging
its *members* — tenant data, owned by the club. `billing` is RosterChief charging the *club*:
platform-owned, never visible to a club user except as the one notice described below. Nothing
here inherits `ClubScopedModel` — these rows reference a `Club`, they are not owned by one, and a
tenant-scoped manager would be exactly the wrong default.
**`Plan`** — a duration and three clocks, named for what they measure *from*, which is the easy
thing to get wrong: `duration_months` (period length, from its start), `renewal_lead_days` (how far
*before* a period starts its invoice is raised), `grace_days` (how long *after* a period starts it
may stay unpaid). Two `CheckConstraint`s keep them coherent. `is_trial` marks a plan offered as a
trial; a trial's length is simply its own `duration_months`.
**`PlanPrice`** — a dated price (`active_from`). A rate change is a new row, never an edit, so
every period already opened keeps what it was billed at.
**`Subscription`** — one per club (`OneToOneField`): its current `plan`, `auto_renew`,
`auto_archive`, and the trial pair (`trial_ends_at` + `post_trial_plan`, constrained to be set
together or not at all).
**`Due`** — one billing period for one club, and **the snapshot boundary**. `plan`, `amount`,
`period_end` and `grace_until` are all frozen when the period opens and never read back through
the plan at display time: raise a price or edit a plan's grace and last year's invoice must still
say what was actually charged. Storing the computed *dates* rather than the plan's *numbers* is
what buys that.
**`DuePayment`** / **`Invoice`** — money received against a due (several may land on one), and the
gapless per-year invoice number. The PDF itself is rendered on demand from the `Due` snapshot;
only the number is stored.
All lifecycle changes go through `billing/services/``dues.py` (open, renew, pay, waive,
archive), `notices.py` (the one club-facing warning), `reminders.py` (its email). Never through
the models directly: a `Due` whose `amount_paid` disagrees with its payments is a wrong invoice.
**`BILLING.md` is the authoritative document for this app** — the lifecycle, the worked timelines,
and the migration hazards live there rather than here.
---
## 5. Planned models (design)