Auto-renew subscriptions before they lapse

Answers "does a plan renew itself?": until now, no — and that was a silent revenue
leak, not merely a missing convenience. A club whose period ended with its last due
PAID owes nothing, so dues_overdue() is empty, so archive_overdue_clubs never fires.
The club kept using the platform for free and no dashboard number went red, because
nothing was ever billed. The safety net only caught clubs you remembered to invoice.

`renew_subscriptions` (cron) issues the next period 30 days before the current one
ends, so the invoice lands before the period lapses and grace only matters for
genuine non-payers. It is idempotent by construction: a just-renewed club has a
latest period a year out, past the horizon, so a second run is a no-op.

It ACTS by default and previews with --dry-run — the opposite asymmetry to
archiving, and deliberately so. Archiving switches off a customer, so not-acting is
safe there; here, not-acting is the expensive failure, because an unbilled club is
also an unchased one. An unpriced tier fails that one club loudly (non-zero exit, so
cron mails you) without stopping the rest.

Opt-out per club via Subscription.auto_renew, mirroring auto_archive: off means you
invoice that club by hand. The dashboard gains a "renewals pending" count that
should sit at ~0 — a number here means cron has died and a club is about to go free,
which no other metric would reveal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 07:26:41 +02:00
parent 2d43b0b903
commit 9a616c20e4
10 changed files with 256 additions and 11 deletions

View File

@@ -6,17 +6,17 @@ from datetime import date, timedelta
from decimal import Decimal
from django.db import transaction
from django.db.models import Sum
from django.db.models import DateField, OuterRef, Subquery, Sum
from django.utils import timezone
from billing.models import ZERO, Due, DuePayment, Subscription, Tier
from billing.models import RENEWAL_LEAD_DAYS, 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:
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})
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
@@ -151,3 +151,36 @@ def reactivate(club, *, start: date | None = None) -> Due:
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)