diff --git a/app/stiftung/management/commands/backfill_quarterly_payments.py b/app/stiftung/management/commands/backfill_quarterly_payments.py new file mode 100644 index 0000000..c62ccbd --- /dev/null +++ b/app/stiftung/management/commands/backfill_quarterly_payments.py @@ -0,0 +1,209 @@ +""" +Backfill quarterly support payments for all active destinatare. + +Companion to the STI-107 hotfix that decoupled Q1/Q3 payments from the +study proof. Run once after the deploy to make sure each active +destinatare has a `DestinataerUnterstuetzung` record for the requested +quarter, so the existing payment pipeline picks them up at the due date. + +Usage: + python manage.py backfill_quarterly_payments --jahr 2026 --quartal 3 + python manage.py backfill_quarterly_payments --jahr 2026 --quartal 3 --dry-run + python manage.py backfill_quarterly_payments --jahr 2026 --quartal 3 --force + +`--force` skips the payment-readiness check (`is_complete_for_payment`) +and creates the payment as soon as the destinatare has IBAN and amount — +intended for one-off catch-up like Q3/2026 where the staff did not yet +record the income/assets review for every entry. +""" + +from datetime import date + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from stiftung.models import ( + Destinataer, + DestinataerUnterstuetzung, + StiftungsKonto, + VierteljahresNachweis, +) +from stiftung.views.unterstuetzungen import create_quarterly_support_payment + + +PAYMENT_DUE_DATES = { + 1: lambda jahr: date(jahr - 1, 12, 15), + 2: lambda jahr: date(jahr, 3, 15), + 3: lambda jahr: date(jahr, 6, 15), + 4: lambda jahr: date(jahr, 9, 15), +} + + +class Command(BaseCommand): + help = "Create quarterly support payments for all active destinatare for a given Jahr/Quartal." + + def add_arguments(self, parser): + parser.add_argument("--jahr", type=int, required=True, help="Jahr, z.B. 2026") + parser.add_argument("--quartal", type=int, required=True, choices=[1, 2, 3, 4]) + parser.add_argument( + "--dry-run", + action="store_true", + help="Nichts speichern, nur anzeigen, was passieren würde.", + ) + parser.add_argument( + "--force", + action="store_true", + help=( + "Zahlung anlegen, auch wenn der Vierteljahresnachweis noch nicht " + "ausgefüllt ist (überspringt is_complete_for_payment-Check)." + ), + ) + + def handle(self, *args, **options): + jahr = options["jahr"] + quartal = options["quartal"] + dry_run = options["dry_run"] + force = options["force"] + + payment_due_date = PAYMENT_DUE_DATES[quartal](jahr) + mode = "DRY RUN" if dry_run else "LIVE" + self.stdout.write( + self.style.WARNING( + f"[{mode}] Backfill Q{quartal}/{jahr} — Zahlungsfälligkeit {payment_due_date.strftime('%d.%m.%Y')}" + + (" — FORCE-Modus aktiv" if force else "") + ) + ) + + eligible = Destinataer.objects.filter( + aktiv=True, + vierteljaehrlicher_betrag__isnull=False, + vierteljaehrlicher_betrag__gt=0, + ).exclude(iban__isnull=True).exclude(iban="") + + total = eligible.count() + self.stdout.write(f"Aktive Destinatäre mit Betrag und IBAN: {total}") + + created = 0 + updated = 0 + skipped_existing = 0 + skipped_incomplete = [] + skipped_no_konto = [] + errors = [] + + for destinataer in eligible.order_by("nachname", "vorname"): + try: + with transaction.atomic(): + result = self._process_destinataer( + destinataer, + jahr=jahr, + quartal=quartal, + payment_due_date=payment_due_date, + force=force, + dry_run=dry_run, + ) + + if result == "created": + created += 1 + elif result == "updated": + updated += 1 + elif result == "exists": + skipped_existing += 1 + elif result == "incomplete": + skipped_incomplete.append(destinataer) + elif result == "no_konto": + skipped_no_konto.append(destinataer) + except Exception as exc: + errors.append((destinataer, str(exc))) + + self.stdout.write("") + self.stdout.write(self.style.SUCCESS(f"Neu angelegt: {created}")) + self.stdout.write(f"Bereits vorhandene (aktualisiert): {updated}") + self.stdout.write(f"Bereits vorhandene (unverändert): {skipped_existing}") + + if skipped_incomplete: + self.stdout.write( + self.style.WARNING( + f"\nUnvollständige Nachweise (kein Eink./Verm. bestätigt) — {len(skipped_incomplete)}:" + ) + ) + for d in skipped_incomplete: + self.stdout.write(f" - {d.get_full_name()} (ID {d.id})") + + if skipped_no_konto: + self.stdout.write( + self.style.WARNING( + f"\nKein Auszahlungskonto verfügbar — {len(skipped_no_konto)}:" + ) + ) + for d in skipped_no_konto: + self.stdout.write(f" - {d.get_full_name()} (ID {d.id})") + + if errors: + self.stdout.write(self.style.ERROR(f"\nFehler — {len(errors)}:")) + for d, msg in errors: + self.stdout.write(f" - {d.get_full_name()}: {msg}") + + def _process_destinataer( + self, destinataer, *, jahr, quartal, payment_due_date, force, dry_run + ): + nachweis, _nachweis_created = VierteljahresNachweis.objects.get_or_create( + destinataer=destinataer, + jahr=jahr, + quartal=quartal, + defaults={"studiennachweis_erforderlich": True}, + ) + + if nachweis.get_related_support_payment(): + return "exists" + + if not force and not nachweis.is_complete_for_payment(): + return "incomplete" + + konto = destinataer.standard_konto or StiftungsKonto.objects.first() + if not konto: + return "no_konto" + + verb = "FORCE " if force else "create" + label = ( + f"{destinataer.get_full_name()} " + f"({destinataer.vierteljaehrlicher_betrag}€ → {payment_due_date.strftime('%d.%m.%Y')})" + ) + + if dry_run: + self.stdout.write(f" {verb} würde Zahlung anlegen für {label}") + transaction.set_rollback(True) + return "created" + + if force: + payment = DestinataerUnterstuetzung.objects.create( + destinataer=destinataer, + konto=konto, + betrag=destinataer.vierteljaehrlicher_betrag, + faellig_am=payment_due_date, + status="geplant", + beschreibung=( + f"Vierteljährliche Unterstützung Q{quartal}/{jahr} " + "(Backfill STI-107, ohne Nachweis-Prüfung)" + ), + empfaenger_iban=destinataer.iban, + empfaenger_name=destinataer.get_full_name(), + verwendungszweck=( + f"Vierteljährliche Unterstützung Q{quartal}/{jahr} - " + f"{destinataer.get_full_name()}" + ), + erstellt_am=timezone.now(), + aktualisiert_am=timezone.now(), + ) + else: + payment = create_quarterly_support_payment(nachweis) + if payment is None: + return "incomplete" + + self.stdout.write( + self.style.SUCCESS( + f" {verb} {destinataer.get_full_name()} " + f"({payment.betrag}€ → {payment.faellig_am.strftime('%d.%m.%Y')}, #{payment.pk})" + ) + ) + return "created" diff --git a/app/stiftung/models/destinataere.py b/app/stiftung/models/destinataere.py index 00926d4..60dbbe4 100644 --- a/app/stiftung/models/destinataere.py +++ b/app/stiftung/models/destinataere.py @@ -909,6 +909,32 @@ class VierteljahresNachweis(models.Model): return complete + def is_complete_for_payment(self): + """Check if the quarter is sufficiently confirmed to release the support payment. + + Semester logic: study proof for Q1/Q3 is due AFTER the matching payment due date + (Q3 payment 15.06., study proof 15.09.; Q1 payment 15.12. previous year, + study proof 15.03.). For those quarters the study proof cannot block the payment; + the previous semester's study proof or initial enrollment already covers them. + + For Q2/Q4 the study proof deadline coincides with the payment due date (15.03./15.09.), + so we keep the full completeness requirement. + """ + # Income and asset confirmations are always required + income_ok = self.einkommenssituation_bestaetigt and ( + bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei) + or bool(self.einkommenssituation_dms_dokument_id) + ) + assets_ok = self.vermogenssituation_bestaetigt and ( + bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei) + or bool(self.vermogenssituation_dms_dokument_id) + ) + + if self.quartal in (1, 3): + return bool(income_ok and assets_ok) + + return self.is_complete() + def is_overdue(self): """Check if the deadline has passed""" if not self.faelligkeitsdatum: @@ -1011,8 +1037,10 @@ class VierteljahresNachweis(models.Model): if not self.faelligkeitsdatum: self.faelligkeitsdatum = self.studiennachweis_faelligkeitsdatum - # Auto-update status based on completion - if self.is_complete(): + # Auto-update status based on completion. + # For Q1/Q3 the study proof falls due AFTER the payment, so payment-ready + # confirmation (income + assets) is enough to advance to 'eingereicht'. + if self.is_complete_for_payment(): if self.status == "offen": self.status = "eingereicht" self.eingereicht_am = timezone.now() diff --git a/app/stiftung/views/unterstuetzungen.py b/app/stiftung/views/unterstuetzungen.py index c10bc6d..ad0b184 100644 --- a/app/stiftung/views/unterstuetzungen.py +++ b/app/stiftung/views/unterstuetzungen.py @@ -1060,26 +1060,27 @@ def quarterly_confirmation_update(request, pk): # Calculate current status before saving old_status = nachweis.status - - # Auto-update status based on completion - if quarterly_proof.is_complete(): + + # Auto-update status based on completion. Q1/Q3 reach 'eingereicht' on + # income+assets alone (study proof is due after the payment). + if quarterly_proof.is_complete_for_payment(): if quarterly_proof.status in ['offen', 'teilweise']: quarterly_proof.status = 'eingereicht' quarterly_proof.eingereicht_am = timezone.now() else: # If not complete, set to teilweise if some fields are filled has_partial_data = ( - quarterly_proof.einkommenssituation_bestaetigt or + quarterly_proof.einkommenssituation_bestaetigt or quarterly_proof.vermogenssituation_bestaetigt or quarterly_proof.studiennachweis_eingereicht ) if has_partial_data and quarterly_proof.status == 'offen': quarterly_proof.status = 'teilweise' - + quarterly_proof.save() - - # Try to create automatic support payment if complete - if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht': + + # Try to create automatic support payment if payment-ready + if quarterly_proof.is_complete_for_payment() and quarterly_proof.status == 'eingereicht': support_payment = create_quarterly_support_payment(quarterly_proof) if support_payment: messages.success( @@ -1130,8 +1131,10 @@ def create_quarterly_support_payment(nachweis): from datetime import date destinataer = nachweis.destinataer - # Check if all requirements are met - if not nachweis.is_complete(): + # Check if payment-relevant requirements are met. For Q1/Q3 the study proof is + # due AFTER the payment date (15.12./15.06. vs. 15.03./15.09.), so we use the + # payment-ready check which skips the study proof for those quarters. + if not nachweis.is_complete_for_payment(): return None # Check if destinataer has required payment info @@ -1347,8 +1350,9 @@ def quarterly_confirmation_edit(request, pk): # Calculate current status before saving old_status = nachweis.status - # Auto-update status based on completion - if quarterly_proof.is_complete(): + # Auto-update status based on completion. Q1/Q3 reach 'eingereicht' on + # income+assets alone (study proof is due after the payment). + if quarterly_proof.is_complete_for_payment(): if quarterly_proof.status in ['offen', 'teilweise']: quarterly_proof.status = 'eingereicht' quarterly_proof.eingereicht_am = timezone.now() @@ -1364,8 +1368,8 @@ def quarterly_confirmation_edit(request, pk): quarterly_proof.save() - # Try to create automatic support payment if complete - if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht': + # Try to create automatic support payment if payment-ready + if quarterly_proof.is_complete_for_payment() and quarterly_proof.status == 'eingereicht': support_payment = create_quarterly_support_payment(quarterly_proof) if support_payment: messages.success( @@ -1510,8 +1514,9 @@ def quarterly_confirmation_reset(request, pk): if request.method == "POST": if nachweis.status in ['geprueft', 'eingereicht']: - # Reset the quarterly confirmation status - nachweis.status = 'eingereicht' if nachweis.is_complete() else 'teilweise' + # Reset the quarterly confirmation status. Use the payment-ready check + # so Q1/Q3 keep their 'eingereicht' status after reset even without study proof. + nachweis.status = 'eingereicht' if nachweis.is_complete_for_payment() else 'teilweise' nachweis.geprueft_am = None nachweis.geprueft_von = None nachweis.aktualisiert_am = timezone.now()