feat: add member address and SEPA data

This commit is contained in:
Marcel Peterkau
2026-06-21 22:14:45 +02:00
parent 0622a22794
commit 6c7bf63280
12 changed files with 362 additions and 10 deletions
+11 -1
View File
@@ -73,7 +73,12 @@ Placeholders use `{{group.field}}`. Available values include:
- Member: `member.number`, `member.first_name`, `member.last_name`, - Member: `member.number`, `member.first_name`, `member.last_name`,
`member.full_name`, `member.email`, `member.birth_date`, `member.status`, `member.full_name`, `member.email`, `member.birth_date`, `member.status`,
`member.accepted_at`, `member.started_at` `member.accepted_at`, `member.started_at`, `member.phone`, `member.street`,
`member.address_addition`, `member.postal_code`, `member.city`,
`member.country`, `member.address_line`, `member.account_holder`,
`member.iban`, `member.bic`, `member.mandate_reference`,
`member.mandate_signed_at`, `member.mandate_revoked_at`,
`member.mandate_active`
- Claim: `claim.id`, `claim.title`, `claim.due_date`, `claim.total`, - Claim: `claim.id`, `claim.title`, `claim.due_date`, `claim.total`,
`claim.paid`, `claim.balance`, `claim.status`, `claim.items` `claim.paid`, `claim.balance`, `claim.status`, `claim.items`
- Reminder: `reminder.id`, `reminder.level`, `reminder.name`, - Reminder: `reminder.id`, `reminder.level`, `reminder.name`,
@@ -81,6 +86,11 @@ Placeholders use `{{group.field}}`. Available values include:
`reminder.payment_deadline`, `reminder.payment_deadline_days`, `reminder.payment_deadline`, `reminder.payment_deadline_days`,
`reminder.fee`, `reminder.detail`, `reminder.channel` `reminder.fee`, `reminder.detail`, `reminder.channel`
- Document: `document.date`, `document.datetime` - Document: `document.date`, `document.datetime`
- Organization: `organization.name`, `organization.street`,
`organization.postal_code`, `organization.city`, `organization.country`,
`organization.address_line`, `organization.email`, `organization.phone`,
`organization.website`, `organization.iban`, `organization.bic`,
`organization.creditor_id`
Claim placeholders are available from a claim tab. Reminder placeholders are Claim placeholders are available from a claim tab. Reminder placeholders are
available when a reminder row is selected before opening the document dialog. available when a reminder row is selected before opening the document dialog.
+1
View File
@@ -26,6 +26,7 @@
"Forderungen besitzen eigene Arbeitsansichten mit Teilzahlungen, GnuCash-Referenzen, Zahlungszuordnungen, Mahnungen und Gebühren.", "Forderungen besitzen eigene Arbeitsansichten mit Teilzahlungen, GnuCash-Referenzen, Zahlungszuordnungen, Mahnungen und Gebühren.",
"Positionen, Zahlungen und Mahnungen einer Forderung werden gemeinsam in einer farblich gruppierten Übersicht dargestellt.", "Positionen, Zahlungen und Mahnungen einer Forderung werden gemeinsam in einer farblich gruppierten Übersicht dargestellt.",
"OpenDocument-Templates mit Platzhaltern, lokaler PDF-Erzeugung, Audit-Verknüpfung und Dokumentöffnung aus der Mitgliederakte ergänzt.", "OpenDocument-Templates mit Platzhaltern, lokaler PDF-Erzeugung, Audit-Verknüpfung und Dokumentöffnung aus der Mitgliederakte ergänzt.",
"Zentrale Vereins- und Absenderdaten sowie getrennte Mitgliedsbereiche für Anschrift, Telefon und validierte Bank-/SEPA-Daten ergänzt.",
"Dropdowns zeigen deutsche Begriffe bei weiterhin englischen Speicher-Keys; der Hausmeisterstatus liegt einheitlich in housekeeper.json.", "Dropdowns zeigen deutsche Begriffe bei weiterhin englischen Speicher-Keys; der Hausmeisterstatus liegt einheitlich in housekeeper.json.",
"Mehrstufiger Mahnworkflow mit Hausmeister-Regel, Entwurf, Versandbestätigung, Zahlungsfrist, optionaler Gebühr und Mahnsperre ergänzt.", "Mehrstufiger Mahnworkflow mit Hausmeister-Regel, Entwurf, Versandbestätigung, Zahlungsfrist, optionaler Gebühr und Mahnsperre ergänzt.",
"Splash-Screen auf das eingebettete CCMA-Hintergrundmotiv umgestellt und redundante Titeltexte entfernt.", "Splash-Screen auf das eingebettete CCMA-Hintergrundmotiv umgestellt und redundante Titeltexte entfernt.",
+4 -1
View File
@@ -6,17 +6,20 @@
</office:styles> </office:styles>
<office:body> <office:body>
<office:text> <office:text>
<text:p text:style-name="Title">Chaos Computer Club Mannheim e.V.</text:p> <text:p text:style-name="Title">{{organization.name}}</text:p>
<text:p>{{organization.address_line}} · {{organization.email}}</text:p>
<text:p>Erstellt am {{document.date}}</text:p> <text:p>Erstellt am {{document.date}}</text:p>
<text:p text:style-name="Heading">Forderung: {{claim.title}}</text:p> <text:p text:style-name="Heading">Forderung: {{claim.title}}</text:p>
<text:p>Mitglied: {{member.full_name}} ({{member.number}})</text:p> <text:p>Mitglied: {{member.full_name}} ({{member.number}})</text:p>
<text:p>E-Mail: {{member.email}}</text:p> <text:p>E-Mail: {{member.email}}</text:p>
<text:p>Anschrift: {{member.address_line}}</text:p>
<text:p>Fällig am: {{claim.due_date}}</text:p> <text:p>Fällig am: {{claim.due_date}}</text:p>
<text:p text:style-name="Heading">Positionen</text:p> <text:p text:style-name="Heading">Positionen</text:p>
<text:p>{{claim.items}}</text:p> <text:p>{{claim.items}}</text:p>
<text:p>Gesamtbetrag: {{claim.total}}</text:p> <text:p>Gesamtbetrag: {{claim.total}}</text:p>
<text:p>Bereits bezahlt: {{claim.paid}}</text:p> <text:p>Bereits bezahlt: {{claim.paid}}</text:p>
<text:p text:style-name="Heading">Offener Betrag: {{claim.balance}}</text:p> <text:p text:style-name="Heading">Offener Betrag: {{claim.balance}}</text:p>
<text:p>Zahlung an {{organization.iban}} · BIC {{organization.bic}}</text:p>
<text:p>Forderungs-ID: {{claim.id}}</text:p> <text:p>Forderungs-ID: {{claim.id}}</text:p>
</office:text> </office:text>
</office:body> </office:body>
+4 -1
View File
@@ -6,13 +6,16 @@
</office:styles> </office:styles>
<office:body> <office:body>
<office:text> <office:text>
<text:p text:style-name="Title">Chaos Computer Club Mannheim e.V.</text:p> <text:p text:style-name="Title">{{organization.name}}</text:p>
<text:p>{{organization.address_line}} · {{organization.email}}</text:p>
<text:p>{{document.date}}</text:p> <text:p>{{document.date}}</text:p>
<text:p>An {{member.full_name}} ({{member.number}})</text:p> <text:p>An {{member.full_name}} ({{member.number}})</text:p>
<text:p>{{member.address_line}}</text:p>
<text:p text:style-name="Heading">{{reminder.name}}</text:p> <text:p text:style-name="Heading">{{reminder.name}}</text:p>
<text:p>Zu unserer Forderung „{{claim.title}}“ ist weiterhin ein Betrag von {{claim.balance}} offen.</text:p> <text:p>Zu unserer Forderung „{{claim.title}}“ ist weiterhin ein Betrag von {{claim.balance}} offen.</text:p>
<text:p>Bitte begleiche den offenen Betrag innerhalb von {{reminder.payment_deadline_days}} Tagen.</text:p> <text:p>Bitte begleiche den offenen Betrag innerhalb von {{reminder.payment_deadline_days}} Tagen.</text:p>
<text:p>Mahngebühr: {{reminder.fee}}</text:p> <text:p>Mahngebühr: {{reminder.fee}}</text:p>
<text:p>Zahlung an {{organization.iban}} · BIC {{organization.bic}}</text:p>
<text:p>{{reminder.detail}}</text:p> <text:p>{{reminder.detail}}</text:p>
<text:p>Forderungs-ID: {{claim.id}}</text:p> <text:p>Forderungs-ID: {{claim.id}}</text:p>
<text:p>Mahnstufe: {{reminder.level}}</text:p> <text:p>Mahnstufe: {{reminder.level}}</text:p>
+3 -1
View File
@@ -6,12 +6,14 @@
</office:styles> </office:styles>
<office:body> <office:body>
<office:text> <office:text>
<text:p text:style-name="Title">Chaos Computer Club Mannheim e.V.</text:p> <text:p text:style-name="Title">{{organization.name}}</text:p>
<text:p>Erstellt am {{document.date}}</text:p> <text:p>Erstellt am {{document.date}}</text:p>
<text:p text:style-name="Heading">Mitgliedsdaten</text:p> <text:p text:style-name="Heading">Mitgliedsdaten</text:p>
<text:p>Name: {{member.full_name}}</text:p> <text:p>Name: {{member.full_name}}</text:p>
<text:p>Mitgliedsnummer: {{member.number}}</text:p> <text:p>Mitgliedsnummer: {{member.number}}</text:p>
<text:p>E-Mail: {{member.email}}</text:p> <text:p>E-Mail: {{member.email}}</text:p>
<text:p>Telefon: {{member.phone}}</text:p>
<text:p>Anschrift: {{member.address_line}}</text:p>
<text:p>Geburtsdatum: {{member.birth_date}}</text:p> <text:p>Geburtsdatum: {{member.birth_date}}</text:p>
<text:p>Mitglied seit: {{member.started_at}}</text:p> <text:p>Mitglied seit: {{member.started_at}}</text:p>
<text:p>Status: {{member.status}}</text:p> <text:p>Status: {{member.status}}</text:p>
+45
View File
@@ -28,7 +28,20 @@ class Member:
first_name: str first_name: str
last_name: str last_name: str
email: str = "" email: str = ""
phone: str = ""
birth_date: str = "" birth_date: str = ""
street: str = ""
address_addition: str = ""
postal_code: str = ""
city: str = ""
country: str = "Deutschland"
account_holder: str = ""
iban: str = ""
bic: str = ""
mandate_reference: str = ""
mandate_signed_at: str = ""
mandate_active: bool = False
mandate_revoked_at: str = ""
status: str = "application" status: str = "application"
accepted_at: str = "" accepted_at: str = ""
membership_started_at: str = "" membership_started_at: str = ""
@@ -54,6 +67,23 @@ class Member:
"last_name": self.last_name, "last_name": self.last_name,
"birth_date": self.birth_date, "birth_date": self.birth_date,
"email": self.email, "email": self.email,
"phone": self.phone,
},
"address": {
"street": self.street,
"addition": self.address_addition,
"postal_code": self.postal_code,
"city": self.city,
"country": self.country,
},
"banking": {
"account_holder": self.account_holder,
"iban": self.iban,
"bic": self.bic,
"mandate_reference": self.mandate_reference,
"mandate_signed_at": self.mandate_signed_at,
"mandate_active": self.mandate_active,
"mandate_revoked_at": self.mandate_revoked_at,
}, },
"membership": { "membership": {
"status": self.status, "status": self.status,
@@ -75,6 +105,8 @@ class Member:
person = data.get("person") or {} person = data.get("person") or {}
membership = data.get("membership") or {} membership = data.get("membership") or {}
contribution = data.get("contribution_profile") or {} contribution = data.get("contribution_profile") or {}
address = data.get("address") or {}
banking = data.get("banking") or {}
return cls( return cls(
schema_version=int(data.get("schema_version", 1)), schema_version=int(data.get("schema_version", 1)),
member_id=str(data["member_id"]), member_id=str(data["member_id"]),
@@ -82,7 +114,20 @@ class Member:
first_name=str(person.get("first_name", "")), first_name=str(person.get("first_name", "")),
last_name=str(person.get("last_name", "")), last_name=str(person.get("last_name", "")),
email=str(person.get("email", "")), email=str(person.get("email", "")),
phone=str(person.get("phone", "")),
birth_date=str(person.get("birth_date", "")), birth_date=str(person.get("birth_date", "")),
street=str(address.get("street", "")),
address_addition=str(address.get("addition", "")),
postal_code=str(address.get("postal_code", "")),
city=str(address.get("city", "")),
country=str(address.get("country", "Deutschland")),
account_holder=str(banking.get("account_holder", "")),
iban=str(banking.get("iban", "")),
bic=str(banking.get("bic", "")),
mandate_reference=str(banking.get("mandate_reference", "")),
mandate_signed_at=str(banking.get("mandate_signed_at", "")),
mandate_active=bool(banking.get("mandate_active", False)),
mandate_revoked_at=str(banking.get("mandate_revoked_at", "")),
status=str(membership.get("status", "application")), status=str(membership.get("status", "application")),
accepted_at=str(membership.get("accepted_at", "")), accepted_at=str(membership.get("accepted_at", "")),
membership_started_at=str(membership.get("started_at", "")), membership_started_at=str(membership.get("started_at", "")),
+49 -1
View File
@@ -116,7 +116,16 @@ class DocumentService:
if reminder is None: if reminder is None:
raise DocumentError("Die ausgewählte Mahnung wurde nicht gefunden.") raise DocumentError("Die ausgewählte Mahnung wurde nicht gefunden.")
values = _template_values(member, data=data, claim=claim, reminder=reminder) organization = self.repository.get_configuration().get("organization") or {}
if not isinstance(organization, dict):
organization = {}
values = _template_values(
member,
data=data,
claim=claim,
reminder=reminder,
organization=organization,
)
destination_dir = self.repository.members_root / member_id / "files" / "documents" destination_dir = self.repository.members_root / member_id / "files" / "documents"
destination_dir.mkdir(parents=True, exist_ok=True) destination_dir.mkdir(parents=True, exist_ok=True)
destination = _available_path(destination_dir, _safe_pdf_name(output_name)) destination = _available_path(destination_dir, _safe_pdf_name(output_name))
@@ -168,7 +177,18 @@ def _template_values(
data=None, data=None,
claim: dict | None = None, claim: dict | None = None,
reminder: dict | None = None, reminder: dict | None = None,
organization: dict | None = None,
) -> dict[str, str]: ) -> dict[str, str]:
organization = organization or {}
organization_address = " ".join(
part
for part in (
str(organization.get("street", "")),
str(organization.get("postal_code", "")),
str(organization.get("city", "")),
)
if part
)
values = { values = {
"document.date": format_date_for_display(date.today().isoformat()), "document.date": format_date_for_display(date.today().isoformat()),
"document.datetime": datetime.now().astimezone().strftime("%d.%m.%Y %H:%M"), "document.datetime": datetime.now().astimezone().strftime("%d.%m.%Y %H:%M"),
@@ -178,10 +198,38 @@ def _template_values(
"member.last_name": member.last_name, "member.last_name": member.last_name,
"member.full_name": member.display_name, "member.full_name": member.display_name,
"member.email": member.email, "member.email": member.email,
"member.phone": member.phone,
"member.birth_date": format_date_for_display(member.birth_date), "member.birth_date": format_date_for_display(member.birth_date),
"member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), "member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
"member.accepted_at": format_date_for_display(member.accepted_at), "member.accepted_at": format_date_for_display(member.accepted_at),
"member.started_at": format_date_for_display(member.membership_started_at), "member.started_at": format_date_for_display(member.membership_started_at),
"member.street": member.street,
"member.address_addition": member.address_addition,
"member.postal_code": member.postal_code,
"member.city": member.city,
"member.country": member.country,
"member.address_line": " ".join(
part for part in (member.street, member.postal_code, member.city) if part
),
"member.account_holder": member.account_holder,
"member.iban": member.iban,
"member.bic": member.bic,
"member.mandate_reference": member.mandate_reference,
"member.mandate_signed_at": format_date_for_display(member.mandate_signed_at),
"member.mandate_revoked_at": format_date_for_display(member.mandate_revoked_at),
"member.mandate_active": "Ja" if member.mandate_active else "Nein",
"organization.name": str(organization.get("name", "")),
"organization.street": str(organization.get("street", "")),
"organization.postal_code": str(organization.get("postal_code", "")),
"organization.city": str(organization.get("city", "")),
"organization.country": str(organization.get("country", "")),
"organization.address_line": organization_address,
"organization.email": str(organization.get("email", "")),
"organization.phone": str(organization.get("phone", "")),
"organization.website": str(organization.get("website", "")),
"organization.iban": str(organization.get("iban", "")),
"organization.bic": str(organization.get("bic", "")),
"organization.creditor_id": str(organization.get("creditor_id", "")),
} }
if claim is not None and data is not None: if claim is not None and data is not None:
claim_id = str(claim.get("claim_id", "")) claim_id = str(claim.get("claim_id", ""))
+82 -2
View File
@@ -32,7 +32,19 @@ DEFAULT_MEMBER_NUMBER_PATTERN = "CCMA-{number:04d}"
DEFAULT_CONFIGURATION = { DEFAULT_CONFIGURATION = {
"schema_version": 1, "schema_version": 1,
"organization": "Chaos Computer Club Mannheim e.V.", "organization": {
"name": "Chaos Computer Club Mannheim e.V.",
"street": "",
"postal_code": "",
"city": "Mannheim",
"country": "Deutschland",
"email": "",
"phone": "",
"website": "",
"iban": "",
"bic": "",
"creditor_id": "",
},
"member_number_policy": { "member_number_policy": {
"mode": "automatic", "mode": "automatic",
"pattern": DEFAULT_MEMBER_NUMBER_PATTERN, "pattern": DEFAULT_MEMBER_NUMBER_PATTERN,
@@ -165,7 +177,7 @@ class MemberRepository:
raw = read_json(path) raw = read_json(path)
if not isinstance(raw, dict): if not isinstance(raw, dict):
raise TypeError("Wurzelelement muss ein JSON-Objekt sein") raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
for section_name in ("person", "membership", "contribution_profile"): for section_name in ("person", "address", "banking", "membership", "contribution_profile"):
if section_name in raw and not isinstance(raw[section_name], dict): if section_name in raw and not isinstance(raw[section_name], dict):
raise TypeError(f"{section_name} muss ein JSON-Objekt sein") raise TypeError(f"{section_name} muss ein JSON-Objekt sein")
return Member.from_dict(raw) return Member.from_dict(raw)
@@ -193,6 +205,7 @@ class MemberRepository:
first_name: str, first_name: str,
last_name: str, last_name: str,
email: str = "", email: str = "",
phone: str = "",
birth_date: str = "", birth_date: str = "",
member_number: str = "", member_number: str = "",
) -> Member: ) -> Member:
@@ -221,6 +234,7 @@ class MemberRepository:
first_name=first_name.strip(), first_name=first_name.strip(),
last_name=last_name.strip(), last_name=last_name.strip(),
email=email.strip(), email=email.strip(),
phone=phone.strip(),
birth_date=birth_date, birth_date=birth_date,
) )
write_json_atomic(directory / "member.json", member.to_dict()) write_json_atomic(directory / "member.json", member.to_dict())
@@ -240,6 +254,10 @@ class MemberRepository:
member.birth_date = normalize_date_input(member.birth_date, "Geburtsdatum") member.birth_date = normalize_date_input(member.birth_date, "Geburtsdatum")
member.accepted_at = normalize_date_input(member.accepted_at, "Aufnahmebeschluss") member.accepted_at = normalize_date_input(member.accepted_at, "Aufnahmebeschluss")
member.membership_started_at = normalize_date_input(member.membership_started_at, "Mitglied seit") member.membership_started_at = normalize_date_input(member.membership_started_at, "Mitglied seit")
member.mandate_signed_at = normalize_date_input(member.mandate_signed_at, "Mandat erteilt am")
member.mandate_revoked_at = normalize_date_input(
member.mandate_revoked_at, "Mandat widerrufen am"
)
validate_member_dates( validate_member_dates(
birth_date=member.birth_date, birth_date=member.birth_date,
accepted_at=member.accepted_at, accepted_at=member.accepted_at,
@@ -247,6 +265,16 @@ class MemberRepository:
) )
except DateValidationError as exc: except DateValidationError as exc:
raise RepositoryError(str(exc)) from exc raise RepositoryError(str(exc)) from exc
member.iban = normalize_iban(member.iban)
member.bic = member.bic.replace(" ", "").upper()
validate_iban(member.iban)
validate_bic(member.bic)
if member.mandate_active and not (
member.iban and member.mandate_reference.strip() and member.mandate_signed_at
):
raise RepositoryError(
"Ein aktives Lastschriftmandat benötigt IBAN, Mandatsreferenz und Erteilungsdatum."
)
if member.member_number != existing.member_number: if member.member_number != existing.member_number:
self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id) self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id)
changes = self._summarize_changes(existing, member) changes = self._summarize_changes(existing, member)
@@ -721,6 +749,10 @@ class MemberRepository:
member.last_name, member.last_name,
member.display_name, member.display_name,
member.email, member.email,
member.phone,
member.street,
member.postal_code,
member.city,
member.birth_date, member.birth_date,
_german_date(member.birth_date), _german_date(member.birth_date),
] ]
@@ -767,6 +799,18 @@ class MemberRepository:
config.setdefault("member_number_sequences", {}) config.setdefault("member_number_sequences", {})
write_json_atomic(self.root / "repository.json", config) write_json_atomic(self.root / "repository.json", config)
def save_organization(self, values: dict[str, str]) -> None:
organization = {key: str(value).strip() for key, value in values.items()}
organization["iban"] = normalize_iban(organization.get("iban", ""))
organization["bic"] = organization.get("bic", "").replace(" ", "").upper()
validate_iban(organization["iban"])
validate_bic(organization["bic"])
if not organization.get("name"):
raise RepositoryError("Der Vereinsname ist erforderlich.")
config = self.get_configuration()
config["organization"] = organization
write_json_atomic(self.root / "repository.json", config)
def preview_member_number(self, pattern: str | None = None) -> str: def preview_member_number(self, pattern: str | None = None) -> str:
selected_pattern = pattern or self.get_member_number_policy()["pattern"] selected_pattern = pattern or self.get_member_number_policy()["pattern"]
validate_member_number_pattern(selected_pattern) validate_member_number_pattern(selected_pattern)
@@ -832,12 +876,25 @@ class MemberRepository:
"first_name": "Vorname", "first_name": "Vorname",
"last_name": "Nachname", "last_name": "Nachname",
"email": "E-Mail-Adresse", "email": "E-Mail-Adresse",
"phone": "Telefonnummer",
"birth_date": "Geburtsdatum", "birth_date": "Geburtsdatum",
"status": "Status", "status": "Status",
"payment_frequency": "Zahlungsweise", "payment_frequency": "Zahlungsweise",
"contribution_rule_id": "Beitragsregel", "contribution_rule_id": "Beitragsregel",
"honorary": "Ehrenmitgliedschaft", "honorary": "Ehrenmitgliedschaft",
"notes": "interne Notiz", "notes": "interne Notiz",
"street": "Anschrift",
"address_addition": "Adresszusatz",
"postal_code": "Postleitzahl",
"city": "Ort",
"country": "Land",
"account_holder": "Kontoinhaber",
"iban": "IBAN",
"bic": "BIC",
"mandate_reference": "SEPA-Mandatsreferenz",
"mandate_signed_at": "Mandat erteilt am",
"mandate_active": "Lastschriftmandat aktiv",
"mandate_revoked_at": "Mandat widerrufen am",
} }
changes: list[str] = [] changes: list[str] = []
for field, label in labels.items(): for field, label in labels.items():
@@ -854,6 +911,29 @@ class MemberRepository:
return changes return changes
def normalize_iban(value: str) -> str:
return "".join(value.split()).upper()
def validate_iban(value: str) -> None:
if not value:
return
if not 15 <= len(value) <= 34 or not value[:2].isalpha() or not value[2:].isalnum():
raise RepositoryError("Die IBAN hat kein gültiges Format.")
rearranged = value[4:] + value[:4]
numeric = "".join(
str(ord(character) - 55) if character.isalpha() else character
for character in rearranged
)
if int(numeric) % 97 != 1:
raise RepositoryError("Die IBAN-Prüfsumme ist ungültig.")
def validate_bic(value: str) -> None:
if value and (len(value) not in {8, 11} or not value.isalnum()):
raise RepositoryError("Der BIC muss 8 oder 11 Zeichen enthalten.")
def _normalize(value: str) -> str: def _normalize(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value.casefold().strip()) normalized = unicodedata.normalize("NFKD", value.casefold().strip())
return "".join(character for character in normalized if not unicodedata.combining(character)) return "".join(character for character in normalized if not unicodedata.combining(character))
+2 -1
View File
@@ -19,7 +19,7 @@ class NewMemberDialog(tk.Toplevel):
self.number_policy = repository.get_member_number_policy() self.number_policy = repository.get_member_number_policy()
self.variables = { self.variables = {
name: tk.StringVar() name: tk.StringVar()
for name in ("first_name", "last_name", "email", "birth_date", "member_number") for name in ("first_name", "last_name", "email", "phone", "birth_date", "member_number")
} }
self._build_ui() self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy()) self.bind("<Escape>", lambda _event: self.destroy())
@@ -33,6 +33,7 @@ class NewMemberDialog(tk.Toplevel):
("Vorname *", "first_name"), ("Vorname *", "first_name"),
("Nachname *", "last_name"), ("Nachname *", "last_name"),
("E-Mail-Adresse", "email"), ("E-Mail-Adresse", "email"),
("Telefonnummer", "phone"),
(f"Geburtsdatum ({date_input_hint()})", "birth_date"), (f"Geburtsdatum ({date_input_hint()})", "birth_date"),
] ]
if self.number_policy["mode"] == "manual": if self.number_policy["mode"] == "manual":
+58 -2
View File
@@ -83,9 +83,13 @@ class MemberTab(ttk.Frame):
notebook = ttk.Notebook(parent) notebook = ttk.Notebook(parent)
notebook.grid(row=0, column=0, sticky="nsew") notebook.grid(row=0, column=0, sticky="nsew")
data_tab = ttk.Frame(notebook, padding=16) data_tab = ttk.Frame(notebook, padding=16)
address_tab = ttk.Frame(notebook, padding=16)
banking_tab = ttk.Frame(notebook, padding=16)
contribution_tab = ttk.Frame(notebook, padding=16) contribution_tab = ttk.Frame(notebook, padding=16)
documents_tab = ttk.Frame(notebook, padding=16) documents_tab = ttk.Frame(notebook, padding=16)
notebook.add(data_tab, text="Stammdaten") notebook.add(data_tab, text="Stammdaten")
notebook.add(address_tab, text="Anschrift")
notebook.add(banking_tab, text="Bank / SEPA")
notebook.add(contribution_tab, text="Forderungen") notebook.add(contribution_tab, text="Forderungen")
notebook.add(documents_tab, text="Dokumente") notebook.add(documents_tab, text="Dokumente")
@@ -94,6 +98,7 @@ class MemberTab(ttk.Frame):
("Vorname", "first_name"), ("Vorname", "first_name"),
("Nachname", "last_name"), ("Nachname", "last_name"),
("E-Mail-Adresse", "email"), ("E-Mail-Adresse", "email"),
("Telefonnummer", "phone"),
(f"Geburtsdatum ({date_input_hint()})", "birth_date"), (f"Geburtsdatum ({date_input_hint()})", "birth_date"),
(f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"), (f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"),
(f"Mitglied seit ({date_input_hint()})", "membership_started_at"), (f"Mitglied seit ({date_input_hint()})", "membership_started_at"),
@@ -139,6 +144,50 @@ class MemberTab(ttk.Frame):
row=len(fields) + 2, column=1, sticky="e", pady=(18, 0) row=len(fields) + 2, column=1, sticky="e", pady=(18, 0)
) )
address_fields = (
("Straße und Hausnummer", "street"),
("Adresszusatz", "address_addition"),
("Postleitzahl", "postal_code"),
("Ort", "city"),
("Land", "country"),
)
address_tab.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(address_fields):
self.variables[key] = tk.StringVar()
ttk.Label(address_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
ttk.Entry(address_tab, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5
)
ttk.Button(
address_tab, text="Anschrift speichern", style="Accent.TButton", command=self._save
).grid(row=len(address_fields), column=1, sticky="e", pady=(18, 0))
banking_fields = (
("Kontoinhaber", "account_holder"),
("IBAN", "iban"),
("BIC", "bic"),
("Mandatsreferenz", "mandate_reference"),
(f"Mandat erteilt am ({date_input_hint()})", "mandate_signed_at"),
(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.variables[key] = tk.StringVar()
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(
row=row, column=1, sticky="ew", pady=5
)
self.variables["mandate_active"] = tk.BooleanVar()
ttk.Checkbutton(
banking_tab,
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))
ttk.Button(
banking_tab, text="Bankdaten speichern", style="Accent.TButton", command=self._save
).grid(row=len(banking_fields) + 1, column=1, sticky="e", pady=(18, 0))
contribution_tab.columnconfigure(0, weight=1) contribution_tab.columnconfigure(0, weight=1)
contribution_tab.rowconfigure(1, weight=1) contribution_tab.rowconfigure(1, weight=1)
self.contribution_summary = tk.StringVar() self.contribution_summary = tk.StringVar()
@@ -225,7 +274,13 @@ class MemberTab(ttk.Frame):
self.member = self.repository.get_member(self.member_id) self.member = self.repository.get_member(self.member_id)
self.title_var.set(f"{self.member.member_number or ''} · {self.member.display_name}") self.title_var.set(f"{self.member.member_number or ''} · {self.member.display_name}")
self.status_var.set(STATUS_LABELS.get(self.member.status, self.member.status.upper())) self.status_var.set(STATUS_LABELS.get(self.member.status, self.member.status.upper()))
date_fields = {"birth_date", "accepted_at", "membership_started_at"} date_fields = {
"birth_date",
"accepted_at",
"membership_started_at",
"mandate_signed_at",
"mandate_revoked_at",
}
for key, variable in self.variables.items(): for key, variable in self.variables.items():
value = getattr(self.member, key) value = getattr(self.member, key)
if key == "status": if key == "status":
@@ -303,7 +358,8 @@ class MemberTab(ttk.Frame):
def _save(self) -> None: def _save(self) -> None:
for key, variable in self.variables.items(): for key, variable in self.variables.items():
value = variable.get().strip() raw_value = variable.get()
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
if key == "status": if key == "status":
value = storage_key(STATUS_LABELS, value) value = storage_key(STATUS_LABELS, value)
setattr(self.member, key, value) setattr(self.member, key, value)
+37
View File
@@ -42,6 +42,16 @@ class OptionsDialog(tk.Toplevel):
self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual") self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual")
self.number_pattern_var = tk.StringVar(value=number_policy["pattern"]) self.number_pattern_var = tk.StringVar(value=number_policy["pattern"])
self.number_preview_var = tk.StringVar() self.number_preview_var = tk.StringVar()
organization = repository.get_configuration().get("organization") or {}
if not isinstance(organization, dict):
organization = {}
self.organization_vars = {
key: tk.StringVar(value=str(organization.get(key, "")))
for key in (
"name", "street", "postal_code", "city", "country", "email", "phone",
"website", "iban", "bic", "creditor_id",
)
}
self.title("Optionen") self.title("Optionen")
self.geometry("900x650") self.geometry("900x650")
self.minsize(760, 540) self.minsize(760, 540)
@@ -65,16 +75,19 @@ class OptionsDialog(tk.Toplevel):
paths = ttk.Frame(notebook, padding=16) paths = ttk.Frame(notebook, padding=16)
appearance = ttk.Frame(notebook, padding=16) appearance = ttk.Frame(notebook, padding=16)
member_numbers = ttk.Frame(notebook, padding=16) member_numbers = ttk.Frame(notebook, padding=16)
organization = ttk.Frame(notebook, padding=16)
automation = ttk.Frame(notebook, padding=16) automation = ttk.Frame(notebook, padding=16)
changelog = ChangelogView(notebook) changelog = ChangelogView(notebook)
notebook.add(paths, text="Pfade") notebook.add(paths, text="Pfade")
notebook.add(appearance, text="Darstellung") notebook.add(appearance, text="Darstellung")
notebook.add(member_numbers, text="Mitgliedsnummern") notebook.add(member_numbers, text="Mitgliedsnummern")
notebook.add(organization, text="Verein / Absender")
notebook.add(automation, text="Hausmeister") notebook.add(automation, text="Hausmeister")
notebook.add(changelog, text="Changelog") notebook.add(changelog, text="Changelog")
self._build_paths(paths) self._build_paths(paths)
self._build_appearance(appearance) self._build_appearance(appearance)
self._build_member_numbers(member_numbers) self._build_member_numbers(member_numbers)
self._build_organization(organization)
self._build_automation(automation) self._build_automation(automation)
buttons = ttk.Frame(root) buttons = ttk.Frame(root)
@@ -148,6 +161,27 @@ class OptionsDialog(tk.Toplevel):
style="Muted.TLabel", style="Muted.TLabel",
).grid(row=1, column=1, sticky="w") ).grid(row=1, column=1, sticky="w")
def _build_organization(self, parent: ttk.Frame) -> None:
parent.columnconfigure(1, weight=1)
fields = (
("Vereinsname", "name"),
("Straße und Hausnummer", "street"),
("Postleitzahl", "postal_code"),
("Ort", "city"),
("Land", "country"),
("E-Mail-Adresse", "email"),
("Telefonnummer", "phone"),
("Webseite", "website"),
("IBAN", "iban"),
("BIC", "bic"),
("SEPA-Gläubiger-ID", "creditor_id"),
)
for row, (label, key) in enumerate(fields):
ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
ttk.Entry(parent, textvariable=self.organization_vars[key], width=48).grid(
row=row, column=1, sticky="ew", pady=5
)
def _build_automation(self, parent: ttk.Frame) -> None: def _build_automation(self, parent: ttk.Frame) -> None:
parent.columnconfigure(1, weight=1) parent.columnconfigure(1, weight=1)
ttk.Checkbutton( ttk.Checkbutton(
@@ -350,6 +384,9 @@ class OptionsDialog(tk.Toplevel):
try: try:
self.config_obj.save() self.config_obj.save()
self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern) self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern)
self.repository.save_organization(
{key: variable.get() for key, variable in self.organization_vars.items()}
)
except (OSError, RepositoryError) as exc: except (OSError, RepositoryError) as exc:
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self) messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
return return
+66
View File
@@ -168,3 +168,69 @@ def test_invalid_member_number_patterns_are_rejected(pattern) -> None:
def test_member_number_formatter_supports_padding_and_year() -> None: def test_member_number_formatter_supports_padding_and_year() -> None:
assert format_member_number("CCMA-{year}-{number:05d}", 42, year=2026) == "CCMA-2026-00042" assert format_member_number("CCMA-{year}-{number:05d}", 42, year=2026) == "CCMA-2026-00042"
def test_member_address_and_sepa_data_are_structured_and_audited_without_values(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Bank", last_name="Member", phone="0621 12345")
member.street = "Teststraße 23"
member.postal_code = "68159"
member.city = "Mannheim"
member.account_holder = "Bank Member"
member.iban = "DE89 3704 0044 0532 0130 00"
member.bic = "COBADEFFXXX"
member.mandate_reference = "CCMA-M-42"
member.mandate_signed_at = "21.06.2026"
member.mandate_active = True
repository.save_member(member)
loaded = repository.get_member(member.member_id)
assert loaded.iban == "DE89370400440532013000"
assert loaded.mandate_signed_at == "2026-06-21"
raw = json.loads((repository.members_root / member.member_id / "member.json").read_text())
assert raw["address"]["city"] == "Mannheim"
assert raw["banking"]["mandate_active"] is True
event = repository.get_events(member.member_id)[-1]
assert "IBAN" in event.summary
assert loaded.iban not in event.summary
assert loaded.street not in event.summary
def test_active_sepa_mandate_requires_valid_complete_data(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Invalid", last_name="Mandate")
member.iban = "DE001234"
with pytest.raises(RepositoryError, match="IBAN"):
repository.save_member(member)
member.iban = "DE89370400440532013000"
member.mandate_active = True
with pytest.raises(RepositoryError, match="aktives Lastschriftmandat"):
repository.save_member(member)
def test_organization_sender_data_is_stored_centrally(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.save_organization(
{
"name": "Chaos Computer Club Mannheim e.V.",
"street": "Testweg 1",
"postal_code": "68159",
"city": "Mannheim",
"country": "Deutschland",
"email": "vorstand@example.org",
"phone": "",
"website": "https://example.org",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
)
organization = repository.get_configuration()["organization"]
assert organization["street"] == "Testweg 1"
assert organization["iban"] == "DE89370400440532013000"