mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +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>
157 lines
5.6 KiB
Python
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)
|