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:
@@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Club, ClubMembership, ClubRole, Season
|
||||
from .models import Club, ClubMembership, ClubRole, FeePayment, Season
|
||||
|
||||
|
||||
@admin.register(Club)
|
||||
@@ -20,17 +20,33 @@ class SeasonAdmin(admin.ModelAdmin):
|
||||
ordering = ["club", "-start_date"]
|
||||
|
||||
|
||||
class FeePaymentInline(admin.TabularInline):
|
||||
model = FeePayment
|
||||
extra = 0
|
||||
readonly_fields = ["recorded_by"]
|
||||
|
||||
|
||||
@admin.register(ClubMembership)
|
||||
class ClubMembershipAdmin(admin.ModelAdmin):
|
||||
list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "license"]
|
||||
list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "fee_amount", "amount_paid", "license"]
|
||||
search_fields = ["club__name", "member__last_name", "member__first_name", "license"]
|
||||
list_filter = ["club", "season", "status", "fee_status"]
|
||||
raw_id_fields = ["member"]
|
||||
# Money is settled by club.services.fees, which re-derives fee_status from the payments.
|
||||
readonly_fields = ["amount_paid", "fee_status"]
|
||||
fieldsets = [
|
||||
[None, {"fields": ["club", "season", "member"]}],
|
||||
[_("Membership"), {"fields": ["license", "status", "fee_status"]}],
|
||||
[_("Membership"), {"fields": ["license", "status", "fee_status", "fee_amount", "amount_paid"]}],
|
||||
[_("Dates"), {"fields": ["signed_up_at", "activated_at"]}],
|
||||
]
|
||||
inlines = [FeePaymentInline]
|
||||
|
||||
|
||||
@admin.register(FeePayment)
|
||||
class FeePaymentAdmin(admin.ModelAdmin):
|
||||
list_display = ["membership", "amount", "method", "paid_at", "recorded_by"]
|
||||
list_filter = ["method"]
|
||||
search_fields = ["membership__club__name", "membership__member__last_name", "reference"]
|
||||
|
||||
|
||||
@admin.register(ClubRole)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-03 11:22
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0015_alter_club_logo'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='clubmembership',
|
||||
name='amount_paid',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), help_text='Kept in step with payments by the fee service; not hand-edited.', max_digits=10, verbose_name='amount paid'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clubmembership',
|
||||
name='fee_amount',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='fee amount'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FeePayment',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='amount')),
|
||||
('method', models.CharField(choices=[('bank_transfer', 'bank transfer'), ('cash', 'cash'), ('card', 'card'), ('other', 'other')], default='bank_transfer', max_length=20, verbose_name='method')),
|
||||
('reference', models.CharField(blank=True, help_text='Bank reference, transaction id — whatever lets you find this again.', max_length=255, verbose_name='reference')),
|
||||
('paid_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='paid at')),
|
||||
('note', models.TextField(blank=True, verbose_name='note')),
|
||||
('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='club.clubmembership', verbose_name='membership')),
|
||||
('recorded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recorded_fee_payments', to=settings.AUTH_USER_MODEL, verbose_name='recorded by')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'fee payment',
|
||||
'verbose_name_plural': 'fee payments',
|
||||
'ordering': ['-paid_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
51
club/mixins.py
Normal file
51
club/mixins.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.http import Http404
|
||||
|
||||
from .services.access import has_management_access, is_club_admin, teams_managed_by
|
||||
|
||||
|
||||
class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
|
||||
"""Gate for the club-facing management UI.
|
||||
|
||||
Two rules, the mirror image of ``controlpanel.mixins.PlatformStaffRequiredMixin``:
|
||||
|
||||
* **Club subdomain only.** This UI manages *one* club, so it doesn't exist on the
|
||||
base domain — same reasoning as the control panel refusing to exist on a club
|
||||
subdomain, just inverted.
|
||||
* **Staff only.** ADMIN/EDITOR, or a current-season ``StaffAssignment`` (coach,
|
||||
team manager, ...) — see ``has_management_access``. The plain MEMBER role every
|
||||
active player/club member holds automatically does *not* count: a club member
|
||||
with neither is a player/parent, and belongs in the separate app that serves
|
||||
them.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if getattr(request, "club", None) is None:
|
||||
raise Http404("The management app is not available on the base domain.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def test_func(self):
|
||||
return has_management_access(self.request.user, self.request.club)
|
||||
|
||||
|
||||
class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""ADMIN role only — club-wide settings that aren't scoped to a single team:
|
||||
seasons, positions, roles, shop configuration."""
|
||||
|
||||
def test_func(self):
|
||||
return is_club_admin(self.request.user, self.request.club)
|
||||
|
||||
|
||||
class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""A manager of *this* team, or a club ADMIN. ``self.get_team()`` must return the
|
||||
``Team`` the view acts on (e.g. from the URL's ``pk``) before ``test_func`` runs.
|
||||
"""
|
||||
|
||||
def get_team(self):
|
||||
raise NotImplementedError("Subclasses must return the Team this view acts on.")
|
||||
|
||||
def test_func(self):
|
||||
user, club = self.request.user, self.request.club
|
||||
if is_club_admin(user, club):
|
||||
return True
|
||||
return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists()
|
||||
@@ -1,6 +1,8 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.validators import FileExtensionValidator, RegexValidator
|
||||
from django.conf import settings
|
||||
from django.core.validators import FileExtensionValidator, MinValueValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -182,6 +184,9 @@ class ClubMembership(ClubScopedModel):
|
||||
status = models.CharField(_("status"), max_length=250, choices=StatusChoices.choices, default=StatusChoices.PENDING)
|
||||
fee_status = models.CharField(_("fee status"), max_length=250, choices=FeeStatus.choices, default=FeeStatus.UNPAID)
|
||||
|
||||
fee_amount = models.DecimalField(_("fee amount"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True)
|
||||
amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True, help_text=_("Kept in step with payments by the fee service; not hand-edited."))
|
||||
|
||||
signed_up_at = models.DateField(_("signed up at"), blank=True, null=True)
|
||||
activated_at = models.DateField(_("activated at"), blank=True, null=True)
|
||||
|
||||
@@ -200,6 +205,35 @@ class ClubMembership(ClubScopedModel):
|
||||
validate_club_scope(self, self.club_id, same_club_fields=("season",))
|
||||
|
||||
|
||||
class FeePayment(UUIDModel):
|
||||
"""Money received against one membership's fee. Several may land on one
|
||||
membership: a family paying in two installments must not read as unpaid, and
|
||||
the part that did arrive has to be recorded somewhere. Not itself club-scoped
|
||||
-- its club is reached through ``membership``, same as DuePayment/Due."""
|
||||
|
||||
class Method(models.TextChoices):
|
||||
BANK_TRANSFER = "bank_transfer", _("bank transfer")
|
||||
CASH = "cash", _("cash")
|
||||
CARD = "card", _("card")
|
||||
OTHER = "other", _("other")
|
||||
|
||||
membership = models.ForeignKey(ClubMembership, on_delete=models.CASCADE, related_name="payments", verbose_name=_("membership"))
|
||||
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))])
|
||||
method = models.CharField(_("method"), max_length=20, choices=Method.choices, default=Method.BANK_TRANSFER)
|
||||
reference = models.CharField(_("reference"), max_length=255, blank=True, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
|
||||
paid_at = models.DateTimeField(_("paid at"), default=timezone.now)
|
||||
note = models.TextField(_("note"), blank=True)
|
||||
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_fee_payments", verbose_name=_("recorded by"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("fee payment")
|
||||
verbose_name_plural = _("fee payments")
|
||||
ordering = ["-paid_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.membership} — {self.amount}"
|
||||
|
||||
|
||||
class ClubRole(ClubScopedModel):
|
||||
class Roles(models.TextChoices):
|
||||
ADMIN = "admin", _("admin")
|
||||
|
||||
@@ -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
69
club/services/fees.py
Normal 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)
|
||||
113
club/tests.py
113
club/tests.py
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from decimal import Decimal
|
||||
|
||||
from allauth.mfa.models import Authenticator
|
||||
from django.contrib import admin as django_admin
|
||||
@@ -16,7 +17,7 @@ from events.models import Event
|
||||
from members.models import Family, FamilyMembership, Member
|
||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||
|
||||
from .models import Club, ClubMembership, ClubRole, Season, club_logo_path
|
||||
from .models import Club, ClubMembership, ClubRole, FeePayment, Season, club_logo_path
|
||||
from .services.access import (
|
||||
COACH_MANAGER,
|
||||
can_edit_event,
|
||||
@@ -27,6 +28,7 @@ from .services.access import (
|
||||
teams_managed_by,
|
||||
teams_staffed_by,
|
||||
)
|
||||
from .services.fees import mark_as_paid, record_payment, remaining_balance
|
||||
from .tenancy import (
|
||||
ClubTenantMiddleware,
|
||||
get_current_club,
|
||||
@@ -440,14 +442,17 @@ class SeasonGetCurrentTests(TestCase):
|
||||
self.assertEqual(found.club, self.other)
|
||||
|
||||
def test_defaults_to_today(self):
|
||||
# self.other, not self.club -- setUp's self.season (2026-08-01 to 2027-05-31)
|
||||
# would otherwise also cover "today" once real dates reach that window,
|
||||
# colliding with the one created here.
|
||||
today = timezone.now().date()
|
||||
current = Season.objects.create(
|
||||
club=self.club,
|
||||
club=self.other,
|
||||
start_date=today - datetime.timedelta(days=10),
|
||||
end_date=today + datetime.timedelta(days=10),
|
||||
)
|
||||
|
||||
with with_club(self.club):
|
||||
with with_club(self.other):
|
||||
self.assertEqual(Season.get_current(), current)
|
||||
|
||||
def test_requires_an_active_club(self):
|
||||
@@ -1083,3 +1088,105 @@ class RootViewTests(TestCase):
|
||||
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
self.assertRedirects(response, f"{reverse('account_login')}?next=/", fetch_redirect_response=False)
|
||||
|
||||
|
||||
class FeeServiceTests(TestCase):
|
||||
"""club.services.fees -- record_payment/mark_as_paid/remaining_balance, the
|
||||
service layer behind the Memberships page's per-row payment actions."""
|
||||
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
self.season = make_season(self.club)
|
||||
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
||||
self.membership = ClubMembership.objects.create(
|
||||
club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("150.00")
|
||||
)
|
||||
|
||||
def roles(self):
|
||||
return ClubRole.objects.filter(club=self.club, member=self.member)
|
||||
|
||||
def test_remaining_balance_starts_at_the_full_fee(self):
|
||||
self.assertEqual(remaining_balance(self.membership), Decimal("150.00"))
|
||||
|
||||
def test_remaining_balance_is_never_negative(self):
|
||||
record_payment(self.membership, amount=Decimal("200.00"))
|
||||
|
||||
self.assertEqual(remaining_balance(self.membership), Decimal("0.00"))
|
||||
|
||||
def test_a_partial_payment_creates_a_record_and_updates_the_running_total(self):
|
||||
payment = record_payment(self.membership, amount=Decimal("50.00"), method=FeePayment.Method.CASH, reference="R1", note="first installment")
|
||||
|
||||
self.assertEqual(payment.membership, self.membership)
|
||||
self.assertEqual(payment.amount, Decimal("50.00"))
|
||||
self.membership.refresh_from_db()
|
||||
self.assertEqual(self.membership.amount_paid, Decimal("50.00"))
|
||||
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID)
|
||||
# Not yet settled -- status doesn't change on a partial payment.
|
||||
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING)
|
||||
|
||||
def test_multiple_partial_payments_accumulate(self):
|
||||
record_payment(self.membership, amount=Decimal("50.00"))
|
||||
record_payment(self.membership, amount=Decimal("60.00"))
|
||||
|
||||
self.membership.refresh_from_db()
|
||||
self.assertEqual(self.membership.amount_paid, Decimal("110.00"))
|
||||
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID)
|
||||
self.assertEqual(FeePayment.objects.filter(membership=self.membership).count(), 2)
|
||||
|
||||
def test_reaching_the_full_amount_settles_and_activates(self):
|
||||
record_payment(self.membership, amount=Decimal("100.00"))
|
||||
record_payment(self.membership, amount=Decimal("50.00"))
|
||||
|
||||
self.membership.refresh_from_db()
|
||||
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
|
||||
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE)
|
||||
self.assertEqual(self.membership.activated_at, timezone.localdate())
|
||||
self.assertTrue(self.roles().filter(role=ClubRole.Roles.MEMBER).exists())
|
||||
|
||||
def test_settling_in_full_does_not_overwrite_an_earlier_activated_at(self):
|
||||
earlier = datetime.date(2026, 1, 1)
|
||||
self.membership.activated_at = earlier
|
||||
self.membership.save()
|
||||
|
||||
record_payment(self.membership, amount=Decimal("150.00"))
|
||||
|
||||
self.membership.refresh_from_db()
|
||||
self.assertEqual(self.membership.activated_at, earlier)
|
||||
|
||||
def test_a_waived_membership_is_untouched_by_a_payment(self):
|
||||
self.membership.fee_status = ClubMembership.FeeStatus.WAIVED
|
||||
self.membership.save()
|
||||
|
||||
record_payment(self.membership, amount=Decimal("50.00"))
|
||||
|
||||
self.membership.refresh_from_db()
|
||||
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.WAIVED)
|
||||
|
||||
def test_mark_as_paid_records_the_exact_remaining_balance(self):
|
||||
record_payment(self.membership, amount=Decimal("100.00"))
|
||||
|
||||
mark_as_paid(self.membership)
|
||||
|
||||
self.membership.refresh_from_db()
|
||||
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
|
||||
payment = FeePayment.objects.get(membership=self.membership, amount=Decimal("50.00"))
|
||||
self.assertEqual(payment.note, "Marked as paid")
|
||||
|
||||
def test_mark_as_paid_with_no_fee_amount_set_skips_creating_a_zero_payment(self):
|
||||
# FeePayment.amount has a MinValueValidator(0.01) -- a $0 "payment" isn't a
|
||||
# real transaction, so this must flip the flags directly instead.
|
||||
unpriced = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="No", last_name="Price"), season=self.season, status=ClubMembership.StatusChoices.PENDING)
|
||||
|
||||
mark_as_paid(unpriced)
|
||||
|
||||
unpriced.refresh_from_db()
|
||||
self.assertEqual(unpriced.fee_status, ClubMembership.FeeStatus.PAID)
|
||||
self.assertEqual(unpriced.status, ClubMembership.StatusChoices.ACTIVE)
|
||||
self.assertFalse(FeePayment.objects.filter(membership=unpriced).exists())
|
||||
|
||||
def test_recorded_by_is_stored_on_the_payment(self):
|
||||
user = get_user_model().objects.create_user(email="admin-fees@example.com", password="pw-secret-123")
|
||||
|
||||
payment = record_payment(self.membership, amount=Decimal("50.00"), recorded_by=user)
|
||||
|
||||
self.assertEqual(payment.recorded_by, user)
|
||||
|
||||
Reference in New Issue
Block a user