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>
This commit is contained in:
138
billing/migrations/0001_initial.py
Normal file
138
billing/migrations/0001_initial.py
Normal file
@@ -0,0 +1,138 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-13 23:40
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('club', '0013_club_created_club_modified_clubmembership_created_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Tier',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('slug', models.SlugField(blank=True, max_length=255, unique=True, verbose_name='slug')),
|
||||
('description', models.TextField(blank=True, verbose_name='description')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Inactive tiers keep billing existing subscriptions but cannot be chosen for new ones.', verbose_name='active')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'tier',
|
||||
'verbose_name_plural': 'tiers',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Due',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='amount')),
|
||||
('amount_paid', models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Kept in step with the payments by the billing service.', max_digits=10, verbose_name='amount paid')),
|
||||
('period_start', models.DateField(verbose_name='period start')),
|
||||
('period_end', models.DateField(blank=True, verbose_name='period end')),
|
||||
('grace_until', models.DateField(blank=True, help_text='Past this date an unpaid club is archived.', verbose_name='grace until')),
|
||||
('status', models.CharField(choices=[('unpaid', 'unpaid'), ('partial', 'partially paid'), ('paid', 'paid'), ('waived', 'waived'), ('cancelled', 'cancelled')], default='unpaid', max_length=20, verbose_name='status')),
|
||||
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='paid at')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dues', to='club.club', verbose_name='club')),
|
||||
('tier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dues', to='billing.tier', verbose_name='tier')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'due',
|
||||
'verbose_name_plural': 'dues',
|
||||
'ordering': ['-period_start', 'club__name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DuePayment',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='amount')),
|
||||
('method', models.CharField(choices=[('bank_transfer', 'bank transfer'), ('card', 'card'), ('cash', 'cash'), ('other', 'other')], default='bank_transfer', max_length=20, verbose_name='method')),
|
||||
('reference', models.CharField(blank=True, help_text='Bank reference, transaction id — whatever lets you find this again.', max_length=255, verbose_name='reference')),
|
||||
('paid_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='paid at')),
|
||||
('note', models.TextField(blank=True, verbose_name='note')),
|
||||
('due', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='billing.due', verbose_name='due')),
|
||||
('recorded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recorded_due_payments', to=settings.AUTH_USER_MODEL, verbose_name='recorded by')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'due payment',
|
||||
'verbose_name_plural': 'due payments',
|
||||
'ordering': ['-paid_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invoice',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('number', models.CharField(blank=True, max_length=32, unique=True, verbose_name='number')),
|
||||
('issued_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='issued at')),
|
||||
('due', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='invoice', to='billing.due', verbose_name='due')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'invoice',
|
||||
'verbose_name_plural': 'invoices',
|
||||
'ordering': ['-issued_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Subscription',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('auto_archive', models.BooleanField(default=True, help_text='Archive this club when a period goes unpaid past its grace period.', verbose_name='auto archive')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('club', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='club.club', verbose_name='club')),
|
||||
('tier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='billing.tier', verbose_name='tier')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'subscription',
|
||||
'verbose_name_plural': 'subscriptions',
|
||||
'ordering': ['club__name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TierPrice',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('active_from', models.DateField(help_text='Periods opening on or after this date are billed at this amount.', verbose_name='active from')),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='amount')),
|
||||
('tier', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prices', to='billing.tier', verbose_name='tier')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'tier price',
|
||||
'verbose_name_plural': 'tier prices',
|
||||
'ordering': ['tier__name', '-active_from'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='due',
|
||||
constraint=models.UniqueConstraint(fields=('club', 'period_start'), name='unique_due_per_club_per_period'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='tierprice',
|
||||
constraint=models.UniqueConstraint(fields=('tier', 'active_from'), name='unique_tier_price_per_start_date'),
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user