diff --git a/billing/__init__.py b/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billing/admin.py b/billing/admin.py new file mode 100644 index 0000000..a72326f --- /dev/null +++ b/billing/admin.py @@ -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"] diff --git a/billing/apps.py b/billing/apps.py new file mode 100644 index 0000000..33b9fa5 --- /dev/null +++ b/billing/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class BillingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "billing" + verbose_name = "Billing" diff --git a/billing/management/__init__.py b/billing/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billing/management/commands/__init__.py b/billing/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billing/management/commands/archive_overdue_clubs.py b/billing/management/commands/archive_overdue_clubs.py new file mode 100644 index 0000000..3c6368b --- /dev/null +++ b/billing/management/commands/archive_overdue_clubs.py @@ -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.")) diff --git a/billing/migrations/0001_initial.py b/billing/migrations/0001_initial.py new file mode 100644 index 0000000..71943f4 --- /dev/null +++ b/billing/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/billing/migrations/__init__.py b/billing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billing/models.py b/billing/models.py new file mode 100644 index 0000000..eacb9b7 --- /dev/null +++ b/billing/models.py @@ -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}" diff --git a/billing/services/__init__.py b/billing/services/__init__.py new file mode 100644 index 0000000..b96f3b2 --- /dev/null +++ b/billing/services/__init__.py @@ -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. + """ diff --git a/billing/services/dues.py b/billing/services/dues.py new file mode 100644 index 0000000..13cb9e7 --- /dev/null +++ b/billing/services/dues.py @@ -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) diff --git a/billing/services/invoices.py b/billing/services/invoices.py new file mode 100644 index 0000000..18cde7a --- /dev/null +++ b/billing/services/invoices.py @@ -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) diff --git a/billing/templates/billing/invoice.html b/billing/templates/billing/invoice.html new file mode 100644 index 0000000..e4f98fe --- /dev/null +++ b/billing/templates/billing/invoice.html @@ -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 %} + + + + + {{ invoice.number }} + + + +
+
+

RosterChief

+
Club & team management
+
+
+

Invoice

+
{{ invoice.number }}
+
Issued {{ invoice.issued_at|date:"j F Y" }}
+
+
+ +
+
+

Billed to

+
{{ club.name }}
+
{{ club.slug }}.rosterchief.app
+
+
+

Period

+
{{ due.period_start|date:"j F Y" }} — {{ due.period_end|date:"j F Y" }}
+
Payable by {{ due.grace_until|date:"j F Y" }}
+
+
+ + + + + + + + + + + + + + {% for payment in payments %} + + + + + {% endfor %} + + + + + +
DescriptionAmount
+ {{ due.tier.name }} — platform subscription +
{{ due.period_start|date:"j M Y" }} to {{ due.period_end|date:"j M Y" }}
+
€{{ due.amount|floatformat:2 }}
+ Payment received {{ payment.paid_at|date:"j M Y" }} ({{ payment.get_method_display }}{% if payment.reference %}, {{ payment.reference }}{% endif %}) + −€{{ payment.amount|floatformat:2 }}
Balance due€{{ due.balance|floatformat:2 }}
+ + {% if due.status == "paid" %} + + {% elif due.status == "waived" %} +

Waived. Nothing is owed for this period.

+ {% else %} +

+ Payable by {{ due.grace_until|date:"j F Y" }}. Unpaid past that date the club is archived: its + subdomain stops resolving, though nothing is deleted. +

+ {% endif %} + + diff --git a/billing/tests.py b/billing/tests.py new file mode 100644 index 0000000..5808b26 --- /dev/null +++ b/billing/tests.py @@ -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("

hi

"), 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("

hi

") + + 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))) diff --git a/pyproject.toml b/pyproject.toml index 7fa16ec..c541f1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/rosterchief/settings.py b/rosterchief/settings.py index c13f0e1..4deccc0 100644 --- a/rosterchief/settings.py +++ b/rosterchief/settings.py @@ -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", diff --git a/static/css/app.css b/static/css/app.css index 6c628d3..13066f5 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1197,6 +1197,9 @@ .collapse { visibility: collapse; } + .invisible { + visibility: hidden; + } .visible { visibility: visible; } diff --git a/uv.lock b/uv.lock index cecb875..74e172a 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, +]