diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index 3bdc5cb..826002a 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -1426,6 +1426,44 @@ class MemberRepository: ) return payment + def update_payment_reference( + self, + member_id: str, + payment_id: str, + *, + reference: str, + gnucash_transaction_id: str = "", + actor_name: str = "Vorstand", + ) -> dict: + """Relabel an existing payment without touching its date, amount, or + allocations -- used e.g. when a GnuCash import recognizes a booking as + matching an already-recorded payment and the board wants to adopt the + (better) description from the statement instead of re-importing it.""" + data = self.get_contributions(member_id) + payment = next( + (item for item in data.payments if str(item.get("payment_id", "")) == payment_id), + None, + ) + if payment is None: + raise RepositoryError("Zahlung nicht gefunden.") + gnucash_id = gnucash_transaction_id.strip() + if gnucash_id and gnucash_id != str(payment.get("gnucash_transaction_id", "")): + self._assert_gnucash_id_available(gnucash_id, exclude_payment_id=payment_id) + payment["reference"] = reference.strip() + if gnucash_id: + payment["gnucash_transaction_id"] = gnucash_id + payment["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds") + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="payment_changed", + summary=f"Zahlung geändert: Referenz aktualisiert ({payment['reference']})", + actor_type="user", + actor_name=actor_name, + references={"payment_id": payment_id}, + ) + return payment + def delete_payment(self, member_id: str, payment_id: str) -> None: data = self.get_contributions(member_id) payment = next( diff --git a/src/ccma/ui/gnucash_import_dialog.py b/src/ccma/ui/gnucash_import_dialog.py index 5b4fd0e..9fa1853 100644 --- a/src/ccma/ui/gnucash_import_dialog.py +++ b/src/ccma/ui/gnucash_import_dialog.py @@ -27,12 +27,14 @@ 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 tick which ones to import as payments for this member. + 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), and any booking whose date + amount already match an existing payment - for this member is flagged and cannot be checked -- avoiding accidental double - imports if the same statement is imported twice.""" + 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, @@ -51,8 +53,7 @@ class GnuCashImportDialog(tk.Toplevel): self.accounts: list[GnuCashAccount] = [] self.account_by_label: dict[str, GnuCashAccount] = {} self.transactions: list[GnuCashTransaction] = [] - self.selected_guids: set[str] = set() - self.existing_payment_keys: set[tuple[str, str]] = set() + self.existing_payment_by_key: dict[tuple[str, str], str] = {} self.title("Zahlungen aus GnuCash importieren") self.transient(master.winfo_toplevel()) @@ -123,12 +124,11 @@ class GnuCashImportDialog(tk.Toplevel): tree_frame.rowconfigure(0, weight=1) self.tree = ttk.Treeview( tree_frame, - columns=("selected", "date", "description", "memo", "amount", "status"), + columns=("date", "description", "memo", "amount", "status"), show="headings", - selectmode="browse", + selectmode="extended", ) for key, title, width in ( - ("selected", "Import", 60), ("date", "Datum", 100), ("description", "Beschreibung", 280), ("memo", "Memo", 180), @@ -137,20 +137,21 @@ class GnuCashImportDialog(tk.Toplevel): ): self.tree.heading(key, text=title) self.tree.column(key, width=width, anchor="w", stretch=key in {"description", "memo"}) - self.tree.column("selected", anchor="center") 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("", self._on_tree_click) + 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). Rot markierte Buchungen haben Datum und Betrag einer bereits " - "vorhandenen Zahlung dieses Mitglieds und können nicht erneut importiert werden." + "(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, @@ -174,10 +175,10 @@ class GnuCashImportDialog(tk.Toplevel): def _load_existing_payment_keys(self) -> None: data = self.repository.get_contributions(self.member_id) - self.existing_payment_keys = { - (str(payment.get("date", "")), money_text(payment.get("amount", "0"))) - for payment in data.payments - } + 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() @@ -213,7 +214,6 @@ class GnuCashImportDialog(tk.Toplevel): 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))) - self.selected_guids.clear() if self.account_var.get(): self._account_selected() else: @@ -257,7 +257,6 @@ class GnuCashImportDialog(tk.Toplevel): self._render_transactions() return self.transactions = [item for item in all_transactions if item.amount > 0] - self.selected_guids.clear() 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: @@ -286,22 +285,22 @@ class GnuCashImportDialog(tk.Toplevel): 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 (item.date.isoformat(), money_text(item.amount)) in self.existing_payment_keys + 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) - if duplicate: - self.selected_guids.discard(item.guid) - checked = item.guid in self.selected_guids self.tree.insert( "", "end", iid=item.guid, values=( - "☑" if checked else "☐", format_date_for_display(item.date.isoformat()), item.description, item.memo, @@ -310,25 +309,15 @@ class GnuCashImportDialog(tk.Toplevel): ), 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 _on_tree_click(self, event: tk.Event) -> None: - row_id = self.tree.identify_row(event.y) - column = self.tree.identify_column(event.x) - if not row_id or column != "#1": - return - item = next((entry for entry in self.transactions if entry.guid == row_id), None) - if item is None or self._is_duplicate(item): - return - if row_id in self.selected_guids: - self.selected_guids.discard(row_id) - else: - self.selected_guids.add(row_id) - self._render_transactions() - def _update_summary(self) -> None: filtered = self._filtered_transactions() - selected_items = [item for item in filtered if item.guid in self.selected_guids] + 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 · " @@ -336,17 +325,38 @@ class GnuCashImportDialog(tk.Toplevel): ) def _import_selected(self) -> None: - selected_items = [ - item - for item in self.transactions - if item.guid in self.selected_guids and not self._is_duplicate(item) - ] + 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 selected_items: + for item in new_items: try: self.repository.create_payment( self.member_id, @@ -356,21 +366,54 @@ class GnuCashImportDialog(tk.Toplevel): gnucash_transaction_id=item.guid, ) imported += 1 - self.selected_guids.discard(item.guid) + 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 imported: + 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"{imported} Zahlung(en) importiert.\n\nNicht importiert:\n" + "\n".join(errors), + f"{summary}.\n\nFehler:\n" + "\n".join(errors), parent=self, ) else: - messagebox.showinfo("Import abgeschlossen", f"{imported} Zahlung(en) importiert.", parent=self) - if imported: + messagebox.showinfo("Import abgeschlossen", f"{summary}.", parent=self) + if imported or updated: self.on_imported() diff --git a/tests/test_contributions.py b/tests/test_contributions.py index d389e2d..7113d6e 100644 --- a/tests/test_contributions.py +++ b/tests/test_contributions.py @@ -251,6 +251,71 @@ def test_payment_can_be_deleted_with_its_allocations(tmp_path) -> None: assert repository.get_events(member.member_id)[-1].event_type == "payment_deleted" +def test_payment_reference_can_be_replaced_without_touching_amount_or_allocations(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path) + payment = repository.record_payment( + member.member_id, + "claim-1", + payment_date="2026-06-21", + amount="10.00", + allocation_amount="10.00", + reference="Alte Referenz", + ) + + updated = repository.update_payment_reference( + member.member_id, + payment["payment_id"], + reference="Neue Referenz aus GnuCash", + gnucash_transaction_id="TX-99", + ) + + data = repository.get_contributions(member.member_id) + assert updated["reference"] == "Neue Referenz aus GnuCash" + assert updated["gnucash_transaction_id"] == "TX-99" + assert data.payments[0]["amount"] == "10.00" + assert payment_allocated_total(data, payment["payment_id"]) == Decimal("10.00") + assert repository.get_events(member.member_id)[-1].event_type == "payment_changed" + + +def test_payment_reference_update_rejects_gnucash_id_already_used_elsewhere(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path) + repository.record_payment( + member.member_id, + "claim-1", + payment_date="2026-06-21", + amount="10.00", + allocation_amount="10.00", + gnucash_transaction_id="TX-1", + ) + data = repository.get_contributions(member.member_id) + data.claims.append( + { + "claim_id": "claim-2", + "claim_key": "second-claim", + "title": "Zweite Forderung", + "amount": "20.00", + "due_date": "2026-12-31", + "status": "open", + } + ) + repository.save_contributions(member.member_id, data) + second_payment = repository.record_payment( + member.member_id, + "claim-2", + payment_date="2026-06-22", + amount="20.00", + allocation_amount="20.00", + ) + + with pytest.raises(RepositoryError, match="GnuCash-ID bereits verwendet"): + repository.update_payment_reference( + member.member_id, + second_payment["payment_id"], + reference="Duplikatversuch", + gnucash_transaction_id="TX-1", + ) + + def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None: claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"} data = ContributionData(