diff --git a/controlpanel/forms.py b/controlpanel/forms.py index 0bddd5a..92a077d 100644 --- a/controlpanel/forms.py +++ b/controlpanel/forms.py @@ -1,7 +1,10 @@ +from decimal import Decimal + from django import forms from django.utils.translation import gettext_lazy as _ from waffle import get_waffle_flag_model +from billing.models import DuePayment, Subscription, Tier, TierPrice from club.models import Club from .services.admins import find_member_by_email @@ -56,3 +59,47 @@ class FlagForm(forms.ModelForm): help_texts = { "everyone": _("Yes = on for all clubs, No = off everywhere (overrides club targeting). Leave unknown to target clubs."), } + + +class TierForm(forms.ModelForm): + class Meta: + model = Tier + fields = ["name", "description", "is_active"] + + +class TierPriceForm(forms.ModelForm): + class Meta: + model = TierPrice + fields = ["active_from", "amount"] + widgets = {"active_from": forms.DateInput(attrs={"type": "date"})} + help_texts = {"active_from": _("Periods opening on or after this date are billed at this amount. Existing periods keep the amount they were billed at.")} + + +class SubscriptionForm(forms.ModelForm): + """Put a club on a tier. The first period opens when the subscription is created.""" + + start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("First period starts"), help_text=_("Left blank, the period starts today.")) + + class Meta: + model = Subscription + fields = ["tier", "auto_archive", "notes"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # An inactive tier still bills its existing subscriptions, but must not be picked up + # by a new one — which is the whole point of retiring a tier. + self.fields["tier"].queryset = Tier.objects.filter(is_active=True) + + +class DuePaymentForm(forms.Form): + amount = forms.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal("0.01"), label=_("Amount")) + method = forms.ChoiceField(choices=DuePayment.Method.choices, initial=DuePayment.Method.BANK_TRANSFER, label=_("Method")) + reference = forms.CharField(required=False, label=_("Reference"), help_text=_("Bank reference, transaction id — whatever lets you find this again.")) + paid_at = forms.DateTimeField(required=False, widget=forms.DateTimeInput(attrs={"type": "datetime-local"}), label=_("Received"), help_text=_("Left blank, now.")) + note = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 2}), label=_("Note")) + + +class OpenPeriodForm(forms.Form): + """Renew, or reactivate an archived club.""" + + start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("Period starts"), help_text=_("Left blank, it continues from the end of the last period — so a lapsed year is still owed.")) diff --git a/controlpanel/services/statistics.py b/controlpanel/services/statistics.py index 406fb91..ea0fe45 100644 --- a/controlpanel/services/statistics.py +++ b/controlpanel/services/statistics.py @@ -17,6 +17,8 @@ from django.utils import timezone from waffle import get_waffle_flag_model from authentication.middleware import ELEVATED_ROLES +from billing.models import Due, DuePayment, Subscription +from billing.services.dues import dues_in_grace, dues_overdue from club.models import Club, ClubMembership, ClubRole, Season from events.models import Attendance, Event from members.models import Member @@ -78,6 +80,10 @@ def clubs_with_health(queryset=None, today=None, now=None): team_count=_subquery(Team.objects.all(), Count("pk"), IntegerField()), teams_managed=_subquery(Team.objects.filter(managed_this_season), Count("pk", distinct=True), IntegerField()), admin_count=_subquery(ClubRole.objects.filter(role=ClubRole.Roles.ADMIN), Count("pk"), IntegerField()), + tier_name=Subquery(Subscription.objects.filter(club=OuterRef("pk")).values("tier__name")[:1]), + dues_owed=_subquery(Due.objects.filter(status__in=Due.OWING), Sum(F("amount") - F("amount_paid")), DecimalField(max_digits=10, decimal_places=2)), + dues_grace_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("grace_until").values("grace_until")[:1]), + dues_period_end=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("period_end").values("period_end")[:1]), ) .annotate(teams_without_coach=F("team_count") - F("teams_managed")) .order_by("name") @@ -160,9 +166,22 @@ def platform_attention(): "outstanding": _money(Order.objects.filter(status__in=OWED_STATUSES)), "members_without_login": Member.objects.filter(user__isnull=True).count(), "members": members, + # Platform billing: what the clubs owe US. Distinct from `outstanding`, which is + # what members owe their clubs — that money is never ours. + "dues_owed": _dues_owed(), + "dues_in_grace": dues_in_grace().count(), + "dues_overdue": dues_overdue().count(), + "clubs_unbilled": Club.objects.active().filter(subscription__isnull=True).count(), } +def _dues_owed(): + """What clubs owe the platform right now.""" + owed = Due.objects.filter(status__in=Due.OWING).aggregate(total=Sum(F("amount") - F("amount_paid")))["total"] + + return owed or ZERO + + def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY): """A dense month-by-month series — zero-filled, because a chart that silently skips empty months draws a smooth line over a month where nothing happened.""" @@ -183,7 +202,11 @@ def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY): def platform_charts(): return { "signups": signup_split(), - "revenue": _monthly(Order.objects.filter(status__in=PAID_STATUSES), "created", Sum("total")), + # Two different pots of money: `dues` is platform income (clubs paying us), while + # `club_revenue` is members paying their clubs — never ours, and labelling it + # "revenue" on our dashboard would be a lie. + "dues": _monthly(DuePayment.objects.all(), "paid_at", Sum("amount")), + "club_revenue": _monthly(Order.objects.filter(status__in=PAID_STATUSES), "created", Sum("total")), } diff --git a/controlpanel/templates/controlpanel/_billing_form.html b/controlpanel/templates/controlpanel/_billing_form.html new file mode 100644 index 0000000..1ec68c7 --- /dev/null +++ b/controlpanel/templates/controlpanel/_billing_form.html @@ -0,0 +1,40 @@ +{% load lucide ui %} + +{% comment %} + The shell every billing form uses: card, fields, cancel + submit. Included with + `heading`, `blurb`, `submit_label`, `submit_icon` and `cancel_url`. +{% endcomment %} +
{{ blurb }}
{% endif %} + +What the platform charges its clubs.
{% endblock subheading %} + +{% block actions %} + {% lucide "plus" size=16 %} New tier +{% endblock actions %} + +{% block panel %} +A rate change is a new dated price. Periods already billed keep the amount they were issued at.
+| Tier | +Clubs | +Prices | ++ |
|---|---|---|---|
|
+ {{ tier.name }}
+ {% if not tier.is_active %}Retired{% endif %}
+ {% if tier.description %}{{ tier.description }} {% endif %}
+ |
+ {{ tier.club_count }} | +
+ {% for price in tier.prices.all %}
+
+ €{{ price.amount|floatformat:2 }}
+ from {{ price.active_from|date:"j M Y" }}
+ {% if price.active_from > today %}Scheduled{% endif %}
+
+ {% empty %}
+ No price — cannot be billed
+ {% endfor %}
+ |
+ + {% lucide "euro" size=14 %} New price + {% lucide "pencil" size=14 %} Edit + | +
| No tiers yet. | +|||
| Club | +Period | +Owed | +Status | ++ |
|---|---|---|---|---|
|
+ {{ due.club.name }}
+ {{ due.tier.name }}
+ |
+
+ {{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
+ Grace to {{ due.grace_until|date:"j M Y" }}
+ |
+ €{{ due.balance|floatformat:2 }} | ++ {% if due.grace_until < today %} + {% lucide "triangle-alert" size=12 %} Overdue + {% elif due.period_end < today %} + {% lucide "hourglass" size=12 %} In grace + {% else %} + {{ due.get_status_display }} + {% endif %} + | ++ {% lucide "banknote" size=14 %} Record payment + {% lucide "file-text" size=14 %} Invoice + | +
| Nothing outstanding. | +||||
This club is not billed for anything. Put it on a tier to start.
+ {% else %} ++ On {{ subscription.tier.name }}. + {% if subscription.auto_archive %} + Archived automatically when a period goes unpaid past its grace period. + {% else %} + Auto-archive off — it will never be archived for non-payment. + {% endif %} +
+ +| Period | +Billed | +Paid | +Status | ++ |
|---|---|---|---|---|
|
+ {{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
+ {{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}
+ |
+ €{{ due.amount|floatformat:2 }} | +€{{ due.amount_paid|floatformat:2 }} | ++ {% if due.status == "paid" %} + {% lucide "check" size=12 %} Paid + {% elif due.status == "waived" %} + Waived + {% elif due.grace_until < today %} + {% lucide "triangle-alert" size=12 %} Overdue + {% elif due.period_end < today %} + {% lucide "hourglass" size=12 %} In grace + {% else %} + {{ due.get_status_display }} + {% endif %} + | ++ {% if due.is_owing %} + {% lucide "banknote" size=14 %} Pay + {% if not due.payments.all %} + + {% endif %} + {% endif %} + {% lucide "file-text" size=14 %} Invoice + | +
| + {% lucide "corner-down-right" size=12 %} + {{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %} + | +€{{ payment.amount|floatformat:2 }} | ++ | ||
| No periods billed yet. | +||||
What clubs paid us. Club-shop money is theirs, not ours.
+ {{ due.period_start|date:"j M Y" }} to {{ due.period_end|date:"j M Y" }} · €{{ due.amount|floatformat:2 }} billed · €{{ due.balance|floatformat:2 }} outstanding +
+{% endblock subheading %} + +{% block panel %} + {% url 'controlpanel:club_detail' due.club.pk as club_url %} + {% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Record payment" submit_icon="banknote" blurb="Part payments are fine: they accumulate against the period until it is settled." %} +{% endblock panel %} diff --git a/controlpanel/templates/controlpanel/period_form.html b/controlpanel/templates/controlpanel/period_form.html new file mode 100644 index 0000000..0d5bbe6 --- /dev/null +++ b/controlpanel/templates/controlpanel/period_form.html @@ -0,0 +1,12 @@ +{% extends "controlpanel/base.html" %} + +{% block heading %}{% if club.is_archived %}Reactivate{% else %}Open period{% endif %} — {{ club }}{% endblock heading %} + +{% block subheading %} +Next period starts {{ next_start|date:"j M Y" }} unless you say otherwise.
+{% endblock subheading %} + +{% block panel %} + {% url 'controlpanel:club_detail' club.pk as club_url %} + {% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Open period" submit_icon="calendar-plus" blurb="By default the period continues from the end of the last one, so a lapsed year is still owed. Pick a start date to forgive the gap." %} +{% endblock panel %} diff --git a/controlpanel/templates/controlpanel/subscription_form.html b/controlpanel/templates/controlpanel/subscription_form.html new file mode 100644 index 0000000..0557502 --- /dev/null +++ b/controlpanel/templates/controlpanel/subscription_form.html @@ -0,0 +1,8 @@ +{% extends "controlpanel/base.html" %} + +{% block heading %}{% if subscription %}Change plan{% else %}Start billing{% endif %} — {{ club }}{% endblock heading %} + +{% block panel %} + {% url 'controlpanel:club_detail' club.pk as club_url %} + {% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Save plan" submit_icon="layers" blurb="Changing tier does not re-bill: the current period keeps the amount it was issued at, and the new rate applies from the next one." %} +{% endblock panel %} diff --git a/controlpanel/templates/controlpanel/tier_form.html b/controlpanel/templates/controlpanel/tier_form.html new file mode 100644 index 0000000..d38cb77 --- /dev/null +++ b/controlpanel/templates/controlpanel/tier_form.html @@ -0,0 +1,8 @@ +{% extends "controlpanel/base.html" %} + +{% block heading %}{% if object %}Edit {{ object }}{% else %}New tier{% endif %}{% endblock heading %} + +{% block panel %} + {% url 'controlpanel:billing' as billing_url %} + {% include "controlpanel/_billing_form.html" with cancel_url=billing_url submit_label="Save tier" submit_icon="layers" blurb="A tier has no price until you add one. A tier with no price cannot be billed." %} +{% endblock panel %} diff --git a/controlpanel/templates/controlpanel/tier_price_form.html b/controlpanel/templates/controlpanel/tier_price_form.html new file mode 100644 index 0000000..5802ebf --- /dev/null +++ b/controlpanel/templates/controlpanel/tier_price_form.html @@ -0,0 +1,8 @@ +{% extends "controlpanel/base.html" %} + +{% block heading %}New price for {{ tier }}{% endblock heading %} + +{% block panel %} + {% url 'controlpanel:billing' as billing_url %} + {% include "controlpanel/_billing_form.html" with cancel_url=billing_url submit_label="Add price" submit_icon="euro" blurb="Prices are dated, never edited: periods already opened keep the amount they were billed at, so this cannot rewrite an invoice you have already sent." %} +{% endblock panel %} diff --git a/controlpanel/tests.py b/controlpanel/tests.py index c40a4df..444151e 100644 --- a/controlpanel/tests.py +++ b/controlpanel/tests.py @@ -1,6 +1,7 @@ import datetime import pathlib from decimal import Decimal +from unittest import mock from allauth.mfa.models import Authenticator from django import forms @@ -14,6 +15,9 @@ 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.services import BillingError +from billing.services.dues import record_payment, subscribe from club.models import Club, ClubMembership, ClubRole, Season from events.models import Attendance, Event from members.models import Member @@ -730,11 +734,13 @@ class PlatformChartTests(TestCase): self.assertEqual(platform_charts()["signups"][-1]["new"], 1) - def test_only_paid_orders_count_as_revenue(self): + def test_only_paid_orders_count_as_club_revenue(self): + # `club_revenue` is members paying their clubs. It is NOT platform income, which is + # why it no longer shares a chart (or a name) with our dues. Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("50.00"), status=Order.OrderStatus.PAID) Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("30.00"), status=Order.OrderStatus.PENDING) - self.assertEqual(platform_charts()["revenue"][-1]["value"], 50.0) + self.assertEqual(platform_charts()["club_revenue"][-1]["value"], 50.0) self.assertEqual(platform_attention()["outstanding"], Decimal("30.00")) @@ -1055,3 +1061,257 @@ class TemplateCommentTests(TestCase): self.assertTrue(templates) # the glob must actually be finding our templates self.assertEqual(offenders, [], "use {% comment %} for multi-line comments") + + +class PlatformDuesMetricTests(TestCase): + """What the clubs owe US — kept strictly apart from what members owe their clubs.""" + + 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")) + + def test_dues_owed_is_the_unpaid_balance_across_every_club(self): + subscribe(self.club, self.tier) + record_payment(self.club.dues.first(), Decimal("200.00")) + + self.assertEqual(platform_attention()["dues_owed"], Decimal("300.00")) + + 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)) + + 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): + self.assertEqual(platform_attention()["clubs_unbilled"], 1) + + subscribe(self.club, self.tier) + + self.assertEqual(platform_attention()["clubs_unbilled"], 0) + + def test_platform_dues_and_club_shop_money_are_different_charts(self): + subscribe(self.club, self.tier) + record_payment(self.club.dues.first(), Decimal("500.00")) + + charts = platform_charts() + + self.assertEqual(charts["dues"][-1]["value"], 500.0) + 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) + + club = clubs_with_health().get(pk=self.club.pk) + + self.assertEqual(club.tier_name, "Standard") + self.assertEqual(club.dues_owed, Decimal("500.00")) + + 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) + + with self.assertNumQueries(1): + [(club.tier_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")) + + def test_the_billing_page_lists_tiers_and_what_is_owed(self): + subscribe(self.club, self.tier) + + 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") + + self.client.post(reverse("controlpanel:tier_price_create", args=[tier.pk]), {"active_from": self.today.isoformat(), "amount": "900.00"}) + + self.assertEqual(tier.price_on(self.today), Decimal("900.00")) + + def test_a_rate_change_does_not_rewrite_an_open_period(self): + subscribe(self.club, self.tier) + + self.client.post(reverse("controlpanel:tier_price_create", args=[self.tier.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.assertEqual(self.club.dues.count(), 1) + self.assertEqual(self.club.subscription.tier, self.tier) + + def test_a_payment_can_be_recorded_and_settles_the_due(self): + subscribe(self.club, self.tier) + 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": ""}) + + due.refresh_from_db() + self.assertEqual(due.status, Due.Status.PAID) + self.assertEqual(due.payments.first().recorded_by, self.staff) + + def test_a_part_payment_leaves_a_balance(self): + subscribe(self.club, self.tier) + 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": ""}) + + due.refresh_from_db() + self.assertEqual(due.balance, Decimal("300.00")) + + 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) + due = self.club.dues.first() + self.client.post(reverse("controlpanel:due_waive", args=[due.pk])) + + response = self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "50.00", "method": "cash", "reference": "", "paid_at": "", "note": ""}, follow=True) + + self.assertContains(response, "cannot take a payment") + + def test_a_period_can_be_waived(self): + subscribe(self.club, self.tier) + due = self.club.dues.first() + + self.client.post(reverse("controlpanel:due_waive", args=[due.pk])) + + due.refresh_from_db() + 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)) + first = self.club.dues.first() + + self.client.post(reverse("controlpanel:club_open_period", args=[self.club.pk]), {"start": ""}) + + latest = self.club.dues.order_by("-period_start").first() + 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)) + self.club.archive() + + self.client.post(reverse("controlpanel:club_open_period", args=[self.club.pk]), {"start": self.today.isoformat()}) + + self.club.refresh_from_db() + self.assertFalse(self.club.is_archived) + + def test_the_club_page_shows_the_plan_and_its_periods(self): + subscribe(self.club, self.tier) + + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + self.assertContains(response, "Standard") + self.assertContains(response, "INV-") + + def test_an_invoice_downloads_as_a_pdf(self): + subscribe(self.club, self.tier) + due = self.club.dues.first() + + with mock.patch("controlpanel.views.invoice_pdf", return_value=b"%PDF-1.7 fake"): + response = self.client.get(reverse("controlpanel:due_invoice", args=[due.pk])) + + self.assertEqual(response["Content-Type"], "application/pdf") + self.assertIn(due.invoice.number, response["Content-Disposition"]) + + 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) + due = self.club.dues.first() + + with mock.patch("controlpanel.views.invoice_pdf", side_effect=BillingError("PDF rendering needs the native pango/cairo libraries.")): + response = self.client.get(reverse("controlpanel:due_invoice", args=[due.pk]), follow=True) + + self.assertContains(response, "pango") + + +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")) + + def test_the_billing_forms_render(self): + subscribe(self.club, self.tier) + 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:club_subscribe", args=[self.club.pk]), + reverse("controlpanel:club_open_period", args=[self.club.pk]), + reverse("controlpanel:due_pay", args=[due.pk]), + ): + self.assertEqual(self.client.get(url).status_code, 200, url) + + def test_the_payment_form_defaults_to_the_outstanding_balance(self): + subscribe(self.club, self.tier) + due = self.club.dues.first() + record_payment(due, Decimal("200.00")) + due.refresh_from_db() + + response = self.client.get(reverse("controlpanel:due_pay", args=[due.pk])) + + self.assertEqual(response.context["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"}) + + self.tier.refresh_from_db() + self.assertEqual(self.tier.name, "Standard plus") + + def test_changing_tier_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")) + + response = self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": premium.pk, "auto_archive": "on", "notes": ""}, follow=True) + + self.club.refresh_from_db() + self.assertEqual(self.club.subscription.tier, 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") + + response = self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": 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) + 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) + + self.assertContains(response, "already billed") + + def test_waiving_a_paid_period_reports_itself(self): + subscribe(self.club, self.tier) + due = self.club.dues.first() + record_payment(due, Decimal("500.00")) + + response = self.client.post(reverse("controlpanel:due_waive", args=[due.pk]), follow=True) + + self.assertContains(response, "remove them before waiving") diff --git a/controlpanel/urls.py b/controlpanel/urls.py index dc1e25d..db81799 100644 --- a/controlpanel/urls.py +++ b/controlpanel/urls.py @@ -21,6 +21,16 @@ urlpatterns = [ path("features/flags/new/", views.FlagCreateView.as_view(), name="flag_create"), path("features/flags/