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