mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 15:05:18 +02:00
feat: add SEPA direct debit exports and notifications
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user