feat: add per-member payment frequencies

This commit is contained in:
Marcel Peterkau
2026-07-30 19:17:26 +02:00
parent 6d9c50b148
commit c3cdf71506
8 changed files with 168 additions and 6 deletions
+2 -1
View File
@@ -15,7 +15,8 @@
"Offene Forderungen von Mitgliedern mit aktivem Lastschriftmandat können für einen frei wählbaren Fälligkeitszeitraum gesammelt und als bankfähige SEPA-XML-Datei oder als CSV-Übersicht exportiert werden; unvollständige Mandate werden dabei verständlich ausgewiesen.",
"Für geplante SEPA-Einzüge können personalisierte, mit Thunderbird kompatible E-Mail-Entwürfe erzeugt werden. Die Mitteilungen enthalten Betrag, Einzugsdatum und Mandatsdaten und werden automatisch in der jeweiligen Mitgliederakte archiviert.",
"Mahnungsentwürfe können direkt als personalisierte, mit Thunderbird kompatible E-Mail-Datei ausgegeben und in der Mitgliederakte archiviert werden; dabei werden der Versand verbucht sowie Zahlungsfrist und gegebenenfalls Mahngebühr wirksam.",
"Für automatisch vergebene Mitgliedsnummern kann gewählt werden, ob vorhandene Lücken mit der nächsten freien Nummer gefüllt werden oder stets die höchste bestehende Nummer um eins erhöht wird; die Vergabe ist gegen parallele Doppelbelegungen abgesichert."
"Für automatisch vergebene Mitgliedsnummern kann gewählt werden, ob vorhandene Lücken mit der nächsten freien Nummer gefüllt werden oder stets die höchste bestehende Nummer um eins erhöht wird; die Vergabe ist gegen parallele Doppelbelegungen abgesichert.",
"Die Zahlweise kann pro Mitglied als monatlich, quartalsweise, halbjährlich oder jährlich festgelegt werden; Hausmeister und Lastschriftläufe erzeugen und berücksichtigen die dazu passenden Beitragsforderungen."
]
},
{
+9 -2
View File
@@ -20,6 +20,13 @@ MEMBERSHIP_STATUS_LABELS = {
"ended": "BEENDET",
}
PAYMENT_FREQUENCY_LABELS = {
"monthly": "MONATLICH",
"quarterly": "QUARTALSWEISE",
"semiannual": "HALBJÄHRLICH",
"annual": "JÄHRLICH",
}
ASSET_STATUS_LABELS = {
"available": "VERFUEGBAR",
"issued": "AUSGEGEBEN",
@@ -91,7 +98,7 @@ class Member:
accepted_at: str = ""
membership_started_at: str = ""
membership_ended_at: str = ""
payment_frequency: str = "annual"
payment_frequency: str = "semiannual"
contribution_rule_id: str = "standard-2022"
honorary: bool = False
notes: str = ""
@@ -183,7 +190,7 @@ class Member:
membership_ended_at=str(membership.get("ended_at", "")),
honorary=bool(membership.get("honorary", False)),
contribution_rule_id=str(contribution.get("rule_id", "standard-2022")),
payment_frequency=str(contribution.get("payment_frequency", "annual")),
payment_frequency=str(contribution.get("payment_frequency", "semiannual")),
notes=str(data.get("notes", "")),
created_at=str(data.get("created_at", _iso_now())),
updated_at=str(data.get("updated_at", _iso_now())),
@@ -9,6 +9,21 @@ from ccma.rules.scripts._shared import CONTRIBUTION_STATUSES
RULE_ID = "contribution-claims"
ORDER = 40
CENT = Decimal("0.01")
MONTH_NAMES = (
"",
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
)
def evaluate(context: RuleContext):
@@ -85,6 +100,26 @@ def _membership_claims(
("first-half", 1, 6, _due_date(year, configured_due_dates[0], "01-31")),
("second-half", 7, 12, _due_date(year, configured_due_dates[1], "07-31")),
]
elif member.payment_frequency == "quarterly":
periods = [
(
f"quarter-{quarter}",
first_month,
first_month + 2,
_recurring_due_date(year, first_month, rule.get("annual_due"), "01-31"),
)
for quarter, first_month in enumerate((1, 4, 7, 10), start=1)
]
elif member.payment_frequency == "monthly":
periods = [
(
f"month-{month:02d}",
month,
month,
_recurring_due_date(year, month, rule.get("annual_due"), "01-31"),
)
for month in range(1, 13)
]
else:
periods = [("annual", 1, 12, _due_date(year, rule.get("annual_due"), "01-31"))]
@@ -168,7 +203,21 @@ def _due_date(year: int, value, fallback: str) -> date:
return date(year, month, day)
def _recurring_due_date(year: int, month: int, value, fallback: str) -> date:
try:
_configured_month, day = (int(part) for part in str(value or fallback).split("-", 1))
except (TypeError, ValueError):
day = int(fallback.split("-", 1)[1])
return date(year, month, min(day, calendar.monthrange(year, month)[1]))
def _title(year: int, suffix: str) -> str:
if suffix.startswith("month-"):
month = int(suffix.removeprefix("month-"))
return f"Mitgliedsbeitrag {MONTH_NAMES[month]} {year}"
if suffix.startswith("quarter-"):
quarter = int(suffix.removeprefix("quarter-"))
return f"Mitgliedsbeitrag {quarter}. Quartal {year}"
if suffix == "first-half":
return f"Mitgliedsbeitrag 1. Halbjahr {year}"
if suffix == "second-half":
+3
View File
@@ -29,6 +29,7 @@ from ccma.domain.models import (
ASSET_OWNER_TYPE_LABELS,
ASSET_STATUS_LABELS,
MEMBERSHIP_STATUS_LABELS,
PAYMENT_FREQUENCY_LABELS,
Asset,
ContributionData,
Event,
@@ -391,6 +392,8 @@ class MemberRepository:
raise RepositoryError(
"Ein aktives Lastschriftmandat benötigt IBAN, Mandatsreferenz und Erteilungsdatum."
)
if member.payment_frequency not in PAYMENT_FREQUENCY_LABELS:
raise RepositoryError("Ungültige Zahlweise.")
if member.member_number != existing.member_number:
self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id)
changes = self._summarize_changes(existing, member)
+18 -3
View File
@@ -15,7 +15,7 @@ from ccma.domain.contributions import (
payment_allocated_total,
)
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display
from ccma.domain.models import ASSET_STATUS_LABELS, Event
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.dialogs import IntegrityWarningDialog
@@ -204,11 +204,22 @@ class MemberTab(ttk.Frame):
state="readonly",
width=39,
).grid(row=len(fields), column=1, sticky="ew", pady=5)
self._add_variable("payment_frequency", tk.StringVar(), "data")
ttk.Label(data_tab, text="Zahlweise").grid(
row=len(fields) + 1, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Combobox(
data_tab,
textvariable=self.variables["payment_frequency"],
values=list(PAYMENT_FREQUENCY_LABELS.values()),
state="readonly",
width=39,
).grid(row=len(fields) + 1, column=1, sticky="ew", pady=5)
ttk.Label(data_tab, text="Interne Notiz").grid(
row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12)
row=len(fields) + 2, column=0, sticky="nw", pady=5, padx=(0, 12)
)
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
self.notes_text.grid(row=len(fields) + 1, column=1, sticky="ew", pady=5)
self.notes_text.grid(row=len(fields) + 2, column=1, sticky="ew", pady=5)
self.notes_text.bind("<<Modified>>", lambda _event: self._mark_dirty_from_text("data"), add="+")
data_tab.columnconfigure(1, weight=1)
@@ -474,6 +485,8 @@ class MemberTab(ttk.Frame):
value = getattr(self.member, key)
if key == "status":
variable.set(display_label(STATUS_LABELS, str(value)))
elif key == "payment_frequency":
variable.set(display_label(PAYMENT_FREQUENCY_LABELS, str(value)))
else:
variable.set(format_date_for_display(value) if key in date_fields else value)
if self.notes_text is not None:
@@ -688,6 +701,8 @@ class MemberTab(ttk.Frame):
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
if key == "status":
value = storage_key(STATUS_LABELS, value)
elif key == "payment_frequency":
value = storage_key(PAYMENT_FREQUENCY_LABELS, value)
setattr(self.member, key, value)
if self.notes_text is not None:
self.member.notes = self.notes_text.get("1.0", "end-1c").strip()