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:
@@ -319,7 +319,10 @@ tenant-scoped manager would be exactly the wrong default.
|
|||||||
thing to get wrong: `duration_months` (period length, from its start), `renewal_lead_days` (how far
|
thing to get wrong: `duration_months` (period length, from its start), `renewal_lead_days` (how far
|
||||||
*before* a period starts its invoice is raised), `grace_days` (how long *after* a period starts it
|
*before* a period starts its invoice is raised), `grace_days` (how long *after* a period starts it
|
||||||
may stay unpaid). Two `CheckConstraint`s keep them coherent. `is_trial` marks a plan offered as a
|
may stay unpaid). Two `CheckConstraint`s keep them coherent. `is_trial` marks a plan offered as a
|
||||||
trial; a trial's length is simply its own `duration_months`.
|
trial; a trial's length is simply its own `duration_months`. `deleted_at` is a soft-delete marker:
|
||||||
|
`Due.plan` is `PROTECT`, so a plan that has ever billed anyone can't really be removed — "delete"
|
||||||
|
hides it (`Plan.objects.visible()` excludes it) and unsubscribes every club currently on it instead;
|
||||||
|
see `billing/services/plans.py` and `BILLING.md` §11.
|
||||||
|
|
||||||
**`PlanPrice`** — a dated price (`active_from`). A rate change is a new row, never an edit, so
|
**`PlanPrice`** — a dated price (`active_from`). A rate change is a new row, never an edit, so
|
||||||
every period already opened keeps what it was billed at.
|
every period already opened keeps what it was billed at.
|
||||||
|
|||||||
33
BILLING.md
33
BILLING.md
@@ -404,3 +404,36 @@ default tries localhost:25 and raises `ConnectionRefused` on a box with no MTA
|
|||||||
deployment that forgets `DJANGO_EMAIL_HOST` will watch `send_billing_reminders --commit` report
|
deployment that forgets `DJANGO_EMAIL_HOST` will watch `send_billing_reminders --commit` report
|
||||||
success while no club hears anything. Set the mail variables in `.env.production` (see
|
success while no club hears anything. Set the mail variables in `.env.production` (see
|
||||||
`.env.production.example`) before trusting the job.
|
`.env.production.example`) before trusting the job.
|
||||||
|
|
||||||
|
## 11. Addendum: deleting a plan
|
||||||
|
|
||||||
|
Added after the initial implementation. `Due.plan` is `PROTECT` — a plan that has ever billed
|
||||||
|
anyone can never truly be removed, on purpose: `amount`, `period_end` and `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`):
|
||||||
|
|
||||||
|
- **No `Due` ever referenced the plan** (created, never actually used to bill anyone) — the row is
|
||||||
|
removed outright.
|
||||||
|
- **At least one `Due` references it** — soft-deleted instead: `Plan.deleted_at` is set and
|
||||||
|
`is_active` turned off. The row survives (so old invoices still say what they were billed under)
|
||||||
|
but is hidden from every picker and listing via `Plan.objects.visible()` — an opt-in queryset
|
||||||
|
method, same shape as `Club.objects.active()`, so the plain default manager stays unfiltered for
|
||||||
|
Django admin and anything reading historical data.
|
||||||
|
|
||||||
|
Either way, every club **currently on the plan** is unsubscribed outright — its `Subscription` row
|
||||||
|
is deleted, not just its `plan` field cleared. "No plan" was already a state the rest of the app
|
||||||
|
fully understood (every billing view already handles `getattr(club, "subscription", None)` being
|
||||||
|
`None`), so this reuses it rather than inventing a new one.
|
||||||
|
|
||||||
|
One 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 club's trial would try to
|
||||||
|
convert onto a plan that no longer exists (or has been hidden) the moment `open_period()`'s
|
||||||
|
trial-conversion check next runs. Handled at delete time instead: that club's trial is ended
|
||||||
|
(`trial_ends_at` and `post_trial_plan` both cleared, per the `CheckConstraint` that requires them
|
||||||
|
set together or not at all), leaving it on the trial plan with no scheduled conversion until a
|
||||||
|
platform admin picks a new one.
|
||||||
|
|
||||||
|
The confirmation screen (`controlpanel/templates/controlpanel/plan_delete.html`) is a real page,
|
||||||
|
not a modal like every other billing action — the whole point is naming exactly which clubs are
|
||||||
|
affected, in both groups, and that list can be long.
|
||||||
|
|||||||
18
billing/migrations/0007_plan_deleted_at.py
Normal file
18
billing/migrations/0007_plan_deleted_at.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-08 17:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0006_due_last_reminder_level_due_last_reminder_sent_at'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='deleted_at',
|
||||||
|
field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='deleted at'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -37,6 +37,17 @@ def add_months(day: date, months: int) -> date:
|
|||||||
return day + relativedelta.relativedelta(months=months)
|
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):
|
class Plan(UUIDModel):
|
||||||
"""What a club is billed on: a duration, a set of clocks, and a dated price.
|
"""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."),
|
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:
|
class Meta:
|
||||||
verbose_name = _("plan")
|
verbose_name = _("plan")
|
||||||
verbose_name_plural = _("plans")
|
verbose_name_plural = _("plans")
|
||||||
@@ -86,6 +105,10 @@ class Plan(UUIDModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_deleted(self) -> bool:
|
||||||
|
return self.deleted_at is not None
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""The same two invariants the CheckConstraints enforce, as form errors.
|
"""The same two invariants the CheckConstraints enforce, as form errors.
|
||||||
|
|
||||||
|
|||||||
85
billing/services/plans.py
Normal file
85
billing/services/plans.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Deleting a plan.
|
||||||
|
|
||||||
|
Due.plan is PROTECT -- a plan that has ever billed anyone can never truly be removed, and
|
||||||
|
must not be: `amount`, `period_end` and `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. So "delete" means one of two things,
|
||||||
|
chosen automatically depending on whether the plan has billing history:
|
||||||
|
|
||||||
|
* No Due ever referenced it (created, then never actually used to bill anyone) -- the row
|
||||||
|
itself is removed.
|
||||||
|
* At least one Due references it -- soft-deleted instead (Plan.deleted_at set, is_active
|
||||||
|
turned off): hidden from every picker and listing (Plan.objects.visible()), but the row
|
||||||
|
survives so every old invoice still says what it was for.
|
||||||
|
|
||||||
|
Either way, every club CURRENTLY on the plan is unsubscribed outright -- its Subscription
|
||||||
|
row deleted, not just its `plan` field cleared. "No plan" is already a state the rest of the
|
||||||
|
app fully understands (every billing view already handles `getattr(club, "subscription",
|
||||||
|
None)` being None), so there is no new state to teach it.
|
||||||
|
|
||||||
|
A second, easy-to-miss group: a club on a DIFFERENT plan, mid-trial, configured to convert
|
||||||
|
to THIS plan once its trial ends (Subscription.post_trial_plan). Deleting the target plan
|
||||||
|
out from under that trial can't be allowed to raise a stale IntegrityError days or weeks
|
||||||
|
later when the trial tries to convert -- so it's handled now, at delete time: that club's
|
||||||
|
trial is ended (trial_ends_at and post_trial_plan both cleared, per the CheckConstraint that
|
||||||
|
requires them set together or not at all), leaving it on the trial plan with no scheduled
|
||||||
|
conversion until a platform admin picks a new one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from billing.models import Plan, Subscription
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlanDeletionImpact:
|
||||||
|
plan: Plan
|
||||||
|
#: Clubs currently ON this plan -- lose it entirely (Subscription row deleted).
|
||||||
|
unsubscribed_clubs: list
|
||||||
|
#: Clubs on a different plan, mid-trial, configured to convert to this one -- their
|
||||||
|
#: trial's landing plan is cleared, leaving no scheduled conversion.
|
||||||
|
broken_trial_clubs: list
|
||||||
|
#: Whether the Plan row itself will be removed (True) or only hidden (False, because
|
||||||
|
#: it has billing history).
|
||||||
|
will_hard_delete: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_impact(self) -> bool:
|
||||||
|
return bool(self.unsubscribed_clubs or self.broken_trial_clubs)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_deletion_impact(plan: Plan) -> PlanDeletionImpact:
|
||||||
|
"""What deleting `plan` right now would do -- read-only, for the confirmation screen.
|
||||||
|
|
||||||
|
delete_plan() re-derives the same two lists itself rather than trust one computed here
|
||||||
|
moments earlier and possibly stale by the time the platform admin actually confirms.
|
||||||
|
"""
|
||||||
|
unsubscribed = Subscription.objects.filter(plan=plan).select_related("club").order_by("club__name")
|
||||||
|
broken_trial = Subscription.objects.filter(post_trial_plan=plan).exclude(plan=plan).select_related("club").order_by("club__name")
|
||||||
|
|
||||||
|
return PlanDeletionImpact(
|
||||||
|
plan=plan,
|
||||||
|
unsubscribed_clubs=[subscription.club for subscription in unsubscribed],
|
||||||
|
broken_trial_clubs=[subscription.club for subscription in broken_trial],
|
||||||
|
will_hard_delete=not plan.dues.exists(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def delete_plan(plan: Plan) -> PlanDeletionImpact:
|
||||||
|
impact = plan_deletion_impact(plan)
|
||||||
|
|
||||||
|
Subscription.objects.filter(plan=plan).delete()
|
||||||
|
Subscription.objects.filter(post_trial_plan=plan).update(trial_ends_at=None, post_trial_plan=None)
|
||||||
|
|
||||||
|
if impact.will_hard_delete:
|
||||||
|
plan.delete()
|
||||||
|
else:
|
||||||
|
plan.deleted_at = timezone.now()
|
||||||
|
plan.is_active = False
|
||||||
|
plan.save(update_fields=["deleted_at", "is_active", "modified"])
|
||||||
|
|
||||||
|
return impact
|
||||||
122
billing/tests.py
122
billing/tests.py
@@ -21,6 +21,7 @@ from .services import BillingError
|
|||||||
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, renew, start_trial, subscribe, subscriptions_due_for_renewal, waive
|
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, renew, start_trial, subscribe, subscriptions_due_for_renewal, waive
|
||||||
from .services.invoices import invoice_pdf, issue_invoice, render_pdf
|
from .services.invoices import invoice_pdf, issue_invoice, render_pdf
|
||||||
from .services.notices import club_billing_notice
|
from .services.notices import club_billing_notice
|
||||||
|
from .services.plans import delete_plan, plan_deletion_impact
|
||||||
from .services.reminders import admin_emails, reminders_to_send, send_reminder
|
from .services.reminders import admin_emails, reminders_to_send, send_reminder
|
||||||
|
|
||||||
|
|
||||||
@@ -835,3 +836,124 @@ class BillingReminderTests(BillingTestBase):
|
|||||||
record_payment(self.due, Decimal("500.00"))
|
record_payment(self.due, Decimal("500.00"))
|
||||||
|
|
||||||
self.assertEqual(reminders_to_send([self.club], self.today), [])
|
self.assertEqual(reminders_to_send([self.club], self.today), [])
|
||||||
|
|
||||||
|
|
||||||
|
class PlanVisibilityTests(BillingTestBase):
|
||||||
|
"""Plan.objects.visible() -- see PlanQuerySet."""
|
||||||
|
|
||||||
|
def test_a_plain_plan_is_visible(self):
|
||||||
|
self.assertIn(self.plan, Plan.objects.visible())
|
||||||
|
|
||||||
|
def test_a_soft_deleted_plan_is_excluded(self):
|
||||||
|
self.plan.deleted_at = timezone.now()
|
||||||
|
self.plan.save(update_fields=["deleted_at"])
|
||||||
|
|
||||||
|
self.assertNotIn(self.plan, Plan.objects.visible())
|
||||||
|
|
||||||
|
def test_the_default_manager_still_returns_a_soft_deleted_plan(self):
|
||||||
|
# Django admin, and anything reading historical data, must still be able to find it.
|
||||||
|
self.plan.deleted_at = timezone.now()
|
||||||
|
self.plan.save(update_fields=["deleted_at"])
|
||||||
|
|
||||||
|
self.assertIn(self.plan, Plan.objects.all())
|
||||||
|
|
||||||
|
|
||||||
|
class PlanDeletionTests(BillingTestBase):
|
||||||
|
"""billing.services.plans -- see its module docstring for the full reasoning."""
|
||||||
|
|
||||||
|
def test_a_never_billed_plan_is_hard_deleted(self):
|
||||||
|
unused = Plan.objects.create(name="Unused")
|
||||||
|
|
||||||
|
impact = delete_plan(unused)
|
||||||
|
|
||||||
|
self.assertTrue(impact.will_hard_delete)
|
||||||
|
self.assertFalse(Plan.objects.filter(pk=unused.pk).exists())
|
||||||
|
|
||||||
|
def test_a_plan_with_a_due_cannot_be_hard_deleted(self):
|
||||||
|
self.bill()
|
||||||
|
|
||||||
|
impact = delete_plan(self.plan)
|
||||||
|
|
||||||
|
self.assertFalse(impact.will_hard_delete)
|
||||||
|
self.assertTrue(Plan.objects.filter(pk=self.plan.pk).exists())
|
||||||
|
|
||||||
|
def test_a_cancelled_due_still_protects_the_plan(self):
|
||||||
|
# PROTECT does not care about the referencing row's own status -- a cancelled due is
|
||||||
|
# still a row, and financial history includes rows nobody expects to see again.
|
||||||
|
due = self.bill()
|
||||||
|
due.status = Due.Status.CANCELLED
|
||||||
|
due.save(update_fields=["status"])
|
||||||
|
|
||||||
|
impact = delete_plan(self.plan)
|
||||||
|
|
||||||
|
self.assertFalse(impact.will_hard_delete)
|
||||||
|
|
||||||
|
def test_soft_delete_marks_the_plan_inactive_and_deleted(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
|
||||||
|
delete_plan(self.plan)
|
||||||
|
self.plan.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertTrue(self.plan.is_deleted)
|
||||||
|
self.assertFalse(self.plan.is_active)
|
||||||
|
|
||||||
|
def test_deleting_unsubscribes_the_club_entirely_rather_than_nulling_a_field(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
|
||||||
|
delete_plan(self.plan)
|
||||||
|
|
||||||
|
self.assertFalse(Subscription.objects.filter(club=self.club).exists())
|
||||||
|
|
||||||
|
def test_a_deleted_plans_historical_due_is_untouched(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
due = self.club.dues.first()
|
||||||
|
amount, period_end, grace_until = due.amount, due.period_end, due.grace_until
|
||||||
|
|
||||||
|
delete_plan(self.plan)
|
||||||
|
due.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(due.plan_id, self.plan.pk)
|
||||||
|
self.assertEqual(due.amount, amount)
|
||||||
|
self.assertEqual(due.period_end, period_end)
|
||||||
|
self.assertEqual(due.grace_until, grace_until)
|
||||||
|
|
||||||
|
def test_a_club_not_on_the_plan_is_unaffected(self):
|
||||||
|
other_plan = Plan.objects.create(name="Other")
|
||||||
|
PlanPrice.objects.create(plan=other_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("100.00"))
|
||||||
|
untouched = Club.objects.create(name="Untouched FC")
|
||||||
|
subscribe(untouched, other_plan)
|
||||||
|
|
||||||
|
delete_plan(self.plan)
|
||||||
|
|
||||||
|
self.assertTrue(Subscription.objects.filter(club=untouched, plan=other_plan).exists())
|
||||||
|
|
||||||
|
def test_a_trial_scheduled_to_convert_to_the_deleted_plan_is_cleared(self):
|
||||||
|
trial_plan = Plan.objects.create(name="Trial", is_trial=True, duration_months=2, renewal_lead_days=7, grace_days=14)
|
||||||
|
PlanPrice.objects.create(plan=trial_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("0.00"))
|
||||||
|
club = Club.objects.create(name="Mid Trial FC")
|
||||||
|
start_trial(club, trial_plan, post_trial_plan=self.plan)
|
||||||
|
|
||||||
|
impact = delete_plan(self.plan)
|
||||||
|
|
||||||
|
self.assertEqual([c.pk for c in impact.broken_trial_clubs], [club.pk])
|
||||||
|
club.refresh_from_db()
|
||||||
|
subscription = club.subscription
|
||||||
|
self.assertEqual(subscription.plan, trial_plan)
|
||||||
|
self.assertIsNone(subscription.trial_ends_at)
|
||||||
|
self.assertIsNone(subscription.post_trial_plan)
|
||||||
|
|
||||||
|
def test_a_club_currently_on_the_plan_is_not_also_counted_as_a_broken_trial(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
|
||||||
|
impact = delete_plan(self.plan)
|
||||||
|
|
||||||
|
self.assertEqual(impact.unsubscribed_clubs, [self.club])
|
||||||
|
self.assertEqual(impact.broken_trial_clubs, [])
|
||||||
|
|
||||||
|
def test_plan_deletion_impact_is_read_only(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
|
||||||
|
plan_deletion_impact(self.plan)
|
||||||
|
|
||||||
|
self.assertTrue(Subscription.objects.filter(club=self.club).exists())
|
||||||
|
self.assertFalse(self.plan.is_deleted)
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ class SubscriptionForm(forms.ModelForm):
|
|||||||
# An inactive plan still bills its existing subscriptions, but must not be picked up
|
# An inactive plan still bills its existing subscriptions, but must not be picked up
|
||||||
# by a new one — which is the whole point of retiring a plan. Trial plans are excluded
|
# by a new one — which is the whole point of retiring a plan. Trial plans are excluded
|
||||||
# too: they are reached through the trial form, which converts them properly.
|
# too: they are reached through the trial form, which converts them properly.
|
||||||
self.fields["plan"].queryset = Plan.objects.filter(is_active=True, is_trial=False)
|
self.fields["plan"].queryset = Plan.objects.visible().filter(is_active=True, is_trial=False)
|
||||||
|
|
||||||
|
|
||||||
class TrialForm(forms.Form):
|
class TrialForm(forms.Form):
|
||||||
@@ -150,8 +150,8 @@ class TrialForm(forms.Form):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Same reasoning as SubscriptionForm: a retired plan keeps billing whoever is
|
# Same reasoning as SubscriptionForm: a retired plan keeps billing whoever is
|
||||||
# already on it, but must not be offered for a new trial or a new plan either.
|
# already on it, but must not be offered for a new trial or a new plan either.
|
||||||
self.fields["trial_plan"].queryset = Plan.objects.filter(is_active=True, is_trial=True)
|
self.fields["trial_plan"].queryset = Plan.objects.visible().filter(is_active=True, is_trial=True)
|
||||||
self.fields["post_trial_plan"].queryset = Plan.objects.filter(is_active=True, is_trial=False)
|
self.fields["post_trial_plan"].queryset = Plan.objects.visible().filter(is_active=True, is_trial=False)
|
||||||
|
|
||||||
|
|
||||||
class DuePaymentForm(forms.Form):
|
class DuePaymentForm(forms.Form):
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
<td class="flex flex-row gap-2 justify-end">
|
<td class="flex flex-row gap-2 justify-end">
|
||||||
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ plan.pk|dom_id:"plan_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
|
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ plan.pk|dom_id:"plan_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
|
||||||
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ plan.pk|dom_id:"plan_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ plan.pk|dom_id:"plan_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
||||||
|
<a class="btn btn-error btn-outline btn-sm gap-1" href="{% url 'controlpanel:plan_delete' plan.pk %}">{% lucide "trash-2" size=14 %} Delete</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
82
controlpanel/templates/controlpanel/plan_delete.html
Normal file
82
controlpanel/templates/controlpanel/plan_delete.html
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{% extends "controlpanel/base.html" %}
|
||||||
|
{% load i18n lucide %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
Confirm-then-delete for a plan. A real page rather than a modal (unlike every other
|
||||||
|
billing action) because the whole point is naming exactly which clubs are affected, and
|
||||||
|
that list can be long. See billing.services.plans for what "delete" actually does.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block heading %}{% blocktrans with plan=plan.name %}Delete “{{ plan }}”{% endblocktrans %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:billing' %}">{% lucide "arrow-left" size=16 %} {% trans "Back to billing" %}</a>
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card mb-6 bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "triangle-alert" size=18 %} {% trans "This can't be undone" %}</h2>
|
||||||
|
{% if impact.will_hard_delete %}
|
||||||
|
<p class="text-sm opacity-70">{% blocktrans with plan=plan.name %}“{{ plan }}” has never billed anyone, so it will be removed completely.{% endblocktrans %}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm opacity-70">{% blocktrans with plan=plan.name %}“{{ plan }}” has billing history, so it will be hidden rather than removed — past invoices will still show what they were billed under.{% endblocktrans %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if impact.unsubscribed_clubs %}
|
||||||
|
<div class="card mb-6 bg-base-100 shadow border-l-4 border-error">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">
|
||||||
|
{% lucide "building-2" size=18 %}
|
||||||
|
{% blocktrans count counter=impact.unsubscribed_clubs|length %}{{ counter }} club is currently on this plan{% plural %}{{ counter }} clubs are currently on this plan{% endblocktrans %}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm opacity-70">{% trans "Deleting this plan removes their subscription outright — each shows as not billed for anything afterwards, the same as a club that was never put on a plan." %}</p>
|
||||||
|
<ul class="mt-2 divide-y divide-base-200">
|
||||||
|
{% for club in impact.unsubscribed_clubs %}
|
||||||
|
<li class="py-2 flex items-center justify-between">
|
||||||
|
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
|
||||||
|
<span class="badge badge-error badge-outline gap-1">{% lucide "circle-x" size=12 %} {% trans "Loses this plan" %}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if impact.broken_trial_clubs %}
|
||||||
|
<div class="card mb-6 bg-base-100 shadow border-l-4 border-warning">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">
|
||||||
|
{% lucide "hourglass" size=18 %}
|
||||||
|
{% blocktrans count counter=impact.broken_trial_clubs|length %}{{ counter }} club's trial is scheduled to switch to this plan{% plural %}{{ counter }} clubs' trials are scheduled to switch to this plan{% endblocktrans %}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm opacity-70">{% trans "They stay on their current trial plan, but the scheduled switch is cancelled — pick a new plan for them before the trial ends." %}</p>
|
||||||
|
<ul class="mt-2 divide-y divide-base-200">
|
||||||
|
{% for club in impact.broken_trial_clubs %}
|
||||||
|
<li class="py-2 flex items-center justify-between">
|
||||||
|
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
|
||||||
|
<span class="badge badge-warning badge-outline gap-1">{% lucide "octagon-alert" size=12 %} {% trans "Trial needs a new plan" %}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not impact.has_impact %}
|
||||||
|
<div class="alert alert-info mb-6">
|
||||||
|
{% lucide "info" size=20 %}
|
||||||
|
<span>{% trans "No club is currently on this plan, or has a trial scheduled to switch to it." %}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'controlpanel:plan_delete' plan.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="flex flex-row justify-end gap-2">
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:billing' %}">{% lucide "x" size=16 %} {% trans "Cancel" %}</a>
|
||||||
|
<button class="btn btn-error gap-2" type="submit">{% lucide "trash-2" size=16 %} {% trans "Delete plan" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock panel %}
|
||||||
@@ -16,9 +16,9 @@ from django.urls import reverse
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from waffle import get_waffle_flag_model, get_waffle_switch_model
|
from waffle import get_waffle_flag_model, get_waffle_switch_model
|
||||||
|
|
||||||
from billing.models import DEFAULT_GRACE_DAYS, Due, Plan, PlanPrice
|
from billing.models import DEFAULT_GRACE_DAYS, Due, Plan, PlanPrice, Subscription
|
||||||
from billing.services import BillingError
|
from billing.services import BillingError
|
||||||
from billing.services.dues import record_payment, subscribe, waive
|
from billing.services.dues import record_payment, start_trial, subscribe, waive
|
||||||
from club.models import Club, ClubMembership, ClubRole, Season
|
from club.models import Club, ClubMembership, ClubRole, Season
|
||||||
from events.models import Attendance, Event, Location
|
from events.models import Attendance, Event, Location
|
||||||
from features.models import Maintenance
|
from features.models import Maintenance
|
||||||
@@ -1662,6 +1662,121 @@ class BillingFormRenderTests(ControlPanelTestBase):
|
|||||||
self.assertContains(response, "remove them before waiving")
|
self.assertContains(response, "remove them before waiving")
|
||||||
|
|
||||||
|
|
||||||
|
class PlanDeleteTests(ControlPanelTestBase):
|
||||||
|
"""billing.services.plans and controlpanel.views.PlanDeleteView."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.today = timezone.localdate()
|
||||||
|
self.plan = Plan.objects.create(name="Standard")
|
||||||
|
PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
|
||||||
|
|
||||||
|
def test_a_never_used_plan_is_removed_completely(self):
|
||||||
|
unused = Plan.objects.create(name="Unused")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("controlpanel:plan_delete", args=[unused.pk]))
|
||||||
|
self.assertTrue(response.context["impact"].will_hard_delete)
|
||||||
|
|
||||||
|
self.client.post(reverse("controlpanel:plan_delete", args=[unused.pk]))
|
||||||
|
|
||||||
|
self.assertFalse(Plan.objects.filter(pk=unused.pk).exists())
|
||||||
|
|
||||||
|
def test_a_plan_with_billing_history_is_hidden_not_removed(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
|
||||||
|
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
|
||||||
|
self.plan.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertTrue(Plan.objects.filter(pk=self.plan.pk).exists())
|
||||||
|
self.assertTrue(self.plan.is_deleted)
|
||||||
|
self.assertFalse(self.plan.is_active)
|
||||||
|
self.assertIsNotNone(self.plan.deleted_at)
|
||||||
|
|
||||||
|
def test_deleting_unsubscribes_every_club_currently_on_it(self):
|
||||||
|
other = Club.objects.create(name="Feyenoord")
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
subscribe(other, self.plan)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
|
||||||
|
self.assertCountEqual([c.pk for c in response.context["impact"].unsubscribed_clubs], [self.club.pk, other.pk])
|
||||||
|
self.assertContains(response, "Ajax United")
|
||||||
|
self.assertContains(response, "Feyenoord")
|
||||||
|
|
||||||
|
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
|
||||||
|
|
||||||
|
self.assertFalse(hasattr(self.club, "subscription") and Subscription.objects.filter(club=self.club).exists())
|
||||||
|
self.assertFalse(Subscription.objects.filter(club=other).exists())
|
||||||
|
# The club itself, and its billing history, are untouched.
|
||||||
|
self.assertTrue(Club.objects.filter(pk=self.club.pk).exists())
|
||||||
|
self.assertEqual(self.club.dues.first().plan, self.plan)
|
||||||
|
|
||||||
|
def test_the_amount_and_dates_on_a_deleted_plans_dues_are_unchanged(self):
|
||||||
|
# The whole reason a plan with history can't be hard-deleted: Due.plan, .amount,
|
||||||
|
# .period_end and .grace_until are frozen snapshots, and deleting the plan must not
|
||||||
|
# touch any of them.
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
due = self.club.dues.first()
|
||||||
|
amount, period_end, grace_until = due.amount, due.period_end, due.grace_until
|
||||||
|
|
||||||
|
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
|
||||||
|
due.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(due.amount, amount)
|
||||||
|
self.assertEqual(due.period_end, period_end)
|
||||||
|
self.assertEqual(due.grace_until, grace_until)
|
||||||
|
self.assertEqual(due.plan_id, self.plan.pk)
|
||||||
|
|
||||||
|
def test_a_club_mid_trial_scheduled_to_convert_to_the_deleted_plan_is_flagged(self):
|
||||||
|
trial_plan = Plan.objects.create(name="Trial", is_trial=True, duration_months=2, renewal_lead_days=7, grace_days=14)
|
||||||
|
PlanPrice.objects.create(plan=trial_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("0.00"))
|
||||||
|
start_trial(self.club, trial_plan, post_trial_plan=self.plan)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
|
||||||
|
self.assertEqual([c.pk for c in response.context["impact"].broken_trial_clubs], [self.club.pk])
|
||||||
|
# Not double-counted as "currently on this plan" -- it's still on the trial plan.
|
||||||
|
self.assertEqual(response.context["impact"].unsubscribed_clubs, [])
|
||||||
|
|
||||||
|
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
|
||||||
|
self.club.refresh_from_db()
|
||||||
|
subscription = self.club.subscription
|
||||||
|
|
||||||
|
self.assertEqual(subscription.plan, trial_plan)
|
||||||
|
self.assertIsNone(subscription.trial_ends_at)
|
||||||
|
self.assertIsNone(subscription.post_trial_plan)
|
||||||
|
|
||||||
|
def test_a_deleted_plan_is_removed_from_the_billing_page(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
|
||||||
|
|
||||||
|
response = self.client.get(reverse("controlpanel:billing"))
|
||||||
|
|
||||||
|
self.assertNotIn(self.plan, response.context["plans"])
|
||||||
|
|
||||||
|
def test_a_deleted_plan_cannot_be_picked_for_a_new_subscription(self):
|
||||||
|
other = Club.objects.create(name="Feyenoord")
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
|
||||||
|
|
||||||
|
response = self.client.get(reverse("controlpanel:club_detail", args=[other.pk]))
|
||||||
|
|
||||||
|
self.assertNotIn(self.plan, response.context["subscription_form"].fields["plan"].queryset)
|
||||||
|
|
||||||
|
def test_visiting_an_already_deleted_plan_redirects_with_a_message(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
self.client.post(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
|
||||||
|
|
||||||
|
response = self.client.get(reverse("controlpanel:plan_delete", args=[self.plan.pk]), follow=True)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("controlpanel:billing"))
|
||||||
|
self.assertContains(response, "already been deleted")
|
||||||
|
|
||||||
|
def test_a_plan_with_no_clubs_reports_no_impact(self):
|
||||||
|
response = self.client.get(reverse("controlpanel:plan_delete", args=[self.plan.pk]))
|
||||||
|
|
||||||
|
self.assertFalse(response.context["impact"].has_impact)
|
||||||
|
self.assertContains(response, "No club is currently on this plan")
|
||||||
|
|
||||||
|
|
||||||
class MaintenancePanelTests(ControlPanelTestBase):
|
class MaintenancePanelTests(ControlPanelTestBase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ urlpatterns = [
|
|||||||
path("billing/", views.BillingView.as_view(), name="billing"),
|
path("billing/", views.BillingView.as_view(), name="billing"),
|
||||||
path("billing/plans/new/", views.PlanCreateView.as_view(), name="plan_create"),
|
path("billing/plans/new/", views.PlanCreateView.as_view(), name="plan_create"),
|
||||||
path("billing/plans/<uuid:pk>/edit/", views.PlanUpdateView.as_view(), name="plan_update"),
|
path("billing/plans/<uuid:pk>/edit/", views.PlanUpdateView.as_view(), name="plan_update"),
|
||||||
|
path("billing/plans/<uuid:pk>/delete/", views.PlanDeleteView.as_view(), name="plan_delete"),
|
||||||
path("billing/plans/<uuid:pk>/prices/new/", views.PlanPriceCreateView.as_view(), name="plan_price_create"),
|
path("billing/plans/<uuid:pk>/prices/new/", views.PlanPriceCreateView.as_view(), name="plan_price_create"),
|
||||||
path("billing/dues/<uuid:pk>/pay/", views.RecordPaymentView.as_view(), name="due_pay"),
|
path("billing/dues/<uuid:pk>/pay/", views.RecordPaymentView.as_view(), name="due_pay"),
|
||||||
path("billing/dues/<uuid:pk>/waive/", views.WaiveDueView.as_view(), name="due_waive"),
|
path("billing/dues/<uuid:pk>/waive/", views.WaiveDueView.as_view(), name="due_waive"),
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from django.shortcuts import get_object_or_404, redirect
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils.translation import ngettext
|
||||||
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
|
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
|
||||||
from waffle import get_waffle_flag_model, get_waffle_switch_model
|
from waffle import get_waffle_flag_model, get_waffle_switch_model
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ from billing.models import Due, Plan, PlanPrice
|
|||||||
from billing.services import BillingError
|
from billing.services import BillingError
|
||||||
from billing.services.dues import next_period_start, open_period, reactivate, record_payment, start_trial, subscribe, waive
|
from billing.services.dues import next_period_start, open_period, reactivate, record_payment, start_trial, subscribe, waive
|
||||||
from billing.services.invoices import invoice_pdf, issue_invoice
|
from billing.services.invoices import invoice_pdf, issue_invoice
|
||||||
|
from billing.services.plans import delete_plan, plan_deletion_impact
|
||||||
from club.models import Club, ClubRole
|
from club.models import Club, ClubRole
|
||||||
from events.models import Location
|
from events.models import Location
|
||||||
from features.models import Maintenance
|
from features.models import Maintenance
|
||||||
@@ -401,7 +404,7 @@ class BillingView(PlatformStaffRequiredMixin, TemplateView):
|
|||||||
# Bound per-row so each "Edit" / "New price" modal can render its own form: the
|
# Bound per-row so each "Edit" / "New price" modal can render its own form: the
|
||||||
# template can't call PlanForm(instance=plan) itself, so the form rides along on
|
# template can't call PlanForm(instance=plan) itself, so the form rides along on
|
||||||
# the object it belongs to.
|
# the object it belongs to.
|
||||||
plans = list(Plan.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")))
|
plans = list(Plan.objects.visible().prefetch_related("prices").annotate(club_count=Count("subscriptions")))
|
||||||
for plan in plans:
|
for plan in plans:
|
||||||
plan.edit_form = PlanForm(instance=plan)
|
plan.edit_form = PlanForm(instance=plan)
|
||||||
plan.price_form = PlanPriceForm()
|
plan.price_form = PlanPriceForm()
|
||||||
@@ -448,6 +451,53 @@ class PlanUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateV
|
|||||||
return reverse("controlpanel:billing")
|
return reverse("controlpanel:billing")
|
||||||
|
|
||||||
|
|
||||||
|
class PlanDeleteView(PlatformStaffRequiredMixin, TemplateView):
|
||||||
|
"""Confirm-then-delete for a plan. Unlike every other billing action, this is a real
|
||||||
|
page rather than a modal: the whole point is naming exactly which clubs lose their plan
|
||||||
|
(or lose their trial's scheduled landing plan), and that list can be long -- see
|
||||||
|
billing.services.plans for what "delete" actually does to a plan with billing history.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "controlpanel/plan_delete.html"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def plan(self):
|
||||||
|
return get_object_or_404(Plan, pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
plan = self.plan
|
||||||
|
if plan.is_deleted:
|
||||||
|
notify(request, f"i|{_('Already deleted')}|" + _("“%(plan)s” has already been deleted.") % {"plan": plan.name})
|
||||||
|
return redirect("controlpanel:billing")
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(nav="billing", plan=self.plan, impact=plan_deletion_impact(self.plan), **kwargs)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
plan = self.plan
|
||||||
|
plan_name = plan.name
|
||||||
|
impact = delete_plan(plan)
|
||||||
|
|
||||||
|
if impact.will_hard_delete:
|
||||||
|
body = _("“%(plan)s” has been deleted.") % {"plan": plan_name}
|
||||||
|
else:
|
||||||
|
body = _("“%(plan)s” has been deleted. It has billing history, so it's hidden rather than removed — past invoices still show it.") % {"plan": plan_name}
|
||||||
|
notify(request, f"w|{_('Plan deleted')}|{body}")
|
||||||
|
|
||||||
|
if impact.unsubscribed_clubs:
|
||||||
|
count = len(impact.unsubscribed_clubs)
|
||||||
|
club_body = ngettext("%(count)d club now has no plan.", "%(count)d clubs now have no plan.", count) % {"count": count}
|
||||||
|
notify(request, f"w|{_('Clubs affected')}|{club_body}")
|
||||||
|
|
||||||
|
if impact.broken_trial_clubs:
|
||||||
|
count = len(impact.broken_trial_clubs)
|
||||||
|
trial_body = ngettext("%(count)d club's trial lost its scheduled plan and needs a new one picked.", "%(count)d clubs' trials lost their scheduled plan and need a new one picked.", count) % {"count": count}
|
||||||
|
notify(request, f"w|{_('Trials affected')}|{trial_body}")
|
||||||
|
|
||||||
|
return redirect("controlpanel:billing")
|
||||||
|
|
||||||
|
|
||||||
class PlanPriceCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
|
class PlanPriceCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
|
||||||
"""A rate change is a new dated price, never an edit of the old one — periods already
|
"""A rate change is a new dated price, never an edit of the old one — periods already
|
||||||
billed keep the amount they were billed at.
|
billed keep the amount they were billed at.
|
||||||
|
|||||||
Reference in New Issue
Block a user