Merge pull request 'feature/sepa-direct-debit-export' (#6) from feature/sepa-direct-debit-export into dev

Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/6
Reviewed-by: Matcha <matcha@noreply.git.hiabuto.net>
This commit is contained in:
Marcel Peterkau
2026-07-30 16:57:03 +00:00
11 changed files with 1350 additions and 2 deletions
+4 -1
View File
@@ -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."
]
},
{
+180
View File
@@ -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)
+217
View File
@@ -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)
)
+160
View File
@@ -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
+78
View File
@@ -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(
+114 -1
View File
@@ -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)
+11
View File
@@ -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)
+266
View File
@@ -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()
+30
View File
@@ -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")
+83
View File
@@ -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()
+207
View File
@@ -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 OConnor 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/")