A club with no subscription yet can be started on a short trial (e.g. 2 months) from the control panel, on a tier picked up front for what it switches to once the trial ends -- no manual follow-up needed. The trial is a real billed period on a dedicated trial tier, reusing the existing invoice/grace/archive machinery unchanged; the switch happens in open_period() itself so it fires whether reached via the scheduled renewal command or a platform admin's manual "Open period" click. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
220 lines
9.5 KiB
Python
220 lines
9.5 KiB
Python
"""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 DateField, OuterRef, Subquery, Sum
|
|
from django.utils import timezone
|
|
|
|
from billing.models import RENEWAL_LEAD_DAYS, ZERO, Due, DuePayment, Subscription, Tier, add_months
|
|
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, auto_renew: 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, "auto_renew": auto_renew})
|
|
open_period(club, start=start)
|
|
|
|
return subscription
|
|
|
|
|
|
@transaction.atomic
|
|
def start_trial(club, trial_tier: Tier, *, post_trial_tier: Tier, trial_months: int, start: date | None = None, auto_renew: bool = True, auto_archive: bool = True) -> Due:
|
|
"""Put a club on a short trial that switches itself to ``post_trial_tier`` the moment
|
|
the trial period is renewed -- see open_period()'s trial-conversion check.
|
|
|
|
Only for a club with no subscription yet -- converting an existing paying subscription
|
|
into a trial is a different, deliberately unsupported operation for now.
|
|
"""
|
|
if trial_months <= 0:
|
|
raise BillingError("Trial length must be at least 1 month.")
|
|
if getattr(club, "subscription", None) is not None:
|
|
raise BillingError(f"{club} is already subscribed -- use Change plan instead.")
|
|
|
|
start = start or next_period_start(club)
|
|
trial_end = add_months(start, trial_months) - timedelta(days=1)
|
|
|
|
Subscription.objects.create(club=club, tier=trial_tier, trial_ends_at=trial_end, post_trial_tier=post_trial_tier, auto_renew=auto_renew, auto_archive=auto_archive)
|
|
|
|
return open_period(club, start=start, period_end=trial_end, is_trial=True)
|
|
|
|
|
|
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, period_end: date | None = None, is_trial: bool = False) -> Due:
|
|
"""Issue the next due for a club, snapshotting the tier and the price of the day."""
|
|
subscription = getattr(club, "subscription", None)
|
|
if tier is None:
|
|
if subscription is None:
|
|
raise BillingError(f"{club} has no tier: put it on a subscription before billing it.")
|
|
# A trial that has run its course: swap onto the pre-selected plan before billing
|
|
# the next period, rather than silently renewing the trial tier forever. Checked
|
|
# here (not in renew()) so it fires whether this period was opened by the renewal
|
|
# command or by a platform admin clicking "Open period"/"Reactivate" by hand --
|
|
# both call open_period() directly.
|
|
if subscription.trial_ends_at is not None and (start or next_period_start(club)) > subscription.trial_ends_at:
|
|
subscription.tier = subscription.post_trial_tier
|
|
subscription.trial_ends_at = None
|
|
subscription.post_trial_tier = None
|
|
subscription.save(update_fields=["tier", "trial_ends_at", "post_trial_tier"])
|
|
tier = subscription.tier
|
|
|
|
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, period_end=period_end, is_trial=is_trial)
|
|
if amount == ZERO:
|
|
# Nothing is actually owed -- left at the default UNPAID, this would eventually
|
|
# trip is_overdue() and get a free club archived for non-payment of nothing.
|
|
due.status = Due.Status.PAID
|
|
due.paid_at = timezone.now()
|
|
due.save(update_fields=["status", "paid_at"])
|
|
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():
|
|
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)
|
|
|
|
|
|
def subscriptions_due_for_renewal(today: date | None = None, lead_days: int = RENEWAL_LEAD_DAYS):
|
|
"""Clubs whose next period should be issued now.
|
|
|
|
Idempotent by construction: a club that has just been renewed has a latest period ending a
|
|
year out, which is past the horizon, so it cannot be picked up twice. Running the job twice
|
|
a day is harmless.
|
|
|
|
A subscription with no period at all (its only due was cancelled) counts too — a club on a
|
|
plan and billed for nothing is the leak this whole job exists to close.
|
|
"""
|
|
today = today or timezone.localdate()
|
|
horizon = today + timedelta(days=lead_days)
|
|
|
|
latest_period_end = Subquery(
|
|
Due.objects.filter(club=OuterRef("club")).exclude(status=Due.Status.CANCELLED).order_by("-period_end").values("period_end")[:1],
|
|
output_field=DateField(),
|
|
)
|
|
|
|
subscriptions = Subscription.objects.filter(auto_renew=True, club__archived_at__isnull=True).select_related("club", "tier").annotate(latest_period_end=latest_period_end).order_by("club__name")
|
|
|
|
return [subscription for subscription in subscriptions if subscription.latest_period_end is None or subscription.latest_period_end <= horizon]
|
|
|
|
|
|
def renew(subscription: Subscription) -> Due:
|
|
"""Open the club's next period, continuing from the last one."""
|
|
return open_period(subscription.club)
|