From c3cdf71506b1f48a44203ef550f8cd0c9470c068 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Thu, 30 Jul 2026 19:17:26 +0200 Subject: [PATCH 1/2] feat: add per-member payment frequencies --- src/ccma/assets/CHANGELOG.json | 3 +- src/ccma/domain/models.py | 11 ++++- src/ccma/rules/scripts/contribution_claims.py | 49 +++++++++++++++++++ src/ccma/storage/repository.py | 3 ++ src/ccma/ui/member_tab.py | 21 ++++++-- tests/test_repository.py | 23 +++++++++ tests/test_rules.py | 42 ++++++++++++++++ tests/test_sepa.py | 22 +++++++++ 8 files changed, 168 insertions(+), 6 deletions(-) diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 0c8f51c..e77d964 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -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." ] }, { diff --git a/src/ccma/domain/models.py b/src/ccma/domain/models.py index b512eb3..b8d50b6 100644 --- a/src/ccma/domain/models.py +++ b/src/ccma/domain/models.py @@ -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())), diff --git a/src/ccma/rules/scripts/contribution_claims.py b/src/ccma/rules/scripts/contribution_claims.py index 9d6e5db..3da4d2e 100644 --- a/src/ccma/rules/scripts/contribution_claims.py +++ b/src/ccma/rules/scripts/contribution_claims.py @@ -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": diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index 98ade57..4bc1c3d 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -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) diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index a7f5cc9..7f04729 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -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("<>", 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() diff --git a/tests/test_repository.py b/tests/test_repository.py index 9113779..f574f0a 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -40,9 +40,32 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None: assert raw["person"]["nickname"] == "Enchantress" assert raw["schema_version"] == 1 assert raw["membership"]["ended_at"] == "" + assert raw["contribution_profile"]["payment_frequency"] == "semiannual" assert raw["content_hash"] +def test_existing_member_without_payment_frequency_defaults_to_semiannual(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Legacy", last_name="Member") + member_path = repository.members_root / member.member_id / "member.json" + raw = json.loads(member_path.read_text(encoding="utf-8")) + raw["contribution_profile"].pop("payment_frequency") + member_path.write_text(json.dumps(raw), encoding="utf-8") + + assert repository.get_member(member.member_id).payment_frequency == "semiannual" + + +def test_repository_rejects_unknown_payment_frequency(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Invalid", last_name="Frequency") + member.payment_frequency = "weekly" + + with pytest.raises(RepositoryError, match="Ungültige Zahlweise"): + repository.save_member(member) + + def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() diff --git a/tests/test_rules.py b/tests/test_rules.py index 705995d..dd389e9 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -1,6 +1,7 @@ import json import shutil from datetime import date +from decimal import Decimal import pytest @@ -96,6 +97,43 @@ def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None: assert state["last_completed_run"] == "2026-04-15:000002" +@pytest.mark.parametrize( + ("frequency", "expected_suffixes", "expected_amount"), + [ + ("monthly", [f"month-{month:02d}" for month in range(1, 13)], "12.50"), + ("quarterly", [f"quarter-{quarter}" for quarter in range(1, 5)], "37.50"), + ("semiannual", ["first-half", "second-half"], "75.00"), + ("annual", ["annual"], "150.00"), + ], +) +def test_housekeeper_creates_claims_for_each_payment_frequency( + tmp_path, frequency, expected_suffixes, expected_amount +) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member( + first_name=frequency, + last_name="Frequency", + birth_date="1990-01-01", + ) + member.status = "active" + member.accepted_at = "2026-01-01" + member.membership_started_at = "2026-01-01" + member.payment_frequency = frequency + repository.save_member(member) + + Housekeeper(repository).run(today=date(2026, 12, 31)) + + membership_claims = [ + claim + for claim in repository.get_contributions(member.member_id).claims + if claim.get("type") == "membership_fee" + ] + assert [claim["claim_key"].rsplit(":", 1)[-1] for claim in membership_claims] == expected_suffixes + assert {claim["amount"] for claim in membership_claims} == {expected_amount} + assert sum(Decimal(claim["amount"]) for claim in membership_claims) == Decimal("150.00") + + def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() @@ -103,6 +141,7 @@ def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_pat member.status = "active" member.accepted_at = "2024-04-15" member.membership_started_at = "2024-04-15" + member.payment_frequency = "annual" repository.save_member(member) settings = housekeeper_module.HousekeeperSettings.from_values( @@ -138,6 +177,7 @@ def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_pat member.status = "active" member.accepted_at = "2021-04-15" member.membership_started_at = "2021-04-15" + member.payment_frequency = "annual" repository.save_member(member) settings = housekeeper_module.HousekeeperSettings.from_values( @@ -165,6 +205,7 @@ def test_housekeeper_does_not_create_retroactive_membership_claims_by_default(tm member.status = "active" member.accepted_at = "2024-04-15" member.membership_started_at = "2024-04-15" + member.payment_frequency = "annual" repository.save_member(member) Housekeeper(repository).run(today=date(2026, 6, 21)) @@ -185,6 +226,7 @@ def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) - member.accepted_at = "2024-01-01" member.membership_started_at = "2024-01-01" member.membership_ended_at = "2026-12-31" + member.payment_frequency = "annual" repository.save_member(member) Housekeeper(repository).run(today=date(2026, 6, 21)) diff --git a/tests/test_sepa.py b/tests/test_sepa.py index 5e64c3f..cc38ef5 100644 --- a/tests/test_sepa.py +++ b/tests/test_sepa.py @@ -5,6 +5,7 @@ from email.parser import BytesParser from email.policy import default from ccma.domain.models import ContributionData +from ccma.services.housekeeper import Housekeeper from ccma.services.sepa import PAIN_NAMESPACE, _safe, csv_text, pain008_bytes, pending_direct_debits from ccma.services.sepa_mail import debit_mail_bytes, generate_debit_mails from ccma.storage.repository import MemberRepository @@ -61,6 +62,27 @@ def test_pending_debits_select_due_open_balance(tmp_path): assert debits[0].claim_ids == ("due",) +def test_pending_debits_follow_members_monthly_payment_frequency(tmp_path): + repository, member = _repository(tmp_path) + member.accepted_at = "2025-01-01" + member.membership_started_at = "2025-01-01" + member.payment_frequency = "monthly" + repository.save_member(member) + repository.save_contributions(member.member_id, ContributionData()) + + Housekeeper(repository).run(today=date(2026, 3, 31)) + debits, warnings = pending_direct_debits( + repository, + due_from=date(2026, 1, 1), + due_until=date(2026, 3, 31), + ) + + assert warnings == [] + assert len(debits) == 1 + assert debits[0].amount == Decimal("37.50") + assert len(debits[0].claim_ids) == 3 + + def test_pending_debits_exclude_claims_before_lower_date(tmp_path): repository, _member = _repository(tmp_path) From ba3238e61a27f42cf13deab919a6e647f42e2d62 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Thu, 30 Jul 2026 19:22:12 +0200 Subject: [PATCH 2/2] ui: move payment frequency to banking tab --- src/ccma/ui/member_tab.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index 7f04729..288b310 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -204,22 +204,11 @@ 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) + 2, column=0, sticky="nw", pady=5, padx=(0, 12) + row=len(fields) + 1, 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) + 2, column=1, sticky="ew", pady=5) + self.notes_text.grid(row=len(fields) + 1, column=1, sticky="ew", pady=5) self.notes_text.bind("<>", lambda _event: self._mark_dirty_from_text("data"), add="+") data_tab.columnconfigure(1, weight=1) @@ -247,7 +236,18 @@ class MemberTab(ttk.Frame): (f"Mandat widerrufen am ({date_input_hint()})", "mandate_revoked_at"), ) banking_tab.columnconfigure(1, weight=1) - for row, (label, key) in enumerate(banking_fields): + self._add_variable("payment_frequency", tk.StringVar(), "banking") + ttk.Label(banking_tab, text="Zahlweise").grid( + row=0, column=0, sticky="w", padx=(0, 12), pady=5 + ) + ttk.Combobox( + banking_tab, + textvariable=self.variables["payment_frequency"], + values=list(PAYMENT_FREQUENCY_LABELS.values()), + state="readonly", + width=39, + ).grid(row=0, column=1, sticky="ew", pady=5) + for row, (label, key) in enumerate(banking_fields, start=1): self._add_variable(key, tk.StringVar(), "banking") ttk.Label(banking_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5) ttk.Entry(banking_tab, textvariable=self.variables[key], width=42).grid( @@ -259,7 +259,7 @@ class MemberTab(ttk.Frame): text="SEPA-Lastschriftmandat ist aktiv", variable=self.variables["mandate_active"], style="Switch", - ).grid(row=len(banking_fields), column=0, columnspan=2, sticky="w", pady=(12, 5)) + ).grid(row=len(banking_fields) + 1, column=0, columnspan=2, sticky="w", pady=(12, 5)) contribution_tab.columnconfigure(0, weight=1) contribution_tab.rowconfigure(1, weight=1) self.contribution_summary = tk.StringVar()