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
|
||||
|
||||
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()
|
||||
|
||||
@@ -1061,8 +1061,9 @@ 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()
|
||||
@@ -1078,8 +1079,8 @@ def quarterly_confirmation_update(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(
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user