Commit Graph

3 Commits

Author SHA1 Message Date
617271f0d0 Honor auto_archive in the reminder email subject and the trial form
Two gaps found while checking whether auto_archive is honored
end-to-end (archive_overdue_clubs and the on-screen banner already
got it right):

- reminder_subject.txt branched only on notice.level, so a club with
  auto_archive off -- one that will NEVER be archived -- still got
  "Action required: X is about to be archived" as its subject line,
  contradicting the correctly-worded body underneath. Now gated on
  notice.level == 'error' AND notice.will_archive.
- TrialForm had no auto_renew/auto_archive fields at all, so a trial
  could only ever be started on the service defaults (both True).
  The only way to change either afterwards was "Change plan", which
  ends the trial as a side effect. Added both, matching
  SubscriptionForm's existing pair.
2026-08-08 19:14:36 +02:00
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
60bfac9881 Add platform billing: tiers, dues, payments and invoices
RosterChief charging the clubs, which is a different domain from `shop` (a club
charging its members). Nothing here is club-scoped: these rows reference a Club,
they are not owned by one, and no club user ever sees them.

- Tier + TierPrice. Prices are dated, not keyed by year: a rate change is one row
  with a future active_from, and price_on(day) answers "what was in force then".
  A tier with no price yet returns None, which callers must treat as "cannot
  bill" -- never as free.
- Due: one rolling-year period per club, with a 45-day grace tail. The tier and
  the amount are SNAPSHOTS taken when the period opens. Raise the price and last
  year's period must still say what was actually charged; reading it back through
  the tier would silently rewrite financial history.
- DuePayment: partial payments accumulate. amount_paid is re-summed from the
  payments on every change, never incremented -- an increment drifts the moment a
  payment is deleted, and the drift still looks like money.
- Invoice: PDF via WeasyPrint, rendered on demand from the frozen snapshot. Only
  the number is stored, in one platform-wide series (unlike the shop's per-club
  order numbers), and re-issuing returns the existing one rather than burning a
  number -- a gap in an invoice series is a question you don't want to answer.
  WeasyPrint is imported lazily: it binds to native pango/cairo, and the app, the
  tests and every other page must still run on a machine without them.
- archive_overdue_clubs reports by default and archives only with --commit. That
  asymmetry is deliberate: this switches off paying customers, so a bad clock or a
  cron misconfiguration should cost an email, not a morning of angry clubs. A club
  with auto_archive off is spared entirely.

Renewal continues from the last period end, not from the payment date: a club that
pays two months late has still used those two months.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:45:15 +02:00