From 65bcc9f4473b0884b5663bf030c052e563ca6eec Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Fri, 14 Aug 2026 23:11:44 +0200 Subject: [PATCH 1/4] Fix column order/widths and Chronik pane share in the member view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forderungen: reorder columns to Fällig, Betrag, Status, Forderung. Zahlungen and Spenden already had the requested order. Across all three tables, the date/amount-like columns now share one fixed width, the status column is a third wider (it tends to hold longer label text), and only the trailing description/reference column stretches to fill the remaining space. The Chronik pane now defaults to a third of the member view's width instead of three fifths, matching the other panes. Co-Authored-By: Claude Sonnet 5 --- src/ccma/ui/member_tab.py | 46 +++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index 12321b7..2eed828 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -37,11 +37,18 @@ from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog from ccma.ui.scrolling import ScrollableFrame +# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date- +# and amount-like columns get the same fixed width, status columns get a third more +# (they tend to hold longer label text), and only the trailing description/reference +# column stretches to take up whatever space remains. +FIXED_COLUMN_WIDTH = 90 +STATUS_COLUMN_WIDTH = round(FIXED_COLUMN_WIDTH * 4 / 3) + CLAIM_TABLE_COLUMNS = ( + ("due", "Fällig", FIXED_COLUMN_WIDTH), + ("amount", "Betrag", FIXED_COLUMN_WIDTH), + ("status", "Status", STATUS_COLUMN_WIDTH), ("title", "Forderung", 220), - ("due", "Fällig", 100), - ("amount", "Betrag", 90), - ("status", "Status", 110), ) @@ -127,7 +134,7 @@ class MemberTab(ttk.Frame): self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0)) self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0)) self.pane.add(self.details_pane, weight=2) - self.pane.add(self.timeline_pane, weight=3) + self.pane.add(self.timeline_pane, weight=1) self._build_details(self.details_pane) self._build_timeline(self.timeline_pane) self._pane_position_initialized = False @@ -139,7 +146,8 @@ class MemberTab(ttk.Frame): try: width = int(getattr(event, "width", 0)) or self.pane.winfo_width() if width > 1: - self.pane.sashpos(0, max(360, int(width * 0.4))) + # Chronik (timeline_pane) defaults to a third of the available width. + self.pane.sashpos(0, max(360, round(width * 2 / 3))) self._pane_position_initialized = True except tk.TclError: return @@ -283,13 +291,13 @@ class MemberTab(ttk.Frame): row=0, column=0, sticky="w", pady=(0, 10) ) self.claims = ttk.Treeview( - contribution_tab, columns=("title", "due", "amount", "status"), show="headings" + contribution_tab, columns=("due", "amount", "status", "title"), show="headings" ) self.claim_sort_column = "due" self.claim_sort_descending = False for key, title, width in CLAIM_TABLE_COLUMNS: self.claims.heading(key, text=title, command=lambda column=key: self._toggle_claim_sort(column)) - self.claims.column(key, width=width, anchor="w") + self.claims.column(key, width=width, anchor="w", stretch=key == "title") self.claims.grid(row=1, column=0, sticky="nsew") self.claims.bind("", lambda _event: self._open_selected_claim()) self.claims.bind("", lambda _event: self._open_selected_claim()) @@ -307,14 +315,14 @@ class MemberTab(ttk.Frame): selectmode="browse", ) for key, title, width in ( - ("date", "Datum", 100), - ("amount", "Betrag", 90), - ("allocated", "Zugeordnet", 90), - ("available", "Frei", 90), + ("date", "Datum", FIXED_COLUMN_WIDTH), + ("amount", "Betrag", FIXED_COLUMN_WIDTH), + ("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH), + ("available", "Frei", FIXED_COLUMN_WIDTH), ("reference", "Referenz", 320), ): self.payments.heading(key, text=title) - self.payments.column(key, width=width, anchor="w") + self.payments.column(key, width=width, anchor="w", stretch=key == "reference") self.payments.grid(row=1, column=0, sticky="nsew") self.payments.bind("", lambda _event: self._edit_selected_payment()) self.payments.bind("", lambda _event: self._edit_selected_payment()) @@ -347,15 +355,15 @@ class MemberTab(ttk.Frame): selectmode="browse", ) for key, title, width in ( - ("date", "Datum", 100), - ("amount", "Betrag", 90), - ("allocated", "Zugeordnet", 90), - ("balance", "Offen", 90), - ("status", "Status", 150), + ("date", "Datum", FIXED_COLUMN_WIDTH), + ("amount", "Betrag", FIXED_COLUMN_WIDTH), + ("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH), + ("balance", "Offen", FIXED_COLUMN_WIDTH), + ("status", "Status", STATUS_COLUMN_WIDTH), ("reference", "Referenz / Zweck", 260), ): self.donations.heading(key, text=title) - self.donations.column(key, width=width, anchor="w") + self.donations.column(key, width=width, anchor="w", stretch=key == "reference") self.donations.grid(row=1, column=0, sticky="nsew") self.donations.bind("", lambda _event: self._edit_selected_donation()) self.donations.bind("", lambda _event: self._edit_selected_donation()) @@ -602,10 +610,10 @@ class MemberTab(ttk.Frame): "end", iid=claim_id, values=( - claim.get("title", "Beitrag"), format_date_for_display(str(claim.get("due_date", ""))), money_text(claim_total(claim)), CLAIM_STATUS_LABELS.get(status, status.upper()), + claim.get("title", "Beitrag"), ), ) for payment in sorted( From e25accd35bf445778de0c3a19952ec1fa48c23be Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Fri, 14 Aug 2026 23:26:07 +0200 Subject: [PATCH 2/4] Fix column order/widths and sizing in Hausmeister and GnuCash import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hausmeister: reorder columns to Level, Fällig, Vorgang, Details. Level, Fällig and Vorgang get a fixed width; only Details stretches to fill the remaining space. GnuCash-Import dialog: opens at 80% of the current monitor's size (monitor-aware via the existing screeninfo-based helpers, not just the raw/virtual screen size) instead of a fixed 960x640. In its booking table, Import, Datum, Betrag and Status get a fixed width; Beschreibung and Memo share the remaining space. Co-Authored-By: Claude Sonnet 5 --- src/ccma/ui/gnucash_import_dialog.py | 8 +++++--- src/ccma/ui/work_tabs.py | 8 ++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/ccma/ui/gnucash_import_dialog.py b/src/ccma/ui/gnucash_import_dialog.py index 0fd75d4..5b4fd0e 100644 --- a/src/ccma/ui/gnucash_import_dialog.py +++ b/src/ccma/ui/gnucash_import_dialog.py @@ -22,6 +22,7 @@ from ccma.services.gnucash_import import ( list_transactions, ) from ccma.storage.repository import MemberRepository, RepositoryError +from ccma.ui.monitors import centered_geometry, preferred_monitor class GnuCashImportDialog(tk.Toplevel): @@ -55,7 +56,8 @@ class GnuCashImportDialog(tk.Toplevel): self.title("Zahlungen aus GnuCash importieren") self.transient(master.winfo_toplevel()) - self.geometry("960x640") + 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()) @@ -134,8 +136,8 @@ class GnuCashImportDialog(tk.Toplevel): ("status", "Status", 220), ): self.tree.heading(key, text=title) - self.tree.column(key, width=width, anchor="w") - self.tree.column("selected", anchor="center", stretch=False) + 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") diff --git a/src/ccma/ui/work_tabs.py b/src/ccma/ui/work_tabs.py index 9c8b3f6..3e27e14 100644 --- a/src/ccma/ui/work_tabs.py +++ b/src/ccma/ui/work_tabs.py @@ -597,15 +597,15 @@ class HousekeeperTab(ttk.Frame): row=0, column=1, rowspan=2, padx=(0, 8) ) ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=2, rowspan=2) - self.tree = ttk.Treeview(self, columns=("severity", "title", "detail", "due"), show="headings") + self.tree = ttk.Treeview(self, columns=("severity", "due", "title", "detail"), show="headings") for key, title, width in ( ("severity", "Level", 90), + ("due", "Fällig", 100), ("title", "Vorgang", 330), ("detail", "Details", 390), - ("due", "Fällig", 110), ): self.tree.heading(key, text=title) - self.tree.column(key, width=width, anchor="w") + self.tree.column(key, width=width, anchor="w", stretch=key == "detail") self.tree.grid(row=1, column=0, sticky="nsew") self.tree.bind("", lambda _event: self._open_selected()) self.tree.bind("<>", lambda _event: self._show_selected_details()) @@ -649,7 +649,7 @@ class HousekeeperTab(ttk.Frame): "", "end", iid=str(index), - values=(finding.severity.upper(), finding.title, finding.detail, finding.due_date or ""), + values=(finding.severity.upper(), finding.due_date or "", finding.title, finding.detail), ) def _show_selected_details(self) -> None: From 195ae0e228ce7de457931a07e7186d1fff60609e Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Fri, 14 Aug 2026 23:43:55 +0200 Subject: [PATCH 3/4] Replace the GnuCash import checkbox column with native multi-select Selecting bookings to import now uses the Treeview's own multi-selection (click, Ctrl+click, Shift+click for ranges) instead of a dedicated checkbox column that had to be clicked precisely -- more standard and much faster for marking many rows at once. Bookings matching an existing payment's date+amount are no longer blocked from selection; they're still flagged (red row, "Bereits vorhanden"). If any selected booking is such a duplicate, importing now asks whether to skip those or instead adopt the booking's description onto the already-recorded payment. That relabeling is handled by a new repository.update_payment_reference, which only touches the reference and gnucash_transaction_id fields, leaving date/amount/allocations untouched. Co-Authored-By: Claude Sonnet 5 --- src/ccma/storage/repository.py | 38 +++++++ src/ccma/ui/gnucash_import_dialog.py | 143 +++++++++++++++++---------- tests/test_contributions.py | 65 ++++++++++++ 3 files changed, 196 insertions(+), 50 deletions(-) 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( From 52904a29b449ceb45b8f7559b12e59fbdca098c2 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Fri, 14 Aug 2026 23:56:51 +0200 Subject: [PATCH 4/4] Document the table/pane usability tuning and GnuCash import improvements Co-Authored-By: Claude Sonnet 5 --- src/ccma/assets/CHANGELOG.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 29dca94..35db039 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -23,7 +23,10 @@ "Ein neuer Spenden-Tab je Mitglied erfasst Beträge, die über den Mitgliedsbeitrag hinausgehen, und erlaubt die Zuordnung vorhandener oder neuer Zahlungen zu einer Spende.", "Beim Anlegen oder Bearbeiten einer Zahlung lassen sich offene Forderungen und Spenden direkt im selben Fenster live auswählen und mit Beträgen zuordnen, inklusive der Möglichkeit, dort sofort eine neue Spende anzulegen.", "Die Zuordnungsübersicht im Zahlungsfenster zeigt nur noch offene oder bereits zugeordnete Forderungen und Spenden; entfernte Zuordnungen lassen sich innerhalb desselben Fensters wieder herstellen.", - "Der Hausmeister meldet eine überfällige Forderung nicht mehr doppelt als eigene Überfällig- und Mahnungsmeldung; bei Mitgliedern mit aktivem Lastschriftmandat erscheint statt einer Mahnung ein Hinweis, dass der Lastschrifteinzug geprüft werden sollte." + "Der Hausmeister meldet eine überfällige Forderung nicht mehr doppelt als eigene Überfällig- und Mahnungsmeldung; bei Mitgliedern mit aktivem Lastschriftmandat erscheint statt einer Mahnung ein Hinweis, dass der Lastschrifteinzug geprüft werden sollte.", + "Die Spaltenbreiten in den Tabellen für Forderungen, Zahlungen, Spenden und Hausmeister-Vorgänge sind jetzt einheitlich: Datum-, Betrag- und Statusspalten haben eine feste Breite, nur die abschließende Beschreibungs- bzw. Referenzspalte passt sich dynamisch an die Fensterbreite an.", + "Die Chronik im Mitgliedsfenster nimmt standardmäßig ein Drittel der Fensterbreite ein.", + "Der GnuCash-Import öffnet sich mit 80 % der Bildschirmgröße und erlaubt die Mehrfachauswahl von Buchungen per Klick, Strg+Klick oder Umschalt+Klick; Buchungen, die einer bereits vorhandenen Zahlung entsprechen, können beim Import übersprungen oder mit der Beschreibung aus GnuCash aktualisiert werden." ] }, {