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/")