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:
2026-07-14 01:45:15 +02:00
parent 6899e203f6
commit 60bfac9881
18 changed files with 1305 additions and 1 deletions

0
billing/__init__.py Normal file
View File

53
billing/admin.py Normal file
View File

@@ -0,0 +1,53 @@
from django.contrib import admin
from .models import Due, DuePayment, Subscription, Tier, TierPrice
class TierPriceInline(admin.TabularInline):
model = TierPrice
extra = 0
@admin.register(Tier)
class TierAdmin(admin.ModelAdmin):
list_display = ["name", "is_active"]
list_filter = ["is_active"]
search_fields = ["name"]
prepopulated_fields = {"slug": ["name"]}
inlines = [TierPriceInline]
@admin.register(TierPrice)
class TierPriceAdmin(admin.ModelAdmin):
list_display = ["tier", "amount", "active_from"]
list_filter = ["tier"]
@admin.register(Subscription)
class SubscriptionAdmin(admin.ModelAdmin):
list_display = ["club", "tier", "auto_archive"]
list_filter = ["tier", "auto_archive"]
search_fields = ["club__name"]
class DuePaymentInline(admin.TabularInline):
model = DuePayment
extra = 0
readonly_fields = ["recorded_by"]
@admin.register(Due)
class DueAdmin(admin.ModelAdmin):
list_display = ["club", "tier", "period_start", "period_end", "amount", "amount_paid", "status"]
list_filter = ["status", "tier"]
search_fields = ["club__name"]
# Money is settled by the billing service, which re-derives these from the payments.
readonly_fields = ["amount_paid", "status", "paid_at"]
inlines = [DuePaymentInline]
@admin.register(DuePayment)
class DuePaymentAdmin(admin.ModelAdmin):
list_display = ["due", "amount", "method", "paid_at", "recorded_by"]
list_filter = ["method"]
search_fields = ["due__club__name", "reference"]

7
billing/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "billing"
verbose_name = "Billing"

View File

View File

View File

@@ -0,0 +1,38 @@
"""Archive clubs whose billing period has gone unpaid past its grace period.
Reports by default and only acts with --commit. That asymmetry is the point: this command
switches off paying customers, and a cron misconfiguration, a clock skew or a bad import
should cost you a confusing email, not a morning of angry clubs.
"""
from django.core.management.base import BaseCommand
from django.utils import timezone
from billing.services.dues import archivable_clubs
class Command(BaseCommand):
help = "Archive clubs that are unpaid past their grace period (dry run unless --commit)."
def add_arguments(self, parser):
parser.add_argument("--commit", action="store_true", help="Actually archive them. Without this the command only reports.")
def handle(self, *args, **options):
today = timezone.localdate()
overdue = list(archivable_clubs(today))
if not overdue:
self.stdout.write(self.style.SUCCESS("Nothing overdue past grace."))
return
for due in overdue:
days = (today - due.grace_until).days
self.stdout.write(f"{due.club}{due.tier}, {due.balance} owed, grace ended {due.grace_until} ({days} day{'s'[: days != 1]} ago)")
if not options["commit"]:
self.stdout.write(self.style.WARNING(f"\nDry run: {len(overdue)} club(s) would be archived. Re-run with --commit to do it."))
return
for due in overdue:
due.club.archive()
self.stdout.write(self.style.SUCCESS(f"\nArchived {len(overdue)} club(s). Their data is kept; restoring re-opens billing."))

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

View File

247
billing/models.py Normal file
View File

