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(3, 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, 2) ) ttk.Label( self, text=( "Standardmäßig sind alle Lastschriften ausgewählt -- einzelne mit Strg+Klick " "abwählen (z. B. während eine Rücklastschrift geklärt wird), um sie erst bei " "einem späteren Einzug zu berücksichtigen." ), style="Muted.TLabel", wraplength=960, ).grid(row=2, column=0, sticky="w", padx=12, pady=(0, 8)) self.table = ttk.Treeview( self, columns=("number", "name", "iban", "mandate", "purpose", "amount"), show="headings", selectmode="extended", ) 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=3, column=0, sticky="nsew", padx=12) self.table.bind("<>", lambda _event: self._update_summary()) footer = ttk.Frame(self, padding=12) footer.grid(row=4, 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: # Preserve which members were deliberately deselected across a refresh (e.g. # _prepare() re-querying right before an export) by identity, not by row index # -- a debit newly appearing in the requeried list still defaults to selected. previously_deselected = { self.debits[int(iid)].member_id for iid in self.table.get_children() if iid not in self.table.selection() } 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 index, debit in enumerate(self.debits): self.table.insert( "", "end", iid=str(index), values=(debit.member_number, debit.account_holder, debit.iban, debit.mandate_reference, debit.purpose, f"{debit.amount:.2f} EUR"), ) self.table.selection_set( [ str(index) for index, debit in enumerate(self.debits) if debit.member_id not in previously_deselected ] ) self._update_summary() self.warning_var.set( f"{len(self.warnings)} unvollständige(s) Mandat(e) übersprungen." if self.warnings else "" ) def _selected_debits(self) -> list: return [self.debits[int(iid)] for iid in sorted(self.table.selection(), key=int)] def _update_summary(self) -> None: selected = self._selected_debits() total = sum((item.amount for item in self.debits), Decimal("0")) if len(selected) == len(self.debits): self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR") return selected_total = sum((item.amount for item in selected), Decimal("0")) self.summary_var.set( f"{len(selected)} von {len(self.debits)} Lastschrift(en) ausgewählt · " f"Summe {selected_total:.2f} EUR (gesamt {total:.2f} EUR)" ) 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 not self._selected_debits(): messagebox.showinfo( "Keine Auswahl", "Bitte mindestens eine Lastschrift auswählen.", 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._selected_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._selected_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._selected_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()