Rework platform billing: per-plan clocks, grace from period start

Implements BILLING.md. The architecture was sound -- snapshot-on-Due,
dated prices, asymmetric dry-run commands are all kept -- so this
fixes the three hardcoded assumptions rather than rewriting.

The real defect: grace ran from period_END, so an annual club used
the whole unpaid year plus 45 days (~410 days) before anything
switched it off. Grace now runs from the period START, and every
clock is per-plan.

- Tier -> Plan (+ TierPrice -> PlanPrice, and every FK). Migration
  0004 is hand-written: run non-interactively, makemigrations emits
  DeleteModel+CreateModel and drops every price, subscription and
  due. Its two RemoveConstraints must come first, or SQLite's
  table-rebuild tries to render a constraint over a just-renamed
  column. Verified by round-tripping real rows through it.
- Plan gains duration_months / renewal_lead_days / grace_days /
  is_trial, with CheckConstraints and a matching clean() so the form
  reports an impossible plan instead of 500ing on IntegrityError.
- Existing dues keep their stored grace_until. Re-deriving it would
  put the date in the past for every open annual period and archive
  the entire paying customer base on the next --commit run.
- Trials take their length from the trial plan's own duration_months;
  start_trial() loses its trial_months argument.
- New BillingNotice service drives a club-facing warning: every level
  on the dashboard, and on every management page once urgent.
- send_billing_reminders emails club admins, once per escalation
  level so a daily cron is not a daily email. SMTP settings are
  env-driven and provider-agnostic; the backend defaults to console.
- Paying does not auto-restore an archived club -- the control panel
  surfaces a Reactivate prompt instead, since a club can also be
  archived by hand.
This commit is contained in:
2026-08-08 18:49:52 +02:00
parent ae93406853
commit fc6488ce55
36 changed files with 1342 additions and 386 deletions

View File

