Files
CCMA/tests/test_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

157 lines
5.6 KiB
Python

import gzip
from decimal import Decimal
import pytest
from ccma.services.gnucash_import import GnuCashImportError, list_accounts, list_transactions
SAMPLE_GNUCASH_XML = """<?xml version="1.0" encoding="utf-8"?>
<gnc-v2
xmlns:gnc="http://www.gnucash.org/XML/gnc"
xmlns:act="http://www.gnucash.org/XML/act"
xmlns:book="http://www.gnucash.org/XML/book"
xmlns:trn="http://www.gnucash.org/XML/trn"
xmlns:ts="http://www.gnucash.org/XML/ts"
xmlns:split="http://www.gnucash.org/XML/split">
<gnc:book version="2.0.0">
<book:id type="guid">book-1</book:id>
<gnc:account version="2.0.0">
<act:name>Root Account</act:name>
<act:id type="guid">root-guid</act:id>
<act:type>ROOT</act:type>
</gnc:account>
<gnc:account version="2.0.0">
<act:name>Aktiva</act:name>
<act:id type="guid">assets-guid</act:id>
<act:type>ASSET</act:type>
<act:parent type="guid">root-guid</act:parent>
</gnc:account>
<gnc:account version="2.0.0">
<act:name>Girokonto</act:name>
<act:id type="guid">bank-guid</act:id>
<act:type>BANK</act:type>
<act:parent type="guid">assets-guid</act:parent>
</gnc:account>
<gnc:account version="2.0.0">
<act:name>Beitraege</act:name>
<act:id type="guid">income-guid</act:id>
<act:type>INCOME</act:type>
<act:parent type="guid">root-guid</act:parent>
</gnc:account>
<gnc:transaction version="2.0.0">
<trn:id type="guid">tx-1</trn:id>
<trn:date-posted><ts:date>2026-06-21 10:59:00 +0200</ts:date></trn:date-posted>
<trn:description>Mitgliedsbeitrag Max Mustermann</trn:description>
<trn:splits>
<trn:split>
<split:id type="guid">split-1a</split:id>
<split:memo>Beitrag Juni</split:memo>
<split:value>15000/100</split:value>
<split:quantity>15000/100</split:quantity>
<split:account type="guid">bank-guid</split:account>
</trn:split>
<trn:split>
<split:id type="guid">split-1b</split:id>
<split:memo></split:memo>
<split:value>-15000/100</split:value>
<split:quantity>-15000/100</split:quantity>
<split:account type="guid">income-guid</split:account>
</trn:split>
</trn:splits>
</gnc:transaction>
<gnc:transaction version="2.0.0">
<trn:id type="guid">tx-2</trn:id>
<trn:date-posted><ts:date>2026-07-01 08:30:00 +0200</ts:date></trn:date-posted>
<trn:description>Bankgebuehr</trn:description>
<trn:splits>
<trn:split>
<split:id type="guid">split-2a</split:id>
<split:memo></split:memo>
<split:value>-500/100</split:value>
<split:quantity>-500/100</split:quantity>
<split:account type="guid">bank-guid</split:account>
</trn:split>
<trn:split>
<split:id type="guid">split-2b</split:id>
<split:memo></split:memo>
<split:value>500/100</split:value>
<split:quantity>500/100</split:quantity>
<split:account type="guid">income-guid</split:account>
</trn:split>
</trn:splits>
</gnc:transaction>
</gnc:book>
</gnc-v2>
"""
def _write_sample(tmp_path, *, gzipped: bool = False, suffix: str = ".gnucash"):
path = tmp_path / f"test{suffix}"
data = SAMPLE_GNUCASH_XML.encode("utf-8")
if gzipped:
data = gzip.compress(data)
path.write_bytes(data)
return path
def test_list_accounts_builds_hierarchical_full_names(tmp_path) -> None:
path = _write_sample(tmp_path)
accounts = list_accounts(path)
by_guid = {account.guid: account for account in accounts}
assert "root-guid" not in by_guid
assert by_guid["bank-guid"].full_name == "Aktiva:Girokonto"
assert by_guid["bank-guid"].account_type == "BANK"
assert by_guid["assets-guid"].full_name == "Aktiva"
assert by_guid["income-guid"].full_name == "Beitraege"
def test_list_transactions_returns_splits_for_selected_account(tmp_path) -> None:
path = _write_sample(tmp_path)
transactions = list_transactions(path, "bank-guid")
assert [item.guid for item in transactions] == ["tx-1", "tx-2"]
first = transactions[0]
assert first.date.isoformat() == "2026-06-21"
assert first.description == "Mitgliedsbeitrag Max Mustermann"
assert first.memo == "Beitrag Juni"
assert first.amount == Decimal("150.00")
second = transactions[1]
assert second.amount == Decimal("-5.00")
def test_list_transactions_only_includes_splits_on_the_requested_account(tmp_path) -> None:
path = _write_sample(tmp_path)
transactions = list_transactions(path, "income-guid")
assert [item.guid for item in transactions] == ["tx-1", "tx-2"]
assert transactions[0].amount == Decimal("-150.00")
assert transactions[1].amount == Decimal("5.00")
def test_gzip_compressed_gnucash_file_is_read_transparently(tmp_path) -> None:
path = _write_sample(tmp_path, gzipped=True, suffix=".gnucash")
accounts = list_accounts(path)
assert any(account.guid == "bank-guid" for account in accounts)
transactions = list_transactions(path, "bank-guid")
assert len(transactions) == 2
def test_invalid_xml_raises_gnucash_import_error(tmp_path) -> None:
path = tmp_path / "broken.gnucash"
path.write_text("not xml at all <<<", encoding="utf-8")
with pytest.raises(GnuCashImportError):
list_accounts(path)
def test_missing_file_raises_gnucash_import_error(tmp_path) -> None:
with pytest.raises(GnuCashImportError):
list_accounts(tmp_path / "does-not-exist.gnucash")
def test_file_without_book_raises_gnucash_import_error(tmp_path) -> None:
path = tmp_path / "empty.gnucash"
path.write_text('<?xml version="1.0"?><gnc-v2></gnc-v2>', encoding="utf-8")
with pytest.raises(GnuCashImportError, match="gnc:book"):
list_accounts(path)