mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
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>
170 lines
5.4 KiB
Python
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
|