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>
139 lines
8.6 KiB
Python
139 lines
8.6 KiB
Python
# 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'),
|
|
),
|
|
]
|