Decouple Q1/Q3 support payments from study proof requirement (STI-107) #1
209
app/stiftung/management/commands/backfill_quarterly_payments.py
Normal file
209
app/stiftung/management/commands/backfill_quarterly_payments.py
Normal 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"
|
||||||
@@ -909,6 +909,32 @@ class VierteljahresNachweis(models.Model):
|
|||||||
|
|
||||||
return complete
|
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):
|
def is_overdue(self):
|
||||||
"""Check if the deadline has passed"""
|
"""Check if the deadline has passed"""
|
||||||
if not self.faelligkeitsdatum:
|
if not self.faelligkeitsdatum:
|
||||||
@@ -1011,8 +1037,10 @@ class VierteljahresNachweis(models.Model):
|
|||||||
if not self.faelligkeitsdatum:
|
if not self.faelligkeitsdatum:
|
||||||
self.faelligkeitsdatum = self.studiennachweis_faelligkeitsdatum
|
self.faelligkeitsdatum = self.studiennachweis_faelligkeitsdatum
|
||||||
|
|
||||||
# Auto-update status based on completion
|
# Auto-update status based on completion.
|
||||||
if self.is_complete():
|
# 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":
|
if self.status == "offen":
|
||||||
self.status = "eingereicht"
|
self.status = "eingereicht"
|
||||||
self.eingereicht_am = timezone.now()
|
self.eingereicht_am = timezone.now()
|
||||||
|
|||||||
@@ -1060,26 +1060,27 @@ def quarterly_confirmation_update(request, pk):
|
|||||||
|
|
||||||
# Calculate current status before saving
|
# Calculate current status before saving
|
||||||
old_status = nachweis.status
|
old_status = nachweis.status
|
||||||
|
|
||||||
# Auto-update status based on completion
|
# Auto-update status based on completion. Q1/Q3 reach 'eingereicht' on
|
||||||
if quarterly_proof.is_complete():
|
# income+assets alone (study proof is due after the payment).
|
||||||
|
if quarterly_proof.is_complete_for_payment():
|
||||||
if quarterly_proof.status in ['offen', 'teilweise']:
|
if quarterly_proof.status in ['offen', 'teilweise']:
|
||||||
quarterly_proof.status = 'eingereicht'
|
quarterly_proof.status = 'eingereicht'
|
||||||
quarterly_proof.eingereicht_am = timezone.now()
|
quarterly_proof.eingereicht_am = timezone.now()
|
||||||
else:
|
else:
|
||||||
# If not complete, set to teilweise if some fields are filled
|
# If not complete, set to teilweise if some fields are filled
|
||||||
has_partial_data = (
|
has_partial_data = (
|
||||||
quarterly_proof.einkommenssituation_bestaetigt or
|
quarterly_proof.einkommenssituation_bestaetigt or
|
||||||
quarterly_proof.vermogenssituation_bestaetigt or
|
quarterly_proof.vermogenssituation_bestaetigt or
|
||||||
quarterly_proof.studiennachweis_eingereicht
|
quarterly_proof.studiennachweis_eingereicht
|
||||||
)
|
)
|
||||||
if has_partial_data and quarterly_proof.status == 'offen':
|
if has_partial_data and quarterly_proof.status == 'offen':
|
||||||
quarterly_proof.status = 'teilweise'
|
quarterly_proof.status = 'teilweise'
|
||||||
|
|
||||||
quarterly_proof.save()
|
quarterly_proof.save()
|
||||||
|
|
||||||
# Try to create automatic support payment if complete
|
# Try to create automatic support payment if payment-ready
|
||||||
if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht':
|
if quarterly_proof.is_complete_for_payment() and quarterly_proof.status == 'eingereicht':
|
||||||
support_payment = create_quarterly_support_payment(quarterly_proof)
|
support_payment = create_quarterly_support_payment(quarterly_proof)
|
||||||
if support_payment:
|
if support_payment:
|
||||||
messages.success(
|
messages.success(
|
||||||
@@ -1130,8 +1131,10 @@ def create_quarterly_support_payment(nachweis):
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
destinataer = nachweis.destinataer
|
destinataer = nachweis.destinataer
|
||||||
|
|
||||||
# Check if all requirements are met
|
# Check if payment-relevant requirements are met. For Q1/Q3 the study proof is
|
||||||
if not nachweis.is_complete():
|
# 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
|
return None
|
||||||
|
|
||||||
# Check if destinataer has required payment info
|
# Check if destinataer has required payment info
|
||||||
@@ -1347,8 +1350,9 @@ def quarterly_confirmation_edit(request, pk):
|
|||||||
# Calculate current status before saving
|
# Calculate current status before saving
|
||||||
old_status = nachweis.status
|
old_status = nachweis.status
|
||||||
|
|
||||||
# Auto-update status based on completion
|
# Auto-update status based on completion. Q1/Q3 reach 'eingereicht' on
|
||||||
if quarterly_proof.is_complete():
|
# income+assets alone (study proof is due after the payment).
|
||||||
|
if quarterly_proof.is_complete_for_payment():
|
||||||
if quarterly_proof.status in ['offen', 'teilweise']:
|
if quarterly_proof.status in ['offen', 'teilweise']:
|
||||||
quarterly_proof.status = 'eingereicht'
|
quarterly_proof.status = 'eingereicht'
|
||||||
quarterly_proof.eingereicht_am = timezone.now()
|
quarterly_proof.eingereicht_am = timezone.now()
|
||||||
@@ -1364,8 +1368,8 @@ def quarterly_confirmation_edit(request, pk):
|
|||||||
|
|
||||||
quarterly_proof.save()
|
quarterly_proof.save()
|
||||||
|
|
||||||
# Try to create automatic support payment if complete
|
# Try to create automatic support payment if payment-ready
|
||||||
if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht':
|
if quarterly_proof.is_complete_for_payment() and quarterly_proof.status == 'eingereicht':
|
||||||
support_payment = create_quarterly_support_payment(quarterly_proof)
|
support_payment = create_quarterly_support_payment(quarterly_proof)
|
||||||
if support_payment:
|
if support_payment:
|
||||||
messages.success(
|
messages.success(
|
||||||
@@ -1510,8 +1514,9 @@ def quarterly_confirmation_reset(request, pk):
|
|||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if nachweis.status in ['geprueft', 'eingereicht']:
|
if nachweis.status in ['geprueft', 'eingereicht']:
|
||||||
# Reset the quarterly confirmation status
|
# Reset the quarterly confirmation status. Use the payment-ready check
|
||||||
nachweis.status = 'eingereicht' if nachweis.is_complete() else 'teilweise'
|
# 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_am = None
|
||||||
nachweis.geprueft_von = None
|
nachweis.geprueft_von = None
|
||||||
nachweis.aktualisiert_am = timezone.now()
|
nachweis.aktualisiert_am = timezone.now()
|
||||||
|
|||||||
Reference in New Issue
Block a user