From 3d3b845d9fcd50f8f7c5892dd561bdefc6c57e49 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Thu, 30 Jul 2026 01:00:32 +0200 Subject: [PATCH 1/4] feat: add SEPA direct debit exports and notifications --- README.md | 15 ++ src/ccma/services/sepa.py | 217 +++++++++++++++++++++++++++ src/ccma/services/sepa_mail.py | 160 ++++++++++++++++++++ src/ccma/storage/repository.py | 78 ++++++++++ src/ccma/ui/main_window.py | 11 ++ src/ccma/ui/sepa_dialog.py | 266 +++++++++++++++++++++++++++++++++ tests/test_contributions.py | 30 ++++ tests/test_sepa.py | 207 +++++++++++++++++++++++++ 8 files changed, 984 insertions(+) create mode 100644 src/ccma/services/sepa.py create mode 100644 src/ccma/services/sepa_mail.py create mode 100644 src/ccma/ui/sepa_dialog.py create mode 100644 tests/test_sepa.py diff --git a/README.md b/README.md index a2a4112..0e06a36 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,21 @@ allocations. Reminders are separate processes and only change the amount when they explicitly add a fee line item. Every change is also appended to the member's `events.jsonl` audit trail. +### Alte Forderungen per Dummy-Zahlung ausgleichen + +Das Verwaltungswerkzeug zeigt standardmäßig nur eine Vorschau. Es fasst pro +Mitglied alle offenen Restbeträge von Forderungen mit Fälligkeit vor 2020 in +genau einer Dummy-Zahlung zusammen: + +```bash +PYTHONPATH=src python -m ccma.tools.settle_legacy_claims /pfad/zum/member-store +PYTHONPATH=src python -m ccma.tools.settle_legacy_claims /pfad/zum/member-store --apply +``` + +Vor `--apply` sollte eine Sicherung des Member-Stores erstellt werden. Ein +erneuter Lauf erzeugt keine weiteren Zahlungen für bereits ausgeglichene +Forderungen. + Overdue claims are evaluated by the reminder rule. It creates housekeeper tasks for the next configured reminder level. Reminder drafts do not change a claim; only confirming actual dispatch starts the new payment deadline and adds an diff --git a/src/ccma/services/sepa.py b/src/ccma/services/sepa.py new file mode 100644 index 0000000..322cbc2 --- /dev/null +++ b/src/ccma/services/sepa.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import csv +import io +import re +import unicodedata +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from ccma.domain.contributions import claim_balance +from ccma.storage.repository import MemberRepository, RepositoryError, validate_iban + +PAIN_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:pain.008.001.08" +SEPA_CHARACTERS = frozenset( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + "':?,-(+.)/ ÄäÖöÜüß&*$%" +) +ET.register_namespace("", PAIN_NAMESPACE) + + +@dataclass(frozen=True, slots=True) +class DirectDebit: + member_id: str + member_number: str + account_holder: str + iban: str + bic: str + mandate_reference: str + mandate_signed_at: str + amount: Decimal + purpose: str + claim_ids: tuple[str, ...] + + +def _safe(value: Any, maximum: int) -> str: + replacements = str.maketrans({"’": "'", "‘": "'", "´": "'", "`": "'", "–": "-", "—": "-"}) + source = str(value).translate(replacements) + normalized: list[str] = [] + for character in source: + if character in SEPA_CHARACTERS: + normalized.append(character) + continue + decomposed = unicodedata.normalize("NFKD", character) + replacement = "".join( + part for part in decomposed if not unicodedata.combining(part) and part in SEPA_CHARACTERS + ) + normalized.append(replacement or " ") + return re.sub(r"\s+", " ", "".join(normalized)).strip()[:maximum] + + +def pending_direct_debits( + repository: MemberRepository, *, due_until: date, due_from: date | None = None +) -> tuple[list[DirectDebit], list[str]]: + if due_from and due_from > due_until: + raise RepositoryError("„Fällig ab“ darf nicht nach „Fällig bis“ liegen.") + debits: list[DirectDebit] = [] + warnings: list[str] = [] + for member in repository.list_members(): + if not member.mandate_active: + continue + missing = [ + label + for value, label in ( + (member.iban, "IBAN"), + (member.mandate_reference, "Mandatsreferenz"), + (member.mandate_signed_at, "Mandatsdatum"), + ) + if not str(value).strip() + ] + if missing: + warnings.append(f"{member.member_number or member.display_name}: {', '.join(missing)} fehlt.") + continue + try: + validate_iban(member.iban) + date.fromisoformat(member.mandate_signed_at) + except (RepositoryError, ValueError) as exc: + warnings.append(f"{member.member_number or member.display_name}: {exc}") + continue + data = repository.get_contributions(member.member_id) + selected = [] + amount = Decimal("0") + for claim in data.claims: + if str(claim.get("status", "")) == "cancelled": + continue + try: + claim_due = date.fromisoformat(str(claim.get("due_date", ""))) + except ValueError: + continue + balance = claim_balance(data, claim) + if (due_from is None or claim_due >= due_from) and claim_due <= due_until and balance > 0: + selected.append(claim) + amount += balance + if not selected: + continue + titles = [_safe(claim.get("title") or "Forderung", 70) for claim in selected] + purpose = _safe(f"{member.member_number} " + ", ".join(dict.fromkeys(titles)), 140) + debits.append( + DirectDebit( + member_id=member.member_id, + member_number=member.member_number, + account_holder=member.account_holder.strip() or member.display_name, + iban=member.iban.replace(" ", "").upper(), + bic=member.bic.replace(" ", "").upper(), + mandate_reference=_safe(member.mandate_reference, 35), + mandate_signed_at=member.mandate_signed_at, + amount=amount.quantize(Decimal("0.01")), + purpose=purpose, + claim_ids=tuple(str(claim.get("claim_id", "")) for claim in selected), + ) + ) + return sorted(debits, key=lambda item: (item.member_number, item.account_holder)), warnings + + +def csv_text(debits: list[DirectDebit], *, collection_date: date) -> str: + target = io.StringIO(newline="") + writer = csv.writer(target, delimiter=";", lineterminator="\r\n") + writer.writerow( + ["Einzugsdatum", "Mitgliedsnummer", "Kontoinhaber", "IBAN", "BIC", "Betrag", + "Waehrung", "Mandatsreferenz", "Mandatsdatum", "Verwendungszweck"] + ) + for debit in debits: + writer.writerow( + [collection_date.isoformat(), debit.member_number, debit.account_holder, debit.iban, + debit.bic, f"{debit.amount:.2f}".replace(".", ","), "EUR", + debit.mandate_reference, debit.mandate_signed_at, debit.purpose] + ) + return target.getvalue() + + +def write_csv(path: Path | str, debits: list[DirectDebit], *, collection_date: date) -> None: + Path(path).write_text(csv_text(debits, collection_date=collection_date), encoding="utf-8-sig") + + +def _el(parent: ET.Element, name: str, text: Any | None = None) -> ET.Element: + child = ET.SubElement(parent, f"{{{PAIN_NAMESPACE}}}{name}") + if text is not None: + child.text = str(text) + return child + + +def pain008_bytes( + debits: list[DirectDebit], *, collection_date: date, organization: dict[str, Any], + message_id: str | None = None, created_at: datetime | None = None, +) -> bytes: + if not debits: + raise RepositoryError("Es gibt keine Lastschriften für den Export.") + creditor_name = _safe(organization.get("name", ""), 70) + creditor_iban = str(organization.get("iban", "")).replace(" ", "").upper() + creditor_bic = str(organization.get("bic", "")).replace(" ", "").upper() + creditor_id = _safe(organization.get("creditor_id", ""), 35) + missing = [label for value, label in ( + (creditor_name, "Vereinsname"), (creditor_iban, "Vereins-IBAN"), + (creditor_bic, "Vereins-BIC"), (creditor_id, "Gläubiger-ID"), + ) if not value] + if missing: + raise RepositoryError("Für den SEPA-XML-Export fehlen in den Optionen: " + ", ".join(missing) + ".") + validate_iban(creditor_iban) + timestamp = created_at or datetime.now().astimezone() + msg_id = _safe(message_id or f"CCMA-{timestamp:%Y%m%d%H%M%S}-{uuid4().hex[:8]}", 35) + total = sum((item.amount for item in debits), Decimal("0")) + document = ET.Element(f"{{{PAIN_NAMESPACE}}}Document") + initiation = _el(document, "CstmrDrctDbtInitn") + header = _el(initiation, "GrpHdr") + for name, value in (("MsgId", msg_id), ("CreDtTm", timestamp.isoformat(timespec="seconds")), + ("NbOfTxs", len(debits)), ("CtrlSum", f"{total:.2f}")): + _el(header, name, value) + _el(_el(header, "InitgPty"), "Nm", creditor_name) + payment = _el(initiation, "PmtInf") + for name, value in (("PmtInfId", _safe(f"{msg_id}-1", 35)), ("PmtMtd", "DD"), + ("BtchBookg", "true"), ("NbOfTxs", len(debits)), + ("CtrlSum", f"{total:.2f}")): + _el(payment, name, value) + payment_type = _el(payment, "PmtTpInf") + _el(_el(payment_type, "SvcLvl"), "Cd", "SEPA") + _el(_el(payment_type, "LclInstrm"), "Cd", "CORE") + _el(payment_type, "SeqTp", "RCUR") + _el(payment, "ReqdColltnDt", collection_date.isoformat()) + _el(_el(payment, "Cdtr"), "Nm", creditor_name) + _el(_el(_el(payment, "CdtrAcct"), "Id"), "IBAN", creditor_iban) + _el(_el(_el(payment, "CdtrAgt"), "FinInstnId"), "BICFI", creditor_bic) + _el(payment, "ChrgBr", "SLEV") + for index, debit in enumerate(debits, start=1): + transaction = _el(payment, "DrctDbtTxInf") + _el(_el(transaction, "PmtId"), "EndToEndId", _safe(f"{debit.member_number}-{index}", 35)) + amount = _el(transaction, "InstdAmt", f"{debit.amount:.2f}") + amount.set("Ccy", "EUR") + direct_debit = _el(transaction, "DrctDbtTx") + mandate = _el(direct_debit, "MndtRltdInf") + _el(mandate, "MndtId", debit.mandate_reference) + _el(mandate, "DtOfSgntr", debit.mandate_signed_at) + other = _el(_el(_el(_el(direct_debit, "CdtrSchmeId"), "Id"), "PrvtId"), "Othr") + _el(other, "Id", creditor_id) + _el(_el(other, "SchmeNm"), "Prtry", "SEPA") + financial = _el(_el(transaction, "DbtrAgt"), "FinInstnId") + if debit.bic: + _el(financial, "BICFI", debit.bic) + else: + _el(_el(financial, "Othr"), "Id", "NOTPROVIDED") + _el(_el(transaction, "Dbtr"), "Nm", _safe(debit.account_holder, 70)) + _el(_el(_el(transaction, "DbtrAcct"), "Id"), "IBAN", debit.iban) + _el(_el(transaction, "RmtInf"), "Ustrd", debit.purpose) + ET.indent(document, space=" ") + return ET.tostring(document, encoding="utf-8", xml_declaration=True) + + +def write_pain008( + path: Path | str, debits: list[DirectDebit], *, collection_date: date, + organization: dict[str, Any], +) -> None: + Path(path).write_bytes( + pain008_bytes(debits, collection_date=collection_date, organization=organization) + ) diff --git a/src/ccma/services/sepa_mail.py b/src/ccma/services/sepa_mail.py new file mode 100644 index 0000000..b5b791a --- /dev/null +++ b/src/ccma/services/sepa_mail.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from datetime import date, datetime +from email.message import EmailMessage +from email.policy import SMTP +from email.utils import format_datetime, make_msgid +from pathlib import Path + +from ccma.services.sepa import DirectDebit +from ccma.storage.repository import MemberRepository, RepositoryError + + +@dataclass(frozen=True, slots=True) +class GeneratedDebitMail: + member_id: str + recipient: str + export_path: Path + archive_path: Path + + +def _safe_filename(value: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._") + return cleaned or "Mitglied" + + +def _available_path(directory: Path, filename: str) -> Path: + candidate = directory / filename + if not candidate.exists(): + return candidate + stem, suffix = Path(filename).stem, Path(filename).suffix + index = 2 + while (directory / f"{stem}-{index}{suffix}").exists(): + index += 1 + return directory / f"{stem}-{index}{suffix}" + + +def debit_mail_bytes( + *, + recipient: str, + first_name: str, + debit: DirectDebit, + collection_date: date, + creditor_id: str, + sender_name: str, + sender_email: str, + signature: str, + created_at: datetime | None = None, +) -> bytes: + if not recipient.strip(): + raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.") + if not sender_email.strip() or "@" not in sender_email: + raise RepositoryError("Für die Info-Mails ist eine gültige Absenderadresse erforderlich.") + timestamp = created_at or datetime.now().astimezone() + message = EmailMessage(policy=SMTP) + message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1]) + message["Date"] = format_datetime(timestamp) + message["From"] = f"{sender_name.strip()} <{sender_email.strip()}>" + message["To"] = recipient.strip() + message["Subject"] = f"SEPA-Lastschrift C3MA – {collection_date.strftime('%d.%m.%Y')}" + message["X-Mozilla-Draft-Info"] = ( + "internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0" + ) + greeting = first_name.strip() or "Mitglied" + message.set_content( + "\n".join( + ( + f"Hallo {greeting},", + "", + "hiermit informieren wir dich, dass wir den folgenden Betrag per " + "SEPA-Lastschrift einziehen werden:", + "", + f"Betrag: {debit.amount:.2f} Euro", + f"Einzugsdatum: {collection_date.strftime('%d.%m.%Y')}", + f"Verwendungszweck: {debit.purpose}", + f"Mandatsreferenz: {debit.mandate_reference}", + f"Gläubiger-Identifikationsnummer: {creditor_id}", + "", + "Sollten die Angaben nicht stimmen, melde dich bitte zeitnah bei uns.", + "", + "Viele Grüße", + signature.strip(), + ) + ), + charset="utf-8", + ) + return message.as_bytes() + + +def generate_debit_mails( + repository: MemberRepository, + debits: list[DirectDebit], + *, + collection_date: date, + output_directory: Path | str, + sender_name: str, + sender_email: str, + signature: str, +) -> tuple[list[GeneratedDebitMail], list[str]]: + output = Path(output_directory) + output.mkdir(parents=True, exist_ok=True) + organization = repository.get_configuration().get("organization") or {} + creditor_id = str(organization.get("creditor_id", "")).strip() + if not creditor_id: + raise RepositoryError("In den Optionen fehlt die Gläubiger-ID.") + generated: list[GeneratedDebitMail] = [] + warnings: list[str] = [] + for debit in debits: + member = repository.get_member(debit.member_id) + if not member.email.strip(): + warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.") + continue + content = debit_mail_bytes( + recipient=member.email, + first_name=member.first_name, + debit=debit, + collection_date=collection_date, + creditor_id=creditor_id, + sender_name=sender_name, + sender_email=sender_email, + signature=signature, + ) + filename = ( + f"SEPA-Info-{collection_date.isoformat()}-" + f"{_safe_filename(member.member_number or member.display_name)}.eml" + ) + export_path = _available_path(output, filename) + archive_dir = repository.members_root / member.member_id / "files" / "documents" / "SEPA" + archive_dir.mkdir(parents=True, exist_ok=True) + archive_path = _available_path(archive_dir, filename) + export_path.write_bytes(content) + try: + archive_path.write_bytes(content) + except OSError: + export_path.unlink(missing_ok=True) + raise + relative_path = archive_path.relative_to( + repository.members_root / member.member_id / "files" + ).as_posix() + digest = hashlib.sha256(content).hexdigest() + repository.append_event( + member.member_id, + event_type="sepa_notification_generated", + summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}", + actor_type="user", + actor_name="Vorstand", + references={"document": relative_path}, + data={ + "sha256": digest, + "recipient": member.email, + "collection_date": collection_date.isoformat(), + "amount": f"{debit.amount:.2f}", + }, + ) + generated.append( + GeneratedDebitMail(member.member_id, member.email, export_path, archive_path) + ) + return generated, warnings diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index ec78ce9..15184f4 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -983,6 +983,84 @@ class MemberRepository: ) return payment + def record_combined_payment( + self, + member_id: str, + *, + payment_date: str, + allocations: dict[str, str], + reference: str = "", + method: str = "bank_transfer", + actor_name: str = "CCMA", + ) -> dict: + """Record one payment and atomically allocate it to multiple claims.""" + try: + normalized_date = normalize_date_input(payment_date, "Zahlungsdatum") + except DateValidationError as exc: + raise RepositoryError(str(exc)) from exc + if not normalized_date: + raise RepositoryError("Zahlungsdatum ist erforderlich.") + if not allocations: + raise RepositoryError("Mindestens eine Zuordnung ist erforderlich.") + + data = self.get_contributions(member_id) + claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims} + selected_allocations: dict[str, Decimal] = {} + for claim_id, raw_amount in allocations.items(): + claim = claims_by_id.get(claim_id) + if claim is None: + raise RepositoryError(f"Forderung nicht gefunden: {claim_id}") + if str(claim.get("status", "")) == "cancelled": + raise RepositoryError("Eine stornierte Forderung kann nicht bezahlt werden.") + try: + selected_amount = decimal_value(raw_amount, "Zuordnung") + except ValueError as exc: + raise RepositoryError(str(exc)) from exc + available = max(claim_balance(data, claim), Decimal("0")) + if selected_amount <= 0 or selected_amount > available: + raise RepositoryError( + f"{claim.get('title', 'Forderung')} hat nur " + f"{money_text(available)} EUR offen." + ) + selected_allocations[claim_id] = selected_amount + + total = sum(selected_allocations.values(), Decimal("0")) + now = datetime.now().astimezone().isoformat(timespec="seconds") + payment = { + "payment_id": str(uuid4()), + "date": normalized_date, + "amount": money_text(total), + "method": method.strip() or "bank_transfer", + "gnucash_transaction_id": "", + "reference": reference.strip(), + "created_at": now, + } + for claim_id, amount in selected_allocations.items(): + data.allocations.append( + { + "allocation_id": str(uuid4()), + "payment_id": payment["payment_id"], + "claim_id": claim_id, + "amount": money_text(amount), + } + ) + data.payments.append(payment) + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="payment_recorded", + summary=f"Zahlung eingegangen: {payment['amount']} EUR", + actor_type="system" if method == "dummy" else "user", + actor_name=actor_name, + references={"payment_id": str(payment["payment_id"])}, + data={ + "allocated_amount": payment["amount"], + "claim_ids": list(selected_allocations), + "method": payment["method"], + }, + ) + return payment + def allocate_payment(self, member_id: str, claim_id: str, *, payment_id: str, amount: str) -> dict: data, claim = self.get_claim(member_id, claim_id) payment = next( diff --git a/src/ccma/ui/main_window.py b/src/ccma/ui/main_window.py index 742d5fe..edb591f 100644 --- a/src/ccma/ui/main_window.py +++ b/src/ccma/ui/main_window.py @@ -14,6 +14,7 @@ from ccma.ui.dialogs import EditAssetDialog, IssueAssetDialog, NewAssetDialog, N from ccma.ui.icons import IconStore from ccma.ui.member_tab import MemberTab from ccma.ui.options_dialog import OptionsDialog +from ccma.ui.sepa_dialog import SepaExportDialog from ccma.ui.theme import load_theme from ccma.ui.work_tabs import AssetsTab, DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab @@ -187,6 +188,13 @@ class MainWindow(ttk.Frame): ) assets_button.image = assets_icon assets_button.pack(side="left", padx=(0, 6)) + sepa_icon = self.icons.get("bank-transfer", 24) or self.icons.get("bank", 24) + sepa_button = ttk.Button( + actions, text="SEPA", image=sepa_icon, compound="top", width=14, + command=self.open_sepa_export, + ) + sepa_button.image = sepa_icon + sepa_button.pack(side="left", padx=(0, 6)) new_icon = self.icons.get("account-plus", 24) new_button = ttk.Button( actions, @@ -403,6 +411,9 @@ class MainWindow(ttk.Frame): icon_name="key-variant", ) + def open_sepa_export(self) -> None: + SepaExportDialog(self, self.repository) + def new_member(self) -> None: NewMemberDialog(self, self.repository, self._member_created) diff --git a/src/ccma/ui/sepa_dialog.py b/src/ccma/ui/sepa_dialog.py new file mode 100644 index 0000000..d13c54c --- /dev/null +++ b/src/ccma/ui/sepa_dialog.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import tkinter as tk +from datetime import date, timedelta +from decimal import Decimal +from pathlib import Path +from tkinter import filedialog, messagebox, ttk + +from ccma.domain.dates import DateValidationError, normalize_date_input +from ccma.services.sepa import pending_direct_debits, write_csv, write_pain008 +from ccma.services.sepa_mail import generate_debit_mails +from ccma.storage.repository import MemberRepository, RepositoryError + + +def _next_weekday(selected: date) -> date: + while selected.weekday() >= 5: + selected += timedelta(days=1) + return selected + + +class SepaExportDialog(tk.Toplevel): + def __init__(self, master: tk.Misc, repository: MemberRepository): + super().__init__(master) + self.repository = repository + self.debits = [] + self.warnings: list[str] = [] + self.title("SEPA-Lastschriften") + self.transient(master.winfo_toplevel()) + self.geometry("1000x560") + self.minsize(760, 420) + self.from_var = tk.StringVar(value=date(date.today().year, 1, 1).isoformat()) + self.due_var = tk.StringVar(value=date.today().isoformat()) + self.collection_var = tk.StringVar( + value=_next_weekday(date.today() + timedelta(days=2)).isoformat() + ) + self.summary_var = tk.StringVar() + self.warning_var = tk.StringVar() + self._build_ui() + self.refresh() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(2, weight=1) + filters = ttk.Frame(self, padding=12) + filters.grid(row=0, column=0, sticky="ew") + ttk.Label(filters, text="Fällig ab").grid(row=0, column=0) + ttk.Entry(filters, textvariable=self.from_var, width=14).grid( + row=0, column=1, padx=(8, 20) + ) + ttk.Label(filters, text="Fällig bis").grid(row=0, column=2) + ttk.Entry(filters, textvariable=self.due_var, width=14).grid( + row=0, column=3, padx=(8, 20) + ) + ttk.Label(filters, text="Einzugsdatum").grid(row=0, column=4) + ttk.Entry(filters, textvariable=self.collection_var, width=14).grid( + row=0, column=5, padx=(8, 20) + ) + ttk.Button(filters, text="Liste aktualisieren", command=self.refresh).grid(row=0, column=6) + ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid( + row=1, column=0, sticky="w", padx=12, pady=(0, 8) + ) + self.table = ttk.Treeview( + self, columns=("number", "name", "iban", "mandate", "purpose", "amount"), show="headings" + ) + for key, label, width, anchor in ( + ("number", "Mitglied", 100, "w"), ("name", "Kontoinhaber", 170, "w"), + ("iban", "IBAN", 190, "w"), ("mandate", "Mandat", 130, "w"), + ("purpose", "Verwendungszweck", 280, "w"), ("amount", "Betrag", 90, "e"), + ): + self.table.heading(key, text=label) + self.table.column(key, width=width, anchor=anchor) + self.table.grid(row=2, column=0, sticky="nsew", padx=12) + footer = ttk.Frame(self, padding=12) + footer.grid(row=3, column=0, sticky="ew") + footer.columnconfigure(0, weight=1) + ttk.Label(footer, textvariable=self.warning_var).grid(row=0, column=0, sticky="w") + ttk.Button(footer, text="CSV-Liste speichern", command=self._save_csv).grid( + row=0, column=1, padx=(8, 0) + ) + ttk.Button(footer, text="Info-Mails erzeugen", command=self._open_mail_export).grid( + row=0, column=2, padx=(8, 0) + ) + ttk.Button( + footer, text="SEPA-XML speichern", style="Accent.TButton", command=self._save_xml + ).grid(row=0, column=3, padx=(8, 0)) + ttk.Button(footer, text="Schließen", command=self.destroy).grid( + row=0, column=4, padx=(8, 0) + ) + + def _dates(self) -> tuple[date, date, date]: + try: + due_from = date.fromisoformat( + normalize_date_input(self.from_var.get(), "Fällig ab") + ) + due = date.fromisoformat(normalize_date_input(self.due_var.get(), "Fällig bis")) + collection = date.fromisoformat( + normalize_date_input(self.collection_var.get(), "Einzugsdatum") + ) + except (DateValidationError, ValueError) as exc: + raise RepositoryError(str(exc)) from exc + if due_from > due: + raise RepositoryError("„Fällig ab“ darf nicht nach „Fällig bis“ liegen.") + if collection.weekday() >= 5: + raise RepositoryError( + "Das Einzugsdatum muss ein Bankarbeitstag von Montag bis Freitag sein." + ) + return due_from, due, collection + + def refresh(self) -> None: + try: + due_from, due, _collection = self._dates() + self.debits, self.warnings = pending_direct_debits( + self.repository, due_from=due_from, due_until=due + ) + except RepositoryError as exc: + messagebox.showerror("SEPA-Liste konnte nicht erstellt werden", str(exc), parent=self) + return + self.table.delete(*self.table.get_children()) + for debit in self.debits: + self.table.insert( + "", "end", + values=(debit.member_number, debit.account_holder, debit.iban, + debit.mandate_reference, debit.purpose, f"{debit.amount:.2f} EUR"), + ) + total = sum((item.amount for item in self.debits), Decimal("0")) + self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR") + self.warning_var.set( + f"{len(self.warnings)} unvollständige(s) Mandat(e) übersprungen." if self.warnings else "" + ) + + def _prepare(self) -> tuple[date, date, date] | None: + self.refresh() + if not self.debits: + messagebox.showinfo( + "Keine Lastschriften", "Für den Stichtag ist nichts einzuziehen.", parent=self + ) + return None + if self.warnings and not messagebox.askyesno( + "Unvollständige Mandate", + "\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?", + parent=self, + ): + return None + return self._dates() + + def _target(self, extension: str, title: str, collection: date) -> Path | None: + selected = filedialog.asksaveasfilename( + parent=self, title=title, defaultextension=extension, + initialfile=f"SEPA-Lastschriften-{collection.isoformat()}{extension}", + filetypes=[(title, f"*{extension}"), ("Alle Dateien", "*.*")], + ) + return Path(selected) if selected else None + + def _save_csv(self) -> None: + dates = self._prepare() + if not dates: + return + target = self._target(".csv", "CSV-Datei", dates[2]) + if target: + write_csv(target, self.debits, collection_date=dates[2]) + messagebox.showinfo("Export gespeichert", f"CSV gespeichert:\n{target}", parent=self) + + def _save_xml(self) -> None: + dates = self._prepare() + if not dates: + return + target = self._target(".xml", "SEPA-XML", dates[2]) + if not target: + return + try: + write_pain008( + target, self.debits, collection_date=dates[2], + organization=self.repository.get_configuration().get("organization") or {}, + ) + except RepositoryError as exc: + messagebox.showerror("SEPA-XML konnte nicht erstellt werden", str(exc), parent=self) + return + messagebox.showinfo("Export gespeichert", f"SEPA-XML gespeichert:\n{target}", parent=self) + + def _open_mail_export(self) -> None: + dates = self._prepare() + if dates: + SepaMailExportDialog(self, self.repository, self.debits, dates[2]) + + +class SepaMailExportDialog(tk.Toplevel): + def __init__( + self, + master: tk.Misc, + repository: MemberRepository, + debits: list, + collection_date: date, + ): + super().__init__(master) + self.repository = repository + self.debits = debits + self.collection_date = collection_date + organization = repository.get_configuration().get("organization") or {} + self.sender_name_var = tk.StringVar( + value=str(organization.get("name") or "Verwaltung C3MA") + ) + self.sender_email_var = tk.StringVar(value=str(organization.get("email") or "")) + self.title("SEPA-Info-Mails erzeugen") + self.transient(master) + self.resizable(True, False) + self.columnconfigure(1, weight=1) + ttk.Label( + self, + text=( + f"Für {len(debits)} Lastschrift(en) werden Thunderbird-kompatible " + ".eml-Entwürfe erzeugt." + ), + wraplength=620, + ).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10)) + ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5) + ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid( + row=1, column=1, sticky="ew", padx=(0, 14), pady=5 + ) + ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5) + ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid( + row=2, column=1, sticky="ew", padx=(0, 14), pady=5 + ) + ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5) + self.signature = tk.Text(self, width=55, height=5, wrap="word") + self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5) + self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.") + buttons = ttk.Frame(self, padding=14) + buttons.grid(row=4, column=0, columnspan=2, sticky="e") + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left") + ttk.Button( + buttons, + text="Ordner wählen und Entwürfe erzeugen", + style="Accent.TButton", + command=self._generate, + ).pack(side="left", padx=(8, 0)) + self.grab_set() + + def _generate(self) -> None: + selected = filedialog.askdirectory( + parent=self, + title="Ausgabeordner für Thunderbird-Entwürfe auswählen", + mustexist=True, + ) + if not selected: + return + try: + generated, warnings = generate_debit_mails( + self.repository, + self.debits, + collection_date=self.collection_date, + output_directory=selected, + sender_name=self.sender_name_var.get(), + sender_email=self.sender_email_var.get(), + signature=self.signature.get("1.0", "end-1c"), + ) + except (OSError, RepositoryError) as exc: + messagebox.showerror("Info-Mails konnten nicht erzeugt werden", str(exc), parent=self) + return + detail = ( + f"{len(generated)} Entwurf/Entwürfe wurden in\n{selected}\nerzeugt und in den " + "Mitgliederakten archiviert." + ) + if warnings: + detail += "\n\nÜbersprungen:\n" + "\n".join(warnings[:12]) + messagebox.showinfo("Info-Mails erzeugt", detail, parent=self) + self.destroy() diff --git a/tests/test_contributions.py b/tests/test_contributions.py index f274bd1..5f454ea 100644 --- a/tests/test_contributions.py +++ b/tests/test_contributions.py @@ -147,6 +147,36 @@ def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None: ) +def test_combined_dummy_payment_settles_multiple_claims(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path) + data = repository.get_contributions(member.member_id) + data.claims.append( + { + "claim_id": "claim-2", + "title": "Zweite Forderung", + "amount": "50.00", + "due_date": "2019-12-31", + "status": "open", + } + ) + repository.save_contributions(member.member_id, data) + + payment = repository.record_combined_payment( + member.member_id, + payment_date="2019-12-31", + allocations={"claim-1": "100.00", "claim-2": "50.00"}, + method="dummy", + reference="Altbestand", + ) + + changed = repository.get_contributions(member.member_id) + assert payment["amount"] == "150.00" + assert payment["method"] == "dummy" + assert payment_allocated_total(changed, payment["payment_id"]) == Decimal("150.00") + assert all(claim_balance(changed, claim) == 0 for claim in changed.claims) + assert repository.get_events(member.member_id)[-1].data["method"] == "dummy" + + def test_payment_allocation_cannot_overpay_claim(tmp_path) -> None: repository, member = _repository_with_claim(tmp_path, amount="12.50") diff --git a/tests/test_sepa.py b/tests/test_sepa.py new file mode 100644 index 0000000..5e64c3f --- /dev/null +++ b/tests/test_sepa.py @@ -0,0 +1,207 @@ +import xml.etree.ElementTree as ET +from datetime import UTC, date, datetime +from decimal import Decimal +from email.parser import BytesParser +from email.policy import default + +from ccma.domain.models import ContributionData +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 +from ccma.ui.sepa_dialog import _next_weekday + + +def _repository(tmp_path): + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Ada", last_name="Lovelace", member_number="C3-42") + member.status = "active" + member.email = "ada@example.org" + member.account_holder = "Ada Lovelace" + member.iban = "DE89370400440532013000" + member.bic = "COBADEFFXXX" + member.mandate_reference = "MANDAT-42" + member.mandate_signed_at = "2025-01-10" + member.mandate_active = True + repository.save_member(member) + repository.save_contributions( + member.member_id, + ContributionData( + claims=[ + { + "claim_id": "due", + "title": "Mitgliedsbeitrag 2026", + "amount": "150.00", + "due_date": "2026-01-31", + "status": "open", + }, + { + "claim_id": "future", + "title": "Mitgliedsbeitrag 2027", + "amount": "150.00", + "due_date": "2027-01-31", + "status": "open", + }, + ], + payments=[{"payment_id": "payment", "amount": "25.00"}], + allocations=[{"payment_id": "payment", "claim_id": "due", "amount": "25.00"}], + ), + ) + return repository, member + + +def test_pending_debits_select_due_open_balance(tmp_path): + repository, member = _repository(tmp_path) + debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31)) + + assert warnings == [] + assert len(debits) == 1 + assert debits[0].member_id == member.member_id + assert debits[0].amount == Decimal("125.00") + assert debits[0].claim_ids == ("due",) + + +def test_pending_debits_exclude_claims_before_lower_date(tmp_path): + repository, _member = _repository(tmp_path) + + debits, warnings = pending_direct_debits( + repository, + due_from=date(2026, 2, 1), + due_until=date(2026, 12, 31), + ) + + assert warnings == [] + assert debits == [] + + +def test_next_weekday_moves_weekend_to_monday(): + assert _next_weekday(date(2026, 8, 1)) == date(2026, 8, 3) + assert _next_weekday(date(2026, 8, 3)) == date(2026, 8, 3) + + +def test_sepa_text_normalizes_unsupported_characters(): + assert _safe("Sébastien O’Connor – Müller", 70) == "Sebastien O'Connor - Müller" + + +def test_csv_is_semicolon_separated_and_uses_decimal_comma(tmp_path): + repository, _member = _repository(tmp_path) + debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31)) + + text = csv_text(debits, collection_date=date(2026, 8, 3)) + + assert "Einzugsdatum;Mitgliedsnummer" in text + assert "125,00;EUR;MANDAT-42" in text + + +def test_pain008_contains_control_sum_mandate_and_creditor(tmp_path): + repository, _member = _repository(tmp_path) + debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31)) + organization = { + "name": "Chaos Computer Club Mannheim e.V.", + "iban": "DE89370400440532013000", + "bic": "COBADEFFXXX", + "creditor_id": "DE98ZZZ09999999999", + } + + content = pain008_bytes( + debits, + collection_date=date(2026, 8, 3), + organization=organization, + message_id="CCMA-TEST", + created_at=datetime(2026, 7, 30, 12, 0, tzinfo=UTC), + ) + root = ET.fromstring(content) + ns = {"p": PAIN_NAMESPACE} + + assert root.findtext(".//p:GrpHdr/p:CtrlSum", namespaces=ns) == "125.00" + assert root.findtext(".//p:MndtId", namespaces=ns) == "MANDAT-42" + assert ( + root.findtext(".//p:DrctDbtTx/p:CdtrSchmeId//p:Othr/p:Id", namespaces=ns) + == "DE98ZZZ09999999999" + ) + assert root.find("p:CstmrDrctDbtInitn/p:PmtInf/p:CdtrSchmeId", ns) is None + assert root.findtext(".//p:ReqdColltnDt", namespaces=ns) == "2026-08-03" + + +def test_pain008_contains_one_logical_batch_with_multiple_transactions(tmp_path): + repository, _member = _repository(tmp_path) + debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0] + organization = { + "name": "Chaos Computer Club Mannheim e.V.", + "iban": "DE89370400440532013000", + "bic": "COBADEFFXXX", + "creditor_id": "DE98ZZZ09999999999", + } + + root = ET.fromstring( + pain008_bytes( + [debit, debit], + collection_date=date(2026, 8, 3), + organization=organization, + message_id="CCMA-BATCH-TEST", + ) + ) + ns = {"p": PAIN_NAMESPACE} + + assert len(root.findall("p:CstmrDrctDbtInitn", ns)) == 1 + assert len(root.findall(".//p:PmtInf", ns)) == 1 + assert len(root.findall(".//p:DrctDbtTxInf", ns)) == 2 + assert root.findtext(".//p:GrpHdr/p:NbOfTxs", namespaces=ns) == "2" + assert root.findtext(".//p:GrpHdr/p:CtrlSum", namespaces=ns) == "250.00" + + +def test_debit_mail_is_thunderbird_draft(tmp_path): + repository, member = _repository(tmp_path) + debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0] + + content = debit_mail_bytes( + recipient=member.email, + first_name=member.first_name, + debit=debit, + collection_date=date(2026, 8, 3), + creditor_id="DE98ZZZ09999999999", + sender_name="Verwaltung C3MA", + sender_email="verwaltung@example.org", + signature="Der Vorstand", + created_at=datetime(2026, 7, 30, 12, 0, tzinfo=UTC), + ) + message = BytesParser(policy=default).parsebytes(content) + + assert message["To"] == "ada@example.org" + assert str(message["X-Mozilla-Draft-Info"]).strip().startswith("internal/draft") + assert "125.00 Euro" in message.get_content() + assert "MANDAT-42" in message.get_content() + + +def test_generated_mail_is_exported_archived_and_logged(tmp_path): + repository, member = _repository(tmp_path) + organization = repository.get_configuration()["organization"] + organization.update( + { + "name": "Chaos Computer Club Mannheim e.V.", + "email": "verwaltung@example.org", + "iban": "DE89370400440532013000", + "bic": "COBADEFFXXX", + "creditor_id": "DE98ZZZ09999999999", + } + ) + repository.save_organization(organization) + debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31)) + + generated, warnings = generate_debit_mails( + repository, + debits, + collection_date=date(2026, 8, 3), + output_directory=tmp_path / "mail-export", + sender_name="Verwaltung C3MA", + sender_email="verwaltung@example.org", + signature="Der Vorstand", + ) + + assert warnings == [] + assert generated[0].export_path.is_file() + assert generated[0].archive_path.is_file() + assert generated[0].archive_path.read_bytes() == generated[0].export_path.read_bytes() + event = repository.get_events(member.member_id)[-1] + assert event.event_type == "sepa_notification_generated" + assert event.references["document"].startswith("documents/SEPA/") From fc4eacb5916c7b6090ee7f3b24b4391ca01cc951 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Thu, 30 Jul 2026 01:17:33 +0200 Subject: [PATCH 2/4] feat: add reminder email drafts --- src/ccma/services/reminder_mail.py | 180 +++++++++++++++++++++++++++++ src/ccma/ui/claim_tab.py | 115 +++++++++++++++++- tests/test_reminder_mail.py | 83 +++++++++++++ 3 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 src/ccma/services/reminder_mail.py create mode 100644 tests/test_reminder_mail.py diff --git a/src/ccma/services/reminder_mail.py b/src/ccma/services/reminder_mail.py new file mode 100644 index 0000000..a9accbd --- /dev/null +++ b/src/ccma/services/reminder_mail.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import hashlib +import os +import re +from dataclasses import dataclass +from datetime import datetime +from email.message import EmailMessage +from email.policy import SMTP +from email.utils import format_datetime, make_msgid +from pathlib import Path + +from ccma.domain.contributions import claim_balance +from ccma.domain.dates import format_date_for_display +from ccma.storage.repository import MemberRepository, RepositoryError + + +@dataclass(frozen=True, slots=True) +class GeneratedReminderMail: + export_path: Path + archive_path: Path + sha256: str + + +def _safe_filename(value: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._") + return cleaned or "Mahnung" + + +def _available_path(directory: Path, filename: str) -> Path: + candidate = directory / filename + if not candidate.exists(): + return candidate + stem, suffix = Path(filename).stem, Path(filename).suffix + index = 2 + while (directory / f"{stem}-{index}{suffix}").exists(): + index += 1 + return directory / f"{stem}-{index}{suffix}" + + +def reminder_mail_bytes( + *, + recipient: str, + first_name: str, + reminder: dict, + claim: dict, + balance: str, + organization: dict, + sender_name: str, + sender_email: str, + signature: str, + created_at: datetime | None = None, +) -> bytes: + if not recipient.strip(): + raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.") + if not sender_email.strip() or "@" not in sender_email: + raise RepositoryError("Für die Mahnung ist eine gültige Absenderadresse erforderlich.") + timestamp = created_at or datetime.now().astimezone() + message = EmailMessage(policy=SMTP) + message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1]) + message["Date"] = format_datetime(timestamp) + message["From"] = f"{sender_name.strip()} <{sender_email.strip()}>" + message["To"] = recipient.strip() + message["Subject"] = f"{reminder.get('name', 'Zahlungserinnerung')} – {claim.get('title', '')}" + message["X-Mozilla-Draft-Info"] = ( + "internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0" + ) + iban = str(organization.get("iban", "")).strip() + bic = str(organization.get("bic", "")).strip() + payment_details = f"IBAN: {iban}" + (f"\nBIC: {bic}" if bic else "") + detail = str(reminder.get("detail", "")).strip() + message.set_content( + "\n".join( + ( + f"Hallo {first_name.strip() or 'Mitglied'},", + "", + f"zu der Forderung „{claim.get('title', 'Offene Forderung')}“ " + "ist noch ein Betrag offen.", + "", + f"Offener Betrag: {balance} Euro", + f"Ursprünglich fällig: " + f"{format_date_for_display(str(claim.get('due_date', '')))}", + f"Neue Zahlungsfrist: " + f"{format_date_for_display(str(reminder.get('payment_deadline', '')))}", + f"Mahngebühr: {reminder.get('fee', '0.00')} Euro", + f"Hinweis: {detail}" if detail else "", + "", + "Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:", + payment_details, + "", + "Sollte sich deine Zahlung mit dieser Nachricht überschnitten haben, " + "betrachte sie bitte als gegenstandslos.", + "", + "Viele Grüße", + signature.strip(), + ) + ), + charset="utf-8", + ) + return message.as_bytes() + + +def generate_and_send_reminder_mail( + repository: MemberRepository, + member_id: str, + claim_id: str, + reminder_id: str, + *, + output_path: Path | str, + sender_name: str, + sender_email: str, + signature: str, +) -> GeneratedReminderMail: + member = repository.get_member(member_id) + data, claim = repository.get_claim(member_id, claim_id) + reminder = repository._find_reminder(data, claim_id, reminder_id) + if str(reminder.get("status", "")) not in {"draft", "generated"}: + raise RepositoryError("Nur ein Mahnungsentwurf kann als E-Mail erzeugt werden.") + if not member.email.strip(): + raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.") + if not sender_email.strip() or "@" not in sender_email: + raise RepositoryError("Für die Mahnung ist eine gültige Absenderadresse erforderlich.") + target = Path(output_path) + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): + raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}") + + sent_reminder = repository.mark_reminder_sent(member_id, claim_id, reminder_id) + updated_data, updated_claim = repository.get_claim(member_id, claim_id) + organization = repository.get_configuration().get("organization") or {} + content = reminder_mail_bytes( + recipient=member.email, + first_name=member.first_name, + reminder=sent_reminder, + claim=updated_claim, + balance=f"{claim_balance(updated_data, updated_claim):.2f}", + organization=organization, + sender_name=sender_name, + sender_email=sender_email, + signature=signature, + ) + archive_dir = repository.members_root / member_id / "files" / "documents" / "Mahnungen" + archive_dir.mkdir(parents=True, exist_ok=True) + filename = ( + f"{datetime.now().date().isoformat()}-" + f"{_safe_filename(str(sent_reminder.get('name', 'Mahnung')))}.eml" + ) + archive_path = _available_path(archive_dir, filename) + export_temp = target.with_name(f".{target.name}.tmp") + archive_temp = archive_path.with_name(f".{archive_path.name}.tmp") + try: + export_temp.write_bytes(content) + archive_temp.write_bytes(content) + os.replace(export_temp, target) + os.replace(archive_temp, archive_path) + finally: + export_temp.unlink(missing_ok=True) + archive_temp.unlink(missing_ok=True) + relative_path = archive_path.relative_to( + repository.members_root / member_id / "files" + ).as_posix() + digest = hashlib.sha256(content).hexdigest() + repository.append_event( + member_id, + event_type="reminder_email_sent", + summary=f"Mahnung per E-Mail versandt: {sent_reminder.get('name', '')}", + actor_type="user", + actor_name="Vorstand", + references={ + "claim_id": claim_id, + "reminder_id": reminder_id, + "document": relative_path, + }, + data={ + "recipient": member.email, + "sha256": digest, + "payment_deadline": str(sent_reminder.get("payment_deadline", "")), + }, + ) + return GeneratedReminderMail(target, archive_path, digest) diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index 5720a04..ef71cc5 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -5,7 +5,7 @@ from collections.abc import Callable from datetime import date from decimal import Decimal from pathlib import Path -from tkinter import messagebox, ttk +from tkinter import filedialog, messagebox, ttk from ccma.domain.contributions import ( CLAIM_STATUS_LABELS, @@ -21,6 +21,7 @@ from ccma.domain.contributions import ( payment_allocated_total, ) from ccma.domain.dates import date_input_hint, format_date_for_display +from ccma.services.reminder_mail import generate_and_send_reminder_mail from ccma.storage.repository import MemberRepository, RepositoryError from ccma.ui.document_dialog import DocumentTemplateDialog from ccma.ui.labels import ( @@ -156,6 +157,13 @@ class ClaimTab(ttk.Frame): buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled" ) self.send_reminder_button.pack(side="right", padx=(8, 0)) + self.email_reminder_button = ttk.Button( + buttons, + text="E-Mail erzeugen & versandt", + command=self._email_reminder, + state="disabled", + ) + self.email_reminder_button.pack(side="right", padx=(8, 0)) ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right") ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack( side="right", padx=(0, 8) @@ -425,8 +433,22 @@ class ClaimTab(ttk.Frame): editable = bool(reminder and str(reminder.get("status", "draft")) in {"draft", "generated"}) state = "normal" if editable else "disabled" self.send_reminder_button.configure(state=state) + self.email_reminder_button.configure(state=state) self.discard_reminder_button.configure(state=state) + def _email_reminder(self) -> None: + reminder = self._selected_reminder() + if reminder: + ReminderMailDialog( + self, + self.repository, + self.member_id, + self.claim_id, + str(reminder["reminder_id"]), + str(reminder.get("name", "Mahnung")), + self._changed, + ) + def _send_reminder(self) -> None: reminder = self._selected_reminder() if not reminder or not messagebox.askyesno( @@ -893,6 +915,97 @@ class ReminderDialog(_Dialog): self.on_saved() +class ReminderMailDialog(tk.Toplevel): + def __init__( + self, + master, + repository, + member_id, + claim_id, + reminder_id, + reminder_name, + on_saved, + ): + super().__init__(master) + self.repository = repository + self.member_id = member_id + self.claim_id = claim_id + self.reminder_id = reminder_id + self.reminder_name = reminder_name + self.on_saved = on_saved + organization = repository.get_configuration().get("organization") or {} + self.sender_name_var = tk.StringVar( + value=str(organization.get("name") or "Verwaltung C3MA") + ) + self.sender_email_var = tk.StringVar(value=str(organization.get("email") or "")) + self.title("Mahnung als E-Mail erzeugen") + self.transient(master) + self.resizable(True, False) + self.columnconfigure(1, weight=1) + ttk.Label( + self, + text=( + "Die erzeugte E-Mail wird in der Mitgliederakte archiviert und die Mahnung " + "sofort als versandt gebucht. Zahlungsfrist und Mahngebühr werden dabei wirksam." + ), + wraplength=620, + ).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10)) + ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5) + ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid( + row=1, column=1, sticky="ew", padx=(0, 14), pady=5 + ) + ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5) + ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid( + row=2, column=1, sticky="ew", padx=(0, 14), pady=5 + ) + ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5) + self.signature = tk.Text(self, width=55, height=5, wrap="word") + self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5) + self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.") + buttons = ttk.Frame(self, padding=14) + buttons.grid(row=4, column=0, columnspan=2, sticky="e") + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left") + ttk.Button( + buttons, + text="EML speichern und als versandt buchen", + style="Accent.TButton", + command=self._generate, + ).pack(side="left", padx=(8, 0)) + self.grab_set() + + def _generate(self) -> None: + selected = filedialog.asksaveasfilename( + parent=self, + title="Mahnung als Thunderbird-Entwurf speichern", + defaultextension=".eml", + initialfile=f"{self.reminder_name}.eml", + filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")], + ) + if not selected: + return + try: + result = generate_and_send_reminder_mail( + self.repository, + self.member_id, + self.claim_id, + self.reminder_id, + output_path=selected, + sender_name=self.sender_name_var.get(), + sender_email=self.sender_email_var.get(), + signature=self.signature.get("1.0", "end-1c"), + ) + except (OSError, RepositoryError) as exc: + messagebox.showerror("Mahnung konnte nicht erzeugt werden", str(exc), parent=self) + return + messagebox.showinfo( + "Mahnung erzeugt", + f"Die E-Mail wurde gespeichert und als versandt gebucht:\n{result.export_path}", + parent=self, + ) + self.destroy() + self.on_saved() + + class DunningHoldDialog(_Dialog): def __init__(self, master, repository, member_id, claim_id, on_saved): super().__init__(master, "Mahnsperre setzen", on_saved) diff --git a/tests/test_reminder_mail.py b/tests/test_reminder_mail.py new file mode 100644 index 0000000..2e3aed2 --- /dev/null +++ b/tests/test_reminder_mail.py @@ -0,0 +1,83 @@ +from email.parser import BytesParser +from email.policy import default + +from test_reminders import _overdue_claim_repository + +from ccma.services.reminder_mail import generate_and_send_reminder_mail + + +def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path): + repository, member = _overdue_claim_repository(tmp_path / "store") + member.email = "reminder@example.org" + repository.save_member(member) + organization = repository.get_configuration()["organization"] + organization.update( + { + "name": "Chaos Computer Club Mannheim e.V.", + "email": "verwaltung@example.org", + "iban": "DE98670505050038907751", + "bic": "MANSDE66XXX", + } + ) + repository.save_organization(organization) + reminder = repository.create_reminder_draft( + member.member_id, + "claim-1", + level=1, + name="Zahlungserinnerung", + payment_deadline_days=14, + detail="Bitte Mitgliedsnummer angeben.", + ) + export_path = tmp_path / "Zahlungserinnerung.eml" + + generated = generate_and_send_reminder_mail( + repository, + member.member_id, + "claim-1", + reminder["reminder_id"], + output_path=export_path, + sender_name="Verwaltung C3MA", + sender_email="verwaltung@example.org", + signature="Der Vorstand", + ) + + assert generated.export_path.read_bytes() == generated.archive_path.read_bytes() + message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes()) + assert message["To"] == "reminder@example.org" + assert "Zahlungserinnerung" in message["Subject"] + assert "100.00 Euro" in message.get_content() + assert "DE98670505050038907751" in message.get_content() + data = repository.get_contributions(member.member_id) + assert data.reminders[0]["status"] == "sent" + assert data.reminders[0]["payment_deadline"] + assert repository.get_events(member.member_id)[-1].event_type == "reminder_email_sent" + assert generated.archive_path.parent.name == "Mahnungen" + + +def test_reminder_mail_includes_fee_in_open_balance(tmp_path): + repository, member = _overdue_claim_repository(tmp_path / "store") + member.email = "reminder@example.org" + repository.save_member(member) + reminder = repository.create_reminder_draft( + member.member_id, + "claim-1", + level=1, + name="Erste Mahnung", + payment_deadline_days=14, + fee="5.00", + ) + + generated = generate_and_send_reminder_mail( + repository, + member.member_id, + "claim-1", + reminder["reminder_id"], + output_path=tmp_path / "Mahnung.eml", + sender_name="Verwaltung C3MA", + sender_email="verwaltung@example.org", + signature="Der Vorstand", + ) + + message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes()) + assert "Offener Betrag: 105.00 Euro" in message.get_content() + assert "Mahngebühr: 5.00 Euro" in message.get_content() From 060a5aea011d0df7cb454b2d988fee4c8fad94c9 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Thu, 30 Jul 2026 18:48:50 +0200 Subject: [PATCH 3/4] docs: remove temporary legacy claims tool --- README.md | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/README.md b/README.md index 0e06a36..a2a4112 100644 --- a/README.md +++ b/README.md @@ -211,21 +211,6 @@ allocations. Reminders are separate processes and only change the amount when they explicitly add a fee line item. Every change is also appended to the member's `events.jsonl` audit trail. -### Alte Forderungen per Dummy-Zahlung ausgleichen - -Das Verwaltungswerkzeug zeigt standardmäßig nur eine Vorschau. Es fasst pro -Mitglied alle offenen Restbeträge von Forderungen mit Fälligkeit vor 2020 in -genau einer Dummy-Zahlung zusammen: - -```bash -PYTHONPATH=src python -m ccma.tools.settle_legacy_claims /pfad/zum/member-store -PYTHONPATH=src python -m ccma.tools.settle_legacy_claims /pfad/zum/member-store --apply -``` - -Vor `--apply` sollte eine Sicherung des Member-Stores erstellt werden. Ein -erneuter Lauf erzeugt keine weiteren Zahlungen für bereits ausgeglichene -Forderungen. - Overdue claims are evaluated by the reminder rule. It creates housekeeper tasks for the next configured reminder level. Reminder drafts do not change a claim; only confirming actual dispatch starts the new payment deadline and adds an From f14011efdcd4a82f1a2cc18f0c01d3ac0310560c Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Thu, 30 Jul 2026 18:51:57 +0200 Subject: [PATCH 4/4] docs: describe SEPA and reminder mail features --- src/ccma/assets/CHANGELOG.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 47896bb..21e3df5 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -11,7 +11,10 @@ "Das Austrittsdatum eines Mitglieds wird gespeichert und validiert; automatische Beitragsforderungen werden nicht mehr für Fälligkeiten nach dem Mitgliedschaftsende erzeugt.", "Anteilig berechnete Mitgliedsbeiträge weisen den abgerechneten Zeitraum verständlicher in ihrer Beschreibung aus.", "Die Mitgliederliste zeigt standardmäßig aktive Mitglieder, ordnet Nachname vor Vorname an und sortiert aufsteigend nach Nachnamen.", - "Mitglieder mit offenen Hausmeister-Vorgängen werden in der Liste je nach höchster Dringlichkeit blau, gelb oder rot hervorgehoben." + "Mitglieder mit offenen Hausmeister-Vorgängen werden in der Liste je nach höchster Dringlichkeit blau, gelb oder rot hervorgehoben.", + "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." ] }, {