Decouple Q1/Q3 support payments from study proof requirement (STI-107) #1

Merged
Remmer merged 2 commits from fix/sti-107-q3-zahlungspipeline into main 2026-06-14 20:33:05 +00:00
3 changed files with 260 additions and 18 deletions

View File

@@ -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"

View File

@@ -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()

View File

@@ -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()