mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
feat: add per-member payment frequencies
This commit is contained in:
@@ -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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user