- Anmeldung über Argon2id und JWT in httpOnly-Cookies, Refresh mit echter Rotation über die neue Tabelle refresh_token - AuthProvider-Protokoll als Vorbereitung für OIDC, Administrator-Anlage beim Erststart mit erzwungenem Passwortwechsel - CRUD für Konten, Kategorien (zweistufiger Baum), Firmen, Recurrences, Preisversionen, Buchungen, Budgets, Vorlagen und Sparziele - Fälligkeiten mit Overlay-Logik: abrufen, bestätigen, auslassen, zurücksetzen - Kontosalden zum Stichtag, Monatsübersicht mit Plan-Ist-Vergleich - SECRET_KEY jetzt mindestens 32 Zeichen; Platzhalter in Produktion abgelehnt - 61 neue Integrationstests, insgesamt 148 grün Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
573 lines
20 KiB
Python
573 lines
20 KiB
Python
"""Recurrence-Engine: virtuelle Expansion wiederkehrender Posten.
|
||
|
||
Das Modul ist bewusst frei von Datenbankzugriffen. Alle Funktionen arbeiten auf
|
||
schlanken Protokollen, sodass sowohl ORM-Objekte als auch einfache Datenklassen
|
||
übergeben werden können. Preishistorie und materialisierte Occurrences werden als
|
||
Sequenzen hereingereicht – das Laden ist Aufgabe der aufrufenden Schicht.
|
||
|
||
Zentrale Regel: Schlüssel einer Fälligkeit ist immer das **nominale** Datum, also
|
||
das von der RRULE gelieferte Datum vor einer Werktagsverschiebung. Nur so bleibt
|
||
die Zuordnung zu `occurrence`-Zeilen stabil, wenn sich Feiertage oder die
|
||
Verschiebungsregel ändern.
|
||
"""
|
||
|
||
from collections.abc import Iterable, Sequence
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime, timedelta
|
||
from decimal import ROUND_HALF_UP, Decimal
|
||
from functools import lru_cache
|
||
from typing import Protocol, runtime_checkable
|
||
|
||
import holidays
|
||
from dateutil.relativedelta import relativedelta
|
||
from dateutil.rrule import rrulestr
|
||
|
||
from app.core.clock import today
|
||
from app.models.enums import BusinessDayShift, EntryKind, OccurrenceStatus
|
||
|
||
ZERO = Decimal("0.00")
|
||
CENT = Decimal("0.01")
|
||
|
||
# Obergrenze für die Werktagssuche – schützt vor Endlosschleifen bei absurden Kalendern.
|
||
MAX_SHIFT_DAYS = 30
|
||
|
||
# Puffer, um den das nominale Fenster erweitert wird, wenn nach Ist-Fälligkeit
|
||
# gefiltert wird. Eine Verschiebung überschreitet realistisch nie wenige Tage.
|
||
DUE_DATE_WINDOW_PADDING_DAYS = 21
|
||
|
||
|
||
class RecurrenceError(ValueError):
|
||
"""Fachlicher Fehler bei der Auswertung einer Wiederholungsregel."""
|
||
|
||
|
||
class InvalidRRuleError(RecurrenceError):
|
||
"""Die RRULE konnte nicht geparst werden."""
|
||
|
||
|
||
# --- Protokolle ---------------------------------------------------------------
|
||
|
||
|
||
@runtime_checkable
|
||
class AmountVersionLike(Protocol):
|
||
"""Eine Preisversion; gültig ab `valid_from` (einschließlich)."""
|
||
|
||
amount: Decimal
|
||
valid_from: date
|
||
|
||
|
||
@runtime_checkable
|
||
class OccurrenceLike(Protocol):
|
||
"""Eine materialisierte Fälligkeit, die das virtuelle Ergebnis überlagert."""
|
||
|
||
id: int | None
|
||
occurrence_date: date
|
||
status: OccurrenceStatus
|
||
actual_amount: Decimal | None
|
||
actual_date: date | None
|
||
account_id: int | None
|
||
note: str | None
|
||
|
||
|
||
@runtime_checkable
|
||
class RecurrenceLike(Protocol):
|
||
"""Die von der Engine benötigten Felder einer Recurrence."""
|
||
|
||
id: int | None
|
||
kind: EntryKind
|
||
amount: Decimal
|
||
is_variable: bool
|
||
rrule: str
|
||
dtstart: date
|
||
until: date | None
|
||
business_day_shift: BusinessDayShift
|
||
holiday_region: str
|
||
installments_total: int | None
|
||
principal_amount: Decimal | None
|
||
contract_start: date | None
|
||
contract_min_term_months: int | None
|
||
contract_notice_period_days: int | None
|
||
contract_auto_renew_months: int | None
|
||
contract_cancelled_at: date | None
|
||
reserve_enabled: bool
|
||
account_id: int | None
|
||
|
||
|
||
# --- Ergebnistypen ------------------------------------------------------------
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class PlannedOccurrence:
|
||
"""Eine einzelne Fälligkeit, virtuell berechnet und ggf. durch ein Overlay ergänzt."""
|
||
|
||
recurrence_id: int | None
|
||
kind: EntryKind
|
||
nominal_date: date
|
||
"""Von der RRULE geliefertes Datum – stabiler Schlüssel, auch nach Verschiebung."""
|
||
due_date: date
|
||
"""Tatsächlicher Zahltag nach Wochenend-/Feiertagsverschiebung."""
|
||
amount: Decimal
|
||
"""Sollbetrag laut Preishistorie zum `nominal_date`."""
|
||
status: OccurrenceStatus
|
||
is_variable: bool
|
||
occurrence_id: int | None = None
|
||
actual_amount: Decimal | None = None
|
||
actual_date: date | None = None
|
||
account_id: int | None = None
|
||
note: str | None = None
|
||
installment_number: int | None = None
|
||
installments_total: int | None = None
|
||
|
||
@property
|
||
def is_materialised(self) -> bool:
|
||
"""True, wenn zu dieser Fälligkeit bereits eine `occurrence`-Zeile existiert."""
|
||
return self.occurrence_id is not None
|
||
|
||
@property
|
||
def is_skipped(self) -> bool:
|
||
return self.status is OccurrenceStatus.SKIPPED
|
||
|
||
@property
|
||
def effective_amount(self) -> Decimal:
|
||
"""Ist-Betrag, sofern bestätigt, sonst der Sollbetrag. Ausgelassene zählen nicht."""
|
||
if self.status is OccurrenceStatus.SKIPPED:
|
||
return ZERO
|
||
if self.actual_amount is not None:
|
||
return self.actual_amount
|
||
return self.amount
|
||
|
||
@property
|
||
def effective_date(self) -> date:
|
||
"""Ist-Datum, sofern erfasst, sonst der geplante Zahltag."""
|
||
return self.actual_date or self.due_date
|
||
|
||
@property
|
||
def signed_amount(self) -> Decimal:
|
||
"""Betrag mit Vorzeichen: Ausgaben negativ, Einkünfte positiv."""
|
||
amount = self.effective_amount
|
||
return -amount if self.kind is EntryKind.EXPENSE else amount
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class InstallmentStatus:
|
||
"""Stand einer Ratenzahlung zu einem Stichtag."""
|
||
|
||
total: int
|
||
paid: int
|
||
remaining: int
|
||
paid_amount: Decimal
|
||
remaining_amount: Decimal
|
||
"""Restschuld: `principal_amount` abzüglich geleisteter Raten, sonst Summe der Restraten."""
|
||
final_due_date: date | None
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ContractTerm:
|
||
"""Aktuelle Vertragsperiode samt Kündigungstermin."""
|
||
|
||
term_start: date
|
||
term_end: date
|
||
"""Letzter Tag der laufenden Periode."""
|
||
notice_deadline: date | None
|
||
"""Spätester Kündigungstermin; None, wenn keine Frist hinterlegt ist."""
|
||
renews_on: date | None
|
||
"""Beginn der Folgeperiode bei automatischer Verlängerung, sonst None."""
|
||
is_cancelled: bool
|
||
|
||
|
||
# --- Feiertage und Werktagsverschiebung ---------------------------------------
|
||
|
||
|
||
@lru_cache(maxsize=16)
|
||
def _holiday_calendar(region: str) -> holidays.HolidayBase:
|
||
"""Feiertagskalender für eine Region wie `DE-NW`. Jahre werden bei Bedarf nachgeladen."""
|
||
country, _, subdiv = region.partition("-")
|
||
try:
|
||
return holidays.country_holidays(country.upper(), subdiv=subdiv.upper() or None)
|
||
except NotImplementedError as exc: # pragma: no cover - nur bei Fehlkonfiguration
|
||
raise RecurrenceError(f"Unbekannte Feiertagsregion: {region}") from exc
|
||
|
||
|
||
def is_business_day(day: date, region: str = "DE-NW") -> bool:
|
||
"""Ein Werktag ist Montag bis Freitag und kein gesetzlicher Feiertag der Region."""
|
||
if day.weekday() >= 5:
|
||
return False
|
||
return day not in _holiday_calendar(region)
|
||
|
||
|
||
def shift_to_business_day(
|
||
day: date,
|
||
shift: BusinessDayShift = BusinessDayShift.NEXT,
|
||
region: str = "DE-NW",
|
||
) -> date:
|
||
"""Verschiebt einen Termin auf den nächsten bzw. vorherigen Werktag."""
|
||
if shift is BusinessDayShift.NONE:
|
||
return day
|
||
|
||
step = timedelta(days=1 if shift is BusinessDayShift.NEXT else -1)
|
||
candidate = day
|
||
for _ in range(MAX_SHIFT_DAYS):
|
||
if is_business_day(candidate, region):
|
||
return candidate
|
||
candidate += step
|
||
# Unerreichbar bei realen Kalendern; lieber das Originaldatum als eine Endlosschleife.
|
||
return day
|
||
|
||
|
||
# --- RRULE --------------------------------------------------------------------
|
||
|
||
|
||
def build_rule(rrule: str, dtstart: date):
|
||
"""Parst eine RRULE ohne DTSTART und bindet sie an den Startzeitpunkt."""
|
||
text = (rrule or "").strip()
|
||
if not text:
|
||
raise InvalidRRuleError("Die Wiederholungsregel darf nicht leer sein.")
|
||
if "DTSTART" in text.upper():
|
||
raise InvalidRRuleError("Die Wiederholungsregel darf kein DTSTART enthalten.")
|
||
try:
|
||
return rrulestr(text, dtstart=_as_datetime(dtstart))
|
||
except Exception as exc:
|
||
raise InvalidRRuleError(f"Ungültige Wiederholungsregel: {exc}") from exc
|
||
|
||
|
||
def validate_rrule(rrule: str, dtstart: date) -> str:
|
||
"""Prüft eine RRULE und liefert sie normalisiert zurück. Wirft `InvalidRRuleError`."""
|
||
normalised = (rrule or "").strip()
|
||
rule = build_rule(normalised, dtstart)
|
||
# Eine Regel, die niemals feuert, ist mit Sicherheit ein Eingabefehler.
|
||
horizon = _as_datetime(dtstart + relativedelta(years=25))
|
||
if not rule.between(_as_datetime(dtstart), horizon, inc=True):
|
||
raise InvalidRRuleError("Die Wiederholungsregel ergibt keine Termine.")
|
||
return normalised
|
||
|
||
|
||
def next_dates(
|
||
recurrence: RecurrenceLike,
|
||
*,
|
||
count: int = 5,
|
||
after: date | None = None,
|
||
) -> list[date]:
|
||
"""Die nächsten `count` nominalen Termine nach `after` (einschließlich)."""
|
||
start = after or today()
|
||
end = start
|
||
results: list[date] = []
|
||
# Fenster schrittweise vergrößern, bis genug Termine gefunden sind.
|
||
for years in (1, 5, 25):
|
||
end = start + relativedelta(years=years)
|
||
results = [item.nominal_date for item in expand(recurrence, start, end)]
|
||
if len(results) >= count:
|
||
break
|
||
return results[:count]
|
||
|
||
|
||
# --- Kernfunktion -------------------------------------------------------------
|
||
|
||
|
||
def expand(
|
||
recurrence: RecurrenceLike,
|
||
window_start: date,
|
||
window_end: date,
|
||
*,
|
||
amount_versions: Sequence[AmountVersionLike] | None = None,
|
||
occurrences: Sequence[OccurrenceLike] | None = None,
|
||
) -> list[PlannedOccurrence]:
|
||
"""Expandiert eine Recurrence über ein Zeitfenster.
|
||
|
||
Das Fenster bezieht sich auf das **nominale** Datum. Eine Verschiebung auf den
|
||
nächsten Werktag kann `due_date` daher über `window_end` hinausschieben; wer nach
|
||
dem tatsächlichen Zahltag filtern will, nutzt `expand_by_due_date`.
|
||
|
||
Args:
|
||
recurrence: Die auszuwertende Regel.
|
||
window_start: Erster Tag des Fensters (einschließlich).
|
||
window_end: Letzter Tag des Fensters (einschließlich).
|
||
amount_versions: Preishistorie. `None` übernimmt `recurrence.amount_versions`,
|
||
falls vorhanden.
|
||
occurrences: Materialisierte Fälligkeiten, die das Ergebnis überlagern.
|
||
"""
|
||
if window_end < window_start:
|
||
return []
|
||
|
||
versions = _resolve_versions(recurrence, amount_versions)
|
||
overlays = _index_overlays(occurrences)
|
||
|
||
rule = build_rule(recurrence.rrule, recurrence.dtstart)
|
||
|
||
# Ratenzahlung: harte Obergrenze an Vorkommen, unabhängig von COUNT in der RRULE.
|
||
installment_numbers, installment_cutoff = _installment_plan(rule, recurrence)
|
||
|
||
series_end = _series_end(recurrence, installment_cutoff)
|
||
effective_end = window_end if series_end is None else min(window_end, series_end)
|
||
if effective_end < window_start:
|
||
return []
|
||
|
||
nominal_dates = rule.between(_as_datetime(window_start), _as_datetime(effective_end), inc=True)
|
||
|
||
results: list[PlannedOccurrence] = []
|
||
for moment in nominal_dates:
|
||
nominal = moment.date()
|
||
overlay = overlays.get(nominal)
|
||
results.append(
|
||
PlannedOccurrence(
|
||
recurrence_id=getattr(recurrence, "id", None),
|
||
kind=recurrence.kind,
|
||
nominal_date=nominal,
|
||
due_date=shift_to_business_day(
|
||
nominal, recurrence.business_day_shift, recurrence.holiday_region
|
||
),
|
||
amount=resolve_amount(recurrence, nominal, versions),
|
||
status=overlay.status if overlay else OccurrenceStatus.PLANNED,
|
||
is_variable=recurrence.is_variable,
|
||
occurrence_id=getattr(overlay, "id", None) if overlay else None,
|
||
actual_amount=overlay.actual_amount if overlay else None,
|
||
actual_date=overlay.actual_date if overlay else None,
|
||
account_id=(
|
||
overlay.account_id
|
||
if overlay and overlay.account_id is not None
|
||
else getattr(recurrence, "account_id", None)
|
||
),
|
||
note=overlay.note if overlay else None,
|
||
installment_number=installment_numbers.get(nominal),
|
||
installments_total=recurrence.installments_total,
|
||
)
|
||
)
|
||
return results
|
||
|
||
|
||
def expand_by_due_date(
|
||
recurrence: RecurrenceLike,
|
||
window_start: date,
|
||
window_end: date,
|
||
*,
|
||
amount_versions: Sequence[AmountVersionLike] | None = None,
|
||
occurrences: Sequence[OccurrenceLike] | None = None,
|
||
) -> list[PlannedOccurrence]:
|
||
"""Wie `expand`, filtert aber nach dem tatsächlichen Zahltag (`effective_date`).
|
||
|
||
Für Kalender und Monatsauswertungen ist das die richtige Sicht: eine Rate vom
|
||
31.05., die auf den 02.06. rutscht, gehört in den Juni.
|
||
"""
|
||
padding = timedelta(days=DUE_DATE_WINDOW_PADDING_DAYS)
|
||
candidates = expand(
|
||
recurrence,
|
||
window_start - padding,
|
||
window_end + padding,
|
||
amount_versions=amount_versions,
|
||
occurrences=occurrences,
|
||
)
|
||
return [item for item in candidates if window_start <= item.effective_date <= window_end]
|
||
|
||
|
||
def resolve_amount(
|
||
recurrence: RecurrenceLike,
|
||
on: date,
|
||
amount_versions: Sequence[AmountVersionLike] | None = None,
|
||
) -> Decimal:
|
||
"""Der zum Stichtag gültige Sollbetrag: größtes `valid_from <= on`."""
|
||
versions = _resolve_versions(recurrence, amount_versions)
|
||
applicable = [version for version in versions if version.valid_from <= on]
|
||
if not applicable:
|
||
return recurrence.amount
|
||
return max(applicable, key=lambda version: version.valid_from).amount
|
||
|
||
|
||
# --- Raten --------------------------------------------------------------------
|
||
|
||
|
||
def installments_remaining(
|
||
recurrence: RecurrenceLike,
|
||
as_of: date | None = None,
|
||
*,
|
||
amount_versions: Sequence[AmountVersionLike] | None = None,
|
||
occurrences: Sequence[OccurrenceLike] | None = None,
|
||
) -> InstallmentStatus | None:
|
||
"""Restraten und Restschuld einer Ratenzahlung. `None`, wenn keine Raten definiert sind.
|
||
|
||
Als geleistet gilt jede Rate, deren Zahltag am Stichtag bereits erreicht ist.
|
||
Die Restschuld folgt `principal_amount` abzüglich der geleisteten Beträge; ohne
|
||
hinterlegte Darlehenssumme wird die Summe der verbleibenden Raten gebildet – das
|
||
berücksichtigt Betragsänderungen mitten in der Serie korrekt.
|
||
"""
|
||
total = recurrence.installments_total
|
||
if not total:
|
||
return None
|
||
|
||
reference = as_of or today()
|
||
schedule = expand(
|
||
recurrence,
|
||
recurrence.dtstart,
|
||
# Weit genug in die Zukunft, um garantiert alle Raten zu erfassen.
|
||
recurrence.dtstart + relativedelta(years=100),
|
||
amount_versions=amount_versions,
|
||
occurrences=occurrences,
|
||
)
|
||
|
||
paid = [item for item in schedule if item.effective_date <= reference and not item.is_skipped]
|
||
outstanding = [
|
||
item for item in schedule if item.effective_date > reference and not item.is_skipped
|
||
]
|
||
|
||
paid_amount = _sum(item.effective_amount for item in paid)
|
||
if recurrence.principal_amount is not None:
|
||
remaining_amount = recurrence.principal_amount - paid_amount
|
||
else:
|
||
remaining_amount = _sum(item.amount for item in outstanding)
|
||
|
||
return InstallmentStatus(
|
||
total=total,
|
||
paid=len(paid),
|
||
remaining=len(outstanding),
|
||
paid_amount=paid_amount,
|
||
remaining_amount=max(remaining_amount, ZERO),
|
||
final_due_date=schedule[-1].due_date if schedule else None,
|
||
)
|
||
|
||
|
||
# --- Verträge und Kündigungsfristen -------------------------------------------
|
||
|
||
|
||
def contract_term(recurrence: RecurrenceLike, as_of: date | None = None) -> ContractTerm | None:
|
||
"""Die zum Stichtag laufende Vertragsperiode.
|
||
|
||
Die Erstlaufzeit beginnt mit `contract_start` (ersatzweise `dtstart`) und endet
|
||
am Tag vor Ablauf von `contract_min_term_months`. Ist eine automatische
|
||
Verlängerung hinterlegt, wird so lange verlängert, bis die Periode den Stichtag
|
||
einschließt.
|
||
"""
|
||
if not recurrence.contract_min_term_months:
|
||
return None
|
||
|
||
start = recurrence.contract_start or recurrence.dtstart
|
||
reference = as_of or today()
|
||
|
||
term_start = start
|
||
term_end = start + relativedelta(months=recurrence.contract_min_term_months) - timedelta(days=1)
|
||
renew_months = recurrence.contract_auto_renew_months
|
||
|
||
if renew_months:
|
||
# Höchstens 200 Verlängerungen – schützt vor Endlosschleifen bei Fehldaten.
|
||
for _ in range(200):
|
||
if term_end >= reference:
|
||
break
|
||
term_start = term_end + timedelta(days=1)
|
||
term_end = term_start + relativedelta(months=renew_months) - timedelta(days=1)
|
||
|
||
deadline: date | None = None
|
||
if recurrence.contract_notice_period_days is not None:
|
||
deadline = term_end - timedelta(days=recurrence.contract_notice_period_days)
|
||
|
||
cancelled = recurrence.contract_cancelled_at is not None
|
||
renews_on = None if cancelled or not renew_months else term_end + timedelta(days=1)
|
||
|
||
return ContractTerm(
|
||
term_start=term_start,
|
||
term_end=term_end,
|
||
notice_deadline=deadline,
|
||
renews_on=renews_on,
|
||
is_cancelled=cancelled,
|
||
)
|
||
|
||
|
||
def notice_deadline(recurrence: RecurrenceLike, as_of: date | None = None) -> date | None:
|
||
"""Letzter Kündigungstermin: Vertragsende minus Kündigungsfrist.
|
||
|
||
`None`, wenn kein Vertrag hinterlegt, keine Frist gesetzt oder bereits gekündigt ist.
|
||
"""
|
||
term = contract_term(recurrence, as_of)
|
||
if term is None or term.is_cancelled:
|
||
return None
|
||
return term.notice_deadline
|
||
|
||
|
||
# --- Rücklagen ----------------------------------------------------------------
|
||
|
||
|
||
def annual_burden(
|
||
recurrence: RecurrenceLike,
|
||
as_of: date | None = None,
|
||
*,
|
||
amount_versions: Sequence[AmountVersionLike] | None = None,
|
||
) -> Decimal:
|
||
"""Belastung der kommenden zwölf Monate aus der tatsächlichen Expansion.
|
||
|
||
Bewusst kein fester Intervallfaktor: eine halbjährliche Zahlung, die im Fenster
|
||
nur einmal fällt, ergibt auch nur eine Belastung.
|
||
"""
|
||
start = as_of or today()
|
||
end = start + relativedelta(years=1) - timedelta(days=1)
|
||
schedule = expand(recurrence, start, end, amount_versions=amount_versions)
|
||
return _sum(item.amount for item in schedule)
|
||
|
||
|
||
def monthly_reserve(
|
||
recurrence: RecurrenceLike,
|
||
as_of: date | None = None,
|
||
*,
|
||
amount_versions: Sequence[AmountVersionLike] | None = None,
|
||
) -> Decimal:
|
||
"""Monatlich zurückzulegender Betrag für nicht-monatliche Posten.
|
||
|
||
Jahresbelastung geteilt durch zwölf, kaufmännisch auf Cent gerundet. Ohne
|
||
aktivierte Rücklagenbildung ist das Ergebnis 0,00.
|
||
"""
|
||
if not recurrence.reserve_enabled:
|
||
return ZERO
|
||
burden = annual_burden(recurrence, as_of, amount_versions=amount_versions)
|
||
return (burden / 12).quantize(CENT, rounding=ROUND_HALF_UP)
|
||
|
||
|
||
# --- Interne Helfer -----------------------------------------------------------
|
||
|
||
|
||
def _as_datetime(day: date) -> datetime:
|
||
"""dateutil rechnet intern mit datetime; die Uhrzeit ist fachlich bedeutungslos."""
|
||
return datetime(day.year, day.month, day.day)
|
||
|
||
|
||
def _sum(values: Iterable[Decimal]) -> Decimal:
|
||
return sum(values, ZERO)
|
||
|
||
|
||
def _resolve_versions(
|
||
recurrence: RecurrenceLike, explicit: Sequence[AmountVersionLike] | None
|
||
) -> Sequence[AmountVersionLike]:
|
||
"""Explizit übergebene Preisversionen haben Vorrang vor denen am Objekt."""
|
||
if explicit is not None:
|
||
return explicit
|
||
return getattr(recurrence, "amount_versions", None) or ()
|
||
|
||
|
||
def _index_overlays(
|
||
occurrences: Sequence[OccurrenceLike] | None,
|
||
) -> dict[date, OccurrenceLike]:
|
||
"""Overlays nach nominalem Datum indizieren."""
|
||
if not occurrences:
|
||
return {}
|
||
return {item.occurrence_date: item for item in occurrences}
|
||
|
||
|
||
def _series_end(recurrence: RecurrenceLike, installment_cutoff: date | None) -> date | None:
|
||
"""Frühestes Serienende aus `until`, Kündigung und Ratenzahl."""
|
||
candidates = [
|
||
value
|
||
for value in (recurrence.until, recurrence.contract_cancelled_at, installment_cutoff)
|
||
if value is not None
|
||
]
|
||
return min(candidates) if candidates else None
|
||
|
||
|
||
def _installment_plan(rule, recurrence: RecurrenceLike) -> tuple[dict[date, int], date | None]:
|
||
"""Nummeriert die Raten ab `dtstart` und liefert das Datum der letzten Rate."""
|
||
total = recurrence.installments_total
|
||
if not total:
|
||
return {}, None
|
||
|
||
numbers: dict[date, int] = {}
|
||
last: date | None = None
|
||
for index, moment in enumerate(rule, start=1):
|
||
if index > total:
|
||
break
|
||
day = moment.date()
|
||
numbers[day] = index
|
||
last = day
|
||
return numbers, last
|