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:
34
billing/migrations/0004_rename_tier_to_plan.py
Normal file
34
billing/migrations/0004_rename_tier_to_plan.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""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"),
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-08 16:30
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.db.models.expressions
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0004_rename_tier_to_plan'),
|
||||
('club', '0020_sponsor_logo_height_sponsor_logo_width'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='plan',
|
||||
options={'ordering': ['name'], 'verbose_name': 'plan', 'verbose_name_plural': 'plans'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='planprice',
|
||||
options={'ordering': ['plan__name', '-active_from'], 'verbose_name': 'plan price', 'verbose_name_plural': 'plan prices'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='duration_months',
|
||||
field=models.PositiveSmallIntegerField(default=12, help_text='How long one billing period runs.', validators=[django.core.validators.MinValueValidator(1)], verbose_name='duration (months)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='grace_days',
|
||||
field=models.PositiveSmallIntegerField(default=30, help_text='Days after a period starts before an unpaid club is archived.', verbose_name='grace (days)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='is_trial',
|
||||
field=models.BooleanField(default=False, help_text='Offered as a trial rather than as a paid plan. A trial converts to the plan chosen on the subscription once it runs out.', verbose_name='trial plan'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='renewal_lead_days',
|
||||
field=models.PositiveSmallIntegerField(default=30, help_text="Raise the next period's invoice this many days before that period starts.", verbose_name='renewal lead (days)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='due',
|
||||
name='grace_until',
|
||||
field=models.DateField(blank=True, help_text='Past this date an unpaid club is archived. Measured from the period start, not its end.', verbose_name='grace until'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='due',
|
||||
name='plan',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dues', to='billing.plan', verbose_name='plan'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='plan',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True, help_text='Inactive plans keep billing existing subscriptions but cannot be chosen for new ones.', verbose_name='active'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='planprice',
|
||||
name='plan',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prices', to='billing.plan', verbose_name='plan'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subscription',
|
||||
name='plan',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='billing.plan', verbose_name='plan'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subscription',
|
||||
name='post_trial_plan',
|
||||
field=models.ForeignKey(blank=True, help_text='The plan this club switches to automatically once its trial ends.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='billing.plan', verbose_name='post-trial plan'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subscription',
|
||||
name='trial_ends_at',
|
||||
field=models.DateField(blank=True, help_text='Set while this club is on a trial. The plan switches to the post-trial plan the next time a period is opened after this date.', null=True, verbose_name='trial ends at'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='plan',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('renewal_lead_days__lt', django.db.models.expressions.CombinedExpression(models.F('duration_months'), '*', models.Value(28)))), name='renewal_lead_shorter_than_duration'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='plan',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('grace_days__lte', django.db.models.expressions.CombinedExpression(models.F('duration_months'), '*', models.Value(28)))), name='grace_no_longer_than_duration'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='planprice',
|
||||
constraint=models.UniqueConstraint(fields=('plan', 'active_from'), name='unique_plan_price_per_start_date'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='subscription',
|
||||
constraint=models.CheckConstraint(condition=models.Q(models.Q(('post_trial_plan__isnull', True), ('trial_ends_at__isnull', True)), models.Q(('post_trial_plan__isnull', False), ('trial_ends_at__isnull', False)), _connector='OR'), name='trial_fields_set_together'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-08 16:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0005_alter_plan_options_alter_planprice_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='due',
|
||||
name='last_reminder_level',
|
||||
field=models.CharField(blank=True, editable=False, max_length=20, verbose_name='last reminder level'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='due',
|
||||
name='last_reminder_sent_at',
|
||||
field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='last reminder sent at'),
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user