Files
RosterChief/billing/migrations/0004_rename_tier_to_plan.py
Bernard Siebens 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

35 lines
1.8 KiB
Python

"""Rename Tier -> Plan, and every field that pointed at it.
Hand-written rather than generated. `makemigrations` only detects a rename by asking
interactively; run non-interactively it emits DeleteModel + CreateModel instead, which drops
every price, subscription and due in the table. RenameModel/RenameField preserve the data.
The two RemoveConstraints have to come FIRST. Both constraints name fields this migration is
about to rename (`tier`, `post_trial_tier`), and SQLite implements a rename by rebuilding the
table -- which re-renders every constraint on it. Left in place, the rebuild tries to emit a
constraint over a column that no longer exists under that name and dies with
FieldDoesNotExist. 0005 adds them back under the new field names.
Split from the field additions (0005) so this migration is pure renaming and can be read --
and if necessary reversed -- without any other change mixed into it.
"""
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("billing", "0003_due_is_trial_subscription_post_trial_tier_and_more"),
]
operations = [
migrations.RemoveConstraint(model_name="tierprice", name="unique_tier_price_per_start_date"),
migrations.RemoveConstraint(model_name="subscription", name="trial_fields_set_together"),
migrations.RenameModel(old_name="Tier", new_name="Plan"),
migrations.RenameModel(old_name="TierPrice", new_name="PlanPrice"),
migrations.RenameField(model_name="planprice", old_name="tier", new_name="plan"),
migrations.RenameField(model_name="subscription", old_name="tier", new_name="plan"),
migrations.RenameField(model_name="subscription", old_name="post_trial_tier", new_name="post_trial_plan"),
migrations.RenameField(model_name="due", old_name="tier", new_name="plan"),
]