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:
0
billing/__init__.py
Normal file
0
billing/__init__.py
Normal file
53
billing/admin.py
Normal file
53
billing/admin.py
Normal 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
7
billing/apps.py
Normal 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"
|
||||
0
billing/management/__init__.py
Normal file
0
billing/management/__init__.py
Normal file
0
billing/management/commands/__init__.py
Normal file
0
billing/management/commands/__init__.py
Normal file
38
billing/management/commands/archive_overdue_clubs.py
Normal file
38
billing/management/commands/archive_overdue_clubs.py
Normal 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."))
|
||||
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'),
|
||||
),
|
||||
]
|
||||
0
billing/migrations/__init__.py
Normal file
0
billing/migrations/__init__.py
Normal file
247
billing/models.py
Normal file
247
billing/models.py
Normal 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}"
|
||||
6
billing/services/__init__.py
Normal file
6
billing/services/__init__.py
Normal 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
153
billing/services/dues.py
Normal 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)
|
||||
40
billing/services/invoices.py
Normal file
40
billing/services/invoices.py
Normal 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)
|
||||
106
billing/templates/billing/invoice.html
Normal file
106
billing/templates/billing/invoice.html
Normal 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 & 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
337
billing/tests.py
Normal 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)))
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"pillow>=12.3.0",
|
||||
"python-dateutil>=2.9.0.post0",
|
||||
"python-decouple>=3.8",
|
||||
"weasyprint>=69.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -49,7 +50,8 @@ ignore = [
|
||||
"rosterchief/settings/*" = ["F403", "F405", "E501"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "news", "pages", "home", "search", "rosterchief"]
|
||||
known-first-party = [
|
||||
"billing", "authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "news", "pages", "home", "search", "rosterchief"]
|
||||
|
||||
[tool.uv.sources]
|
||||
django-lucide = { git = "https://github.com/bsiebens/lucide" }
|
||||
|
||||
@@ -61,6 +61,8 @@ INSTALLED_APPS = [
|
||||
"events.apps.EventsConfig",
|
||||
"formbuilder.apps.FormbuilderConfig",
|
||||
"shop.apps.ShopConfig",
|
||||
# Platform billing: RosterChief charging the clubs. Not tenant data — see billing/models.py.
|
||||
"billing.apps.BillingConfig",
|
||||
"waffle",
|
||||
"features.apps.FeaturesConfig",
|
||||
"controlpanel.apps.ControlpanelConfig",
|
||||
|
||||
@@ -1197,6 +1197,9 @@
|
||||
.collapse {
|
||||
visibility: collapse;
|
||||
}
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
172
uv.lock
generated
172
uv.lock
generated
@@ -11,6 +11,45 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotlicffi"
|
||||
version = "1.2.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.0"
|
||||
@@ -168,6 +207,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssselect2"
|
||||
version = "0.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tinycss2" },
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dj-database-url"
|
||||
version = "3.1.2"
|
||||
@@ -272,6 +324,38 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/84/198d99c3312557ef6121cf78c38281efe9b3bc88cba0e2c05446f38a024d/fido2-2.2.1-py3-none-any.whl", hash = "sha256:ed397da981b9ab133da6ead7309e41f924b566b749956129efe286fae097749f", size = 238354, upload-time = "2026-06-29T17:41:09.921Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fonttools"
|
||||
version = "4.63.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
woff = [
|
||||
{ name = "brotli", marker = "platform_python_implementation == 'CPython'" },
|
||||
{ name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" },
|
||||
{ name = "zopfli" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phonenumbers"
|
||||
version = "9.0.33"
|
||||
@@ -340,6 +424,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydyf"
|
||||
version = "0.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/ee/fb410c5c854b6a081a49077912a9765aeffd8e07cbb0663cfda310b01fb4/pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095", size = 17716, upload-time = "2025-12-02T14:52:14.244Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/11/47efe2f66ba848a107adfd490b508f5c0cedc82127950553dca44d29e6c4/pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc", size = 8028, upload-time = "2025-12-02T14:52:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyphen"
|
||||
version = "0.17.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470, upload-time = "2025-01-20T13:18:36.296Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358, upload-time = "2025-01-20T13:18:29.629Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -387,6 +489,7 @@ dependencies = [
|
||||
{ name = "pillow" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "python-decouple" },
|
||||
{ name = "weasyprint" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -407,6 +510,7 @@ requires-dist = [
|
||||
{ name = "pillow", specifier = ">=12.3.0" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||
{ name = "python-decouple", specifier = ">=3.8" },
|
||||
{ name = "weasyprint", specifier = ">=69.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -459,6 +563,30 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinycss2"
|
||||
version = "1.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyhtml5"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/1f/cfe2f6b30557c92b3f31d41707e09cef5c1efbd87392bc6c0430c46b0e4d/tinyhtml5-2.1.0.tar.gz", hash = "sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67", size = 179242, upload-time = "2026-03-05T17:06:30.704Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/48/01695a036b695f83fea7aef6955d735db0f517b1c8e25ddb399ac0bdbcbf/tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a", size = 39686, upload-time = "2026-03-05T17:06:28.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.2"
|
||||
@@ -467,3 +595,47 @@ sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35c
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weasyprint"
|
||||
version = "69.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
{ name = "cssselect2" },
|
||||
{ name = "fonttools", extra = ["woff"] },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydyf" },
|
||||
{ name = "pyphen" },
|
||||
{ name = "tinycss2" },
|
||||
{ name = "tinyhtml5" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/53/dcc3885c2f7a47faa45f6b8b801412f5f9e055173a52801ef01c09943c5a/weasyprint-69.0.tar.gz", hash = "sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c", size = 1549834, upload-time = "2026-06-02T14:42:17.765Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/cb/208525c6bd5033d7b2589b55e07bec23d9c61bb00703cbaf20ef52c3811f/weasyprint-69.0-py3-none-any.whl", hash = "sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6", size = 322872, upload-time = "2026-06-02T14:42:15.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webencodings"
|
||||
version = "0.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156, upload-time = "2026-06-10T09:10:19.96Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492, upload-time = "2026-06-10T09:10:06.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354, upload-time = "2026-06-10T09:10:07.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436, upload-time = "2026-06-10T09:10:09.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931, upload-time = "2026-06-10T09:10:10.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132, upload-time = "2026-06-10T09:10:11.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715, upload-time = "2026-06-10T09:10:12.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user