beangulp-yapeal 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ site/
10
+ .env
11
+ .DS_Store
12
+ .claude/settings.local.json
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: beangulp-yapeal
3
+ Version: 0.1.0
4
+ Summary: beangulp importer for Yapeal CSV bank statements: drafts transactions with stable bank references for idempotent re-imports
5
+ Keywords: bank-statement,beancount,beangulp,csv,importer,yapeal
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: beancount>=3.0
8
+ Requires-Dist: beangulp>=0.2
9
+ Description-Content-Type: text/markdown
10
+
11
+ # beangulp-yapeal
12
+
13
+ beangulp importer for Yapeal CSV bank statements.
@@ -0,0 +1,3 @@
1
+ # beangulp-yapeal
2
+
3
+ beangulp importer for Yapeal CSV bank statements.
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "beangulp-yapeal"
3
+ version = "0.1.0"
4
+ description = "beangulp importer for Yapeal CSV bank statements: drafts transactions with stable bank references for idempotent re-imports"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ keywords = ["beancount", "beangulp", "importer", "csv", "bank-statement", "yapeal"]
8
+ dependencies = [
9
+ "beancount>=3.0",
10
+ "beangulp>=0.2",
11
+ ]
12
+
13
+ [dependency-groups]
14
+ dev = ["pytest>=8"]
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/beangulp_yapeal"]
@@ -0,0 +1,172 @@
1
+ """beangulp importer for Yapeal CSV bank statements.
2
+
3
+ Parses Yapeal CSV exports into beancount transaction drafts following the
4
+ ``csvbase`` pattern (see :mod:`beangulp.importers.csvbase` and
5
+ ``beangulp/examples/importers/csvbank.py``):
6
+
7
+ - Every draft carries the bank's transaction ID as metadata (``bank_ref:`` by
8
+ default). ``extract()`` skips references already present anywhere in the
9
+ existing ledger, so re-running an import is always safe.
10
+ - ``payee_rules`` — an iterable of ``(regex, account, flag)`` — draft the
11
+ counter leg when the payee/narration matches; anything unmatched is emitted
12
+ with the review flag and the cash leg only, for a human (or agent) to
13
+ complete.
14
+
15
+ The importer is entity-agnostic: account names and rules are constructor
16
+ parameters.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from collections.abc import Sequence
23
+ from decimal import Decimal, InvalidOperation
24
+
25
+ from beancount.core import data, flags
26
+ from beancount.core.amount import Amount
27
+ from beangulp import mimetypes
28
+ from beangulp.importers import csvbase
29
+
30
+ __all__ = ["Importer"]
31
+
32
+ _IDENTIFY_HEADER = "Transaktions-ID,Belastung oder Gutschrift,Buchungs-Datum"
33
+
34
+
35
+ class _YapealAmount(csvbase.Column):
36
+ """Amount from either Gutschrift (credit), Belastung (debit), or Betrag+indicator."""
37
+
38
+ def parse(self, gutschrift: str, belastung: str, betrag: str, indicator: str) -> Decimal: # type: ignore[override]
39
+ if gutschrift:
40
+ return Decimal(gutschrift)
41
+ if belastung:
42
+ return -Decimal(belastung)
43
+ if betrag:
44
+ amount = Decimal(betrag)
45
+ if indicator == "DEBIT":
46
+ amount = -amount
47
+ return amount
48
+ raise ValueError("no amount in row")
49
+
50
+
51
+ class Importer(csvbase.Importer):
52
+ """Yapeal CSV statement importer for one bank account."""
53
+
54
+ ref = csvbase.Column("Transaktions-ID")
55
+ date = csvbase.Date("Transaktions-Datum", "%d.%m.%Y") # type: ignore[assignment]
56
+ payee = csvbase.Column("Gegenpartei")
57
+ narration = csvbase.Column("Transaktions-Info")
58
+ amount = _YapealAmount(
59
+ "Gutschrift (CHF)", "Belastung (CHF)", "Betrag", "Belastung oder Gutschrift"
60
+ )
61
+
62
+ def __init__(
63
+ self,
64
+ account: str,
65
+ *,
66
+ iban: str | None = None,
67
+ currency: str = "CHF",
68
+ meta_key: str = "bank_ref",
69
+ payee_rules: Sequence[tuple[str, str, str]] = (),
70
+ review_flag: str = flags.FLAG_WARNING,
71
+ ):
72
+ super().__init__(account, currency, flag=review_flag)
73
+ self._iban = re.sub(r"\s", "", iban).upper() if iban else None
74
+ self._meta_key = meta_key
75
+ self._rules = [
76
+ (re.compile(pattern, re.IGNORECASE), acct, flag) for pattern, acct, flag in payee_rules
77
+ ]
78
+
79
+ def identify(self, filepath: str) -> bool:
80
+ try:
81
+ mimetype, _ = mimetypes.guess_type(filepath)
82
+ if mimetype != "text/csv":
83
+ return False
84
+ with open(filepath, encoding="utf-8") as f:
85
+ head = f.read(65536)
86
+ if _IDENTIFY_HEADER not in head:
87
+ return False
88
+ if self._iban:
89
+ return self._iban in re.sub(r"\s", "", head).upper()
90
+ return True
91
+ except (OSError, UnicodeError):
92
+ return False
93
+
94
+ def account(self, filepath: str) -> str:
95
+ return self.importer_account
96
+
97
+ def filename(self, filepath: str) -> str:
98
+ return "statement.csv"
99
+
100
+ def metadata(self, filepath: str, lineno: int, row: object) -> dict[str, object]:
101
+ meta = data.new_metadata(filepath, lineno)
102
+ ref = getattr(row, "ref", None)
103
+ if ref:
104
+ meta[self._meta_key] = ref
105
+ return meta
106
+
107
+ def extract(self, filepath: str, existing: data.Entries) -> data.Entries:
108
+ seen = _existing_references(existing, self._meta_key)
109
+ offset = int(self.skiplines) + int(self.names) + 1
110
+
111
+ entries: data.Entries = []
112
+ for lineno, row in enumerate(self.read(filepath), offset):
113
+ if not row:
114
+ continue
115
+
116
+ ref = getattr(row, "ref", None)
117
+ if not ref or ref in seen:
118
+ continue
119
+
120
+ try:
121
+ amount_val = row.amount # type: ignore[attr-defined]
122
+ except (ValueError, InvalidOperation):
123
+ continue
124
+
125
+ meta = self.metadata(filepath, lineno, row)
126
+ payee = row.payee # type: ignore[attr-defined]
127
+ narration = row.narration # type: ignore[attr-defined]
128
+ date_val = row.date # type: ignore[attr-defined]
129
+ units = Amount(amount_val, self.currency)
130
+ postings = [data.Posting(self.importer_account, units, None, None, None, None)]
131
+
132
+ flag = self.flag
133
+ haystack = f"{payee or ''}\n{narration or ''}"
134
+ for pattern, counter, rule_flag in self._rules:
135
+ if pattern.search(haystack):
136
+ postings.append(
137
+ data.Posting(
138
+ counter, Amount(-amount_val, self.currency), None, None, None, None
139
+ )
140
+ )
141
+ flag = rule_flag
142
+ break
143
+
144
+ entries.append(
145
+ data.Transaction(
146
+ meta,
147
+ date_val,
148
+ flag,
149
+ payee,
150
+ narration,
151
+ data.EMPTY_SET,
152
+ data.EMPTY_SET,
153
+ postings,
154
+ )
155
+ )
156
+
157
+ entries.sort(key=lambda e: e.date)
158
+ return entries
159
+
160
+
161
+ def _existing_references(existing: data.Entries, meta_key: str) -> set[str]:
162
+ refs: set[str] = set()
163
+ for entry in existing or []:
164
+ meta = getattr(entry, "meta", None) or {}
165
+ value = meta.get(meta_key)
166
+ if isinstance(value, str):
167
+ refs.add(value)
168
+ for posting in getattr(entry, "postings", ()) or ():
169
+ value = (posting.meta or {}).get(meta_key)
170
+ if isinstance(value, str):
171
+ refs.add(value)
172
+ return refs
@@ -0,0 +1,4 @@
1
+ Transaktions-ID,Belastung oder Gutschrift,Buchungs-Datum,Transaktions-Datum,Transaktions-Info,Betrag,Belastung (CHF),Gutschrift (CHF),Kontowährung,Saldo (CHF),Gegenpartei,IBAN Gegenpartei,Original Transaktionsbetrag,Transaktionswährung,Devisenkurs,Gebühr,Zahlungstyp,Letzte 4 der Kartennummer,Karteninhaber,Status der Spesen,Zahlungsreferenz,Eigene Notiz,Zahlungs-Notiz,Zahlung erfasst am,Zahlung genehmigt von,Zahlung genehmigt am,Kategorie,Karten Programm,usedTags,mentions,ultimateCreditorName,ultimateDebitorName
2
+ e9839ff4-e419-432a-b553-801e13c7e8f1,DEBIT,05.01.2026,05.01.2026,Debit card payment,25.50,25.50,,CHF,-25.50,ACME Hosting Inc,CH9300762011623852957,25.50,CHF,,,,,,,,Subject Test,,,,general,,,,,,,
3
+ f68eb96a-1776-4c4d-b099-12ef65be3b63,CREDIT,10.01.2026,10.01.2026,Invoice payment,1000.00,,1000.00,CHF,1000.00,Client AG,CH9300762011623852957,1000.00,CHF,,,,,,,,Subject Test,,,,general,,,,,,,
4
+ a1b2c3d4-5678-90ab-cdef-1234567890ab,DEBIT,15.01.2026,15.01.2026,Monthly subscription,45.00,45.00,,CHF,-45.00,Swisscom,CH9300762011623852957,45.00,CHF,,,,,,,,Subject Test,,,,general,,,,,,,
@@ -0,0 +1,180 @@
1
+ """Tests for the Yapeal CSV beangulp importer."""
2
+
3
+ from datetime import date as Date
4
+ from decimal import Decimal
5
+ from pathlib import Path
6
+
7
+ from beancount.core import data
8
+
9
+ from beangulp_yapeal import Importer
10
+
11
+ FIXTURE = str(Path(__file__).parent / "fixture.csv")
12
+
13
+ CASH = "Assets:Bank:Checking"
14
+ RULES = ((r"client ag", "Assets:Receivable", "*"),)
15
+
16
+
17
+ def _importer() -> Importer:
18
+ return Importer(CASH, payee_rules=RULES)
19
+
20
+
21
+ def test_identify_accepts_yapeal_csv(tmp_path: Path) -> None:
22
+ assert _importer().identify(FIXTURE)
23
+ other = tmp_path / "not-a-statement.csv"
24
+ other.write_text("date,amount\n2026-01-01,1.00\n")
25
+ assert not _importer().identify(str(other))
26
+ other.write_text("Transaktions-ID,Betrag\n")
27
+ assert not _importer().identify(str(other))
28
+
29
+
30
+ def test_identify_bails_on_binary(tmp_path: Path) -> None:
31
+ binary = tmp_path / "binary.dat"
32
+ binary.write_bytes(b"\x80\x81\x82")
33
+ assert not _importer().identify(str(binary))
34
+
35
+
36
+ def test_identify_with_iban(tmp_path: Path) -> None:
37
+ importer = Importer(CASH, iban="CH9300762011623852957")
38
+ iban_csv = tmp_path / "with-iban.csv"
39
+ iban_csv.write_text(
40
+ "Transaktions-ID,Belastung oder Gutschrift,Buchungs-Datum,"
41
+ "Transaktions-Datum,Transaktions-Info,Betrag,Belastung (CHF),"
42
+ "Gutschrift (CHF),Kontowährung,Saldo (CHF),Gegenpartei,IBAN Gegenpartei,"
43
+ "Original Transaktionsbetrag,Transaktionswährung,Devisenkurs,Gebühr,"
44
+ "Zahlungstyp,Letzte 4 der Kartennummer,Karteninhaber,Status der Spesen,"
45
+ "Zahlungsreferenz,Eigene Notiz,Zahlungs-Notiz,Zahlung erfasst am,"
46
+ "Zahlung genehmigt von,Zahlung genehmigt am,Kategorie,Karten Programm,"
47
+ "usedTags,mentions,ultimateCreditorName,ultimateDebitorName\n"
48
+ "e9839ff4-e419-432a-b553-801e13c7e8f1,CREDIT,30.12.2025,"
49
+ "30.12.2025,John Kert,100.00,,100.00,CHF,100.00,John Kert,CH9300762011623852957,"
50
+ "100.00,CHF,,"
51
+ ",domestic_iban,,,,f68eb96a17764c4db09912ef65be3b63,,"
52
+ "Subject Test,,,,general,,"
53
+ ",,,\n"
54
+ )
55
+ assert importer.identify(str(iban_csv))
56
+
57
+ wrong_iban = Importer(CASH, iban="CH0000000000000000000")
58
+ assert not wrong_iban.identify(str(iban_csv))
59
+
60
+
61
+ def test_identify_iban_matches_spaceless(tmp_path: Path) -> None:
62
+ importer = Importer(CASH, iban="CH9300762011623852957")
63
+ csv_file = tmp_path / "spaceless.csv"
64
+ csv_file.write_text(
65
+ "Transaktions-ID,Belastung oder Gutschrift,Buchungs-Datum,"
66
+ "Transaktions-Datum,Transaktions-Info,Betrag,Belastung (CHF),"
67
+ "Gutschrift (CHF),Kontowährung,Saldo (CHF),Gegenpartei,IBAN Gegenpartei,"
68
+ "Original Transaktionsbetrag,Transaktionswährung,Devisenkurs,Gebühr,"
69
+ "Zahlungstyp,Letzte 4 der Kartennummer,Karteninhaber,Status der Spesen,"
70
+ "Zahlungsreferenz,Eigene Notiz,Zahlungs-Notiz,Zahlung erfasst am,"
71
+ "Zahlung genehmigt von,Zahlung genehmigt am,Kategorie,Karten Programm,"
72
+ "usedTags,mentions,ultimateCreditorName,ultimateDebitorName\n"
73
+ "e9839ff4-e419-432a-b553-801e13c7e8f1,CREDIT,30.12.2025,"
74
+ "30.12.2025,John Kert,100.00,,100.00,CHF,100.00,John Kert,CH9300762011623852957,"
75
+ "100.00,CHF,,"
76
+ ",domestic_iban,,,,f68eb96a17764c4db09912ef65be3b63,,"
77
+ "Subject Test,,,,general,,"
78
+ ",,,\n"
79
+ )
80
+ assert importer.identify(str(csv_file))
81
+
82
+
83
+ def test_identify_iban_ignored_when_none() -> None:
84
+ assert _importer().identify(FIXTURE)
85
+
86
+
87
+ def test_account() -> None:
88
+ assert _importer().account("ignored") == CASH
89
+
90
+
91
+ def test_filename() -> None:
92
+ assert _importer().filename("ignored") == "statement.csv"
93
+
94
+
95
+ def test_extract_transactions() -> None:
96
+ entries = _importer().extract(FIXTURE, existing=[])
97
+ txns = [e for e in entries if isinstance(e, data.Transaction)]
98
+ assert len(txns) == 3
99
+
100
+ debit = txns[0]
101
+ assert debit.date == Date(2026, 1, 5)
102
+ assert debit.payee == "ACME Hosting Inc"
103
+ assert debit.narration == "Debit card payment"
104
+ assert debit.flag == "!" # no rule matched
105
+ assert len(debit.postings) == 1
106
+ assert debit.postings[0].units is not None
107
+ assert debit.postings[0].units.number == Decimal("-25.50")
108
+ assert debit.meta["bank_ref"] == "e9839ff4-e419-432a-b553-801e13c7e8f1"
109
+
110
+ credit = txns[1]
111
+ assert credit.date == Date(2026, 1, 10)
112
+ assert credit.payee == "Client AG"
113
+ assert credit.flag == "*" # rule matched
114
+ assert [p.account for p in credit.postings] == [CASH, "Assets:Receivable"]
115
+ assert credit.postings[1].units is not None
116
+ assert credit.postings[1].units.number == Decimal("-1000.00")
117
+
118
+ debit2 = txns[2]
119
+ assert debit2.date == Date(2026, 1, 15)
120
+ assert debit2.meta["bank_ref"] == "a1b2c3d4-5678-90ab-cdef-1234567890ab"
121
+
122
+
123
+ def test_extract_dedup_by_reference() -> None:
124
+ meta = data.new_metadata("ledger.bean", 1)
125
+ meta["bank_ref"] = "e9839ff4-e419-432a-b553-801e13c7e8f1"
126
+ booked = data.Transaction(
127
+ meta,
128
+ Date(2026, 1, 5),
129
+ "*",
130
+ "ACME",
131
+ "already booked",
132
+ data.EMPTY_SET,
133
+ data.EMPTY_SET,
134
+ [],
135
+ )
136
+ entries = _importer().extract(FIXTURE, existing=[booked])
137
+ txns = [e for e in entries if isinstance(e, data.Transaction)]
138
+ assert [t.meta["bank_ref"] for t in txns] == [
139
+ "f68eb96a-1776-4c4d-b099-12ef65be3b63",
140
+ "a1b2c3d4-5678-90ab-cdef-1234567890ab",
141
+ ]
142
+
143
+
144
+ def test_extract_empty_csv(tmp_path: Path) -> None:
145
+ empty = tmp_path / "empty.csv"
146
+ empty.write_text(
147
+ "Transaktions-ID,Belastung oder Gutschrift,Buchungs-Datum,"
148
+ "Transaktions-Datum,Gegenpartei,Transaktions-Info,"
149
+ "Gutschrift (CHF),Belastung (CHF),Betrag\n"
150
+ )
151
+ entries = _importer().extract(str(empty), existing=[])
152
+ assert not entries
153
+
154
+
155
+ def test_betrag_fallback(tmp_path: Path) -> None:
156
+ csv = tmp_path / "betrag.csv"
157
+ csv.write_text(
158
+ "Transaktions-ID,Belastung oder Gutschrift,Buchungs-Datum,"
159
+ "Transaktions-Datum,Gegenpartei,Transaktions-Info,"
160
+ "Gutschrift (CHF),Belastung (CHF),Betrag\n"
161
+ "R1,DEBIT,01.02.2026,01.02.2026,Some Vendor,Payment,,,100.00\n"
162
+ "R2,CREDIT,02.02.2026,02.02.2026,Income,Salary,,,500.00\n"
163
+ )
164
+ entries = _importer().extract(str(csv), existing=[])
165
+ txns = [e for e in entries if isinstance(e, data.Transaction)]
166
+ assert len(txns) == 2
167
+ assert txns[0].postings[0].units is not None
168
+ assert txns[0].postings[0].units.number == Decimal("-100.00")
169
+ assert txns[1].postings[0].units is not None
170
+ assert txns[1].postings[0].units.number == Decimal("500.00")
171
+
172
+
173
+ def test_payee_rules_draft_counter_leg() -> None:
174
+ importer = Importer(CASH, payee_rules=[(r"client", "Assets:Receivable", "*")])
175
+ entries = importer.extract(FIXTURE, existing=[])
176
+ txns = [e for e in entries if isinstance(e, data.Transaction)]
177
+ matched = [t for t in txns if len(t.postings) == 2]
178
+ assert len(matched) == 1
179
+ assert matched[0].payee == "Client AG"
180
+ assert matched[0].postings[1].account == "Assets:Receivable"