Manage billing from the control panel
A Billing tab (tiers, their dated prices, and everything we are owed), a billing panel on each club (plan, periods, payment history, invoice), and the dues on the dashboard and the club tables. Every state change goes through the billing service, and a BillingError surfaces as a message rather than a 500 -- so "that period is waived", "no price in force", "already billed for that period" and a missing PDF library all explain themselves instead of crashing. The dashboard now separates the two pots of money that were previously one word. "Revenue per month" was CLUB SHOP revenue -- members paying their clubs, which is never ours -- sitting on our dashboard under a label that implied it was income. It is now "Platform dues per month" (what clubs paid us) with the club-shop series renamed club_revenue, and the club tables carry a Plan column and what each club owes us, annotated in the same single query. Rate changes are add-only in the UI as well as the model: the price form creates a dated row and never edits the last one, and a test asserts that raising the rate leaves an already-open period at the amount it was billed at. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from waffle import get_waffle_flag_model
|
from waffle import get_waffle_flag_model
|
||||||
|
|
||||||
|
from billing.models import DuePayment, Subscription, Tier, TierPrice
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
|
|
||||||
from .services.admins import find_member_by_email
|
from .services.admins import find_member_by_email
|
||||||
@@ -56,3 +59,47 @@ class FlagForm(forms.ModelForm):
|
|||||||
help_texts = {
|
help_texts = {
|
||||||
"everyone": _("Yes = on for all clubs, No = off everywhere (overrides club targeting). Leave unknown to target clubs."),
|
"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."))
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ from django.utils import timezone
|
|||||||
from waffle import get_waffle_flag_model
|
from waffle import get_waffle_flag_model
|
||||||
|
|
||||||
from authentication.middleware import ELEVATED_ROLES
|
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 club.models import Club, ClubMembership, ClubRole, Season
|
||||||
from events.models import Attendance, Event
|
from events.models import Attendance, Event
|
||||||
from members.models import Member
|
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()),
|
team_count=_subquery(Team.objects.all(), Count("pk"), IntegerField()),
|
||||||
teams_managed=_subquery(Team.objects.filter(managed_this_season), Count("pk", distinct=True), 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()),
|
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"))
|
.annotate(teams_without_coach=F("team_count") - F("teams_managed"))
|
||||||
.order_by("name")
|
.order_by("name")
|
||||||
@@ -160,9 +166,22 @@ def platform_attention():
|
|||||||
"outstanding": _money(Order.objects.filter(status__in=OWED_STATUSES)),
|
"outstanding": _money(Order.objects.filter(status__in=OWED_STATUSES)),
|
||||||
"members_without_login": Member.objects.filter(user__isnull=True).count(),
|
"members_without_login": Member.objects.filter(user__isnull=True).count(),
|
||||||
"members": members,
|
"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):
|
def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY):
|
||||||
"""A dense month-by-month series — zero-filled, because a chart that silently skips
|
"""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."""
|
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():
|
def platform_charts():
|
||||||
return {
|
return {
|
||||||
"signups": signup_split(),
|
"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")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
40
controlpanel/templates/controlpanel/_billing_form.html
Normal file
40
controlpanel/templates/controlpanel/_billing_form.html
Normal file
@@ -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 %}
|
||||||
|
<div class="card max-w-xl bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if blurb %}<p class="text-sm opacity-70">{{ blurb }}</p>{% endif %}
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="form-control my-3 w-full">
|
||||||
|
{% if field.field.widget.input_type == "checkbox" %}
|
||||||
|
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
|
||||||
|
{{ field|daisy }}
|
||||||
|
<span class="label-text">{{ field.label }}</span>
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<label class="label" for="{{ field.id_for_label }}">
|
||||||
|
<span class="label-text">{{ field.label }}</span>
|
||||||
|
</label>
|
||||||
|
{{ field|daisy }}
|
||||||
|
{% endif %}
|
||||||
|
{% if field.help_text %}<span class="label-text-alt mt-1 block text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||||
|
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="card-actions justify-end pt-2">
|
||||||
|
<a class="btn btn-outline gap-2" href="{{ cancel_url }}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:"Save" }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
<th class="text-right">Members</th>
|
<th class="text-right">Members</th>
|
||||||
<th class="text-right">Unpaid</th>
|
<th class="text-right">Unpaid</th>
|
||||||
<th class="text-right">Owed</th>
|
<th class="text-right">Owed</th>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th class="text-right">Dues</th>
|
||||||
<th class="text-right">Teams</th>
|
<th class="text-right">Teams</th>
|
||||||
<th class="text-right">Upcoming</th>
|
<th class="text-right">Upcoming</th>
|
||||||
<th class="text-right">Admins</th>
|
<th class="text-right">Admins</th>
|
||||||
@@ -43,6 +45,25 @@
|
|||||||
<td class="text-right tabular-nums">{{ club.active_members }}</td>
|
<td class="text-right tabular-nums">{{ club.active_members }}</td>
|
||||||
<td class="text-right tabular-nums {% if club.unpaid_members %}text-warning{% endif %}">{{ club.unpaid_members }}</td>
|
<td class="text-right tabular-nums {% if club.unpaid_members %}text-warning{% endif %}">{{ club.unpaid_members }}</td>
|
||||||
<td class="text-right tabular-nums {% if club.outstanding %}font-semibold text-error{% endif %}">€{{ club.outstanding|floatformat:2 }}</td>
|
<td class="text-right tabular-nums {% if club.outstanding %}font-semibold text-error{% endif %}">€{{ club.outstanding|floatformat:2 }}</td>
|
||||||
|
<td>
|
||||||
|
{% if club.tier_name %}
|
||||||
|
<span class="text-sm">{{ club.tier_name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning badge-xs">Not billed</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">
|
||||||
|
{% if not club.dues_owed %}
|
||||||
|
{% if club.tier_name %}<span class="badge badge-success badge-xs">Paid</span>{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span>
|
||||||
|
{% if club.dues_grace_until < today %}
|
||||||
|
<span class="badge badge-error badge-xs">Overdue</span>
|
||||||
|
{% elif club.dues_period_end < today %}
|
||||||
|
<span class="badge badge-warning badge-xs">Grace</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="text-right tabular-nums">
|
<td class="text-right tabular-nums">
|
||||||
{{ club.team_count }}
|
{{ club.team_count }}
|
||||||
{% if club.teams_without_coach %}
|
{% if club.teams_without_coach %}
|
||||||
@@ -54,7 +75,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="text-center opacity-60">{{ empty_message|default:"No clubs yet." }}</td>
|
<td colspan="9" class="text-center opacity-60">{{ empty_message|default:"No clubs yet." }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit">
|
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit">
|
||||||
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab gap-2 {% if nav == 'dashboard' %}tab-active{% endif %}">{% lucide "layout-dashboard" size=16 %} Dashboard</a>
|
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab gap-2 {% if nav == 'dashboard' %}tab-active{% endif %}">{% lucide "layout-dashboard" size=16 %} Dashboard</a>
|
||||||
<a role="tab" href="{% url 'controlpanel:club_list' %}" class="tab gap-2 {% if nav == 'clubs' %}tab-active{% endif %}">{% lucide "building-2" size=16 %} Clubs</a>
|
<a role="tab" href="{% url 'controlpanel:club_list' %}" class="tab gap-2 {% if nav == 'clubs' %}tab-active{% endif %}">{% lucide "building-2" size=16 %} Clubs</a>
|
||||||
|
<a role="tab" href="{% url 'controlpanel:billing' %}" class="tab gap-2 {% if nav == 'billing' %}tab-active{% endif %}">{% lucide "receipt-euro" size=16 %} Billing</a>
|
||||||
<a role="tab" href="{% url 'controlpanel:features' %}" class="tab gap-2 {% if nav == 'features' %}tab-active{% endif %}">{% lucide "toggle-right" size=16 %} Features</a>
|
<a role="tab" href="{% url 'controlpanel:features' %}" class="tab gap-2 {% if nav == 'features' %}tab-active{% endif %}">{% lucide "toggle-right" size=16 %} Features</a>
|
||||||
{% if user.is_superuser %}
|
{% if user.is_superuser %}
|
||||||
<a role="tab" href="{% url 'controlpanel:admins' %}" class="tab gap-2 {% if nav == 'admins' %}tab-active{% endif %}">{% lucide "user-cog" size=16 %} Admins</a>
|
<a role="tab" href="{% url 'controlpanel:admins' %}" class="tab gap-2 {% if nav == 'admins' %}tab-active{% endif %}">{% lucide "user-cog" size=16 %} Admins</a>
|
||||||
|
|||||||
117
controlpanel/templates/controlpanel/billing.html
Normal file
117
controlpanel/templates/controlpanel/billing.html
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
{% extends "controlpanel/base.html" %}
|
||||||
|
{% load lucide %}
|
||||||
|
|
||||||
|
{% block heading %}Billing{% endblock heading %}
|
||||||
|
{% block subheading %}<p class="text-sm opacity-70">What the platform charges its clubs.</p>{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:tier_create' %}">{% lucide "plus" size=16 %} New tier</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 "layers" size=18 %} Tiers</h2>
|
||||||
|
{% comment %}
|
||||||
|
Prices are dated, not edited. A rate change is a new row with a future
|
||||||
|
active_from; every period already opened keeps the amount it was billed at,
|
||||||
|
so raising the price cannot rewrite an invoice you have already sent.
|
||||||
|
{% endcomment %}
|
||||||
|
<p class="text-sm opacity-70">A rate change is a new dated price. Periods already billed keep the amount they were issued at.</p>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tier</th>
|
||||||
|
<th class="text-right">Clubs</th>
|
||||||
|
<th>Prices</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tier in tiers %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="font-medium">{{ tier.name }}</div>
|
||||||
|
{% if not tier.is_active %}<span class="badge badge-ghost badge-xs">Retired</span>{% endif %}
|
||||||
|
{% if tier.description %}<div class="text-xs opacity-60">{{ tier.description }}</div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">{{ tier.club_count }}</td>
|
||||||
|
<td>
|
||||||
|
{% for price in tier.prices.all %}
|
||||||
|
<div class="text-sm tabular-nums">
|
||||||
|
€{{ price.amount|floatformat:2 }}
|
||||||
|
<span class="opacity-60">from {{ price.active_from|date:"j M Y" }}</span>
|
||||||
|
{% if price.active_from > today %}<span class="badge badge-info badge-xs">Scheduled</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<span class="badge badge-error badge-sm">No price — cannot be billed</span>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:tier_price_create' tier.pk %}">{% lucide "euro" size=14 %} New price</a>
|
||||||
|
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:tier_update' tier.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center opacity-60">No tiers yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Owed</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Club</th>
|
||||||
|
<th>Period</th>
|
||||||
|
<th class="text-right">Owed</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for due in owing %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' due.club.pk %}">{{ due.club.name }}</a>
|
||||||
|
<div class="text-xs opacity-60">{{ due.tier.name }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm">
|
||||||
|
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
|
||||||
|
<div class="text-xs opacity-60">Grace to {{ due.grace_until|date:"j M Y" }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-right font-semibold tabular-nums">€{{ due.balance|floatformat:2 }}</td>
|
||||||
|
<td>
|
||||||
|
{% if due.grace_until < today %}
|
||||||
|
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
|
||||||
|
{% elif due.period_end < today %}
|
||||||
|
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-ghost">{{ due.get_status_display }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<a class="btn btn-primary btn-xs gap-1" href="{% url 'controlpanel:due_pay' due.pk %}">{% lucide "banknote" size=14 %} Record payment</a>
|
||||||
|
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-text" size=14 %} Invoice</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center opacity-60">Nothing outstanding.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
@@ -213,6 +213,102 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card mb-6 bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:club_subscribe' club.pk %}">
|
||||||
|
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
|
||||||
|
</a>
|
||||||
|
{% if subscription %}
|
||||||
|
<a class="btn btn-primary btn-sm gap-2" href="{% url 'controlpanel:club_open_period' club.pk %}">
|
||||||
|
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not subscription %}
|
||||||
|
<p class="text-sm opacity-70">This club is not billed for anything. Put it on a tier to start.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
On <strong>{{ subscription.tier.name }}</strong>.
|
||||||
|
{% if subscription.auto_archive %}
|
||||||
|
Archived automatically when a period goes unpaid past its grace period.
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning badge-sm">Auto-archive off</span> — it will never be archived for non-payment.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Period</th>
|
||||||
|
<th class="text-right">Billed</th>
|
||||||
|
<th class="text-right">Paid</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for due in dues %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
|
||||||
|
<div class="text-xs opacity-60">{{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">€{{ due.amount|floatformat:2 }}</td>
|
||||||
|
<td class="text-right tabular-nums">€{{ due.amount_paid|floatformat:2 }}</td>
|
||||||
|
<td>
|
||||||
|
{% if due.status == "paid" %}
|
||||||
|
<span class="badge badge-success gap-1">{% lucide "check" size=12 %} Paid</span>
|
||||||
|
{% elif due.status == "waived" %}
|
||||||
|
<span class="badge badge-ghost">Waived</span>
|
||||||
|
{% elif due.grace_until < today %}
|
||||||
|
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
|
||||||
|
{% elif due.period_end < today %}
|
||||||
|
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-ghost">{{ due.get_status_display }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
{% if due.is_owing %}
|
||||||
|
<a class="btn btn-primary btn-xs gap-1" href="{% url 'controlpanel:due_pay' due.pk %}">{% lucide "banknote" size=14 %} Pay</a>
|
||||||
|
{% if not due.payments.all %}
|
||||||
|
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-ghost btn-xs gap-1" type="submit">{% lucide "ban" size=14 %} Waive</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-text" size=14 %} Invoice</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% for payment in due.payments.all %}
|
||||||
|
<tr class="text-xs opacity-70">
|
||||||
|
<td colspan="2" class="pl-8">
|
||||||
|
{% lucide "corner-down-right" size=12 %}
|
||||||
|
{{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">€{{ payment.amount|floatformat:2 }}</td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center opacity-60">No periods billed yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card bg-base-100 shadow">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|||||||
@@ -37,11 +37,18 @@
|
|||||||
<div class="text-xs opacity-60">Admins locked out until they enrol</div>
|
<div class="text-xs opacity-60">Admins locked out until they enrol</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
|
<div class="card bg-base-100 shadow {% if attention.dues_owed %}border-l-4 border-error{% endif %}">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div>
|
{% comment %}
|
||||||
<div class="text-3xl font-bold tabular-nums">€{{ attention.outstanding|floatformat:2 }}</div>
|
What the CLUBS owe US. Not to be confused with the club-shop money below,
|
||||||
<div class="text-xs opacity-60">Unpaid across every club</div>
|
which members owe their clubs and is never ours.
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "receipt-euro" size=16 %} Dues owed</div>
|
||||||
|
<div class="text-3xl font-bold tabular-nums">€{{ attention.dues_owed|floatformat:2 }}</div>
|
||||||
|
<div class="text-xs opacity-60">
|
||||||
|
{{ attention.dues_in_grace }} in grace ·
|
||||||
|
<span class="{% if attention.dues_overdue %}font-semibold text-error{% endif %}">{{ attention.dues_overdue }} overdue</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +65,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card bg-base-100 shadow">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-base">{% lucide "euro" size=18 %} Revenue per month</h2>
|
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Platform dues per month</h2>
|
||||||
|
<p class="text-sm opacity-70">What clubs paid us. Club-shop money is theirs, not ours.</p>
|
||||||
<div class="h-56">
|
<div class="h-56">
|
||||||
<canvas id="revenue-chart"></canvas>
|
<canvas id="revenue-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,6 +146,11 @@
|
|||||||
<div class="stat-title">Club admins</div>
|
<div class="stat-title">Club admins</div>
|
||||||
<div class="stat-value">{{ totals.admins }}</div>
|
<div class="stat-value">{{ totals.admins }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Not billed</div>
|
||||||
|
<div class="stat-value {% if attention.clubs_unbilled %}text-warning{% endif %}">{{ attention.clubs_unbilled }}</div>
|
||||||
|
<div class="stat-desc">Clubs on no tier</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card bg-base-100 shadow">
|
||||||
@@ -221,7 +234,7 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return [signups, build("revenue-chart", "Revenue", data.revenue, css("--color-accent", "#0ea5e9"), "line", true)];
|
return [signups, build("revenue-chart", "Dues", data.dues, css("--color-accent", "#0ea5e9"), "bar", true)];
|
||||||
};
|
};
|
||||||
|
|
||||||
let charts = render();
|
let charts = render();
|
||||||
|
|||||||
14
controlpanel/templates/controlpanel/payment_form.html
Normal file
14
controlpanel/templates/controlpanel/payment_form.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "controlpanel/base.html" %}
|
||||||
|
|
||||||
|
{% block heading %}Record payment — {{ due.club }}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block subheading %}
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
{{ 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
|
||||||
|
</p>
|
||||||
|
{% 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 %}
|
||||||
12
controlpanel/templates/controlpanel/period_form.html
Normal file
12
controlpanel/templates/controlpanel/period_form.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "controlpanel/base.html" %}
|
||||||
|
|
||||||
|
{% block heading %}{% if club.is_archived %}Reactivate{% else %}Open period{% endif %} — {{ club }}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block subheading %}
|
||||||
|
<p class="text-sm opacity-70">Next period starts {{ next_start|date:"j M Y" }} unless you say otherwise.</p>
|
||||||
|
{% 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 %}
|
||||||
@@ -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 %}
|
||||||
8
controlpanel/templates/controlpanel/tier_form.html
Normal file
8
controlpanel/templates/controlpanel/tier_form.html
Normal file
@@ -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 %}
|
||||||
8
controlpanel/templates/controlpanel/tier_price_form.html
Normal file
8
controlpanel/templates/controlpanel/tier_price_form.html
Normal file
@@ -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 %}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import pathlib
|
import pathlib
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from allauth.mfa.models import Authenticator
|
from allauth.mfa.models import Authenticator
|
||||||
from django import forms
|
from django import forms
|
||||||
@@ -14,6 +15,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 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 club.models import Club, ClubMembership, ClubRole, Season
|
||||||
from events.models import Attendance, Event
|
from events.models import Attendance, Event
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
@@ -730,11 +734,13 @@ class PlatformChartTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(platform_charts()["signups"][-1]["new"], 1)
|
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("50.00"), status=Order.OrderStatus.PAID)
|
||||||
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("30.00"), status=Order.OrderStatus.PENDING)
|
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"))
|
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.assertTrue(templates) # the glob must actually be finding our templates
|
||||||
self.assertEqual(offenders, [], "use {% comment %} for multi-line comments")
|
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")
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ urlpatterns = [
|
|||||||
path("features/flags/new/", views.FlagCreateView.as_view(), name="flag_create"),
|
path("features/flags/new/", views.FlagCreateView.as_view(), name="flag_create"),
|
||||||
path("features/flags/<int:pk>/edit/", views.FlagUpdateView.as_view(), name="flag_update"),
|
path("features/flags/<int:pk>/edit/", views.FlagUpdateView.as_view(), name="flag_update"),
|
||||||
path("features/switches/<int:pk>/toggle/", views.SwitchToggleView.as_view(), name="switch_toggle"),
|
path("features/switches/<int:pk>/toggle/", views.SwitchToggleView.as_view(), name="switch_toggle"),
|
||||||
|
# Billing (platform charging the clubs)
|
||||||
|
path("billing/", views.BillingView.as_view(), name="billing"),
|
||||||
|
path("billing/tiers/new/", views.TierCreateView.as_view(), name="tier_create"),
|
||||||
|
path("billing/tiers/<uuid:pk>/edit/", views.TierUpdateView.as_view(), name="tier_update"),
|
||||||
|
path("billing/tiers/<uuid:pk>/prices/new/", views.TierPriceCreateView.as_view(), name="tier_price_create"),
|
||||||
|
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>/invoice.pdf", views.InvoicePdfView.as_view(), name="due_invoice"),
|
||||||
|
path("clubs/<uuid:pk>/subscription/", views.SubscribeClubView.as_view(), name="club_subscribe"),
|
||||||
|
path("clubs/<uuid:pk>/period/new/", views.OpenPeriodView.as_view(), name="club_open_period"),
|
||||||
# Platform admins (superusers only)
|
# Platform admins (superusers only)
|
||||||
path("admins/", views.PlatformAdminListView.as_view(), name="admins"),
|
path("admins/", views.PlatformAdminListView.as_view(), name="admins"),
|
||||||
path("admins/add/", views.PlatformAdminAddView.as_view(), name="admin_add"),
|
path("admins/add/", views.PlatformAdminAddView.as_view(), name="admin_add"),
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Count
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
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.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
|
||||||
|
|
||||||
|
from billing.models import Due, Tier, TierPrice
|
||||||
|
from billing.services import BillingError
|
||||||
|
from billing.services.dues import next_period_start, open_period, reactivate, record_payment, subscribe, waive
|
||||||
|
from billing.services.invoices import invoice_pdf, issue_invoice
|
||||||
from club.models import Club, ClubRole
|
from club.models import Club, ClubRole
|
||||||
|
|
||||||
from .forms import ClubAdminForm, ClubForm, FlagForm, PlatformAdminForm
|
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm
|
||||||
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin
|
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin
|
||||||
from .services.admins import grant_club_admin, revoke_club_admin
|
from .services.admins import grant_club_admin, revoke_club_admin
|
||||||
from .services.platform_admins import (
|
from .services.platform_admins import (
|
||||||
@@ -35,6 +42,7 @@ class DashboardView(PlatformStaffRequiredMixin, TemplateView):
|
|||||||
flags=flag_adoption(),
|
flags=flag_adoption(),
|
||||||
charts=platform_charts(),
|
charts=platform_charts(),
|
||||||
clubs=clubs_with_health(),
|
clubs=clubs_with_health(),
|
||||||
|
today=timezone.localdate(),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,7 +63,7 @@ class ClubListView(PlatformStaffRequiredMixin, ListView):
|
|||||||
return clubs_with_health(clubs)
|
return clubs_with_health(clubs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
return super().get_context_data(nav="clubs", show_archived=self.show_archived, search=self.request.GET.get("q", ""), **kwargs)
|
return super().get_context_data(nav="clubs", show_archived=self.show_archived, search=self.request.GET.get("q", ""), today=timezone.localdate(), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ClubCreateView(PlatformStaffRequiredMixin, CreateView):
|
class ClubCreateView(PlatformStaffRequiredMixin, CreateView):
|
||||||
@@ -97,6 +105,9 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
|
|||||||
groups=club_statistics(self.object),
|
groups=club_statistics(self.object),
|
||||||
attention=club_attention(self.object),
|
attention=club_attention(self.object),
|
||||||
charts=club_charts(self.object),
|
charts=club_charts(self.object),
|
||||||
|
subscription=getattr(self.object, "subscription", None),
|
||||||
|
dues=self.object.dues.select_related("tier", "invoice").prefetch_related("payments"),
|
||||||
|
today=timezone.localdate(),
|
||||||
admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"),
|
admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"),
|
||||||
flags=flags_for_club(self.object),
|
flags=flags_for_club(self.object),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
@@ -283,3 +294,198 @@ class PlatformAdminRevokeView(PlatformSuperuserRequiredMixin, View):
|
|||||||
else:
|
else:
|
||||||
messages.warning(request, f"{user.email} no longer has platform access.")
|
messages.warning(request, f"{user.email} no longer has platform access.")
|
||||||
return redirect("controlpanel:admins")
|
return redirect("controlpanel:admins")
|
||||||
|
|
||||||
|
|
||||||
|
class BillingView(PlatformStaffRequiredMixin, TemplateView):
|
||||||
|
"""Tiers and their prices, plus every period we are owed money for."""
|
||||||
|
|
||||||
|
template_name = "controlpanel/billing.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
today = timezone.localdate()
|
||||||
|
return super().get_context_data(
|
||||||
|
nav="billing",
|
||||||
|
tiers=Tier.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")),
|
||||||
|
owing=Due.objects.filter(status__in=Due.OWING).select_related("club", "tier").order_by("grace_until"),
|
||||||
|
today=today,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TierCreateView(PlatformStaffRequiredMixin, CreateView):
|
||||||
|
model = Tier
|
||||||
|
form_class = TierForm
|
||||||
|
template_name = "controlpanel/tier_form.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(nav="billing", **kwargs)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
messages.success(self.request, f"Tier “{self.object}” created. Give it a price before billing anyone.")
|
||||||
|
return reverse("controlpanel:billing")
|
||||||
|
|
||||||
|
|
||||||
|
class TierUpdateView(PlatformStaffRequiredMixin, UpdateView):
|
||||||
|
model = Tier
|
||||||
|
form_class = TierForm
|
||||||
|
template_name = "controlpanel/tier_form.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(nav="billing", **kwargs)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
messages.success(self.request, f"Tier “{self.object}” updated.")
|
||||||
|
return reverse("controlpanel:billing")
|
||||||
|
|
||||||
|
|
||||||
|
class TierPriceCreateView(PlatformStaffRequiredMixin, CreateView):
|
||||||
|
"""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."""
|
||||||
|
|
||||||
|
model = TierPrice
|
||||||
|
form_class = TierPriceForm
|
||||||
|
template_name = "controlpanel/tier_price_form.html"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tier(self):
|
||||||
|
return get_object_or_404(Tier, pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(nav="billing", tier=self.tier, **kwargs)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.tier = self.tier
|
||||||
|
response = super().form_valid(form)
|
||||||
|
messages.success(self.request, f"{self.tier} is €{self.object.amount} for periods opening from {self.object.active_from}.")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("controlpanel:billing")
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeClubView(PlatformStaffRequiredMixin, FormView):
|
||||||
|
"""Put a club on a tier, which opens its first period."""
|
||||||
|
|
||||||
|
form_class = SubscriptionForm
|
||||||
|
template_name = "controlpanel/subscription_form.html"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def club(self):
|
||||||
|
return get_object_or_404(Club, pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
subscription = getattr(self.club, "subscription", None)
|
||||||
|
if subscription:
|
||||||
|
kwargs["instance"] = subscription
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(nav="clubs", club=self.club, subscription=getattr(self.club, "subscription", None), **kwargs)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
club = self.club
|
||||||
|
existing = getattr(club, "subscription", None)
|
||||||
|
try:
|
||||||
|
if existing:
|
||||||
|
# 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.
|
||||||
|
subscription = form.save(commit=False)
|
||||||
|
subscription.club = club
|
||||||
|
subscription.save()
|
||||||
|
messages.success(self.request, f"{club} is now on {subscription.tier}. The current period keeps the amount it was billed at.")
|
||||||
|
else:
|
||||||
|
subscribe(club, form.cleaned_data["tier"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"])
|
||||||
|
messages.success(self.request, f"{club} is on {form.cleaned_data['tier']}. Its first period is open.")
|
||||||
|
except BillingError as error:
|
||||||
|
messages.error(self.request, str(error))
|
||||||
|
|
||||||
|
return redirect("controlpanel:club_detail", pk=club.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordPaymentView(PlatformStaffRequiredMixin, FormView):
|
||||||
|
form_class = DuePaymentForm
|
||||||
|
template_name = "controlpanel/payment_form.html"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def due(self):
|
||||||
|
return get_object_or_404(Due.objects.select_related("club", "tier"), pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
return {"amount": self.due.balance}
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(nav="clubs", due=self.due, **kwargs)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
due = self.due
|
||||||
|
try:
|
||||||
|
record_payment(
|
||||||
|
due,
|
||||||
|
form.cleaned_data["amount"],
|
||||||
|
method=form.cleaned_data["method"],
|
||||||
|
reference=form.cleaned_data["reference"],
|
||||||
|
paid_at=form.cleaned_data["paid_at"],
|
||||||
|
note=form.cleaned_data["note"],
|
||||||
|
user=self.request.user,
|
||||||
|
)
|
||||||
|
due.refresh_from_db()
|
||||||
|
messages.success(self.request, f"€{form.cleaned_data['amount']} recorded. {due.get_status_display().capitalize()} — €{due.balance} outstanding.")
|
||||||
|
except BillingError as error:
|
||||||
|
messages.error(self.request, str(error))
|
||||||
|
|
||||||
|
return redirect("controlpanel:club_detail", pk=due.club_id)
|
||||||
|
|
||||||
|
|
||||||
|
class WaiveDueView(PlatformStaffRequiredMixin, View):
|
||||||
|
def post(self, request, pk):
|
||||||
|
due = get_object_or_404(Due, pk=pk)
|
||||||
|
try:
|
||||||
|
waive(due)
|
||||||
|
messages.warning(request, f"Period {due.period_start} to {due.period_end} waived. Nothing is owed and the club will not be archived for it.")
|
||||||
|
except BillingError as error:
|
||||||
|
messages.error(request, str(error))
|
||||||
|
|
||||||
|
return redirect("controlpanel:club_detail", pk=due.club_id)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenPeriodView(PlatformStaffRequiredMixin, FormView):
|
||||||
|
"""Renew a club, or reactivate an archived one."""
|
||||||
|
|
||||||
|
form_class = OpenPeriodForm
|
||||||
|
template_name = "controlpanel/period_form.html"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def club(self):
|
||||||
|
return get_object_or_404(Club, pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
club = self.club
|
||||||
|
return super().get_context_data(nav="clubs", club=club, next_start=next_period_start(club), **kwargs)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
club = self.club
|
||||||
|
start = form.cleaned_data.get("start")
|
||||||
|
try:
|
||||||
|
due = reactivate(club, start=start) if club.is_archived else open_period(club, start=start)
|
||||||
|
messages.success(self.request, f"Period {due.period_start} to {due.period_end} opened for €{due.amount}. Invoice {due.invoice.number}.")
|
||||||
|
except BillingError as error:
|
||||||
|
messages.error(self.request, str(error))
|
||||||
|
|
||||||
|
return redirect("controlpanel:club_detail", pk=club.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoicePdfView(PlatformStaffRequiredMixin, View):
|
||||||
|
def get(self, request, pk):
|
||||||
|
due = get_object_or_404(Due.objects.select_related("club", "tier", "invoice"), pk=pk)
|
||||||
|
invoice = issue_invoice(due)
|
||||||
|
try:
|
||||||
|
pdf = invoice_pdf(invoice)
|
||||||
|
except BillingError as error:
|
||||||
|
# The native PDF libraries are missing: say so rather than 500.
|
||||||
|
messages.error(request, str(error))
|
||||||
|
return redirect("controlpanel:club_detail", pk=due.club_id)
|
||||||
|
|
||||||
|
response = HttpResponse(pdf, content_type="application/pdf")
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{invoice.number}.pdf"'
|
||||||
|
return response
|
||||||
|
|||||||
@@ -3195,6 +3195,9 @@
|
|||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.inline {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
.inline-block {
|
.inline-block {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
@@ -3558,6 +3561,9 @@
|
|||||||
.pb-2 {
|
.pb-2 {
|
||||||
padding-bottom: calc(var(--spacing) * 2);
|
padding-bottom: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.pl-8 {
|
||||||
|
padding-left: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -3839,6 +3845,12 @@
|
|||||||
--badge-fg: var(--color-error-content);
|
--badge-fg: var(--color-error-content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.badge-info {
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
--badge-color: var(--color-info);
|
||||||
|
--badge-fg: var(--color-info-content);
|
||||||
|
}
|
||||||
|
}
|
||||||
.badge-neutral {
|
.badge-neutral {
|
||||||
@layer daisyui.l1.l2 {
|
@layer daisyui.l1.l2 {
|
||||||
--badge-color: var(--color-neutral);
|
--badge-color: var(--color-neutral);
|
||||||
|
|||||||
Reference in New Issue
Block a user