@@ -16,7 +16,7 @@ from django.urls import reverse
from django.utils import timezone
from waffle import get_waffle_flag_model, get_waffle_switch_model
from billing.models import GRACE_DAYS, Due, Tier, TierPrice
from billing.models import DEFAULT_GRACE_DAYS, Due, Plan, PlanPrice
from billing.services import BillingError
from billing.services.dues import record_payment, subscribe, waive
from club.models import Club, ClubMembership, ClubRole, Season
@@ -278,7 +278,7 @@ class ClubHomeLocationTests(ControlPanelTestBase):
self.assertNotContains(response, 'type="lazyselect"')
self.assertContains(response, "Belgium")
self.assertContains(response, '<select')
self.assertContains(response, "<select")
def test_the_club_detail_page_shows_the_home_location(self):
self.set_home_location(name="Home Ground")
@@ -604,7 +604,7 @@ class NotifyTests(TestCase):
self.assertEqual(LEVELS, {"s": messages.SUCCESS, "i": messages.INFO, "w": messages.WARNING, "e": messages.ERROR, "d": messages.DEBUG})
def test_a_pipe_inside_the_body_is_preserved_intact(self):
# maxsplit=2 stops after the level and the title, so a "|" a club/tier/flag name
# maxsplit=2 stops after the level and the title, so a "|" a club/plan/flag name
# might contain stays part of the body rather than truncating it.
request, storage = self.request()
@@ -1265,11 +1265,11 @@ class PlatformDuesMetricTests(TestCase):
def setUp(self):
self.today = timezone.localdate()
self.club = Club.objects.create(name="Ajax United")
self.tier = Tier.objects.create(name="Standard")
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
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_dues_owed_is_the_unpaid_balance_across_every_club(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
record_payment(self.club.dues.first(), Decimal("200.00"))
self.assertEqual(platform_attention()["dues_owed"], Decimal("300.00"))
@@ -1277,18 +1277,20 @@ class PlatformDuesMetricTests(TestCase):
def test_grace_and_overdue_are_counted_separately(self):
in_grace = Club.objects.create(name="Grace FC")
overdue = Club.objects.create(name="Overdue FC")
subscribe(in_grace, self.tier, start=self.today - datetime.timedelta(days=370))
subscribe(overdue, self.tier, start=self.today - datetime.timedelta(days=365 + GRACE_DAYS + 10))
# Grace runs from the period START now: a club a few days into an unpaid period is
# in grace, where under the old rule this meant one whose period had already ended.
subscribe(in_grace, self.plan, start=self.today - datetime.timedelta(days=5))
subscribe(overdue, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 10))
attention = platform_attention()
self.assertEqual(attention["dues_in_grace"], 1)
self.assertEqual(attention["dues_overdue"], 1)
def test_clubs_on_no_tier_are_flagged(self):
def test_clubs_on_no_plan_are_flagged(self):
self.assertEqual(platform_attention()["clubs_unbilled"], 1)
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
self.assertEqual(platform_attention()["clubs_unbilled"], 0)
@@ -1296,14 +1298,14 @@ class PlatformDuesMetricTests(TestCase):
# ~0 in normal running; a number here means the renewal cron has stopped.
self.assertEqual(platform_attention()["renewals_pending"], 0)
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=350)) # ends in 15 days
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=350)) # ends in 15 days
self.assertEqual(platform_attention()["renewals_pending"], 1)
def test_the_dashboard_surfaces_pending_renewals(self):
# The whole point of the KPI: a club about to go free is visible, though nothing is
# owed yet, so no other figure on the page would show it.
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=350))
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=350))
staff = User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True)
enrol_mfa(staff)
self.client.force_login(staff)
@@ -1311,7 +1313,7 @@ class PlatformDuesMetricTests(TestCase):
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "awaiting renewal")
def test_platform_dues_and_club_shop_money_are_different_charts(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
record_payment(self.club.dues.first(), Decimal("500.00"))
charts = platform_charts()
@@ -1320,16 +1322,16 @@ class PlatformDuesMetricTests(TestCase):
self.assertEqual(charts["club_revenue"][-1]["value"], 0.0) # never ours
def test_the_health_table_carries_the_plan_and_what_is_owed(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
club = clubs_with_health().get(pk=self.club.pk)
self.assertEqual(club.tier_name, "Standard")
self.assertEqual(club.plan_name, "Standard")
self.assertEqual(club.dues_owed, Decimal("500.00"))
def test_a_fully_paid_club_shows_when_its_cover_ends(self):
# The end of the current paid period is the day grace would start if nothing renews.
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
record_payment(due, due.amount)
@@ -1341,7 +1343,7 @@ class PlatformDuesMetricTests(TestCase):
def test_a_waived_period_also_shows_its_cover_end(self):
# Waived is settled too — the club is covered for that time, so its end date shows,
# badged "waived" rather than "paid".
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
waive(due)
@@ -1351,58 +1353,58 @@ class PlatformDuesMetricTests(TestCase):
self.assertEqual(club.covered_status, Due.Status.WAIVED)
def test_a_club_that_owes_has_no_cover(self):
subscribe(self.club, self.tier) # unpaid
subscribe(self.club, self.plan) # unpaid
club = clubs_with_health().get(pk=self.club.pk)
self.assertIsNone(club.covered_until)
self.assertIsNone(club.covered_status)
def test_the_health_table_still_costs_one_query_with_billing_on_it(self):
subscribe(self.club, self.tier)
subscribe(Club.objects.create(name="Feyenoord"), self.tier)
subscribe(self.club, self.plan)
subscribe(Club.objects.create(name="Feyenoord"), self.plan)
with self.assertNumQueries(1):
[(club.tier_name, club.dues_owed, club.outstanding) for club in clubs_with_health()]
[(club.plan_name, club.dues_owed, club.outstanding) for club in clubs_with_health()]
class BillingPanelTests(ControlPanelTestBase):
def setUp(self):
super().setUp()
self.today = timezone.localdate()
self.tier = Tier.objects.create(name="Standard")
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
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_the_billing_page_lists_tiers_and_what_is_owed(self):
subscribe(self.club, self.tier)
def test_the_billing_page_lists_plans_and_what_is_owed(self):
subscribe(self.club, self.plan)
response = self.client.get(reverse("controlpanel:billing"))
self.assertContains(response, "Standard")
self.assertContains(response, "500.00")
def test_a_tier_can_be_created_and_priced(self):
self.client.post(reverse("controlpanel:tier_create"), {"name": "Large", "description": "", "is_active": "on"})
tier = Tier.objects.get(name="Large")
def test_a_plan_can_be_created_and_priced(self):
self.client.post(reverse("controlpanel:plan_create"), {"name": "Large", "description": "", "is_active": "on", "duration_months": 12, "renewal_lead_days": 30, "grace_days": 30})
plan = Plan.objects.get(name="Large")
self.client.post(reverse("controlpanel:tier_price_create", args=[tier.pk]), {"active_from": self.today.isoformat(), "amount": "900.00"})
self.client.post(reverse("controlpanel:plan_price_create", args=[plan.pk]), {"active_from": self.today.isoformat(), "amount": "900.00"})
self.assertEqual(tier.price_on(self.today), Decimal("900.00"))
self.assertEqual(plan.price_on(self.today), Decimal("900.00"))
def test_a_rate_change_does_not_rewrite_an_open_period(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
self.client.post(reverse("controlpanel:tier_price_create", args=[self.tier.pk]), {"active_from": self.today.isoformat(), "amount": "900.00"})
self.client.post(reverse("controlpanel:plan_price_create", args=[self.plan.pk]), {"active_from": self.today.isoformat(), "amount": "900.00"})
self.assertEqual(self.club.dues.first().amount, Decimal("500.00"))
def test_subscribing_a_club_opens_its_first_period(self):
self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": self.tier.pk, "auto_archive": "on", "notes": ""})
self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"plan": self.plan.pk, "auto_archive": "on", "notes": ""})
self.assertEqual(self.club.dues.count(), 1)
self.assertEqual(self.club.subscription.tier, self.tier)
self.assertEqual(self.club.subscription.plan, self.plan)
def test_a_payment_can_be_recorded_and_settles_the_due(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "500.00", "method": "bank_transfer", "reference": "TRX-1", "paid_at": "", "note": ""})
@@ -1412,7 +1414,7 @@ class BillingPanelTests(ControlPanelTestBase):
self.assertEqual(due.payments.first().recorded_by, self.staff)
def test_a_part_payment_leaves_a_balance(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "200.00", "method": "bank_transfer", "reference": "", "paid_at": "", "note": ""})
@@ -1422,7 +1424,7 @@ class BillingPanelTests(ControlPanelTestBase):
def test_a_billing_error_is_shown_rather_than_raised(self):
# A waived period cannot take a payment; the panel must say so, not 500.
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
self.client.post(reverse("controlpanel:due_waive", args=[due.pk]))
@@ -1431,7 +1433,7 @@ class BillingPanelTests(ControlPanelTestBase):
self.assertContains(response, "cannot take a payment")
def test_a_period_can_be_waived(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
self.client.post(reverse("controlpanel:due_waive", args=[due.pk]))
@@ -1440,7 +1442,7 @@ class BillingPanelTests(ControlPanelTestBase):
self.assertEqual(due.status, Due.Status.WAIVED)
def test_opening_a_period_continues_from_the_last_one(self):
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=400))
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=400))
first = self.club.dues.first()
self.client.post(reverse("controlpanel:club_open_period", args=[self.club.pk]), {"start": ""})
@@ -1449,7 +1451,7 @@ class BillingPanelTests(ControlPanelTestBase):
self.assertEqual(latest.period_start, first.period_end + datetime.timedelta(days=1))
def test_reactivating_an_archived_club_restores_it(self):
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=400))
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=400))
self.club.archive()
self.client.post(reverse("controlpanel:club_open_period", args=[self.club.pk]), {"start": self.today.isoformat()})
@@ -1458,7 +1460,7 @@ class BillingPanelTests(ControlPanelTestBase):
self.assertFalse(self.club.is_archived)
def test_the_club_page_shows_the_plan_and_its_periods(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
@@ -1466,7 +1468,7 @@ class BillingPanelTests(ControlPanelTestBase):
self.assertContains(response, "INV-")
def test_an_invoice_downloads_as_a_pdf(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
with mock.patch("controlpanel.views.invoice_pdf", return_value=b"%PDF-1.7 fake"):
@@ -1477,7 +1479,7 @@ class BillingPanelTests(ControlPanelTestBase):
def test_a_missing_pdf_library_is_reported_rather_than_a_500(self):
# WeasyPrint needs native libs. Without them the button must explain itself.
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
with mock.patch("controlpanel.views.invoice_pdf", side_effect=BillingError("PDF rendering needs the native pango/cairo libraries.")):
@@ -1493,39 +1495,40 @@ class TrialPanelTests(ControlPanelTestBase):
def setUp(self):
super().setUp()
self.today = timezone.localdate()
self.trial_tier = Tier.objects.create(name="Trial")
TierPrice.objects.create(tier=self.trial_tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("50.00"))
self.tier = Tier.objects.create(name="Standard")
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
# A trial plan carries its own length; the form only offers plans flagged is_trial.
self.trial_plan = Plan.objects.create(name="Trial", is_trial=True, duration_months=2)
PlanPrice.objects.create(plan=self.trial_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("50.00"))
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 start_trial(self, **data):
data = {"trial_tier": self.trial_tier.pk, "post_trial_tier": self.tier.pk, "trial_months": 2, "start": ""} | data
data = {"trial_plan": self.trial_plan.pk, "post_trial_plan": self.plan.pk, "start": ""} | data
return self.client.post(reverse("controlpanel:club_trial_start", args=[self.club.pk]), data)
def test_starting_a_trial_opens_a_short_first_period(self):
response = self.start_trial()
self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertEqual(self.club.subscription.tier, self.trial_tier)
self.assertEqual(self.club.subscription.post_trial_tier, self.tier)
self.assertEqual(self.club.subscription.plan, self.trial_plan)
self.assertEqual(self.club.subscription.post_trial_plan, self.plan)
due = self.club.dues.first()
self.assertTrue(due.is_trial)
self.assertLess((due.period_end - due.period_start).days, 65)
def test_a_club_already_subscribed_cannot_be_started_on_a_trial(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
response = self.client.post(
reverse("controlpanel:club_trial_start", args=[self.club.pk]),
{"trial_tier": self.trial_tier.pk, "post_trial_tier": self.tier.pk, "trial_months": 2, "start": ""},
{"trial_plan": self.trial_plan.pk, "post_trial_plan": self.plan.pk, "start": ""},
follow=True,
)
self.assertContains(response, "already subscribed")
self.assertEqual(self.club.dues.count(), 1)
def test_an_invalid_trial_length_redirects_back_with_an_error(self):
response = self.client.post(reverse("controlpanel:club_trial_start", args=[self.club.pk]), {"trial_tier": self.trial_tier.pk, "post_trial_tier": self.tier.pk, "trial_months": 0, "start": ""}, follow=True)
def test_a_non_trial_plan_is_not_offered_as_the_trial(self):
response = self.client.post(reverse("controlpanel:club_trial_start", args=[self.club.pk]), {"trial_plan": self.plan.pk, "post_trial_plan": self.plan.pk, "start": ""}, follow=True)
self.assertFalse(hasattr(self.club, "subscription"))
self.assertTrue(response.context["messages"])
@@ -1538,36 +1541,36 @@ class TrialPanelTests(ControlPanelTestBase):
self.assertContains(response, "On trial")
self.assertContains(response, "Standard")
def test_manually_changing_tier_mid_trial_clears_the_trial(self):
def test_manually_changing_plan_mid_trial_clears_the_trial(self):
self.start_trial()
other = Tier.objects.create(name="Other")
TierPrice.objects.create(tier=other, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("300.00"))
other = Plan.objects.create(name="Other")
PlanPrice.objects.create(plan=other, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("300.00"))
self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": other.pk, "auto_archive": "on", "notes": ""})
self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"plan": other.pk, "auto_archive": "on", "notes": ""})
self.club.refresh_from_db()
self.assertEqual(self.club.subscription.tier, other)
self.assertEqual(self.club.subscription.plan, other)
self.assertIsNone(self.club.subscription.trial_ends_at)
self.assertIsNone(self.club.subscription.post_trial_tier)
self.assertIsNone(self.club.subscription.post_trial_plan)
class BillingFormRenderTests(ControlPanelTestBase):
def setUp(self):
super().setUp()
self.today = timezone.localdate()
self.tier = Tier.objects.create(name="Standard")
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
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_the_billing_forms_are_post_only(self):
# Every one of these is reachable only through a modal on the billing or club
# detail page: there is no standalone template to render on a GET.
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
for url in (
reverse("controlpanel:tier_create"),
reverse("controlpanel:tier_update", args=[self.tier.pk]),
reverse("controlpanel:tier_price_create", args=[self.tier.pk]),
reverse("controlpanel:plan_create"),
reverse("controlpanel:plan_update", args=[self.plan.pk]),
reverse("controlpanel:plan_price_create", args=[self.plan.pk]),
reverse("controlpanel:club_subscribe", args=[self.club.pk]),
reverse("controlpanel:club_open_period", args=[self.club.pk]),
reverse("controlpanel:due_pay", args=[due.pk]),
@@ -1578,10 +1581,10 @@ class BillingFormRenderTests(ControlPanelTestBase):
# Rejected input has nowhere to re-render — the modal that submitted it is on a
# page this view no longer serves — so it must bounce back with an error message
# rather than 500 or silently drop the submission.
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
response = self.client.post(reverse("controlpanel:tier_create"), {"name": "", "description": "", "is_active": "on"}, follow=True)
response = self.client.post(reverse("controlpanel:plan_create"), {"name": "", "description": "", "is_active": "on"}, follow=True)
self.assertRedirects(response, reverse("controlpanel:billing"))
self.assertContains(response, "This field is required")
@@ -1590,7 +1593,7 @@ class BillingFormRenderTests(ControlPanelTestBase):
self.assertContains(response, "Enter a number")
def test_the_payment_modal_defaults_to_the_outstanding_balance(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
record_payment(due, Decimal("200.00"))
due.refresh_from_db()
@@ -1600,34 +1603,34 @@ class BillingFormRenderTests(ControlPanelTestBase):
rendered_due = next(rendered for rendered in response.context["dues"] if rendered.pk == due.pk)
self.assertEqual(rendered_due.payment_form.initial["amount"], Decimal("300.00"))
def test_a_tier_can_be_renamed(self):
self.client.post(reverse("controlpanel:tier_update", args=[self.tier.pk]), {"name": "Standard plus", "description": "", "is_active": "on"})
def test_a_plan_can_be_renamed(self):
self.client.post(reverse("controlpanel:plan_update", args=[self.plan.pk]), {"name": "Standard plus", "description": "", "is_active": "on", "duration_months": 12, "renewal_lead_days": 30, "grace_days": 30})
self.tier.refresh_from_db()
self.assertEqual(self.tier.name, "Standard plus")
self.plan.refresh_from_db()
self.assertEqual(self.plan.name, "Standard plus")
def test_changing_tier_leaves_the_open_period_alone(self):
def test_changing_plan_leaves_the_open_period_alone(self):
# The current period keeps the amount it was issued at; the new rate bites next time.
subscribe(self.club, self.tier)
premium = Tier.objects.create(name="Premium")
TierPrice.objects.create(tier=premium, active_from=self.today, amount=Decimal("900.00"))
subscribe(self.club, self.plan)
premium = Plan.objects.create(name="Premium")
PlanPrice.objects.create(plan=premium, active_from=self.today, amount=Decimal("900.00"))
response = self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": premium.pk, "auto_archive": "on", "notes": ""}, follow=True)
response = self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"plan": premium.pk, "auto_archive": "on", "notes": ""}, follow=True)
self.club.refresh_from_db()
self.assertEqual(self.club.subscription.tier, premium)
self.assertEqual(self.club.subscription.plan, premium)
self.assertEqual(self.club.dues.first().amount, Decimal("500.00"))
self.assertContains(response, "keeps the amount it was billed at")
def test_subscribing_to_an_unpriced_tier_reports_itself(self):
unpriced = Tier.objects.create(name="Enterprise")
def test_subscribing_to_an_unpriced_plan_reports_itself(self):
unpriced = Plan.objects.create(name="Enterprise")
response = self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": unpriced.pk, "auto_archive": "on", "notes": ""}, follow=True)
response = self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"plan": unpriced.pk, "auto_archive": "on", "notes": ""}, follow=True)
self.assertContains(response, "no price in force")
def test_billing_a_period_twice_reports_itself(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
start = self.club.dues.first().period_start
response = self.client.post(reverse("controlpanel:club_open_period", args=[self.club.pk]), {"start": start.isoformat()}, follow=True)
@@ -1635,7 +1638,7 @@ class BillingFormRenderTests(ControlPanelTestBase):
self.assertContains(response, "already billed")
def test_waiving_a_paid_period_reports_itself(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
due = self.club.dues.first()
record_payment(due, Decimal("500.00"))