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

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'),
),
]