@@ -0,0 +1,247 @@
"""What the platform charges a club.
Deliberately NOT club-scoped. `shop` is a club charging its members — tenant data, owned by
the club. This is RosterChief charging the club: platform-owned, and no club user ever sees
it. Nothing here inherits ClubScopedModel: these rows reference a Club, they are not owned
by one, and a tenant-scoped manager would be exactly the wrong default.
"""
from datetime import date, timedelta
from decimal import Decimal
from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from rosterchief.base import UUIDModel, unique_slugify
ZERO = Decimal("0.00")
#: A club stays live for six weeks past the end of an unpaid period before it is archived.
GRACE_DAYS = 45
def add_one_year(day: date) -> date:
"""The day one year on. 29 February has no counterpart in a common year, so it falls
back to the 28th rather than raising."""
try:
return day.replace(year=day.year + 1)
except ValueError:
return day.replace(year=day.year + 1, day=28)
class Tier(UUIDModel):
"""A price band. The price itself lives in TierPrice, which is dated."""
name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True)
description = models.TextField(_("description"), blank=True)
is_active = models.BooleanField(_("active"), default=True, help_text=_("Inactive tiers keep billing existing subscriptions but cannot be chosen for new ones."))
class Meta:
verbose_name = _("tier")
verbose_name_plural = _("tiers")
ordering = ["name"]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = unique_slugify(self, self.name)
super().save(*args, **kwargs)
def price_on(self, day: date | None = None) -> Decimal | None:
"""The price in force on ``day`` — the latest one that had started by then.
None means the tier had no price yet on that date. Callers must treat that as
"cannot bill", never as free.
"""
day = day or timezone.localdate()
price = self.prices.filter(active_from__lte=day).order_by("-active_from").first()
return price.amount if price else None
class TierPrice(UUIDModel):
"""A dated price for a tier.
Dated rather than keyed by year: a rate change is one new row with a future
``active_from``, and every period already opened keeps the amount it was billed at.
"""
tier = models.ForeignKey(Tier, on_delete=models.CASCADE, related_name="prices", verbose_name=_("tier"))
active_from = models.DateField(_("active from"), help_text=_("Periods opening on or after this date are billed at this amount."))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(ZERO)])
class Meta:
verbose_name = _("tier price")
verbose_name_plural = _("tier prices")
ordering = ["tier__name", "-active_from"]
constraints = [
models.UniqueConstraint(fields=["tier", "active_from"], name="unique_tier_price_per_start_date"),
]
def __str__(self):
return f"{self.tier}{self.amount} from {self.active_from}"
class Subscription(UUIDModel):
"""A club's current plan. The periods it is billed for are Dues."""
club = models.OneToOneField("club.Club", on_delete=models.CASCADE, related_name="subscription", verbose_name=_("club"))
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("tier"))
auto_archive = models.BooleanField(_("auto archive"), default=True, help_text=_("Archive this club when a period goes unpaid past its grace period."))
notes = models.TextField(_("notes"), blank=True)
class Meta:
verbose_name = _("subscription")
verbose_name_plural = _("subscriptions")
ordering = ["club__name"]
def __str__(self):
return f"{self.club}{self.tier}"
class Due(UUIDModel):
"""One billing period for one club.
``tier`` and ``amount`` are snapshots taken when the period opens, never read back
through the tier at display time: raise the price and last year's period must still say
what was actually charged. A live lookup would rewrite financial history.
"""
class Status(models.TextChoices):
UNPAID = "unpaid", _("unpaid")
PARTIAL = "partial", _("partially paid")
PAID = "paid", _("paid")
WAIVED = "waived", _("waived")
CANCELLED = "cancelled", _("cancelled")
#: Statuses that still owe money.
OWING = (Status.UNPAID, Status.PARTIAL)
club = models.ForeignKey("club.Club", on_delete=models.CASCADE, related_name="dues", verbose_name=_("club"))
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="dues", verbose_name=_("tier"))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(ZERO)])
amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=ZERO, help_text=_("Kept in step with the payments by the billing service."))
period_start = models.DateField(_("period start"))
period_end = models.DateField(_("period end"), blank=True)
grace_until = models.DateField(_("grace until"), blank=True, help_text=_("Past this date an unpaid club is archived."))
status = models.CharField(_("status"), max_length=20, choices=Status.choices, default=Status.UNPAID)
paid_at = models.DateTimeField(_("paid at"), null=True, blank=True)
class Meta:
verbose_name = _("due")
verbose_name_plural = _("dues")
ordering = ["-period_start", "club__name"]
constraints = [
models.UniqueConstraint(fields=["club", "period_start"], name="unique_due_per_club_per_period"),
]
def __str__(self):
return f"{self.club}{self.period_start} to {self.period_end}"
def save(self, *args, **kwargs):
# A period runs a rolling year from its start and the grace hangs off its end.
# Derived here so no caller can open a period without them.
if not self.period_end:
self.period_end = add_one_year(self.period_start) - timedelta(days=1)
if not self.grace_until:
self.grace_until = self.period_end + timedelta(days=GRACE_DAYS)
super().save(*args, **kwargs)
@property
def balance(self) -> Decimal:
return self.amount - self.amount_paid
@property
def is_owing(self) -> bool:
return self.status in self.OWING
def is_in_grace(self, today: date | None = None) -> bool:
"""The period has ended unpaid, but the club is not archivable yet."""
today = today or timezone.localdate()
return self.is_owing and self.period_end < today <= self.grace_until
def is_overdue(self, today: date | None = None) -> bool:
"""Unpaid past grace — this is what makes a club archivable."""
today = today or timezone.localdate()
return self.is_owing and self.grace_until < today
class DuePayment(UUIDModel):
"""Money received against a due.
Several may land on one due: a club that pays in two transfers must not read as unpaid,
and the half that did arrive has to be recorded somewhere.
"""
class Method(models.TextChoices):
BANK_TRANSFER = "bank_transfer", _("bank transfer")
CARD = "card", _("card")
CASH = "cash", _("cash")
OTHER = "other", _("other")
due = models.ForeignKey(Due, on_delete=models.CASCADE, related_name="payments", verbose_name=_("due"))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))])
method = models.CharField(_("method"), max_length=20, choices=Method.choices, default=Method.BANK_TRANSFER)
reference = models.CharField(_("reference"), max_length=255, blank=True, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
paid_at = models.DateTimeField(_("paid at"), default=timezone.now)
note = models.TextField(_("note"), blank=True)
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_due_payments", verbose_name=_("recorded by"))
class Meta:
verbose_name = _("due payment")
verbose_name_plural = _("due payments")
ordering = ["-paid_at"]
def __str__(self):
return f"{self.amount}{self.due}"
class Invoice(UUIDModel):
"""The bill for one period.
Only the number and the issue date are stored: the money, the tier and the dates are
already frozen on the Due, so the PDF is rendered from those snapshots on demand. The
number, though, must be stable and gapless — it is the thing an accountant reconciles
against, so it is allocated once and never recomputed.
"""
due = models.OneToOneField(Due, on_delete=models.CASCADE, related_name="invoice", verbose_name=_("due"))
number = models.CharField(_("number"), max_length=32, unique=True, blank=True)
issued_at = models.DateTimeField(_("issued at"), default=timezone.now)
class Meta:
verbose_name = _("invoice")
verbose_name_plural = _("invoices")
ordering = ["-issued_at"]
def __str__(self):
return self.number
def save(self, *args, **kwargs):
if not self.number:
self.number = self.next_number(self.issued_at.year)
super().save(*args, **kwargs)
@classmethod
def next_number(cls, year: int) -> str:
"""INV-2026-00001, restarting each year.
Platform-wide, unlike the shop's order numbers, which are per club: these are OUR
invoices, and one sequence has to cover every club we bill.
"""
prefix = f"INV-{year}-"
last = cls.objects.filter(number__startswith=prefix).order_by("-number").first()
sequence = int(last.number.removeprefix(prefix)) + 1 if last else 1
return f"{prefix}{sequence:05d}"

View File

@@ -0,0 +1,6 @@
class BillingError(Exception):
"""A billing action that must not silently half-happen.
Lives here rather than in dues.py so invoices.py can raise it without the two modules
importing each other in a circle.
"""

153
billing/services/dues.py Normal file
View File

@@ -0,0 +1,153 @@
"""The billing lifecycle. Views and the archive command go through here, never through the
models directly — a Due whose amount_paid disagrees with its payments is a wrong invoice.
"""
from datetime import date, timedelta
from decimal import Decimal
from django.db import transaction
from django.db.models import Sum
from django.utils import timezone
from billing.models import ZERO, Due, DuePayment, Subscription, Tier
from billing.services import BillingError
from billing.services.invoices import issue_invoice
def subscribe(club, tier: Tier, *, start: date | None = None, auto_archive: bool = True) -> Subscription:
"""Put a club on a tier and open its first period."""
subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"tier": tier, "auto_archive": auto_archive})
open_period(club, start=start)
return subscription
def next_period_start(club, today: date | None = None) -> date:
"""Where the club's next period begins.
The day after the last one ended — not today. A club that pays two months late has still
used those two months, and restarting the clock at the payment date would quietly gift
them away. Callers can override; that is what the start field on the renew form is for.
"""
today = today or timezone.localdate()
last = club.dues.exclude(status=Due.Status.CANCELLED).order_by("-period_end").first()
return last.period_end + timedelta(days=1) if last else today
@transaction.atomic
def open_period(club, *, start: date | None = None, tier: Tier | None = None) -> Due:
"""Issue the next due for a club, snapshotting the tier and the price of the day."""
subscription = getattr(club, "subscription", None)
tier = tier or (subscription.tier if subscription else None)
if tier is None:
raise BillingError(f"{club} has no tier: put it on a subscription before billing it.")
start = start or next_period_start(club)
amount = tier.price_on(start)
if amount is None:
raise BillingError(f"{tier} has no price in force on {start:%d %b %Y}. Add one before opening the period.")
if club.dues.filter(period_start=start).exists():
raise BillingError(f"{club} is already billed for a period starting {start:%d %b %Y}.")
due = Due.objects.create(club=club, tier=tier, amount=amount, period_start=start)
issue_invoice(due) # every period is billable the moment it opens
return due
@transaction.atomic
def record_payment(due: Due, amount: Decimal, *, method=DuePayment.Method.BANK_TRANSFER, reference: str = "", paid_at=None, note: str = "", user=None) -> DuePayment:
"""Log money against a due and re-derive its status from the payments."""
if due.status in (Due.Status.WAIVED, Due.Status.CANCELLED):
raise BillingError(f"This period is {due.get_status_display()}; it cannot take a payment.")
if amount <= ZERO:
raise BillingError("A payment must be for a positive amount.")
payment = DuePayment.objects.create(due=due, amount=amount, method=method, reference=reference, paid_at=paid_at or timezone.now(), note=note, recorded_by=user)
_resettle(due)
return payment
@transaction.atomic
def remove_payment(payment: DuePayment) -> None:
"""Undo a mis-keyed payment, then re-derive the due from what is left."""
due = payment.due
payment.delete()
_resettle(due)
def _resettle(due: Due) -> None:
"""Recompute amount_paid and status from the payments on record.
Summed from the payments rather than incremented: an increment drifts the moment a
payment is edited or deleted, and the drift is invisible — the number still looks like
money.
"""
paid = due.payments.aggregate(total=Sum("amount"))["total"] or ZERO
due.amount_paid = paid
if paid >= due.amount:
due.status = Due.Status.PAID
due.paid_at = due.payments.order_by("-paid_at").first().paid_at
elif paid > ZERO:
due.status = Due.Status.PARTIAL
due.paid_at = None
else:
due.status = Due.Status.UNPAID
due.paid_at = None
due.save(update_fields=["amount_paid", "status", "paid_at", "modified"])
@transaction.atomic
def waive(due: Due, *, note: str = "") -> Due:
"""Write a period off. It stops owing, and stops counting towards archiving."""
if due.payments.exists():
raise BillingError("This period has payments against it; remove them before waiving it.")
due.status = Due.Status.WAIVED
due.save(update_fields=["status", "modified"])
return due
def owing_dues(today: date | None = None):
return Due.objects.filter(status__in=Due.OWING)
def dues_in_grace(today: date | None = None):
"""Period over, unpaid, not yet archivable."""
today = today or timezone.localdate()
return owing_dues().filter(period_end__lt=today, grace_until__gte=today)
def dues_overdue(today: date | None = None):
"""Past grace: these are the clubs the archive command would take down."""
today = today or timezone.localdate()
return owing_dues().filter(grace_until__lt=today)
def archivable_clubs(today: date | None = None):
"""Clubs the archive command would act on: overdue, still live, and opted in.
A club with auto_archive off is deliberately spared — that flag is how you keep a club
you are negotiating with from being switched off overnight.
"""
return dues_overdue(today).filter(club__archived_at__isnull=True, club__subscription__auto_archive=True).select_related("club", "tier").order_by("club__name")
@transaction.atomic
def reactivate(club, *, start: date | None = None) -> Due:
"""Bring an archived club back and bill it again.
The new period defaults to continuing from the last one, so a lapsed year is still owed.
Pass ``start`` to forgive the gap and begin today instead.
"""
club.restore()
return open_period(club, start=start)

