Add the management app: club-facing UI + real fee-payment tracking

Gives clubs a self-service /manage/ area for members, families, teams,
roles, and season memberships, alongside real fee-payment tracking
(FeePayment, record_payment/mark_as_paid/remaining_balance) so a
membership's paid status reflects actual money received instead of a
single manually-set flag.
This commit is contained in:
2026-08-03 17:01:15 +02:00
parent 5bea8a4a6c
commit 062da00bb9
44 changed files with 5095 additions and 11 deletions

View File

@@ -49,6 +49,18 @@ def is_club_admin(user: User, club: Club) -> bool:
return has_club_role(user, club, ClubRole.Roles.ADMIN)
def has_management_access(user: User, club: Club) -> bool:
"""Anyone with real authority in the club: ADMIN/EDITOR, or *any* current-season
staff assignment (coach, team manager, physio, ...).
Deliberately excludes the plain MEMBER role -- every signed-up player (or club
member generally) holds that automatically the moment their ClubMembership goes
active (club/signals.py), so it says nothing about whether someone is staff.
"""
elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)).exists()
return elevated or teams_staffed_by(user, club).exists()
def is_coach_manager(user: User, club: Club) -> bool:
"""Derived from a current-season StaffAssignment in a *management* position."""
return StaffAssignment.objects.filter(

69
club/services/fees.py Normal file
View File

@@ -0,0 +1,69 @@
"""Recording money received against a membership's fee.
Mirrors billing.services.dues.record_payment for a different kind of money: a
member's own club fee, not the club's platform subscription. amount_paid is kept in
step here, never recomputed by re-aggregating FeePayment on every read.
"""
from decimal import Decimal
from django.db.models import F
from django.utils import timezone
from club.models import ClubMembership, FeePayment
def remaining_balance(membership):
return max(membership.fee_amount - membership.amount_paid, Decimal("0.00"))
def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None):
"""Record money received against one membership's fee. Several payments may
land on one membership -- a family paying in two installments must not read as
unpaid. Updates amount_paid and re-syncs fee_status/status to match."""
payment = FeePayment.objects.create(membership=membership, amount=amount, method=method, reference=reference, note=note, recorded_by=recorded_by)
membership.amount_paid = F("amount_paid") + amount
membership.save(update_fields=["amount_paid"])
membership.refresh_from_db(fields=["amount_paid"])
_sync_fee_status(membership)
return payment
def mark_as_paid(membership, *, recorded_by=None):
"""The "settle this one" action behind both the per-row and bulk buttons. If
there's a real remaining balance, records it as a payment (auditable, shows up
in history); if fee_amount was never priced (remaining is 0), just flips the
flags directly -- there's no real transaction to log."""
remaining = remaining_balance(membership)
if remaining > 0:
record_payment(membership, amount=remaining, method=FeePayment.Method.OTHER, note="Marked as paid", recorded_by=recorded_by)
else:
_sync_fee_status(membership, force_paid=True)
def _sync_fee_status(membership, *, force_paid=False):
if membership.fee_status == ClubMembership.FeeStatus.WAIVED:
return # manual, independent of payments -- this never overrides it
if force_paid or (membership.fee_amount > 0 and membership.amount_paid >= membership.fee_amount):
new_status = ClubMembership.FeeStatus.PAID
elif membership.amount_paid > 0:
new_status = ClubMembership.FeeStatus.PARTIALLY_PAID
else:
new_status = ClubMembership.FeeStatus.UNPAID
membership.fee_status = new_status
update_fields = ["fee_status"]
# Same "become a full member" behavior the bulk action already had: settling
# the fee in full also activates the membership, once, first time only.
if new_status == ClubMembership.FeeStatus.PAID:
membership.status = ClubMembership.StatusChoices.ACTIVE
update_fields.append("status")
if membership.activated_at is None:
membership.activated_at = timezone.localdate()
update_fields.append("activated_at")
membership.save(update_fields=update_fields)