feat(finance): add payment method field and post-festival accounting tools

- Add Payment.method field (cash/card/paypal/bank) with migration
- Show method in PaymentAdmin list view
- Add merge_users management command (atomic, reassigns all FKs)
- Add update_crates_2026 command (actual purchased/returned crates)
- Add finance_summary_2026 command (Ausgaben, Einnahmen, Kasse, Bilanz)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 16:19:05 +02:00
parent 89ea65a0c8
commit ae9d749356
6 changed files with 198 additions and 2 deletions
+2 -2
View File
@@ -210,8 +210,8 @@ class DonationAdmin(admin.ModelAdmin):
@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
list_display = ("date", "purpose", "amount")
list_filter = ("date",)
list_display = ("date", "purpose", "amount", "method")
list_filter = ("method", "date")
ordering = ("-date",)
search_fields = ("purpose",)
@@ -0,0 +1,86 @@
from decimal import Decimal, ROUND_HALF_UP
from django.core.management.base import BaseCommand
from django.db.models import F, Sum
from gaehsnitz.models import Consumption, Payment, UserPayment
YEAR = 2026
CASH_PREFILL = Decimal("500.00")
def eur(amount):
return str(Decimal(amount).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
class Command(BaseCommand):
help = "Print full 2026 festival finance summary"
def handle(self, *args, **options):
w = self.stdout.write
sep = "-" * 40
# --- Ausgaben ---
w("\nAUSGABEN")
w(sep)
payments = Payment.objects.filter(date__year=YEAR).order_by("date")
total_out = Decimal("0")
for p in payments:
method_label = dict(Payment.Method.choices).get(p.method, p.method)
w(f" {p.date} {eur(p.amount):>6}{method_label:<15} {p.purpose}")
total_out += p.amount
w(sep)
w(f" {'TOTAL':<11} {eur(total_out):>6}")
# by method
w("")
for method, label in Payment.Method.choices:
subtotal = payments.filter(method=method).aggregate(s=Sum("amount"))["s"] or Decimal("0")
if subtotal:
w(f" {label:<15} {eur(subtotal):>6}")
# --- Einnahmen ---
w("\nEINNAHMEN")
w(sep)
up = UserPayment.objects.filter(created_at__year=YEAR)
cash_prefill = CASH_PREFILL
cash_payments = up.filter(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
cash_from_sales = cash_payments - cash_prefill
non_cash = up.exclude(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
total_in = cash_from_sales + non_cash
drink_revenue = Consumption.objects.filter(for_free=False, drink__year=YEAR).annotate(
cost=F("amount") * F("drink__sale_price_per_bottle")
).aggregate(s=Sum("cost"))["s"] or Decimal("0")
entry_donations = total_in - drink_revenue
w(f" Cash: {eur(cash_from_sales):>6}")
w(f" Cashless: {eur(non_cash):>6}")
w(f" Einnahmen Gesamt: {eur(total_in):>6}")
w(f" - Getränke-Umsatz: {eur(drink_revenue):>6}")
w(f" - Eintrittsspenden: {eur(entry_donations):>6}")
# --- Kassensaldo ---
w("\nKASSE")
w(sep)
cash_out = payments.filter(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
expected_cash = cash_prefill + cash_from_sales - cash_out
w(f" Kassen-Vorschuss: {eur(cash_prefill):>6}")
w(f" Cash-Einnahmen: {eur(cash_from_sales):>6}")
w(f" Cash-Ausgaben: {eur(cash_out):>6}")
w(f" -> in Kasse: {eur(expected_cash):>6}")
# --- Gesamtbilanz ---
w("\nGESAMTBILANZ")
w(sep)
net = total_in - total_out
out_of_pocket = total_out - total_in
w(f" Gesamtausgaben: {eur(total_out):>6}")
w(f" Gesamteinnahmen: {eur(total_in):>6}")
w(sep)
label = "Verlust" if out_of_pocket > 0 else "Gewinn"
w(f" {label}: {eur(abs(net)):>6}\n")
@@ -0,0 +1,46 @@
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from gaehsnitz.models import User
class Command(BaseCommand):
help = "Merge source user into target user, then delete source"
def add_arguments(self, parser):
parser.add_argument("source", help="Username of user to merge FROM (will be deleted)")
parser.add_argument("target", help="Username of user to merge INTO (will be kept)")
def handle(self, *args, **options):
source_name = options["source"]
target_name = options["target"]
if source_name == target_name:
raise CommandError("Source and target must be different users")
try:
source = User.objects.get(username=source_name)
except User.DoesNotExist:
raise CommandError(f"Source user '{source_name}' not found")
try:
target = User.objects.get(username=target_name)
except User.DoesNotExist:
raise CommandError(f"Target user '{target_name}' not found")
with transaction.atomic():
payments = source.user_payments.count()
consumptions = source.consumption_list.count()
source.user_payments.update(user=target)
source.consumption_list.update(user=target)
source.delete()
self.stdout.write(
self.style.SUCCESS(
f"Merged '{source_name}' into '{target_name}': "
f"{payments} payment(s), {consumptions} consumption(s) reassigned. "
f"Source user deleted."
)
)
@@ -0,0 +1,39 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from gaehsnitz.models import Drink
# Actual crates bought from supplier (all others were returned full, no cost)
# (name, crates_ordered, crates_purchased)
# crates_returned = crates_purchased (all empties back, full deposit refunded)
ACTUAL_CRATES = [
("Sternburg Export", 12, 11),
("Ur-Krostitzer", 5, 2),
("Budweiser", 5, 2),
("Altenburger Helles", 5, 5),
("Feldschl. Radler", 2, 1),
("Lübzer Grapef. 0,0", 1, 1),
("Freiberger 0,0", 4, 3),
("Club Mate", 2, 2),
("Vita Cola", 2, 1),
("Paulaner Spezi", 2, 2),
("Wasser", 10, 5),
]
class Command(BaseCommand):
help = "Update 2026 drink crates to actual purchased amounts"
def handle(self, *args, **options):
with transaction.atomic():
for name, ordered, purchased in ACTUAL_CRATES:
updated = Drink.objects.filter(name=name, year=2026).update(
crates_ordered=ordered,
crates_purchased=purchased,
crates_returned=purchased,
)
if updated:
self.stdout.write(f" {name}: ordered={ordered}, purchased={purchased}, returned={purchased}")
else:
self.stdout.write(self.style.WARNING(f" NOT FOUND: {name}"))
self.stdout.write(self.style.SUCCESS("Done."))
@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-06-20 12:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gaehsnitz', '0010_add_sekt_category'),
]
operations = [
migrations.AddField(
model_name='payment',
name='method',
field=models.CharField(choices=[('cash', 'Bar'), ('card', 'EC-Karte'), ('paypal', 'PayPal'), ('bank', 'Überweisung')], default='paypal', max_length=16, verbose_name='Methode'),
),
]
+7
View File
@@ -73,9 +73,16 @@ class Payment(models.Model):
bands = 11, "Bands"
supply_purchase = 12, "Getränke-/Essenseinkauf"
class Method(models.TextChoices):
cash = "cash", "Bar"
card = "card", "EC-Karte"
paypal = "paypal", "PayPal"
bank = "bank", "Überweisung"
purpose = models.CharField("Zweck", max_length=64)
date = models.DateField("Datum")
amount = PriceField("Betrag")
method = models.CharField("Methode", max_length=16, choices=Method.choices, default=Method.paypal)
class Meta:
verbose_name = "Ausgabe"