mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e7a18b5cde
commit
745e634a8b
@@ -0,0 +1,169 @@
|
||||
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
|
||||
Reference in New Issue
Block a user