Files
CCMA/src/ccma/ui/sepa_dialog.py
T

267 lines
11 KiB
Python

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()