from __future__ import annotations import tkinter as tk from collections.abc import Callable from decimal import Decimal from pathlib import Path from tkinter import filedialog, messagebox, ttk from ccma.config import AppConfig from ccma.domain.contributions import money_text from ccma.domain.dates import ( DateValidationError, date_input_hint, format_date_for_display, normalize_date_input, ) from ccma.services.gnucash_import import ( GnuCashAccount, GnuCashImportError, GnuCashTransaction, list_accounts, list_transactions, ) from ccma.storage.repository import MemberRepository, RepositoryError from ccma.ui.monitors import centered_geometry, preferred_monitor class GnuCashImportDialog(tk.Toplevel): """Lets the board point at a GnuCash file, pick one account, narrow its bookings down with filters, and select (multi-select: click, Ctrl+click, Shift+click for ranges) which ones to import as payments for this member. Only bookings with a positive amount on the selected account are offered (money coming in). A booking whose date + amount already match an existing payment for this member is flagged, but can still be selected -- at import time, the board is asked whether to skip those or instead adopt the booking's description onto the already-recorded payment, rather than silently blocking them.""" def __init__( self, master: tk.Misc, repository: MemberRepository, member_id: str, config: AppConfig, on_imported: Callable[[], None], ): super().__init__(master) self.repository = repository self.member_id = member_id self.config = config self.on_imported = on_imported self.accounts: list[GnuCashAccount] = [] self.account_by_label: dict[str, GnuCashAccount] = {} self.transactions: list[GnuCashTransaction] = [] self.existing_payment_by_key: dict[tuple[str, str], str] = {} self.title("Zahlungen aus GnuCash importieren") self.transient(master.winfo_toplevel()) monitor = preferred_monitor(master.winfo_toplevel()) self.geometry(centered_geometry(round(monitor.width * 0.8), round(monitor.height * 0.8), monitor)) self.minsize(780, 480) self.protocol("WM_DELETE_WINDOW", self.destroy) self.bind("", lambda _event: self.destroy()) self._build_ui() self._load_existing_payment_keys() self.after_idle(self._activate) if self.file_var.get().strip(): self._load_file() def _activate(self) -> None: try: self.deiconify() self.lift() self.focus_force() self.grab_set() except tk.TclError: return def _build_ui(self) -> None: self.columnconfigure(0, weight=1) self.rowconfigure(2, weight=1) file_frame = ttk.Frame(self, padding=(16, 16, 16, 8)) file_frame.grid(row=0, column=0, sticky="ew") file_frame.columnconfigure(1, weight=1) self.file_var = tk.StringVar(value=self.config.gnucash_path) ttk.Label(file_frame, text="GnuCash-Datei").grid(row=0, column=0, sticky="w", padx=(0, 12)) ttk.Entry(file_frame, textvariable=self.file_var).grid(row=0, column=1, sticky="ew") ttk.Button(file_frame, text="Durchsuchen…", command=self._browse_file).grid( row=0, column=2, padx=(8, 0) ) ttk.Label(file_frame, text="Konto").grid(row=1, column=0, sticky="w", padx=(0, 12), pady=(8, 0)) self.account_var = tk.StringVar() self.account_combo = ttk.Combobox( file_frame, textvariable=self.account_var, state="readonly" ) self.account_combo.grid(row=1, column=1, sticky="ew", pady=(8, 0)) self.account_combo.bind("<>", lambda _event: self._account_selected()) ttk.Button(file_frame, text="Datei laden", command=self._load_file).grid( row=1, column=2, padx=(8, 0), pady=(8, 0) ) filter_frame = ttk.Frame(self, padding=(16, 0, 16, 8)) filter_frame.grid(row=1, column=0, sticky="ew") ttk.Label(filter_frame, text="Beschreibung enthält").pack(side="left") self.description_filter_var = tk.StringVar() ttk.Entry(filter_frame, textvariable=self.description_filter_var, width=26).pack( side="left", padx=(8, 16) ) self.description_filter_var.trace_add("write", lambda *_args: self._render_transactions()) ttk.Label(filter_frame, text=f"Von ({date_input_hint()})").pack(side="left") self.date_from_var = tk.StringVar() ttk.Entry(filter_frame, textvariable=self.date_from_var, width=12).pack(side="left", padx=(8, 16)) self.date_from_var.trace_add("write", lambda *_args: self._render_transactions()) ttk.Label(filter_frame, text=f"Bis ({date_input_hint()})").pack(side="left") self.date_to_var = tk.StringVar() ttk.Entry(filter_frame, textvariable=self.date_to_var, width=12).pack(side="left", padx=(8, 0)) self.date_to_var.trace_add("write", lambda *_args: self._render_transactions()) tree_frame = ttk.Frame(self, padding=(16, 0, 16, 8)) tree_frame.grid(row=2, column=0, sticky="nsew") tree_frame.columnconfigure(0, weight=1) tree_frame.rowconfigure(0, weight=1) self.tree = ttk.Treeview( tree_frame, columns=("date", "description", "memo", "amount", "status"), show="headings", selectmode="extended", ) for key, title, width in ( ("date", "Datum", 100), ("description", "Beschreibung", 280), ("memo", "Memo", 180), ("amount", "Betrag", 100), ("status", "Status", 220), ): self.tree.heading(key, text=title) self.tree.column(key, width=width, anchor="w", stretch=key in {"description", "memo"}) self.tree.grid(row=0, column=0, sticky="nsew") scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview) scrollbar.grid(row=0, column=1, sticky="ns") self.tree.configure(yscrollcommand=scrollbar.set) self.tree.tag_configure("duplicate", background="#7a2323", foreground="#ffffff") self.tree.bind("<>", lambda _event: self._update_summary()) ttk.Label( self, text=( "Es werden nur Buchungen mit positivem Betrag auf dem gewählten Konto angezeigt " "(eingehende Zahlungen). Mehrfachauswahl per Klick, Strg+Klick oder Umschalt+Klick. " "Rot markierte Buchungen haben Datum und Betrag einer bereits vorhandenen Zahlung " "dieses Mitglieds; beim Import kann gewählt werden, ob diese übersprungen werden " "oder ob die Beschreibung der vorhandenen Zahlung ersetzt wird." ), style="Mono.TLabel", wraplength=900, ).grid(row=3, column=0, sticky="w", padx=16) summary_frame = ttk.Frame(self, padding=(16, 8, 16, 8)) summary_frame.grid(row=4, column=0, sticky="ew") self.summary_var = tk.StringVar(value="Keine Datei geladen.") ttk.Label(summary_frame, textvariable=self.summary_var, style="Mono.TLabel").pack(side="left") buttons = ttk.Frame(self, padding=(16, 0, 16, 16)) buttons.grid(row=5, column=0, sticky="e") ttk.Button(buttons, text="Schließen", command=self.destroy).pack(side="left", padx=(0, 8)) self.import_button = ttk.Button( buttons, text="Ausgewählte importieren", style="Accent.TButton", command=self._import_selected, ) self.import_button.pack(side="left") def _load_existing_payment_keys(self) -> None: data = self.repository.get_contributions(self.member_id) self.existing_payment_by_key = {} for payment in data.payments: key = (str(payment.get("date", "")), money_text(payment.get("amount", "0"))) self.existing_payment_by_key.setdefault(key, str(payment.get("payment_id", ""))) def _browse_file(self) -> None: current_text = self.file_var.get().strip() current = Path(current_text).expanduser() if current_text else Path.home() initial_dir = current.parent if current.is_file() else current selected = filedialog.askopenfilename( parent=self, title="GnuCash-Datei auswählen", initialdir=str(initial_dir) if initial_dir.is_dir() else str(Path.home()), filetypes=[("GnuCash-Dateien", "*.gnucash *.xac"), ("Alle Dateien", "*.*")], ) if selected: self.file_var.set(selected) self._load_file() def _load_file(self) -> None: path_text = self.file_var.get().strip() if not path_text: messagebox.showinfo("GnuCash-Datei", "Bitte zuerst eine GnuCash-Datei auswählen.", parent=self) return path = Path(path_text).expanduser() if not path.is_file(): messagebox.showerror("GnuCash-Datei", "Die ausgewählte Datei existiert nicht.", parent=self) return try: self.accounts = list_accounts(path) except GnuCashImportError as exc: messagebox.showerror("GnuCash-Datei konnte nicht gelesen werden", str(exc), parent=self) return self.account_by_label = { f"{account.full_name} ({account.account_type})": account for account in self.accounts } self.account_combo.configure(values=list(self.account_by_label)) if self.account_by_label and self.account_var.get() not in self.account_by_label: self.account_var.set(self._preferred_account_label() or next(iter(self.account_by_label))) if self.account_var.get(): self._account_selected() else: self.transactions = [] self._render_transactions() if path_text != self.config.gnucash_path: self.config.gnucash_path = path_text self.config.save() def _current_file_key(self) -> str | None: path_text = self.file_var.get().strip() if not path_text: return None path = Path(path_text).expanduser() if not path.is_file(): return None return str(path.resolve()) def _preferred_account_label(self) -> str | None: file_key = self._current_file_key() if file_key is None: return None last_guid = self.config.gnucash_last_accounts.get(file_key) if not last_guid: return None return next( (label for label, account in self.account_by_label.items() if account.guid == last_guid), None, ) def _account_selected(self) -> None: account = self.account_by_label.get(self.account_var.get()) if account is None: return path = Path(self.file_var.get().strip()).expanduser() try: all_transactions = list_transactions(path, account.guid) except GnuCashImportError as exc: messagebox.showerror("GnuCash-Datei konnte nicht gelesen werden", str(exc), parent=self) self.transactions = [] self._render_transactions() return self.transactions = [item for item in all_transactions if item.amount > 0] self._render_transactions() file_key = self._current_file_key() if file_key is not None and self.config.gnucash_last_accounts.get(file_key) != account.guid: self.config.gnucash_last_accounts[file_key] = account.guid self.config.save() def _filtered_transactions(self) -> list[GnuCashTransaction]: needle = self.description_filter_var.get().strip().casefold() try: date_from = normalize_date_input(self.date_from_var.get(), "Von") or None except DateValidationError: date_from = None try: date_to = normalize_date_input(self.date_to_var.get(), "Bis") or None except DateValidationError: date_to = None result = [] for item in self.transactions: if needle and needle not in item.description.casefold() and needle not in item.memo.casefold(): continue iso = item.date.isoformat() if date_from and iso < date_from: continue if date_to and iso > date_to: continue result.append(item) return result def _duplicate_payment_id(self, item: GnuCashTransaction) -> str | None: return self.existing_payment_by_key.get((item.date.isoformat(), money_text(item.amount))) def _is_duplicate(self, item: GnuCashTransaction) -> bool: return self._duplicate_payment_id(item) is not None def _render_transactions(self) -> None: previously_selected = set(self.tree.selection()) self.tree.delete(*self.tree.get_children()) for item in self._filtered_transactions(): duplicate = self._is_duplicate(item) self.tree.insert( "", "end", iid=item.guid, values=( format_date_for_display(item.date.isoformat()), item.description, item.memo, f"{money_text(item.amount)} EUR", "Bereits vorhanden" if duplicate else "", ), tags=("duplicate",) if duplicate else (), ) still_present = [guid for guid in previously_selected if self.tree.exists(guid)] if still_present: self.tree.selection_set(still_present) self._update_summary() def _update_summary(self) -> None: filtered = self._filtered_transactions() selected_guids = set(self.tree.selection()) selected_items = [item for item in filtered if item.guid in selected_guids] total = sum((item.amount for item in selected_items), Decimal("0")) self.summary_var.set( f"{len(filtered)} Buchungen gefunden · {len(selected_items)} ausgewählt · " f"{money_text(total)} EUR" ) def _import_selected(self) -> None: selected_guids = set(self.tree.selection()) selected_items = [item for item in self.transactions if item.guid in selected_guids] if not selected_items: messagebox.showinfo("Import", "Bitte mindestens eine Buchung auswählen.", parent=self) return new_items = [item for item in selected_items if not self._is_duplicate(item)] duplicate_items = [item for item in selected_items if self._is_duplicate(item)] replace_description = False if duplicate_items: response = messagebox.askyesnocancel( "Bereits vorhandene Buchungen ausgewählt", ( f"{len(duplicate_items)} der ausgewählten Buchungen stimmen in Datum und Betrag " "mit bereits vorhandenen Zahlungen dieses Mitglieds überein.\n\n" "Ja: Diese werden beim Import übersprungen.\n" "Nein: Bei diesen wird stattdessen die Beschreibung der vorhandenen Zahlung " "durch die Beschreibung aus GnuCash ersetzt.\n" "Abbrechen: Es wird nichts importiert." ), parent=self, ) if response is None: return replace_description = not response imported = 0 updated = 0 processed_guids: set[str] = set() errors: list[str] = [] for item in new_items: try: self.repository.create_payment( self.member_id, payment_date=item.date.isoformat(), amount=money_text(item.amount), reference=item.description, gnucash_transaction_id=item.guid, ) imported += 1 processed_guids.add(item.guid) except RepositoryError as exc: errors.append( f"{format_date_for_display(item.date.isoformat())} · {money_text(item.amount)} EUR: {exc}" ) if replace_description: for item in duplicate_items: payment_id = self._duplicate_payment_id(item) if not payment_id: continue try: self.repository.update_payment_reference( self.member_id, payment_id, reference=item.description, gnucash_transaction_id=item.guid, ) updated += 1 processed_guids.add(item.guid) except RepositoryError as exc: errors.append( f"{format_date_for_display(item.date.isoformat())} · " f"{money_text(item.amount)} EUR: {exc}" ) if imported or updated: self._load_existing_payment_keys() self._render_transactions() still_selected = set(self.tree.selection()) - processed_guids self.tree.selection_set(list(still_selected)) summary_parts = [] if imported: summary_parts.append(f"{imported} Zahlung(en) importiert") if updated: summary_parts.append(f"{updated} Beschreibung(en) aktualisiert") skipped = len(duplicate_items) if not replace_description else 0 if skipped: summary_parts.append(f"{skipped} übersprungen (bereits vorhanden)") summary = ", ".join(summary_parts) or "Keine Buchung verarbeitet." if errors: messagebox.showwarning( "Import teilweise fehlgeschlagen", f"{summary}.\n\nFehler:\n" + "\n".join(errors), parent=self, ) else: messagebox.showinfo("Import abgeschlossen", f"{summary}.", parent=self) if imported or updated: self.on_imported()