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

@@ -4,64 +4,78 @@ from decimal import Decimal
from io import StringIO
from unittest import mock
from django.core import mail
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.core.management.base import CommandError
from django.db.utils import IntegrityError
from django.test import TestCase
from django.utils import timezone
from club.models import Club
from authentication.models import User
from club.models import Club, ClubRole
from members.models import Member
from .models import GRACE_DAYS, Due, Invoice, Subscription, Tier, TierPrice, add_one_year
from .models import DEFAULT_DURATION_MONTHS, DEFAULT_GRACE_DAYS, DEFAULT_RENEWAL_LEAD_DAYS, Due, Invoice, Plan, PlanPrice, Subscription, add_months
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.invoices import invoice_pdf, issue_invoice, render_pdf
from .services.notices import club_billing_notice
from .services.reminders import admin_emails, reminders_to_send, send_reminder
class BillingTestBase(TestCase):
def setUp(self):
self.today = timezone.localdate()
self.club = Club.objects.create(name="Ajax United")
self.tier = Tier.objects.create(name="Standard")
self.plan = Plan.objects.create(name="Standard")
# Priced well back, so a backdated (lapsed) period still has a price in force —
# opening one before any price existed is refused, and rightly so.
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
def bill(self, start=None, club=None):
return open_period(club or self.club, start=start, tier=self.tier)
return open_period(club or self.club, start=start, plan=self.plan)
class TierPriceTests(BillingTestBase):
class PlanPriceTests(BillingTestBase):
def test_the_price_in_force_is_the_latest_one_that_has_started(self):
TierPrice.objects.create(tier=self.tier, active_from=self.today, amount=Decimal("600.00"))
PlanPrice.objects.create(plan=self.plan, active_from=self.today, amount=Decimal("600.00"))
self.assertEqual(self.tier.price_on(self.today - datetime.timedelta(days=1)), Decimal("500.00"))
self.assertEqual(self.tier.price_on(self.today), Decimal("600.00"))
self.assertEqual(self.plan.price_on(self.today - datetime.timedelta(days=1)), Decimal("500.00"))
self.assertEqual(self.plan.price_on(self.today), Decimal("600.00"))
def test_a_future_price_does_not_apply_yet(self):
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=30), amount=Decimal("600.00"))
PlanPrice.objects.create(plan=self.plan, active_from=self.today + datetime.timedelta(days=30), amount=Decimal("600.00"))
self.assertEqual(self.tier.price_on(self.today), Decimal("500.00"))
self.assertEqual(self.plan.price_on(self.today), Decimal("500.00"))
def test_a_tier_with_no_price_yet_cannot_be_billed(self):
def test_a_plan_with_no_price_yet_cannot_be_billed(self):
# None must never be read as free.
empty = Tier.objects.create(name="Enterprise")
empty = Plan.objects.create(name="Enterprise")
self.assertIsNone(empty.price_on(self.today))
with self.assertRaises(BillingError):
open_period(self.club, tier=empty)
open_period(self.club, plan=empty)
class PeriodTests(BillingTestBase):
def test_a_period_runs_a_rolling_year_with_a_grace_tail(self):
def test_a_period_runs_for_the_plans_duration(self):
due = self.bill(start=datetime.date(2026, 3, 1))
self.assertEqual(due.period_end, datetime.date(2027, 2, 28))
self.assertEqual(due.grace_until, due.period_end + datetime.timedelta(days=GRACE_DAYS))
def test_grace_is_measured_from_the_period_start_not_its_end(self):
# The whole point of the redesign: measured from the end, an annual club would get
# ~410 days of unpaid use before anything switched it off.
due = self.bill(start=datetime.date(2026, 3, 1))
self.assertEqual(due.grace_until, datetime.date(2026, 3, 1) + datetime.timedelta(days=DEFAULT_GRACE_DAYS))
self.assertLess(due.grace_until, due.period_end)
def test_a_leap_day_period_does_not_explode(self):
# 29 February has no counterpart in a common year.
self.assertEqual(add_one_year(datetime.date(2028, 2, 29)), datetime.date(2029, 2, 28))
self.assertEqual(add_months(datetime.date(2028, 2, 29), 12), datetime.date(2029, 2, 28))
def test_the_next_period_continues_from_the_last_one(self):
# Not from today: a club that pays two months late has still used those two months,
@@ -75,7 +89,7 @@ class PeriodTests(BillingTestBase):
def test_the_amount_is_snapshotted_at_the_price_of_the_day(self):
due = self.bill()
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=1), amount=Decimal("900.00"))
PlanPrice.objects.create(plan=self.plan, active_from=self.today + datetime.timedelta(days=1), amount=Decimal("900.00"))
due.refresh_from_db()
# Raising the rate must not rewrite what was already billed.
@@ -87,16 +101,16 @@ class PeriodTests(BillingTestBase):
with self.assertRaises(BillingError):
self.bill(start=self.today)
def test_a_club_with_no_tier_cannot_be_billed(self):
def test_a_club_with_no_plan_cannot_be_billed(self):
with self.assertRaises(BillingError):
open_period(Club.objects.create(name="Feyenoord"))
def test_subscribing_puts_a_club_on_a_tier_and_opens_a_period(self):
def test_subscribing_puts_a_club_on_a_plan_and_opens_a_period(self):
club = Club.objects.create(name="Feyenoord")
subscribe(club, self.tier)
subscribe(club, self.plan)
self.assertEqual(Subscription.objects.get(club=club).tier, self.tier)
self.assertEqual(Subscription.objects.get(club=club).plan, self.plan)
self.assertEqual(club.dues.count(), 1)
@@ -175,15 +189,24 @@ class PaymentTests(BillingTestBase):
class GraceAndArchiveTests(BillingTestBase):
LAPSED = 365 + GRACE_DAYS + 10
LAPSED = DEFAULT_GRACE_DAYS + 10
def test_a_period_past_its_end_but_inside_grace_is_in_grace(self):
due = self.bill(start=self.today - datetime.timedelta(days=370))
def test_a_started_but_unpaid_period_inside_grace_is_in_grace(self):
# Grace runs from the period START now, so this is a period that began a few days
# ago and has not been paid -- not one that has already run its full length.
due = self.bill(start=self.today - datetime.timedelta(days=5))
self.assertTrue(due.is_in_grace(self.today))
self.assertFalse(due.is_overdue(self.today))
self.assertIn(due, dues_in_grace(self.today))
def test_a_period_issued_ahead_of_its_start_is_not_yet_in_grace(self):
due = self.bill(start=self.today + datetime.timedelta(days=10))
self.assertTrue(due.is_issued_ahead(self.today))
self.assertFalse(due.is_in_grace(self.today))
self.assertFalse(due.is_overdue(self.today))
def test_a_period_past_grace_is_overdue(self):
due = self.bill(start=self.today - datetime.timedelta(days=self.LAPSED))
@@ -200,19 +223,19 @@ class GraceAndArchiveTests(BillingTestBase):
self.assertNotIn(due, dues_overdue(self.today))
def test_an_overdue_club_is_archivable(self):
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=self.LAPSED))
self.assertEqual(archivable_clubs(self.today).count(), 1)
def test_a_club_that_opted_out_is_never_archived(self):
# auto_archive off is how you stop a club you are negotiating with from being
# switched off overnight.
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED), auto_archive=False)
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=self.LAPSED), auto_archive=False)
self.assertEqual(archivable_clubs(self.today).count(), 0)
def test_an_already_archived_club_is_not_archived_again(self):
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=self.LAPSED))
self.club.archive()
self.assertEqual(archivable_clubs(self.today).count(), 0)
@@ -221,7 +244,7 @@ class GraceAndArchiveTests(BillingTestBase):
class ArchiveCommandTests(BillingTestBase):
def setUp(self):
super().setUp()
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=365 + GRACE_DAYS + 10))
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 10))
def run_command(self, *args):
out = StringIO()
@@ -253,9 +276,9 @@ class ArchiveCommandTests(BillingTestBase):
class ReactivationTests(BillingTestBase):
def setUp(self):
super().setUp()
# Through subscribe(), not open_period(): reactivating reads the club's tier off its
# Through subscribe(), not open_period(): reactivating reads the club's plan off its
# subscription, and a club billed without one cannot be re-billed later.
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.first = self.club.dues.first()
self.club.archive()
@@ -282,7 +305,7 @@ class InvoiceTests(BillingTestBase):
# Unlike the shop's per-club order numbers: these are OUR invoices, and one sequence
# covers every club we bill.
first = self.bill(start=self.today).invoice
second = open_period(Club.objects.create(name="Feyenoord"), tier=self.tier).invoice
second = open_period(Club.objects.create(name="Feyenoord"), plan=self.plan).invoice
year = timezone.now().year
self.assertEqual(first.number, f"INV-{year}-00001")
@@ -330,12 +353,12 @@ class ModelStringTests(BillingTestBase):
due = self.bill()
payment = record_payment(due, Decimal("10.00"))
self.assertEqual(str(self.tier), "Standard")
self.assertIn("500.00", str(self.tier.prices.first()))
self.assertEqual(str(self.plan), "Standard")
self.assertIn("500.00", str(self.plan.prices.first()))
self.assertIn("Ajax United", str(due))
self.assertIn("10.00", str(payment))
self.assertIn("INV-", str(due.invoice))
self.assertIn("Standard", str(subscribe(Club.objects.create(name="PSV"), self.tier)))
self.assertIn("Standard", str(subscribe(Club.objects.create(name="PSV"), self.plan)))
class RenewalTests(BillingTestBase):
@@ -346,7 +369,7 @@ class RenewalTests(BillingTestBase):
def ending_in(self, days, **kwargs):
"""A club whose current period ends `days` from now."""
club = Club.objects.create(name=f"Club {days}")
subscribe(club, self.tier, start=self.today - datetime.timedelta(days=365 - days), **kwargs)
subscribe(club, self.plan, start=self.today - datetime.timedelta(days=365 - days), **kwargs)
return club
def test_a_club_nearing_its_end_date_is_picked_up(self):
@@ -395,7 +418,7 @@ class RenewalTests(BillingTestBase):
def test_the_new_period_is_billed_at_the_price_in_force_then(self):
club = self.ending_in(20)
TierPrice.objects.create(tier=self.tier, active_from=self.today, amount=Decimal("900.00"))
PlanPrice.objects.create(plan=self.plan, active_from=self.today, amount=Decimal("900.00"))
due = renew(club.subscription)
@@ -427,15 +450,15 @@ class RenewalTests(BillingTestBase):
self.assertEqual(club.dues.count(), 2)
def test_an_unpriced_tier_fails_loudly_without_stopping_the_others(self):
def test_an_unpriced_plan_fails_loudly_without_stopping_the_others(self):
priced = self.ending_in(20)
broken = Club.objects.create(name="Unpriced FC")
subscribe(broken, self.tier, start=self.today - datetime.timedelta(days=350))
subscribe(broken, self.plan, start=self.today - datetime.timedelta(days=350))
# Its next period starts beyond the last price... by removing every price, it cannot bill.
TierPrice.objects.all().delete()
cheap = Tier.objects.create(name="Cheap")
TierPrice.objects.create(tier=cheap, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("100.00"))
priced.subscription.tier = cheap
PlanPrice.objects.all().delete()
cheap = Plan.objects.create(name="Cheap")
PlanPrice.objects.create(plan=cheap, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("100.00"))
priced.subscription.plan = cheap
priced.subscription.save()
with self.assertRaises(CommandError):
@@ -446,7 +469,7 @@ class RenewalTests(BillingTestBase):
def test_a_subscription_with_no_period_at_all_is_renewed(self):
club = Club.objects.create(name="Orphan FC")
Subscription.objects.create(club=club, tier=self.tier)
Subscription.objects.create(club=club, plan=self.plan)
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal()])
@@ -469,7 +492,7 @@ class RenewedButUnpaidTests(BillingTestBase):
"""A club on its first, PAID period — far enough back that a renewal from its end is
itself already past grace, so only the renewal's payment state decides the outcome."""
club = Club.objects.create(name="Renewed FC")
subscribe(club, self.tier, start=self.today - datetime.timedelta(days=800))
subscribe(club, self.plan, start=self.today - datetime.timedelta(days=800))
first = club.dues.first()
record_payment(first, first.amount) # the FIRST period is settled; only the renewal is in question
return club
@@ -498,66 +521,281 @@ class TrialTests(BillingTestBase):
def setUp(self):
super().setUp()
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"))
# A trial is a plan whose own duration_months IS the trial length -- there is no
# trial_months argument any more.
self.trial_plan = Plan.objects.create(name="Trial", duration_months=2, is_trial=True, grace_days=14, renewal_lead_days=7)
PlanPrice.objects.create(plan=self.trial_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("50.00"))
def test_start_trial_creates_a_short_trial_period(self):
due = start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
due = start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
subscription = self.club.subscription
self.assertEqual(subscription.tier, self.trial_tier)
self.assertEqual(subscription.post_trial_tier, self.tier)
self.assertEqual(subscription.plan, self.trial_plan)
self.assertEqual(subscription.post_trial_plan, self.plan)
self.assertEqual(subscription.trial_ends_at, due.period_end)
self.assertTrue(due.is_trial)
# Roughly 2 months, nowhere near the standard ~1-year period.
self.assertLess((due.period_end - due.period_start).days, 65)
def test_start_trial_refuses_if_already_subscribed(self):
subscribe(self.club, self.tier)
subscribe(self.club, self.plan)
with self.assertRaises(BillingError):
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
def test_start_trial_refuses_a_non_positive_length(self):
with self.assertRaises(BillingError):
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=0)
def test_the_trials_length_comes_from_its_plan(self):
due = start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
def test_renewing_after_the_trial_switches_to_the_post_trial_tier(self):
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
self.assertEqual(due.period_end, add_months(due.period_start, 2) - datetime.timedelta(days=1))
def test_renewing_after_the_trial_switches_to_the_post_trial_plan(self):
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
due = renew(self.club.subscription)
self.club.refresh_from_db()
self.assertEqual(self.club.subscription.tier, self.tier)
self.assertEqual(self.club.subscription.plan, self.plan)
self.assertIsNone(self.club.subscription.trial_ends_at)
self.assertIsNone(self.club.subscription.post_trial_tier)
self.assertEqual(due.tier, self.tier)
self.assertIsNone(self.club.subscription.post_trial_plan)
self.assertEqual(due.plan, self.plan)
self.assertFalse(due.is_trial)
self.assertEqual(due.amount, Decimal("500.00"))
def test_manually_opening_the_next_period_also_switches_tier(self):
def test_manually_opening_the_next_period_also_switches_plan(self):
# Same conversion must fire via the control panel's "Open period" button, which
# calls open_period() directly rather than renew().
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
open_period(self.club)
self.club.refresh_from_db()
self.assertEqual(self.club.subscription.tier, self.tier)
self.assertEqual(self.club.subscription.plan, self.plan)
def test_a_trial_nearing_its_end_is_picked_up_for_renewal(self):
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2, start=self.today - datetime.timedelta(days=50))
# Inside the TRIAL PLAN's own 7-day lead, not the 30-day one an annual plan uses:
# a 2-month trial renewed a month early would be renewed before it had begun.
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan, start=self.today - datetime.timedelta(days=57))
self.assertIn(self.club, [s.club for s in subscriptions_due_for_renewal()])
def test_a_zero_amount_trial_is_created_already_paid(self):
free_tier = Tier.objects.create(name="Free Trial")
TierPrice.objects.create(tier=free_tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("0.00"))
def test_a_trial_outside_its_own_lead_window_is_not_yet_renewed(self):
# Same trial 7 days earlier in its life: an annual plan's 30-day lead would have
# picked this up, and the per-plan lead is exactly what stops that.
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan, start=self.today - datetime.timedelta(days=40))
due = start_trial(self.club, free_tier, post_trial_tier=self.tier, trial_months=2)
self.assertNotIn(self.club, [s.club for s in subscriptions_due_for_renewal()])
def test_a_zero_amount_trial_is_created_already_paid(self):
free_plan = Plan.objects.create(name="Free Trial")
PlanPrice.objects.create(plan=free_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("0.00"))
due = start_trial(self.club, free_plan, post_trial_plan=self.plan)
self.assertEqual(due.status, Due.Status.PAID)
self.assertIsNotNone(due.paid_at)
far_future = due.grace_until + datetime.timedelta(days=100)
self.assertNotIn(due, dues_overdue(far_future))
self.assertNotIn(self.club, [d.club for d in archivable_clubs(far_future)])
class PlanClockTests(BillingTestBase):
"""The three per-plan clocks, and the constraints that keep them sane -- see BILLING.md §3."""
def make_plan(self, **kwargs):
# A short plan cannot keep the annual defaults -- 30 days' lead on a 1-month period is
# exactly what the constraints forbid, so scale them down with the duration.
months = kwargs.get("duration_months", DEFAULT_DURATION_MONTHS)
defaults = {"name": f"Plan {Plan.objects.count()}", "renewal_lead_days": min(DEFAULT_RENEWAL_LEAD_DAYS, months * 7), "grace_days": min(DEFAULT_GRACE_DAYS, months * 14)}
plan = Plan.objects.create(**defaults | kwargs)
PlanPrice.objects.create(plan=plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("10.00"))
return plan
def test_a_monthly_plan_gets_a_one_month_period(self):
plan = self.make_plan(duration_months=1)
due = open_period(self.club, plan=plan, start=datetime.date(2026, 3, 1))
self.assertEqual(due.period_end, datetime.date(2026, 3, 31))
def test_a_quarterly_plan_gets_a_three_month_period(self):
plan = self.make_plan(duration_months=3)
due = open_period(self.club, plan=plan, start=datetime.date(2026, 3, 1))
self.assertEqual(due.period_end, datetime.date(2026, 5, 31))
def test_grace_days_are_per_plan(self):
plan = self.make_plan(duration_months=1, grace_days=14)
due = open_period(self.club, plan=plan, start=datetime.date(2026, 3, 1))
self.assertEqual(due.grace_until, datetime.date(2026, 3, 15))
def test_editing_a_plans_grace_does_not_move_an_open_period(self):
# grace_until is a stored snapshot for the same reason `amount` is: repricing the
# plan must not silently re-date an archiving already in flight.
plan = self.make_plan(grace_days=30)
due = open_period(self.club, plan=plan, start=self.today)
original = due.grace_until
plan.grace_days = 1
plan.save(update_fields=["grace_days"])
due.refresh_from_db()
self.assertEqual(due.grace_until, original)
def test_a_lead_longer_than_the_period_is_rejected(self):
with self.assertRaises(IntegrityError):
Plan.objects.create(name="Runaway", duration_months=1, renewal_lead_days=90)
def test_grace_longer_than_the_period_is_rejected(self):
with self.assertRaises(IntegrityError):
Plan.objects.create(name="Never archives", duration_months=1, grace_days=90)
def test_full_clean_reports_an_impossible_lead_as_a_form_error(self):
# Not an IntegrityError/500: a platform admin typing this into the plan form should
# be told which field is wrong.
plan = Plan(name="Runaway", duration_months=1, renewal_lead_days=90, grace_days=14)
with self.assertRaises(ValidationError) as caught:
plan.full_clean()
self.assertIn("renewal_lead_days", caught.exception.error_dict)
def test_renewal_lead_is_read_from_each_plan(self):
monthly = self.make_plan(duration_months=1, renewal_lead_days=7, grace_days=14)
club = Club.objects.create(name="Monthly FC")
# Period ends in 3 days: inside a 7-day lead, well outside an annual plan's 30.
subscribe(club, monthly, start=self.today - datetime.timedelta(days=27))
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_an_explicit_lead_days_overrides_every_plan(self):
monthly = self.make_plan(duration_months=1, renewal_lead_days=1, grace_days=14)
club = Club.objects.create(name="Override FC")
subscribe(club, monthly, start=self.today - datetime.timedelta(days=20))
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal(lead_days=30)])
class BillingNoticeTests(BillingTestBase):
"""What a club's own admins are told -- see billing/services/notices.py."""
def test_no_notice_when_nothing_is_owed(self):
due = self.bill()
record_payment(due, Decimal("500.00"))
self.assertIsNone(club_billing_notice(self.club, self.today))
def test_no_notice_for_a_club_that_was_never_billed(self):
self.assertIsNone(club_billing_notice(self.club, self.today))
def test_a_period_issued_ahead_of_its_start_is_only_informational(self):
self.bill(start=self.today + datetime.timedelta(days=10))
self.assertEqual(club_billing_notice(self.club, self.today).level, "info")
def test_an_unpaid_started_period_warns(self):
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=1))
notice = club_billing_notice(self.club, self.today)
self.assertEqual(notice.level, "warning")
self.assertEqual(notice.amount_outstanding, Decimal("500.00"))
self.assertFalse(notice.is_urgent)
def test_the_last_week_before_archiving_is_urgent(self):
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS - 2))
notice = club_billing_notice(self.club, self.today)
self.assertEqual(notice.level, "error")
self.assertTrue(notice.is_urgent)
self.assertEqual(notice.days_until_archive, 2)
def test_an_overdue_period_is_urgent_with_a_negative_countdown(self):
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 5))
notice = club_billing_notice(self.club, self.today)
self.assertEqual(notice.level, "error")
self.assertLess(notice.days_until_archive, 0)
def test_auto_archive_off_still_reports_the_debt_but_promises_no_archiving(self):
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=1), auto_archive=False)
notice = club_billing_notice(self.club, self.today)
self.assertEqual(notice.amount_outstanding, Decimal("500.00"))
self.assertFalse(notice.will_archive)
def test_the_soonest_archiving_due_is_the_one_reported(self):
self.bill(start=self.today - datetime.timedelta(days=1))
later = self.bill(start=self.today + datetime.timedelta(days=400))
self.assertNotEqual(club_billing_notice(self.club, self.today).due, later)
class BillingReminderTests(BillingTestBase):
"""Reminder emails -- see billing/services/reminders.py. Sent once per escalation
level, because the command is on a daily cron."""
def setUp(self):
super().setUp()
user = User.objects.create_user(email="admin@ajax.example", password="pw-secret-123")
member = Member.objects.create(user=user, first_name="Ada", last_name="Admin")
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.ADMIN)
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=1))
self.due = self.club.dues.first()
def test_a_reminder_goes_to_the_club_admins(self):
self.assertEqual(admin_emails(self.club), ["admin@ajax.example"])
def test_sending_records_the_level_and_fills_the_outbox(self):
notice = club_billing_notice(self.club, self.today)
send_reminder(self.club, notice, recipients=["admin@ajax.example"])
self.due.refresh_from_db()
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(self.due.last_reminder_level, notice.level)
self.assertIsNotNone(self.due.last_reminder_sent_at)
def test_a_second_run_at_the_same_level_sends_nothing(self):
notice = club_billing_notice(self.club, self.today)
send_reminder(self.club, notice, recipients=["admin@ajax.example"])
self.due.refresh_from_db()
results = reminders_to_send([self.club], self.today)
self.assertFalse(results[0].sent)
self.assertIn("already reminded", results[0].skipped_reason)
def test_an_escalation_gets_through(self):
send_reminder(self.club, club_billing_notice(self.club, self.today), recipients=["admin@ajax.example"])
# Far enough on that the same due is now urgent rather than merely a warning.
later = self.today + datetime.timedelta(days=DEFAULT_GRACE_DAYS)
results = reminders_to_send([self.club], later)
self.assertTrue(results[0].sent)
self.assertEqual(results[0].notice.level, "error")
def test_force_resends_at_the_same_level(self):
send_reminder(self.club, club_billing_notice(self.club, self.today), recipients=["admin@ajax.example"])
self.due.refresh_from_db()
self.assertTrue(reminders_to_send([self.club], self.today, force=True)[0].sent)
def test_a_club_with_no_reachable_admin_is_reported_not_skipped_silently(self):
ClubRole.objects.all().delete()
results = reminders_to_send([self.club], self.today)
self.assertFalse(results[0].sent)
self.assertIn("no club admin", results[0].skipped_reason)
def test_a_settled_club_produces_no_reminder(self):
record_payment(self.due, Decimal("500.00"))
self.assertEqual(reminders_to_send([self.club], self.today), [])