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 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-14 23:43:55 +02:00
co-authored by Claude Sonnet 5
parent e25accd35b
commit 195ae0e228
3 changed files with 196 additions and 50 deletions
+38
View File
@@ -1426,6 +1426,44 @@ class MemberRepository:
) )
return payment 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: def delete_payment(self, member_id: str, payment_id: str) -> None:
data = self.get_contributions(member_id) data = self.get_contributions(member_id)
payment = next( payment = next(
+93 -50
View File
@@ -27,12 +27,14 @@ from ccma.ui.monitors import centered_geometry, preferred_monitor
class GnuCashImportDialog(tk.Toplevel): class GnuCashImportDialog(tk.Toplevel):
"""Lets the board point at a GnuCash file, pick one account, narrow its bookings """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 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 coming in). A booking whose date + amount already match an existing payment for
for this member is flagged and cannot be checked -- avoiding accidental double this member is flagged, but can still be selected -- at import time, the board is
imports if the same statement is imported twice.""" asked whether to skip those or instead adopt the booking's description onto the
already-recorded payment, rather than silently blocking them."""
def __init__( def __init__(
self, self,
@@ -51,8 +53,7 @@ class GnuCashImportDialog(tk.Toplevel):
self.accounts: list[GnuCashAccount] = [] self.accounts: list[GnuCashAccount] = []
self.account_by_label: dict[str, GnuCashAccount] = {} self.account_by_label: dict[str, GnuCashAccount] = {}
self.transactions: list[GnuCashTransaction] = [] self.transactions: list[GnuCashTransaction] = []
self.selected_guids: set[str] = set() self.existing_payment_by_key: dict[tuple[str, str], str] = {}
self.existing_payment_keys: set[tuple[str, str]] = set()
self.title("Zahlungen aus GnuCash importieren") self.title("Zahlungen aus GnuCash importieren")
self.transient(master.winfo_toplevel()) self.transient(master.winfo_toplevel())
@@ -123,12 +124,11 @@ class GnuCashImportDialog(tk.Toplevel):
tree_frame.rowconfigure(0, weight=1) tree_frame.rowconfigure(0, weight=1)
self.tree = ttk.Treeview( self.tree = ttk.Treeview(
tree_frame, tree_frame,
columns=("selected", "date", "description", "memo", "amount", "status"), columns=("date", "description", "memo", "amount", "status"),
show="headings", show="headings",
selectmode="browse", selectmode="extended",
) )
for key, title, width in ( for key, title, width in (
("selected", "Import", 60),
("date", "Datum", 100), ("date", "Datum", 100),
("description", "Beschreibung", 280), ("description", "Beschreibung", 280),
("memo", "Memo", 180), ("memo", "Memo", 180),
@@ -137,20 +137,21 @@ class GnuCashImportDialog(tk.Toplevel):
): ):
self.tree.heading(key, text=title) self.tree.heading(key, text=title)
self.tree.column(key, width=width, anchor="w", stretch=key in {"description", "memo"}) 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") self.tree.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview) scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
scrollbar.grid(row=0, column=1, sticky="ns") scrollbar.grid(row=0, column=1, sticky="ns")
self.tree.configure(yscrollcommand=scrollbar.set) self.tree.configure(yscrollcommand=scrollbar.set)
self.tree.tag_configure("duplicate", background="#7a2323", foreground="#ffffff") self.tree.tag_configure("duplicate", background="#7a2323", foreground="#ffffff")
self.tree.bind("<Button-1>", self._on_tree_click) self.tree.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
ttk.Label( ttk.Label(
self, self,
text=( text=(
"Es werden nur Buchungen mit positivem Betrag auf dem gewählten Konto angezeigt " "Es werden nur Buchungen mit positivem Betrag auf dem gewählten Konto angezeigt "
"(eingehende Zahlungen). Rot markierte Buchungen haben Datum und Betrag einer bereits " "(eingehende Zahlungen). Mehrfachauswahl per Klick, Strg+Klick oder Umschalt+Klick. "
"vorhandenen Zahlung dieses Mitglieds und können nicht erneut importiert werden." "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", style="Mono.TLabel",
wraplength=900, wraplength=900,
@@ -174,10 +175,10 @@ class GnuCashImportDialog(tk.Toplevel):
def _load_existing_payment_keys(self) -> None: def _load_existing_payment_keys(self) -> None:
data = self.repository.get_contributions(self.member_id) data = self.repository.get_contributions(self.member_id)
self.existing_payment_keys = { self.existing_payment_by_key = {}
(str(payment.get("date", "")), money_text(payment.get("amount", "0"))) for payment in data.payments:
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: def _browse_file(self) -> None:
current_text = self.file_var.get().strip() 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)) 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: 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.account_var.set(self._preferred_account_label() or next(iter(self.account_by_label)))
self.selected_guids.clear()
if self.account_var.get(): if self.account_var.get():
self._account_selected() self._account_selected()
else: else:
@@ -257,7 +257,6 @@ class GnuCashImportDialog(tk.Toplevel):
self._render_transactions() self._render_transactions()
return return
self.transactions = [item for item in all_transactions if item.amount > 0] self.transactions = [item for item in all_transactions if item.amount > 0]
self.selected_guids.clear()
self._render_transactions() self._render_transactions()
file_key = self._current_file_key() file_key = self._current_file_key()
if file_key is not None and self.config.gnucash_last_accounts.get(file_key) != account.guid: 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) result.append(item)
return result 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: 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: def _render_transactions(self) -> None:
previously_selected = set(self.tree.selection())
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
for item in self._filtered_transactions(): for item in self._filtered_transactions():
duplicate = self._is_duplicate(item) duplicate = self._is_duplicate(item)
if duplicate:
self.selected_guids.discard(item.guid)
checked = item.guid in self.selected_guids
self.tree.insert( self.tree.insert(
"", "",
"end", "end",
iid=item.guid, iid=item.guid,
values=( values=(
"" if checked else "",
format_date_for_display(item.date.isoformat()), format_date_for_display(item.date.isoformat()),
item.description, item.description,
item.memo, item.memo,
@@ -310,25 +309,15 @@ class GnuCashImportDialog(tk.Toplevel):
), ),
tags=("duplicate",) 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() 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: def _update_summary(self) -> None:
filtered = self._filtered_transactions() 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")) total = sum((item.amount for item in selected_items), Decimal("0"))
self.summary_var.set( self.summary_var.set(
f"{len(filtered)} Buchungen gefunden · {len(selected_items)} ausgewählt · " f"{len(filtered)} Buchungen gefunden · {len(selected_items)} ausgewählt · "
@@ -336,17 +325,38 @@ class GnuCashImportDialog(tk.Toplevel):
) )
def _import_selected(self) -> None: def _import_selected(self) -> None:
selected_items = [ selected_guids = set(self.tree.selection())
item selected_items = [item for item in self.transactions if item.guid in selected_guids]
for item in self.transactions
if item.guid in self.selected_guids and not self._is_duplicate(item)
]
if not selected_items: if not selected_items:
messagebox.showinfo("Import", "Bitte mindestens eine Buchung auswählen.", parent=self) messagebox.showinfo("Import", "Bitte mindestens eine Buchung auswählen.", parent=self)
return 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 imported = 0
updated = 0
processed_guids: set[str] = set()
errors: list[str] = [] errors: list[str] = []
for item in selected_items: for item in new_items:
try: try:
self.repository.create_payment( self.repository.create_payment(
self.member_id, self.member_id,
@@ -356,21 +366,54 @@ class GnuCashImportDialog(tk.Toplevel):
gnucash_transaction_id=item.guid, gnucash_transaction_id=item.guid,
) )
imported += 1 imported += 1
self.selected_guids.discard(item.guid) processed_guids.add(item.guid)
except RepositoryError as exc: except RepositoryError as exc:
errors.append( errors.append(
f"{format_date_for_display(item.date.isoformat())} · {money_text(item.amount)} EUR: {exc}" 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._load_existing_payment_keys()
self._render_transactions() 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: if errors:
messagebox.showwarning( messagebox.showwarning(
"Import teilweise fehlgeschlagen", "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, parent=self,
) )
else: else:
messagebox.showinfo("Import abgeschlossen", f"{imported} Zahlung(en) importiert.", parent=self) messagebox.showinfo("Import abgeschlossen", f"{summary}.", parent=self)
if imported: if imported or updated:
self.on_imported() self.on_imported()
+65
View File
@@ -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" 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: def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None:
claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"} claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"}
data = ContributionData( data = ContributionData(