From ae9d749356c4866e0bd20a6546e9ca2f32a31532 Mon Sep 17 00:00:00 2001 From: Flo Ha Date: Sat, 20 Jun 2026 16:19:05 +0200 Subject: [PATCH] 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 --- gaehsnitz/admin.py | 4 +- .../commands/finance_summary_2026.py | 86 +++++++++++++++++++ gaehsnitz/management/commands/merge_users.py | 46 ++++++++++ .../management/commands/update_crates_2026.py | 39 +++++++++ .../migrations/0011_add_payment_method.py | 18 ++++ gaehsnitz/models.py | 7 ++ 6 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 gaehsnitz/management/commands/finance_summary_2026.py create mode 100644 gaehsnitz/management/commands/merge_users.py create mode 100644 gaehsnitz/management/commands/update_crates_2026.py create mode 100644 gaehsnitz/migrations/0011_add_payment_method.py diff --git a/gaehsnitz/admin.py b/gaehsnitz/admin.py index d31ed88..d61472f 100644 --- a/gaehsnitz/admin.py +++ b/gaehsnitz/admin.py @@ -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",) diff --git a/gaehsnitz/management/commands/finance_summary_2026.py b/gaehsnitz/management/commands/finance_summary_2026.py new file mode 100644 index 0000000..1db2b42 --- /dev/null +++ b/gaehsnitz/management/commands/finance_summary_2026.py @@ -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") diff --git a/gaehsnitz/management/commands/merge_users.py b/gaehsnitz/management/commands/merge_users.py new file mode 100644 index 0000000..d94b952 --- /dev/null +++ b/gaehsnitz/management/commands/merge_users.py @@ -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." + ) + ) diff --git a/gaehsnitz/management/commands/update_crates_2026.py b/gaehsnitz/management/commands/update_crates_2026.py new file mode 100644 index 0000000..fac203e --- /dev/null +++ b/gaehsnitz/management/commands/update_crates_2026.py @@ -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.")) diff --git a/gaehsnitz/migrations/0011_add_payment_method.py b/gaehsnitz/migrations/0011_add_payment_method.py new file mode 100644 index 0000000..1b44cbb --- /dev/null +++ b/gaehsnitz/migrations/0011_add_payment_method.py @@ -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'), + ), + ] diff --git a/gaehsnitz/models.py b/gaehsnitz/models.py index bb1a03a..05eca89 100644 --- a/gaehsnitz/models.py +++ b/gaehsnitz/models.py @@ -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"