Files
CCMA/src/ccma/services/gnucash_import.py
T
Marcel PeterkauandClaude Sonnet 5 745e634a8b Import payments per member from a GnuCash file
Adds a "Zahlungen importieren" button to the member Zahlungen tab that opens
a dedicated window: point it at a GnuCash file (plain or gzip-compressed
XML, defaults to the file configured in Optionen), pick one of its accounts,
and narrow the account's bookings down with a description-contains filter
and an optional date range.

Each matching booking gets a checkbox to mark it for import as a payment.
Before anything is ticked, bookings are cross-checked against this member's
existing payments by date + amount; a match is highlighted and its checkbox
is refused, so re-importing the same statement can't create a duplicate
payment. Only bookings with a positive amount on the selected account are
offered, since those are the ones that make sense as an incoming payment.

Parsing lives in ccma.services.gnucash_import, independent of the UI, and
is covered by tests against a synthetic GnuCash XML fixture (plain and
gzip-compressed) -- no gnucash/piecash dependency needed since the native
file format is just XML.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:02:37 +02:00

170 lines
5.4 KiB
Python

from __future__ import annotations
import gzip
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path
from xml.etree import ElementTree as ET
NS = {
"gnc": "http://www.gnucash.org/XML/gnc",
"act": "http://www.gnucash.org/XML/act",
"trn": "http://www.gnucash.org/XML/trn",
"split": "http://www.gnucash.org/XML/split",
"ts": "http://www.gnucash.org/XML/ts",
}
CENT = Decimal("0.01")
class GnuCashImportError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class GnuCashAccount:
guid: str
name: str
full_name: str
account_type: str
@dataclass(frozen=True, slots=True)
class GnuCashTransaction:
guid: str
date: date
description: str
memo: str
amount: Decimal
def _read_root(path: Path) -> ET.Element:
try:
raw = path.read_bytes()
except OSError as exc:
raise GnuCashImportError(f"GnuCash-Datei konnte nicht gelesen werden: {exc}") from exc
if raw[:2] == b"\x1f\x8b":
try:
raw = gzip.decompress(raw)
except OSError as exc:
raise GnuCashImportError(f"GnuCash-Datei konnte nicht entpackt werden: {exc}") from exc
try:
return ET.fromstring(raw)
except ET.ParseError as exc:
raise GnuCashImportError(f"GnuCash-Datei ist kein gültiges XML: {exc}") from exc
def _book(root: ET.Element) -> ET.Element:
book = root.find("gnc:book", NS)
if book is None:
raise GnuCashImportError("Keine GnuCash-Buchführung (gnc:book) in der Datei gefunden.")
return book
def _text(element: ET.Element, path: str) -> str:
return (element.findtext(path, default="", namespaces=NS) or "").strip()
def list_accounts(path: str | Path) -> list[GnuCashAccount]:
root = _read_root(Path(path))
book = _book(root)
by_guid: dict[str, ET.Element] = {}
parent_of: dict[str, str] = {}
for account_el in book.findall("gnc:account", NS):
guid = _text(account_el, "act:id")
if not guid:
continue
by_guid[guid] = account_el
parent_of[guid] = _text(account_el, "act:parent")
if not by_guid:
raise GnuCashImportError("Die Datei enthält keine Konten.")
def full_name(guid: str, seen: frozenset[str]) -> str:
account_el = by_guid.get(guid)
if account_el is None or guid in seen or _text(account_el, "act:type") == "ROOT":
return ""
name = _text(account_el, "act:name")
parent = parent_of.get(guid, "")
if not parent:
return name
parent_name = full_name(parent, seen | {guid})
return f"{parent_name}:{name}" if parent_name else name
accounts = []
for guid, account_el in by_guid.items():
account_type = _text(account_el, "act:type")
if account_type in {"ROOT", ""}:
continue
accounts.append(
GnuCashAccount(
guid=guid,
name=_text(account_el, "act:name"),
full_name=full_name(guid, frozenset()) or _text(account_el, "act:name"),
account_type=account_type,
)
)
accounts.sort(key=lambda item: item.full_name.casefold())
return accounts
def _parse_fraction(text: str) -> Decimal | None:
text = text.strip()
if "/" not in text:
return None
numerator, _, denominator = text.partition("/")
try:
denom = Decimal(denominator)
if denom == 0:
return None
return (Decimal(numerator) / denom).quantize(CENT)
except InvalidOperation:
return None
def _parse_posted_date(text: str) -> date | None:
text = text.strip()
if not text:
return None
for fmt in ("%Y-%m-%d %H:%M:%S %z", "%Y-%m-%d"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
def list_transactions(path: str | Path, account_guid: str) -> list[GnuCashTransaction]:
"""Return every booking that has a split on the given account, one row per
matching split (a transaction with two splits on the same account, e.g. a
transfer to itself, yields two rows -- rare in practice, harmless here)."""
root = _read_root(Path(path))
book = _book(root)
results: list[GnuCashTransaction] = []
for transaction_el in book.findall("gnc:transaction", NS):
guid = _text(transaction_el, "trn:id")
posted_date = _parse_posted_date(_text(transaction_el, "trn:date-posted/ts:date"))
if not guid or posted_date is None:
continue
description = _text(transaction_el, "trn:description")
splits_el = transaction_el.find("trn:splits", NS)
if splits_el is None:
continue
for split_el in splits_el.findall("trn:split", NS):
if _text(split_el, "split:account") != account_guid:
continue
amount = _parse_fraction(_text(split_el, "split:value"))
if amount is None:
continue
results.append(
GnuCashTransaction(
guid=guid,
date=posted_date,
description=description,
memo=_text(split_el, "split:memo"),
amount=amount,
)
)
results.sort(key=lambda item: (item.date, item.description.casefold()))
return results