Compare commits

..

2 Commits

Author SHA1 Message Date
flo b5142ce3c7 fix(dashboard): subtract cash prefill from income to fix Bilanz
500€ Kassen-Vorschuss was counted as income, inflating Einnahmen by 500€
and showing Bilanz -166€ instead of the correct -666€.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 16:26:53 +02:00
flo ae9d749356 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>
2026-06-20 16:19:05 +02:00
9 changed files with 215 additions and 7 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"
+9 -1
View File
@@ -1,5 +1,6 @@
import math
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
from django.conf import settings
@@ -747,9 +748,14 @@ DAY_LABELS = {1: "Donnerstag", 2: "Freitag", 3: "Samstag", 4: "Sonntag"}
def dashboard_view(request):
year = current_year()
CASH_PREFILL = Decimal("500.00")
total_donations = Donation.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
total_costs = Payment.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
user_payments_total = UserPayment.objects.filter(created_at__year=year).aggregate(s=Sum("amount"))["s"] or 0
cash_raw = UserPayment.objects.filter(created_at__year=year, method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
cash_net = cash_raw - CASH_PREFILL
non_cash = UserPayment.objects.filter(created_at__year=year).exclude(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
user_payments_total = cash_net + non_cash # real income, prefill excluded
drinks = list(Drink.objects.filter(year=year).order_by("name"))
drink_rows = [
@@ -958,6 +964,8 @@ def dashboard_view(request):
"total_entry": total_entry,
"method_split": method_split,
"cash_total": cash_total,
"cash_prefill": CASH_PREFILL,
"cash_net": cash_net,
"top_free_recipient": top_free_recipient,
"free_total_count": free_total_count,
}
+7 -3
View File
@@ -30,12 +30,16 @@
<span class="hist-price">{{ total_donations|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Zahlungen (User-Tab)</span>
<span class="hist-what">Cashless (User-Tab)</span>
<span class="hist-price">{{ user_payments_total|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Kasse (bar)</span>
<span class="hist-price">{{ cash_total|euro }}</span>
<span class="hist-what">Kasse (bar, nach Vorschuss)</span>
<span class="hist-price">{{ cash_net|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Kassen-Vorschuss</span>
<span class="hist-price">{{ cash_prefill|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Einkaufspreis Getränke</span>
+1 -1
View File
@@ -10,7 +10,7 @@ dependencies = [
[dependency-groups]
dev = [
"ruff==0.15.18",
"ruff==0.15.16",
]
[tool.ruff]