View File

@@ -0,0 +1,40 @@
"""Invoice PDFs.
The PDF is rendered on demand from the Due's frozen snapshot (tier, amount, dates), so it
carries no state of its own beyond the number. Only the number is stored — an accountant
reconciles against it, so it is allocated once, never recomputed.
"""
from django.template.loader import render_to_string
from billing.models import Due, Invoice
from billing.services import BillingError
def issue_invoice(due: Due) -> Invoice:
"""One invoice per due, allocated once. Re-issuing returns the existing one rather than
burning a number — a gap in an invoice series is a question you do not want to answer."""
invoice, _created = Invoice.objects.get_or_create(due=due)
return invoice
def render_pdf(html: str) -> bytes:
"""HTML to PDF.
WeasyPrint is imported here, not at module scope: it binds to native pango/cairo
libraries, and a machine without them must still be able to run the app, the tests and
every other page — it should only fail when someone actually asks for a PDF, and say why.
"""
try:
from weasyprint import HTML
except (ImportError, OSError) as error:
raise BillingError("PDF rendering needs the native pango/cairo libraries (on macOS: brew install pango).") from error
return HTML(string=html).write_pdf()
def invoice_pdf(invoice: Invoice, base_url: str | None = None) -> bytes:
html = render_to_string("billing/invoice.html", {"invoice": invoice, "due": invoice.due, "club": invoice.due.club, "payments": invoice.due.payments.all()})
return render_pdf(html)

