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:
+2
-2
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user