mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +02:00
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:
@@ -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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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/")
|
||||
Reference in New Issue
Block a user