diff --git a/README.md b/README.md index f44edea..adc9217 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,12 @@ Placeholders use `{{group.field}}`. Available values include: - Member: `member.number`, `member.first_name`, `member.last_name`, `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.paid`, `claim.balance`, `claim.status`, `claim.items` - 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.fee`, `reminder.detail`, `reminder.channel` - 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 available when a reminder row is selected before opening the document dialog. diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index f7985be..3e23f1c 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -26,6 +26,7 @@ "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.", "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.", "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.", diff --git a/src/ccma/assets/templates/Forderung.fodt b/src/ccma/assets/templates/Forderung.fodt index 995004b..2f884e8 100644 --- a/src/ccma/assets/templates/Forderung.fodt +++ b/src/ccma/assets/templates/Forderung.fodt @@ -6,17 +6,20 @@ - Chaos Computer Club Mannheim e.V. + {{organization.name}} + {{organization.address_line}} · {{organization.email}} Erstellt am {{document.date}} Forderung: {{claim.title}} Mitglied: {{member.full_name}} ({{member.number}}) E-Mail: {{member.email}} + Anschrift: {{member.address_line}} Fällig am: {{claim.due_date}} Positionen {{claim.items}} Gesamtbetrag: {{claim.total}} Bereits bezahlt: {{claim.paid}} Offener Betrag: {{claim.balance}} + Zahlung an {{organization.iban}} · BIC {{organization.bic}} Forderungs-ID: {{claim.id}} diff --git a/src/ccma/assets/templates/Mahnung.fodt b/src/ccma/assets/templates/Mahnung.fodt index fe34e4d..87afdb1 100644 --- a/src/ccma/assets/templates/Mahnung.fodt +++ b/src/ccma/assets/templates/Mahnung.fodt @@ -6,13 +6,16 @@ - Chaos Computer Club Mannheim e.V. + {{organization.name}} + {{organization.address_line}} · {{organization.email}} {{document.date}} An {{member.full_name}} ({{member.number}}) + {{member.address_line}} {{reminder.name}} Zu unserer Forderung „{{claim.title}}“ ist weiterhin ein Betrag von {{claim.balance}} offen. Bitte begleiche den offenen Betrag innerhalb von {{reminder.payment_deadline_days}} Tagen. Mahngebühr: {{reminder.fee}} + Zahlung an {{organization.iban}} · BIC {{organization.bic}} {{reminder.detail}} Forderungs-ID: {{claim.id}} Mahnstufe: {{reminder.level}} diff --git a/src/ccma/assets/templates/Mitglied.fodt b/src/ccma/assets/templates/Mitglied.fodt index 8ad25d4..e06374b 100644 --- a/src/ccma/assets/templates/Mitglied.fodt +++ b/src/ccma/assets/templates/Mitglied.fodt @@ -6,12 +6,14 @@ - Chaos Computer Club Mannheim e.V. + {{organization.name}} Erstellt am {{document.date}} Mitgliedsdaten Name: {{member.full_name}} Mitgliedsnummer: {{member.number}} E-Mail: {{member.email}} + Telefon: {{member.phone}} + Anschrift: {{member.address_line}} Geburtsdatum: {{member.birth_date}} Mitglied seit: {{member.started_at}} Status: {{member.status}} diff --git a/src/ccma/domain/models.py b/src/ccma/domain/models.py index 9cd2c84..81cef64 100644 --- a/src/ccma/domain/models.py +++ b/src/ccma/domain/models.py @@ -28,7 +28,20 @@ class Member: first_name: str last_name: str email: str = "" + phone: 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" accepted_at: str = "" membership_started_at: str = "" @@ -54,6 +67,23 @@ class Member: "last_name": self.last_name, "birth_date": self.birth_date, "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": { "status": self.status, @@ -75,6 +105,8 @@ class Member: person = data.get("person") or {} membership = data.get("membership") or {} contribution = data.get("contribution_profile") or {} + address = data.get("address") or {} + banking = data.get("banking") or {} return cls( schema_version=int(data.get("schema_version", 1)), member_id=str(data["member_id"]), @@ -82,7 +114,20 @@ class Member: first_name=str(person.get("first_name", "")), last_name=str(person.get("last_name", "")), email=str(person.get("email", "")), + phone=str(person.get("phone", "")), 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")), accepted_at=str(membership.get("accepted_at", "")), membership_started_at=str(membership.get("started_at", "")), diff --git a/src/ccma/services/documents.py b/src/ccma/services/documents.py index 6c8c0f3..70dad3a 100644 --- a/src/ccma/services/documents.py +++ b/src/ccma/services/documents.py @@ -116,7 +116,16 @@ class DocumentService: if reminder is None: 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.mkdir(parents=True, exist_ok=True) destination = _available_path(destination_dir, _safe_pdf_name(output_name)) @@ -168,7 +177,18 @@ def _template_values( data=None, claim: dict | None = None, reminder: dict | None = None, + organization: dict | None = None, ) -> 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 = { "document.date": format_date_for_display(date.today().isoformat()), "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.full_name": member.display_name, "member.email": member.email, + "member.phone": member.phone, "member.birth_date": format_date_for_display(member.birth_date), "member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), "member.accepted_at": format_date_for_display(member.accepted_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: claim_id = str(claim.get("claim_id", "")) diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index b5b7f50..7ace5e5 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -32,7 +32,19 @@ DEFAULT_MEMBER_NUMBER_PATTERN = "CCMA-{number:04d}" DEFAULT_CONFIGURATION = { "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": { "mode": "automatic", "pattern": DEFAULT_MEMBER_NUMBER_PATTERN, @@ -165,7 +177,7 @@ class MemberRepository: raw = read_json(path) if not isinstance(raw, dict): 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): raise TypeError(f"{section_name} muss ein JSON-Objekt sein") return Member.from_dict(raw) @@ -193,6 +205,7 @@ class MemberRepository: first_name: str, last_name: str, email: str = "", + phone: str = "", birth_date: str = "", member_number: str = "", ) -> Member: @@ -221,6 +234,7 @@ class MemberRepository: first_name=first_name.strip(), last_name=last_name.strip(), email=email.strip(), + phone=phone.strip(), birth_date=birth_date, ) 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.accepted_at = normalize_date_input(member.accepted_at, "Aufnahmebeschluss") 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( birth_date=member.birth_date, accepted_at=member.accepted_at, @@ -247,6 +265,16 @@ class MemberRepository: ) except DateValidationError as 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: self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id) changes = self._summarize_changes(existing, member) @@ -721,6 +749,10 @@ class MemberRepository: member.last_name, member.display_name, member.email, + member.phone, + member.street, + member.postal_code, + member.city, member.birth_date, _german_date(member.birth_date), ] @@ -767,6 +799,18 @@ class MemberRepository: config.setdefault("member_number_sequences", {}) 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: selected_pattern = pattern or self.get_member_number_policy()["pattern"] validate_member_number_pattern(selected_pattern) @@ -832,12 +876,25 @@ class MemberRepository: "first_name": "Vorname", "last_name": "Nachname", "email": "E-Mail-Adresse", + "phone": "Telefonnummer", "birth_date": "Geburtsdatum", "status": "Status", "payment_frequency": "Zahlungsweise", "contribution_rule_id": "Beitragsregel", "honorary": "Ehrenmitgliedschaft", "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] = [] for field, label in labels.items(): @@ -854,6 +911,29 @@ class MemberRepository: 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: normalized = unicodedata.normalize("NFKD", value.casefold().strip()) return "".join(character for character in normalized if not unicodedata.combining(character)) diff --git a/src/ccma/ui/dialogs.py b/src/ccma/ui/dialogs.py index a8c997b..7c52d3f 100644 --- a/src/ccma/ui/dialogs.py +++ b/src/ccma/ui/dialogs.py @@ -19,7 +19,7 @@ class NewMemberDialog(tk.Toplevel): self.number_policy = repository.get_member_number_policy() self.variables = { 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.bind("", lambda _event: self.destroy()) @@ -33,6 +33,7 @@ class NewMemberDialog(tk.Toplevel): ("Vorname *", "first_name"), ("Nachname *", "last_name"), ("E-Mail-Adresse", "email"), + ("Telefonnummer", "phone"), (f"Geburtsdatum ({date_input_hint()})", "birth_date"), ] if self.number_policy["mode"] == "manual": diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index b0b5ed3..ee7dd71 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -83,9 +83,13 @@ class MemberTab(ttk.Frame): notebook = ttk.Notebook(parent) notebook.grid(row=0, column=0, sticky="nsew") 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) documents_tab = ttk.Frame(notebook, padding=16) 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(documents_tab, text="Dokumente") @@ -94,6 +98,7 @@ class MemberTab(ttk.Frame): ("Vorname", "first_name"), ("Nachname", "last_name"), ("E-Mail-Adresse", "email"), + ("Telefonnummer", "phone"), (f"Geburtsdatum ({date_input_hint()})", "birth_date"), (f"Aufnahmebeschluss ({date_input_hint()})", "accepted_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) ) + 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.rowconfigure(1, weight=1) self.contribution_summary = tk.StringVar() @@ -225,7 +274,13 @@ class MemberTab(ttk.Frame): self.member = self.repository.get_member(self.member_id) 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())) - 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(): value = getattr(self.member, key) if key == "status": @@ -303,7 +358,8 @@ class MemberTab(ttk.Frame): def _save(self) -> None: 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": value = storage_key(STATUS_LABELS, value) setattr(self.member, key, value) diff --git a/src/ccma/ui/options_dialog.py b/src/ccma/ui/options_dialog.py index 2d73a55..7e2802b 100644 --- a/src/ccma/ui/options_dialog.py +++ b/src/ccma/ui/options_dialog.py @@ -42,6 +42,16 @@ class OptionsDialog(tk.Toplevel): self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual") self.number_pattern_var = tk.StringVar(value=number_policy["pattern"]) 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.geometry("900x650") self.minsize(760, 540) @@ -65,16 +75,19 @@ class OptionsDialog(tk.Toplevel): paths = ttk.Frame(notebook, padding=16) appearance = ttk.Frame(notebook, padding=16) member_numbers = ttk.Frame(notebook, padding=16) + organization = ttk.Frame(notebook, padding=16) automation = ttk.Frame(notebook, padding=16) changelog = ChangelogView(notebook) notebook.add(paths, text="Pfade") notebook.add(appearance, text="Darstellung") notebook.add(member_numbers, text="Mitgliedsnummern") + notebook.add(organization, text="Verein / Absender") notebook.add(automation, text="Hausmeister") notebook.add(changelog, text="Changelog") self._build_paths(paths) self._build_appearance(appearance) self._build_member_numbers(member_numbers) + self._build_organization(organization) self._build_automation(automation) buttons = ttk.Frame(root) @@ -148,6 +161,27 @@ class OptionsDialog(tk.Toplevel): style="Muted.TLabel", ).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: parent.columnconfigure(1, weight=1) ttk.Checkbutton( @@ -350,6 +384,9 @@ class OptionsDialog(tk.Toplevel): try: self.config_obj.save() 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: messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self) return diff --git a/tests/test_repository.py b/tests/test_repository.py index 28afb8d..4ec77d1 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -168,3 +168,69 @@ def test_invalid_member_number_patterns_are_rejected(pattern) -> 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" + + +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"