View File

@@ -0,0 +1,106 @@
{% load static %}
{% comment %}
Rendered by WeasyPrint, not by a browser: this is a standalone document with its own
print stylesheet. It deliberately does NOT pull in app.css — daisyUI is built for a
screen, and half of it (dark theme, flex layouts) means nothing on paper.
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ invoice.number }}</title>
<style>
@page {
size: A4;
margin: 20mm;
@bottom-center {
content: "RosterChief — invoice {{ invoice.number }} — page " counter(page) " of " counter(pages);
font-size: 8pt;
color: #666;
}
}
body { font-family: sans-serif; font-size: 10pt; color: #111; }
h1 { font-size: 20pt; margin: 0 0 2mm; }
.muted { color: #666; }
.header { display: flex; justify-content: space-between; margin-bottom: 12mm; }
.parties { display: flex; justify-content: space-between; margin-bottom: 10mm; }
.parties h2 { font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; margin: 0 0 2mm; }
table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
th { text-align: left; font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; border-bottom: 1px solid #ccc; padding: 2mm 0; }
td { padding: 2mm 0; border-bottom: 1px solid #eee; }
.right { text-align: right; }
.total td { font-weight: bold; border-bottom: 2px solid #111; border-top: 1px solid #111; }
.balance { font-size: 12pt; font-weight: bold; }
.paid { color: #15803d; }
.owed { color: #b91c1c; }
</style>
</head>
<body>
<div class="header">
<div>
<h1>RosterChief</h1>
<div class="muted">Club &amp; team management</div>
</div>
<div class="right">
<h1>Invoice</h1>
<div><strong>{{ invoice.number }}</strong></div>
<div class="muted">Issued {{ invoice.issued_at|date:"j F Y" }}</div>
</div>
</div>
<div class="parties">
<div>
<h2>Billed to</h2>
<div><strong>{{ club.name }}</strong></div>
<div class="muted">{{ club.slug }}.rosterchief.app</div>
</div>
<div class="right">
<h2>Period</h2>
<div>{{ due.period_start|date:"j F Y" }} — {{ due.period_end|date:"j F Y" }}</div>
<div class="muted">Payable by {{ due.grace_until|date:"j F Y" }}</div>
</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th class="right">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<strong>{{ due.tier.name }}</strong> — platform subscription
<div class="muted">{{ due.period_start|date:"j M Y" }} to {{ due.period_end|date:"j M Y" }}</div>
</td>
<td class="right">€{{ due.amount|floatformat:2 }}</td>
</tr>
{% for payment in payments %}
<tr>
<td class="muted">
Payment received {{ payment.paid_at|date:"j M Y" }} ({{ payment.get_method_display }}{% if payment.reference %}, {{ payment.reference }}{% endif %})
</td>
<td class="right muted">−€{{ payment.amount|floatformat:2 }}</td>
</tr>
{% endfor %}
<tr class="total">
<td>Balance due</td>
<td class="right balance {% if due.balance > 0 %}owed{% else %}paid{% endif %}">€{{ due.balance|floatformat:2 }}</td>
</tr>
</tbody>
</table>
{% if due.status == "paid" %}
<p class="paid"><strong>Paid in full.</strong> Thank you.</p>
{% elif due.status == "waived" %}
<p class="muted"><strong>Waived.</strong> Nothing is owed for this period.</p>
{% else %}
<p class="muted">
Payable by <strong>{{ due.grace_until|date:"j F Y" }}</strong>. Unpaid past that date the club is archived: its
subdomain stops resolving, though nothing is deleted.
</p>
{% endif %}
</body>
</html>

337
billing/tests.py Normal file
View File

@@ -0,0 +1,337 @@
import datetime
import sys
from decimal import Decimal
from io import StringIO
from unittest import mock
from django.core.management import call_command
from django.test import TestCase
from django.utils import timezone
from club.models import Club
from .models import GRACE_DAYS, Due, Invoice, Subscription, Tier, TierPrice, add_one_year
from .services import BillingError
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, subscribe, waive
from .services.invoices import invoice_pdf, issue_invoice, render_pdf
class BillingTestBase(TestCase):
def setUp(self):
self.today = timezone.localdate()
self.club = Club.objects.create(name="Ajax United")
self.tier = Tier.objects.create(name="Standard")
# Priced well back, so a backdated (lapsed) period still has a price in force —
# opening one before any price existed is refused, and rightly so.
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
def bill(self, start=None, club=None):
return open_period(club or self.club, start=start, tier=self.tier)
class TierPriceTests(BillingTestBase):
def test_the_price_in_force_is_the_latest_one_that_has_started(self):
TierPrice.objects.create(tier=self.tier, active_from=self.today, amount=Decimal("600.00"))
self.assertEqual(self.tier.price_on(self.today - datetime.timedelta(days=1)), Decimal("500.00"))
self.assertEqual(self.tier.price_on(self.today), Decimal("600.00"))
def test_a_future_price_does_not_apply_yet(self):
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=30), amount=Decimal("600.00"))
self.assertEqual(self.tier.price_on(self.today), Decimal("500.00"))
def test_a_tier_with_no_price_yet_cannot_be_billed(self):
# None must never be read as free.
empty = Tier.objects.create(name="Enterprise")
self.assertIsNone(empty.price_on(self.today))
with self.assertRaises(BillingError):
open_period(self.club, tier=empty)
class PeriodTests(BillingTestBase):
def test_a_period_runs_a_rolling_year_with_a_grace_tail(self):
due = self.bill(start=datetime.date(2026, 3, 1))
self.assertEqual(due.period_end, datetime.date(2027, 2, 28))
self.assertEqual(due.grace_until, due.period_end + datetime.timedelta(days=GRACE_DAYS))
def test_a_leap_day_period_does_not_explode(self):
# 29 February has no counterpart in a common year.
self.assertEqual(add_one_year(datetime.date(2028, 2, 29)), datetime.date(2029, 2, 28))
def test_the_next_period_continues_from_the_last_one(self):
# Not from today: a club that pays two months late has still used those two months,
# and restarting the clock at the payment date would quietly gift them away.
first = self.bill(start=self.today - datetime.timedelta(days=400))
self.assertEqual(next_period_start(self.club), first.period_end + datetime.timedelta(days=1))
def test_a_first_period_starts_today(self):
self.assertEqual(next_period_start(self.club), self.today)
def test_the_amount_is_snapshotted_at_the_price_of_the_day(self):
due = self.bill()
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=1), amount=Decimal("900.00"))
due.refresh_from_db()
# Raising the rate must not rewrite what was already billed.
self.assertEqual(due.amount, Decimal("500.00"))
def test_a_club_cannot_be_billed_twice_for_one_period(self):
self.bill(start=self.today)
with self.assertRaises(BillingError):
self.bill(start=self.today)
def test_a_club_with_no_tier_cannot_be_billed(self):
with self.assertRaises(BillingError):
open_period(Club.objects.create(name="Feyenoord"))
def test_subscribing_puts_a_club_on_a_tier_and_opens_a_period(self):
club = Club.objects.create(name="Feyenoord")
subscribe(club, self.tier)
self.assertEqual(Subscription.objects.get(club=club).tier, self.tier)
self.assertEqual(club.dues.count(), 1)
class PaymentTests(BillingTestBase):
def setUp(self):
super().setUp()
self.due = self.bill()
def test_a_part_payment_leaves_the_due_partially_paid(self):
record_payment(self.due, Decimal("200.00"))
self.due.refresh_from_db()
self.assertEqual(self.due.status, Due.Status.PARTIAL)
self.assertEqual(self.due.balance, Decimal("300.00"))
self.assertIsNone(self.due.paid_at)
def test_payments_accumulate_until_the_due_is_settled(self):
record_payment(self.due, Decimal("200.00"))
record_payment(self.due, Decimal("300.00"))
self.due.refresh_from_db()
self.assertEqual(self.due.status, Due.Status.PAID)
self.assertEqual(self.due.balance, Decimal("0.00"))
self.assertIsNotNone(self.due.paid_at)
def test_an_overpayment_still_settles_the_due(self):
record_payment(self.due, Decimal("600.00"))
self.due.refresh_from_db()
self.assertEqual(self.due.status, Due.Status.PAID)
def test_removing_a_payment_re_derives_the_due(self):
# amount_paid is summed from the payments, never incremented: an increment drifts the
# moment one is deleted, and the drift still looks like money.
first = record_payment(self.due, Decimal("200.00"))
record_payment(self.due, Decimal("300.00"))
remove_payment(first)
self.due.refresh_from_db()
self.assertEqual(self.due.amount_paid, Decimal("300.00"))
self.assertEqual(self.due.status, Due.Status.PARTIAL)
def test_removing_the_only_payment_puts_the_due_back_to_unpaid(self):
payment = record_payment(self.due, Decimal("500.00"))
remove_payment(payment)
self.due.refresh_from_db()
self.assertEqual(self.due.status, Due.Status.UNPAID)
self.assertEqual(self.due.amount_paid, Decimal("0.00"))
self.assertIsNone(self.due.paid_at)
def test_a_zero_payment_is_refused(self):
with self.assertRaises(BillingError):
record_payment(self.due, Decimal("0.00"))
def test_a_waived_period_cannot_take_a_payment(self):
waive(self.due)
with self.assertRaises(BillingError):
record_payment(self.due, Decimal("100.00"))
def test_a_period_with_payments_cannot_be_waived(self):
record_payment(self.due, Decimal("100.00"))
with self.assertRaises(BillingError):
waive(self.due)
def test_a_waived_period_owes_nothing_and_never_archives_a_club(self):
waive(self.due)
self.due.refresh_from_db()
self.assertFalse(self.due.is_owing)
self.assertFalse(self.due.is_overdue(self.due.grace_until + datetime.timedelta(days=1)))
class GraceAndArchiveTests(BillingTestBase):
LAPSED = 365 + GRACE_DAYS + 10
def test_a_period_past_its_end_but_inside_grace_is_in_grace(self):
due = self.bill(start=self.today - datetime.timedelta(days=370))
self.assertTrue(due.is_in_grace(self.today))
self.assertFalse(due.is_overdue(self.today))
self.assertIn(due, dues_in_grace(self.today))
def test_a_period_past_grace_is_overdue(self):
due = self.bill(start=self.today - datetime.timedelta(days=self.LAPSED))
self.assertTrue(due.is_overdue(self.today))
self.assertFalse(due.is_in_grace(self.today))
self.assertIn(due, dues_overdue(self.today))
def test_a_paid_period_is_never_overdue(self):
due = self.bill(start=self.today - datetime.timedelta(days=self.LAPSED))
record_payment(due, Decimal("500.00"))
due.refresh_from_db()
self.assertFalse(due.is_overdue(self.today))
self.assertNotIn(due, dues_overdue(self.today))
def test_an_overdue_club_is_archivable(self):
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
self.assertEqual(archivable_clubs(self.today).count(), 1)
def test_a_club_that_opted_out_is_never_archived(self):
# auto_archive off is how you stop a club you are negotiating with from being
# switched off overnight.
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED), auto_archive=False)
self.assertEqual(archivable_clubs(self.today).count(), 0)
def test_an_already_archived_club_is_not_archived_again(self):
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
self.club.archive()
self.assertEqual(archivable_clubs(self.today).count(), 0)
class ArchiveCommandTests(BillingTestBase):
def setUp(self):
super().setUp()
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=365 + GRACE_DAYS + 10))
def run_command(self, *args):
out = StringIO()
call_command("archive_overdue_clubs", *args, stdout=out)
return out.getvalue()
def test_it_reports_without_archiving_by_default(self):
# The asymmetry is the point: this switches off paying customers, so a cron
# misconfiguration or a clock skew must cost an email, not a morning of angry clubs.
output = self.run_command()
self.club.refresh_from_db()
self.assertFalse(self.club.is_archived)
self.assertIn("Dry run", output)
self.assertIn("Ajax United", output)
def test_it_archives_with_commit(self):
self.run_command("--commit")
self.club.refresh_from_db()
self.assertTrue(self.club.is_archived)
def test_it_says_so_when_nothing_is_overdue(self):
record_payment(self.club.dues.first(), Decimal("500.00"))
self.assertIn("Nothing overdue", self.run_command())
class ReactivationTests(BillingTestBase):
def setUp(self):
super().setUp()
# Through subscribe(), not open_period(): reactivating reads the club's tier off its
# subscription, and a club billed without one cannot be re-billed later.
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=400))
self.first = self.club.dues.first()
self.club.archive()
def test_reactivating_continues_from_the_lapsed_period_by_default(self):
due = reactivate(self.club)
self.club.refresh_from_db()
self.assertFalse(self.club.is_archived)
self.assertEqual(due.period_start, self.first.period_end + datetime.timedelta(days=1))
def test_a_chosen_start_forgives_the_gap(self):
due = reactivate(self.club, start=self.today)
self.assertEqual(due.period_start, self.today)
class InvoiceTests(BillingTestBase):
def test_every_period_is_invoiced_when_it_opens(self):
due = self.bill()
self.assertTrue(Invoice.objects.filter(due=due).exists())
def test_numbers_run_in_one_platform_wide_series(self):
# Unlike the shop's per-club order numbers: these are OUR invoices, and one sequence
# covers every club we bill.
first = self.bill(start=self.today).invoice
second = open_period(Club.objects.create(name="Feyenoord"), tier=self.tier).invoice
year = timezone.now().year
self.assertEqual(first.number, f"INV-{year}-00001")
self.assertEqual(second.number, f"INV-{year}-00002")
def test_re_issuing_does_not_burn_a_number(self):
# A gap in an invoice series is a question you do not want to have to answer.
due = self.bill()
self.assertEqual(issue_invoice(due), due.invoice)
self.assertEqual(Invoice.objects.count(), 1)
def test_the_invoice_renders_the_frozen_snapshot(self):
due = self.bill()
record_payment(due, Decimal("200.00"), reference="TRX-9")
due.refresh_from_db()
with mock.patch("billing.services.invoices.render_pdf", return_value=b"%PDF-fake") as renderer:
invoice_pdf(due.invoice)
html = renderer.call_args.args[0]
self.assertIn("INV-", html)
self.assertIn("Ajax United", html)
self.assertIn("500.00", html) # billed
self.assertIn("200.00", html) # paid
self.assertIn("300.00", html) # balance
def test_the_pdf_library_is_only_needed_when_a_pdf_is_asked_for(self):
# WeasyPrint binds to native pango/cairo. The app, the tests and every other page must
# run without them; only this call may fail.
with mock.patch.dict(sys.modules, {"weasyprint": mock.MagicMock()}):
sys.modules["weasyprint"].HTML.return_value.write_pdf.return_value = b"%PDF-1.7"
self.assertEqual(render_pdf("<p>hi</p>"), b"%PDF-1.7")
def test_a_missing_pdf_library_says_what_is_missing(self):
with mock.patch.dict(sys.modules, {"weasyprint": None}), self.assertRaises(BillingError) as caught:
render_pdf("<p>hi</p>")
self.assertIn("pango", str(caught.exception))
class ModelStringTests(BillingTestBase):
def test_models_describe_themselves(self):
due = self.bill()
payment = record_payment(due, Decimal("10.00"))
self.assertEqual(str(self.tier), "Standard")
self.assertIn("500.00", str(self.tier.prices.first()))
self.assertIn("Ajax United", str(due))
self.assertIn("10.00", str(payment))
self.assertIn("INV-", str(due.invoice))
self.assertIn("Standard", str(subscribe(Club.objects.create(name="PSV"), self.tier)))