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>
248 lines
10 KiB
Python
248 lines
10 KiB
Python
"""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}"
|