Add plan deletion, with a confirmation screen listing affected clubs
Due.plan is PROTECT -- a plan that has ever billed anyone can never truly be removed, on purpose: amount/period_end/grace_until are frozen on a Due precisely so a later change can't rewrite what was actually charged, and losing the plan link off an old Due would do exactly that to every historical invoice. "Delete" therefore means one of two things, chosen automatically (billing/services/plans.py): - never billed anyone -> the row is removed outright. - has billing history -> soft-deleted (Plan.deleted_at, is_active off): hidden from every picker/listing via the new opt-in Plan.objects.visible(), but the row survives so old invoices still show what they were billed under. Either way, every club currently on the plan is unsubscribed outright -- its Subscription row deleted, not just its plan field cleared. "No plan" was already a fully-understood state everywhere else in the app, so this reuses it instead of inventing a new one. Also handles the easy-to-miss second group: a club on a DIFFERENT plan, mid-trial, configured to convert to the plan being deleted (Subscription.post_trial_plan). Left alone that would try to convert onto a hidden/gone plan later; instead that club's trial is ended now (both trial fields cleared, per the CheckConstraint requiring them together) so it needs a new plan picked by hand. The confirmation screen is a real page, not a modal like every other billing action -- naming exactly which clubs are affected, in both groups, and that list can be long.
This commit is contained in:
@@ -37,6 +37,17 @@ def add_months(day: date, months: int) -> date:
|
||||
return day + relativedelta.relativedelta(months=months)
|
||||
|
||||
|
||||
class PlanQuerySet(models.QuerySet):
|
||||
def visible(self):
|
||||
"""Excludes soft-deleted plans -- see billing.services.plans.delete_plan.
|
||||
|
||||
Opt-in, same shape as club.models.ClubManager.active(): the default manager stays
|
||||
unfiltered (Django admin, and anything reading historical data, sees everything),
|
||||
and every picker/listing a platform admin actually chooses from calls this.
|
||||
"""
|
||||
return self.filter(deleted_at__isnull=True)
|
||||
|
||||
|
||||
class Plan(UUIDModel):
|
||||
"""What a club is billed on: a duration, a set of clocks, and a dated price.
|
||||
|
||||
@@ -64,6 +75,14 @@ class Plan(UUIDModel):
|
||||
help_text=_("Offered as a trial rather than as a paid plan. A trial converts to the plan chosen on the subscription once it runs out."),
|
||||
)
|
||||
|
||||
# Not user-editable: set by billing.services.plans.delete_plan. Due.plan is PROTECT, so
|
||||
# a plan that has ever billed anyone can never actually be removed -- deleting it hides
|
||||
# it (and clears every club currently on it) instead, so past invoices still say what
|
||||
# they were billed under. See that module's docstring for the full reasoning.
|
||||
deleted_at = models.DateTimeField(_("deleted at"), null=True, blank=True, editable=False)
|
||||
|
||||
objects = PlanQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("plan")
|
||||
verbose_name_plural = _("plans")
|
||||
@@ -86,6 +105,10 @@ class Plan(UUIDModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def is_deleted(self) -> bool:
|
||||
return self.deleted_at is not None
|
||||
|
||||
def clean(self):
|
||||
"""The same two invariants the CheckConstraints enforce, as form errors.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user