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:
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)
|
||||
Reference in New Issue
Block a user