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>
58 lines
2.7 KiB
Python
58 lines
2.7 KiB
Python
"""Issue the next billing period for clubs whose current one is running out.
|
|
|
|
Unlike archive_overdue_clubs, this ACTS by default and only previews with --dry-run. The
|
|
asymmetry is deliberate and runs the other way: archiving switches off a paying customer, so
|
|
not acting is the safe failure. Here, not acting means a club keeps using the platform for
|
|
free — and because nothing is owed, no dashboard number goes red and the archive job never
|
|
fires either. A missed renewal is silent, and silence is the expensive failure.
|
|
"""
|
|
|
|
from django.core.management.base import CommandError
|
|
|
|
from billing.models import RENEWAL_LEAD_DAYS
|
|
from billing.services import BillingError
|
|
from billing.services.dues import renew, subscriptions_due_for_renewal
|
|
from features.commands import MaintenanceAwareCommand
|
|
|
|
|
|
class Command(MaintenanceAwareCommand):
|
|
help = "Open the next billing period for clubs whose current period ends soon."
|
|
|
|
def add_arguments(self, parser):
|
|
parser.add_argument("--dry-run", action="store_true", help="Report what would be issued, and issue nothing.")
|
|
parser.add_argument("--lead-days", type=int, default=RENEWAL_LEAD_DAYS, help=f"Issue this many days before the period ends (default {RENEWAL_LEAD_DAYS}).")
|
|
|
|
def handle(self, *args, **options):
|
|
due_for_renewal = subscriptions_due_for_renewal(lead_days=options["lead_days"])
|
|
|
|
if not due_for_renewal:
|
|
self.stdout.write(self.style.SUCCESS("Nothing to renew."))
|
|
return
|
|
|
|
failures = []
|
|
for subscription in due_for_renewal:
|
|
club = subscription.club
|
|
|
|
if options["dry_run"]:
|
|
self.stdout.write(f"would renew {club} — {subscription.tier}, current period ends {subscription.latest_period_end or 'never opened'}")
|
|
continue
|
|
|
|
try:
|
|
due = renew(subscription)
|
|
except BillingError as error:
|
|
# One unpriced tier must not stop every other club from being billed.
|
|
failures.append(f"{club}: {error}")
|
|
self.stdout.write(self.style.ERROR(f"{club} — {error}"))
|
|
continue
|
|
|
|
self.stdout.write(self.style.SUCCESS(f"{club} — {due.period_start} to {due.period_end}, {due.amount} ({due.invoice.number})"))
|
|
|
|
if options["dry_run"]:
|
|
self.stdout.write(self.style.WARNING(f"\nDry run: {len(due_for_renewal)} club(s) would be renewed."))
|
|
return
|
|
|
|
if failures:
|
|
# Non-zero, so cron mails you: a club that could not be billed is revenue quietly
|
|
# not being collected.
|
|
raise CommandError(f"{len(failures)} club(s) could not be renewed:\n " + "\n ".join(failures))
|