From af4becaee951fa95181ac6707b49a49b5398aacc Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Thu, 10 Sep 2026 15:47:32 +0200 Subject: [PATCH 01/31] comdirect reader: flatten results --- src/reader.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/reader.py b/src/reader.py index 64ec4e9..dd2be49 100644 --- a/src/reader.py +++ b/src/reader.py @@ -47,14 +47,15 @@ def reader_comdirect(path: Path): elif expect_next == EXPECT_LINE.HEADER: header = re.findall(PATTERN_HEADER, line) if header: - result[account_name]["data"] = [] + result[account_name] = [] expect_next = EXPECT_LINE.DATA elif expect_next == EXPECT_LINE.DATA: data = re.findall(PATTERN_DATA, line) if data: - result[account_name]["data"].append( + result[account_name].append( {column: value for column, value in zip(header, data)} ) + else: expect_next = EXPECT_LINE.ACCOUNT_NAME else: expect_next = EXPECT_LINE.ACCOUNT_NAME From 603be8db38e7f1c7a52e9b59e4c8472fdd171edf Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Thu, 10 Sep 2026 21:55:29 +0200 Subject: [PATCH 02/31] ingest comdirect as raw strings --- pyproject.toml | 1 + src/models.py | 42 ++++++++++++++++++++++++++++++++++ src/reader.py | 37 ++++++++++++++++++++++-------- src/settings/account_ingest.py | 10 ++++++++ uv.lock | 15 ++++++++++++ 5 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 src/settings/account_ingest.py diff --git a/pyproject.toml b/pyproject.toml index 79d3bd4..9eb8986 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,5 +31,6 @@ build-backend = "uv_build" [dependency-groups] dev = [ "black>=26.5.1", + "debugpy>=1.8.21", "polyfactory>=3.3.0", ] diff --git a/src/models.py b/src/models.py index e3e8c04..323e49c 100644 --- a/src/models.py +++ b/src/models.py @@ -100,3 +100,45 @@ class Category(BaseModel): def __repr__(self): return f'Category.from_str("{self.__str__()}")' + + +class ComdirectBase(BaseModel): + date_book: str = Field(alias="Buchungstag", examples=["07.09.2026"]) + amount_eur: str = Field(alias="Umsatz in EUR", examples=["32,43"]) + + +class ComdirectCommon(ComdirectBase): + type: str = Field( + alias="Vorgang", examples=["Lastschrift / Belastung", "Kartenumsatz"] + ) + text: str = Field(alias="Buchungstext") + + +class ComdirectGiro(ComdirectCommon): + # "Buchungstag";"Wertstellung (Valuta)";"Vorgang";"Buchungstext";"Umsatz in EUR"; + # "Buchungstag";"Wertstellung (Valuta)";"Vorgang";"Buchungstext";"Umsatz in EUR"; + date_val: str = Field(alias="Wertstellung (Valuta)", examples=["07.09.2026"]) + + +class ComdirectCreditCard(ComdirectCommon): + # "Buchungstag";"Umsatztag";"Vorgang";"Referenz";"Buchungstext";"Umsatz in EUR"; + date_book: str = Field(alias="Buchungstag", examples=["07.09.2026"]) + date_val: str = Field(alias="Umsatztag", default="") + type: str = Field( + alias="Vorgang", + examples=["Lastschrift / Belastung", "Kartenumsatz"], + default="", + ) + reference: str = Field(alias="Referenz", default="") + text: str = Field(alias="Buchungstext", default="") + amount_eur: str = Field(alias="Umsatz in EUR", examples=["32,43"], default="") + + +class ComdirectDepot(BaseModel): + # "Buchungstag";"Geschäftstag";"Stück / Nom.";"Bezeichnung";"WKN";"Währung";"Ausführungskurs";"Umsatz in EUR"; + date_val: str = Field(alias="Geschäftstag") + num: str = Field(alias="Stück / Nom.") + name: str = Field(alias="Bezeichnung") + wkn: str = Field(alias="WKN") + currency: str = Field(alias="Währung") + spot_price: str = Field(alias="Ausführungskurs") diff --git a/src/reader.py b/src/reader.py index dd2be49..b675164 100644 --- a/src/reader.py +++ b/src/reader.py @@ -4,29 +4,46 @@ from enum import Enum, auto from pathlib import Path from src.models import B4wExport +from src.settings.account_ingest import ACCOUNT_INGESTION_FUNCTIONS -def reader_raw(path: Path): +def _reader_raw(path: Path): with path.open() as fid: return list(csv.DictReader(fid)) def reader_b4w(path: Path): - raw = reader_raw(path) + raw = _reader_raw(path) return list(map(B4wExport.model_validate, raw)) + +PATTERN_ACCOUNT_NAME = r'^"Umsätze ([^"]+)";"Zeitraum: .*$' +PATTERN_HEADER = r'"([^"]+)"' +PATTERN_DATA = r'"([^"]+)"' + +class EXPECT_LINE(Enum): + ACCOUNT_NAME = auto() + BLANK = auto() + HEADER = auto() + DATA = auto() + + def reader_comdirect(path: Path): + bank_name = "comdirect" + ingestion_functions = ACCOUNT_INGESTION_FUNCTIONS.get(bank_name) + assert ingestion_functions + + result = {} + raw = _reader_raw_comdirect(path) + for account_name, account_data in raw.items(): + ingestion_function = ingestion_functions.get(account_name) + assert ingestion_function + result[account_name] = list(map(ingestion_function, account_data)) - PATTERN_ACCOUNT_NAME = r'^"Umsätze ([^"]+)";"Zeitraum: .*$' - PATTERN_HEADER = r'"([^"]+)"' - PATTERN_DATA = r'"([^"]+)"' + return result - class EXPECT_LINE(Enum): - ACCOUNT_NAME = auto() - BLANK = auto() - HEADER = auto() - DATA = auto() +def _reader_raw_comdirect(path: Path): with path.open(encoding="latin1") as fid: lines = fid.readlines() diff --git a/src/settings/account_ingest.py b/src/settings/account_ingest.py new file mode 100644 index 0000000..4e3f9c8 --- /dev/null +++ b/src/settings/account_ingest.py @@ -0,0 +1,10 @@ +from src.models import ComdirectCreditCard, ComdirectDepot, ComdirectGiro + +ACCOUNT_INGESTION_FUNCTIONS = { + "comdirect": { + "Girokonto": lambda x: ComdirectGiro.model_validate(x), + "Visa-Karte (Kreditkarte)": lambda x: ComdirectCreditCard.model_validate(x), + "Tagesgeld PLUS-Konto": lambda x: ComdirectGiro.model_validate(x), + "Depot": lambda x: ComdirectDepot.model_validate(x), + } +} diff --git a/uv.lock b/uv.lock index 7f52dba..92a955e 100644 --- a/uv.lock +++ b/uv.lock @@ -74,6 +74,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, +] + [[package]] name = "dotenv" version = "0.9.9" @@ -114,6 +127,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "black" }, + { name = "debugpy" }, { name = "polyfactory" }, ] @@ -131,6 +145,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "black", specifier = ">=26.5.1" }, + { name = "debugpy", specifier = ">=1.8.21" }, { name = "polyfactory", specifier = ">=3.3.0" }, ] From b93fd35f43fbd687b24f9534985a606571976ec3 Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Thu, 10 Sep 2026 23:10:14 +0200 Subject: [PATCH 03/31] comdirect ingest: proper models --- src/models.py | 63 +++++++++++++++++++++++++++++++------------- tests/test_reader.py | 12 ++++++++- 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/src/models.py b/src/models.py index 323e49c..441f3f6 100644 --- a/src/models.py +++ b/src/models.py @@ -102,28 +102,51 @@ class Category(BaseModel): return f'Category.from_str("{self.__str__()}")' +def _parse_date_german(date_str: str) -> date | None: + if date_str in ["offen", "neu", "--"]: + return None + day, month, year = list(map(int, date_str.split("."))) + return date(day=day, month=month, year=year) + +def _parse_decimal_german(decimal_str: str) -> Decimal: + return Decimal(decimal_str.replace(".", "").replace(",", ".")) + + class ComdirectBase(BaseModel): - date_book: str = Field(alias="Buchungstag", examples=["07.09.2026"]) - amount_eur: str = Field(alias="Umsatz in EUR", examples=["32,43"]) + date_val: date | None = Field(alias="Wertstellung (Valuta)", examples=["07.09.2026"]) + amount_eur: Decimal = Field(alias="Umsatz in EUR", examples=["32,43"]) + + @field_validator("date_val", mode="before") + @classmethod + def parse_date_val(cls, date_str: str) -> date | None: + return _parse_date_german(date_str) + + @field_validator("amount_eur", mode="before") + @classmethod + def parse_amount(cls, decimal_str: str) -> Decimal: + return _parse_decimal_german(decimal_str) -class ComdirectCommon(ComdirectBase): +class ComdirectBaseBook(ComdirectBase): + date_book: date | None = Field(alias="Buchungstag", examples=["07.09.2026"]) + + @field_validator("date_book", mode="before") + @classmethod + def parse_date_book(cls, date_str: str) -> date | None: + return _parse_date_german(date_str) + + +class ComdirectGiro(ComdirectBaseBook): type: str = Field( alias="Vorgang", examples=["Lastschrift / Belastung", "Kartenumsatz"] ) text: str = Field(alias="Buchungstext") -class ComdirectGiro(ComdirectCommon): - # "Buchungstag";"Wertstellung (Valuta)";"Vorgang";"Buchungstext";"Umsatz in EUR"; - # "Buchungstag";"Wertstellung (Valuta)";"Vorgang";"Buchungstext";"Umsatz in EUR"; - date_val: str = Field(alias="Wertstellung (Valuta)", examples=["07.09.2026"]) - - -class ComdirectCreditCard(ComdirectCommon): +class ComdirectCreditCard(ComdirectBaseBook): # "Buchungstag";"Umsatztag";"Vorgang";"Referenz";"Buchungstext";"Umsatz in EUR"; - date_book: str = Field(alias="Buchungstag", examples=["07.09.2026"]) - date_val: str = Field(alias="Umsatztag", default="") + date_book: date | None = Field(alias="Buchungstag", examples=["07.09.2026"]) + date_val: date | None = Field(alias="Umsatztag", default=None) type: str = Field( alias="Vorgang", examples=["Lastschrift / Belastung", "Kartenumsatz"], @@ -131,14 +154,18 @@ class ComdirectCreditCard(ComdirectCommon): ) reference: str = Field(alias="Referenz", default="") text: str = Field(alias="Buchungstext", default="") - amount_eur: str = Field(alias="Umsatz in EUR", examples=["32,43"], default="") + amount_eur: Decimal | None = Field(alias="Umsatz in EUR", examples=["32,43"], default=None) -class ComdirectDepot(BaseModel): - # "Buchungstag";"Geschäftstag";"Stück / Nom.";"Bezeichnung";"WKN";"Währung";"Ausführungskurs";"Umsatz in EUR"; - date_val: str = Field(alias="Geschäftstag") - num: str = Field(alias="Stück / Nom.") +class ComdirectDepot(ComdirectBase): + date_val: date | None = Field(alias="Geschäftstag") + num: Decimal = Field(alias="Stück / Nom.") name: str = Field(alias="Bezeichnung") wkn: str = Field(alias="WKN") currency: str = Field(alias="Währung") - spot_price: str = Field(alias="Ausführungskurs") + spot_price: Decimal = Field(alias="Ausführungskurs") + + @field_validator("num", "spot_price", mode="before") + @classmethod + def parse_decimals(cls, decimal_in: str) -> Decimal: + return _parse_decimal_german(decimal_in) diff --git a/tests/test_reader.py b/tests/test_reader.py index 5b0727e..9bc4911 100644 --- a/tests/test_reader.py +++ b/tests/test_reader.py @@ -1,10 +1,20 @@ from pathlib import Path +from src.models import ComdirectCreditCard, ComdirectDepot, ComdirectGiro from src.reader import reader_comdirect +from src.settings.account_ingest import ACCOUNT_INGESTION_FUNCTIONS -def test_reader(): +def test_reader_comdirect(): + bank_name = "comdirect" + assert bank_name in ACCOUNT_INGESTION_FUNCTIONS test_path = Path(".local/data/comdirect/umsaetze_9773063005_20260906-2043.csv") assert test_path.exists() data = reader_comdirect(test_path) assert data + assert len(data) == len(ACCOUNT_INGESTION_FUNCTIONS.get(bank_name)) + assert set(data) == set(ACCOUNT_INGESTION_FUNCTIONS.get(bank_name)) + assert all(isinstance(row, ComdirectGiro) for row in data["Girokonto"]) + assert all(isinstance(row, ComdirectCreditCard) for row in data["Visa-Karte (Kreditkarte)"]) + assert all(isinstance(row, ComdirectGiro) for row in data["Tagesgeld PLUS-Konto"]) + assert all(isinstance(row, ComdirectDepot) for row in data["Depot"]) From f50b28a3877f07d9db73df291ef75aca2ab0c4f8 Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Fri, 11 Sep 2026 08:59:55 +0200 Subject: [PATCH 04/31] comdirect ingest: simpler classes --- src/models.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/models.py b/src/models.py index 441f3f6..b2597bf 100644 --- a/src/models.py +++ b/src/models.py @@ -127,8 +127,12 @@ class ComdirectBase(BaseModel): return _parse_decimal_german(decimal_str) -class ComdirectBaseBook(ComdirectBase): +class ComdirectGiro(ComdirectBase): date_book: date | None = Field(alias="Buchungstag", examples=["07.09.2026"]) + type: str = Field( + alias="Vorgang", examples=["Lastschrift / Belastung", "Kartenumsatz"] + ) + text: str = Field(alias="Buchungstext") @field_validator("date_book", mode="before") @classmethod @@ -136,14 +140,7 @@ class ComdirectBaseBook(ComdirectBase): return _parse_date_german(date_str) -class ComdirectGiro(ComdirectBaseBook): - type: str = Field( - alias="Vorgang", examples=["Lastschrift / Belastung", "Kartenumsatz"] - ) - text: str = Field(alias="Buchungstext") - - -class ComdirectCreditCard(ComdirectBaseBook): +class ComdirectCreditCard(ComdirectGiro): # "Buchungstag";"Umsatztag";"Vorgang";"Referenz";"Buchungstext";"Umsatz in EUR"; date_book: date | None = Field(alias="Buchungstag", examples=["07.09.2026"]) date_val: date | None = Field(alias="Umsatztag", default=None) From d502e9ef0d568e297b756ad3788aa8b20390e10f Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Fri, 11 Sep 2026 10:16:51 +0200 Subject: [PATCH 05/31] comdirect ingest: some code structurerefinement --- .gitignore | 1 + pyproject.toml | 1 + settings.template.py | 32 ++++++++++++++++++++++++++++++++ src/models.py | 15 +-------------- src/reader.py | 17 ++++++++--------- src/settings/__init__.py | 0 src/settings/account_ingest.py | 10 ---------- src/settings/constants.py | 18 ++++++++++++++++++ src/settings/models.py | 14 ++++++++++++++ tests/test_reader.py | 13 ++++++------- uv.lock | 2 ++ 11 files changed, 83 insertions(+), 40 deletions(-) create mode 100644 settings.template.py create mode 100644 src/settings/__init__.py delete mode 100644 src/settings/account_ingest.py create mode 100644 src/settings/constants.py create mode 100644 src/settings/models.py diff --git a/.gitignore b/.gitignore index 2a04809..2e81ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ wheels/ # Environment variables .env +settings.py # Database instances *.db diff --git a/pyproject.toml b/pyproject.toml index 9eb8986..e236405 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,5 +32,6 @@ build-backend = "uv_build" dev = [ "black>=26.5.1", "debugpy>=1.8.21", + "faker>=40.38.0", "polyfactory>=3.3.0", ] diff --git a/settings.template.py b/settings.template.py new file mode 100644 index 0000000..10677d4 --- /dev/null +++ b/settings.template.py @@ -0,0 +1,32 @@ +from faker import Faker + +from src.models import ComdirectCreditCard, ComdirectDepot, ComdirectGiro +from src.settings.constants import Currency +from src.settings.models import AccountInfo + +fake = Faker() + +BANK_ACCOUNTS = { + "comdirect": { + "Girokonto": AccountInfo( + iban=fake.iban(), + ingestion_function=lambda x: ComdirectGiro.model_validate(x), + currency=Currency.EUR, + ), + "Visa-Karte (Kreditkarte)": AccountInfo( + iban=fake.iban(), + ingestion_function=lambda x: ComdirectCreditCard.model_validate(x), + currency=Currency.EUR, + ), + "Tagesgeld PLUS-Konto": AccountInfo( + iban=fake.iban(), + ingestion_function=lambda x: ComdirectGiro.model_validate(x), + currency=Currency.EUR, + ), + "Depot": AccountInfo( + iban=fake.iban(), + ingestion_function=lambda x: ComdirectDepot.model_validate(x), + currency=Currency.EUR, + ), + } +} diff --git a/src/models.py b/src/models.py index b2597bf..a500eeb 100644 --- a/src/models.py +++ b/src/models.py @@ -4,20 +4,7 @@ from enum import Enum from pydantic import BaseModel, Field, field_validator -IBAN_PATTERN = r"^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$" - - -class Currency(str, Enum): - EUR = "EUR" - USD = "USD" - GBP = "GBP" - CHF = "CHF" - - def __str__(self): - return self.value - - def __repr__(self): - return f"Currency.{self.name}" +from src.settings.constants import IBAN_PATTERN, Currency class Sign(str, Enum): diff --git a/src/reader.py b/src/reader.py index b675164..db7de09 100644 --- a/src/reader.py +++ b/src/reader.py @@ -3,8 +3,9 @@ import re from enum import Enum, auto from pathlib import Path +from settings import BANK_ACCOUNTS from src.models import B4wExport -from src.settings.account_ingest import ACCOUNT_INGESTION_FUNCTIONS +from src.settings.models import AccountInfo def _reader_raw(path: Path): @@ -29,18 +30,16 @@ class EXPECT_LINE(Enum): DATA = auto() +COMDIRECT_ACCOUNTS = BANK_ACCOUNTS.get("comdirect", {}) +assert isinstance(COMDIRECT_ACCOUNTS, dict) + def reader_comdirect(path: Path): - bank_name = "comdirect" - ingestion_functions = ACCOUNT_INGESTION_FUNCTIONS.get(bank_name) - assert ingestion_functions - result = {} raw = _reader_raw_comdirect(path) for account_name, account_data in raw.items(): - ingestion_function = ingestion_functions.get(account_name) - assert ingestion_function - result[account_name] = list(map(ingestion_function, account_data)) - + account = COMDIRECT_ACCOUNTS.get(account_name) + assert isinstance(account, AccountInfo) + result[account_name] = list(map(account.ingestion_function, account_data)) return result def _reader_raw_comdirect(path: Path): diff --git a/src/settings/__init__.py b/src/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/settings/account_ingest.py b/src/settings/account_ingest.py deleted file mode 100644 index 4e3f9c8..0000000 --- a/src/settings/account_ingest.py +++ /dev/null @@ -1,10 +0,0 @@ -from src.models import ComdirectCreditCard, ComdirectDepot, ComdirectGiro - -ACCOUNT_INGESTION_FUNCTIONS = { - "comdirect": { - "Girokonto": lambda x: ComdirectGiro.model_validate(x), - "Visa-Karte (Kreditkarte)": lambda x: ComdirectCreditCard.model_validate(x), - "Tagesgeld PLUS-Konto": lambda x: ComdirectGiro.model_validate(x), - "Depot": lambda x: ComdirectDepot.model_validate(x), - } -} diff --git a/src/settings/constants.py b/src/settings/constants.py new file mode 100644 index 0000000..a8cb886 --- /dev/null +++ b/src/settings/constants.py @@ -0,0 +1,18 @@ + +from enum import Enum + +IBAN_PATTERN = r"^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$" + +class Currency(str, Enum): + EUR = "EUR" + USD = "USD" + GBP = "GBP" + CHF = "CHF" + + def __str__(self): + return self.value + + def __repr__(self): + return f"Currency.{self.name}" + + diff --git a/src/settings/models.py b/src/settings/models.py new file mode 100644 index 0000000..d58994a --- /dev/null +++ b/src/settings/models.py @@ -0,0 +1,14 @@ +from collections.abc import Callable + +from pydantic import BaseModel, Field + +from src.settings.constants import IBAN_PATTERN, Currency + + +class AccountInfo(BaseModel): + iban: str = Field(pattern=IBAN_PATTERN) + ingestion_function: Callable + currency: Currency + + + diff --git a/tests/test_reader.py b/tests/test_reader.py index 9bc4911..30aea9c 100644 --- a/tests/test_reader.py +++ b/tests/test_reader.py @@ -1,19 +1,18 @@ from pathlib import Path from src.models import ComdirectCreditCard, ComdirectDepot, ComdirectGiro -from src.reader import reader_comdirect -from src.settings.account_ingest import ACCOUNT_INGESTION_FUNCTIONS +from src.reader import COMDIRECT_ACCOUNTS, reader_comdirect + +TEST_DATA_PATH = ".local/data/comdirect/umsaetze_9773063005_20260906-2043.csv" def test_reader_comdirect(): - bank_name = "comdirect" - assert bank_name in ACCOUNT_INGESTION_FUNCTIONS - test_path = Path(".local/data/comdirect/umsaetze_9773063005_20260906-2043.csv") + test_path = Path(TEST_DATA_PATH) assert test_path.exists() data = reader_comdirect(test_path) assert data - assert len(data) == len(ACCOUNT_INGESTION_FUNCTIONS.get(bank_name)) - assert set(data) == set(ACCOUNT_INGESTION_FUNCTIONS.get(bank_name)) + assert len(data) == len(COMDIRECT_ACCOUNTS) + assert set(data) == set(COMDIRECT_ACCOUNTS) assert all(isinstance(row, ComdirectGiro) for row in data["Girokonto"]) assert all(isinstance(row, ComdirectCreditCard) for row in data["Visa-Karte (Kreditkarte)"]) assert all(isinstance(row, ComdirectGiro) for row in data["Tagesgeld PLUS-Konto"]) diff --git a/uv.lock b/uv.lock index 92a955e..d235a00 100644 --- a/uv.lock +++ b/uv.lock @@ -128,6 +128,7 @@ dependencies = [ dev = [ { name = "black" }, { name = "debugpy" }, + { name = "faker" }, { name = "polyfactory" }, ] @@ -146,6 +147,7 @@ requires-dist = [ dev = [ { name = "black", specifier = ">=26.5.1" }, { name = "debugpy", specifier = ">=1.8.21" }, + { name = "faker", specifier = ">=40.38.0" }, { name = "polyfactory", specifier = ">=3.3.0" }, ] From 0ca784fd80a442a932d83bdeedef692018f00bb8 Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Fri, 11 Sep 2026 10:17:35 +0200 Subject: [PATCH 06/31] black --- src/models.py | 9 +++++++-- src/reader.py | 4 +++- src/settings/constants.py | 4 +--- src/settings/models.py | 3 --- tests/test_reader.py | 4 +++- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/models.py b/src/models.py index a500eeb..8d03906 100644 --- a/src/models.py +++ b/src/models.py @@ -95,12 +95,15 @@ def _parse_date_german(date_str: str) -> date | None: day, month, year = list(map(int, date_str.split("."))) return date(day=day, month=month, year=year) + def _parse_decimal_german(decimal_str: str) -> Decimal: return Decimal(decimal_str.replace(".", "").replace(",", ".")) class ComdirectBase(BaseModel): - date_val: date | None = Field(alias="Wertstellung (Valuta)", examples=["07.09.2026"]) + date_val: date | None = Field( + alias="Wertstellung (Valuta)", examples=["07.09.2026"] + ) amount_eur: Decimal = Field(alias="Umsatz in EUR", examples=["32,43"]) @field_validator("date_val", mode="before") @@ -138,7 +141,9 @@ class ComdirectCreditCard(ComdirectGiro): ) reference: str = Field(alias="Referenz", default="") text: str = Field(alias="Buchungstext", default="") - amount_eur: Decimal | None = Field(alias="Umsatz in EUR", examples=["32,43"], default=None) + amount_eur: Decimal | None = Field( + alias="Umsatz in EUR", examples=["32,43"], default=None + ) class ComdirectDepot(ComdirectBase): diff --git a/src/reader.py b/src/reader.py index db7de09..4df5906 100644 --- a/src/reader.py +++ b/src/reader.py @@ -18,11 +18,11 @@ def reader_b4w(path: Path): return list(map(B4wExport.model_validate, raw)) - PATTERN_ACCOUNT_NAME = r'^"Umsätze ([^"]+)";"Zeitraum: .*$' PATTERN_HEADER = r'"([^"]+)"' PATTERN_DATA = r'"([^"]+)"' + class EXPECT_LINE(Enum): ACCOUNT_NAME = auto() BLANK = auto() @@ -33,6 +33,7 @@ class EXPECT_LINE(Enum): COMDIRECT_ACCOUNTS = BANK_ACCOUNTS.get("comdirect", {}) assert isinstance(COMDIRECT_ACCOUNTS, dict) + def reader_comdirect(path: Path): result = {} raw = _reader_raw_comdirect(path) @@ -42,6 +43,7 @@ def reader_comdirect(path: Path): result[account_name] = list(map(account.ingestion_function, account_data)) return result + def _reader_raw_comdirect(path: Path): with path.open(encoding="latin1") as fid: diff --git a/src/settings/constants.py b/src/settings/constants.py index a8cb886..fc2cd09 100644 --- a/src/settings/constants.py +++ b/src/settings/constants.py @@ -1,8 +1,8 @@ - from enum import Enum IBAN_PATTERN = r"^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$" + class Currency(str, Enum): EUR = "EUR" USD = "USD" @@ -14,5 +14,3 @@ class Currency(str, Enum): def __repr__(self): return f"Currency.{self.name}" - - diff --git a/src/settings/models.py b/src/settings/models.py index d58994a..485b8db 100644 --- a/src/settings/models.py +++ b/src/settings/models.py @@ -9,6 +9,3 @@ class AccountInfo(BaseModel): iban: str = Field(pattern=IBAN_PATTERN) ingestion_function: Callable currency: Currency - - - diff --git a/tests/test_reader.py b/tests/test_reader.py index 30aea9c..927af5c 100644 --- a/tests/test_reader.py +++ b/tests/test_reader.py @@ -14,6 +14,8 @@ def test_reader_comdirect(): assert len(data) == len(COMDIRECT_ACCOUNTS) assert set(data) == set(COMDIRECT_ACCOUNTS) assert all(isinstance(row, ComdirectGiro) for row in data["Girokonto"]) - assert all(isinstance(row, ComdirectCreditCard) for row in data["Visa-Karte (Kreditkarte)"]) + assert all( + isinstance(row, ComdirectCreditCard) for row in data["Visa-Karte (Kreditkarte)"] + ) assert all(isinstance(row, ComdirectGiro) for row in data["Tagesgeld PLUS-Konto"]) assert all(isinstance(row, ComdirectDepot) for row in data["Depot"]) From f815ff2c0a5b7b5345635eb51cfa7acbbfa3fefd Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Fri, 11 Sep 2026 11:36:25 +0200 Subject: [PATCH 07/31] first Durchstich: comdirect Giro to DB --- alembic/versions/8822a28b01ca_.py | 52 +++++++++++++++++++++++++++++++ src/helper_db.py | 36 ++++++++++++++++++--- src/main.py | 26 +++++++++++++--- src/models.py | 23 +++++++++++++- src/models_db.py | 45 +++++++++++++++++++++++--- src/reader.py | 6 ++-- 6 files changed, 170 insertions(+), 18 deletions(-) create mode 100644 alembic/versions/8822a28b01ca_.py diff --git a/alembic/versions/8822a28b01ca_.py b/alembic/versions/8822a28b01ca_.py new file mode 100644 index 0000000..6a39905 --- /dev/null +++ b/alembic/versions/8822a28b01ca_.py @@ -0,0 +1,52 @@ +"""empty message + +Revision ID: 8822a28b01ca +Revises: 605e46acc08e +Create Date: 2026-09-11 11:13:48.650768 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "8822a28b01ca" +down_revision: str | Sequence[str] | None = "605e46acc08e" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "comdirect_giro", + sa.Column("type", sa.String(), nullable=True), + sa.Column("date_book", sa.Date(), nullable=True), + sa.Column("account_iban", sa.String(), nullable=False), + sa.Column("account_currency", sa.String(length=3), nullable=False), + sa.Column("amount_currency", sa.String(length=3), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("hash_id", sa.String(), nullable=False), + sa.Column("date_valid", sa.Date(), nullable=False), + sa.Column("amount", sa.Numeric(precision=12, scale=2), nullable=False), + sa.Column("text", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column("import_b4w", sa.Column("hash_id", sa.String(), nullable=False)) + op.add_column("import_b4w", sa.Column("text", sa.String(), nullable=True)) + op.drop_column("import_b4w", "reference") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("import_b4w", sa.Column("reference", sa.VARCHAR(), nullable=True)) + op.drop_column("import_b4w", "text") + op.drop_column("import_b4w", "hash_id") + op.drop_table("comdirect_giro") + # ### end Alembic commands ### diff --git a/src/helper_db.py b/src/helper_db.py index 7e38db0..2afe45a 100644 --- a/src/helper_db.py +++ b/src/helper_db.py @@ -2,8 +2,8 @@ from typing import Any from sqlalchemy.orm import Session -from src.models import B4wExport, Category -from src.models_db import B4wDb, BaseModelDb, CategoryDb +from src.models import B4wExport, Category, ComdirectGiro +from src.models_db import B4wDb, BaseModelDb, CategoryDb, ComdirectGiroDb def _query_existing( @@ -62,17 +62,18 @@ def upsert_b4w( ) -> B4wDb | None: category_db = upsert_category(session, data.category) - parameters_existing = {"id_b4w": data.id} + parameters_existing = {"hash_id": data.hash_id} parameters_new = { "id_b4w": data.id, + "hash_id": data.hash_id, "account_iban": data.account_iban, "account_currency": data.account_currency.value, "date_valid": data.date_valid, "date_book": data.date_book, "amount": data.amount_signed, "amount_currency": data.amount_currency.value, - "reference": data.reference, + "text": data.reference, "other_name": data.other_name, "other_iban": data.other_iban, "category": category_db, @@ -87,3 +88,30 @@ def upsert_b4w( do_update=do_update, do_commit=do_commit, ) + + +def upsert_comdirect_giro( + session: Session, data: ComdirectGiro, do_update=False, do_commit=True +) -> ComdirectGiroDb | None: + parameters_existing = {"hash_id": data.hash_id} + + if not data.date_val: + return + + parameters_new = { + "hash_id": data.hash_id, + "date_valid": data.date_val, + "amount": data.amount_eur, + "date_book": data.date_book, + "type": data.type, + "text": data.text, + } + + return _upsert( + session, + ComdirectGiroDb, + parameters_existing, + parameters_new, + do_update=do_update, + do_commit=do_commit, + ) diff --git a/src/main.py b/src/main.py index 8f7f77f..e7a7aa1 100644 --- a/src/main.py +++ b/src/main.py @@ -2,8 +2,8 @@ import argparse from pathlib import Path from src.db import get_session -from src.helper_db import upsert_b4w -from src.reader import reader_b4w +from src.helper_db import upsert_b4w, upsert_comdirect_giro +from src.reader import reader_b4w, reader_comdirect def ingest_b4w(path: Path): @@ -14,16 +14,32 @@ def ingest_b4w(path: Path): session.commit() +def ingest_comdirect(path: Path): + data = reader_comdirect(path).get("Girokonto") + assert isinstance(data, list) + with get_session() as session: + for row in data: + upsert_comdirect_giro(session, row, do_commit=False) + session.commit() + + def main(): parser = argparse.ArgumentParser( description="Eine App um den Überblick über Finanzen zu behalten" ) - parser.add_argument("input", help="Pfad einer einzulesenden Umsatzdatei (CSV)") + parser.add_argument( + "--input_b4w", help="Pfad einer einzulesenden Umsatzdatei (CSV)" + ) + parser.add_argument( + "--input_comdirect", help="Pfad einer einzulesenden Umsatzdatei (CSV)" + ) args = parser.parse_args() - path = Path(args.input) + path_b4w = Path(args.input_b4w) + path_comdirect = Path(args.input_comdirect) - ingest_b4w(path) + ingest_b4w(path_b4w) + ingest_comdirect(path_comdirect) if __name__ == "__main__": diff --git a/src/models.py b/src/models.py index 8d03906..804fc05 100644 --- a/src/models.py +++ b/src/models.py @@ -1,12 +1,18 @@ +import hashlib from datetime import date from decimal import Decimal from enum import Enum -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, computed_field, field_validator from src.settings.constants import IBAN_PATTERN, Currency +def _hash(*args): + raw = "".join(str(args)) + return hashlib.sha256(raw.encode()).hexdigest() + + class Sign(str, Enum): DEBIT = "DBIT" CREDIT = "CRDT" @@ -44,6 +50,11 @@ class B4wExport(BaseModel): ) notes: str = Field(alias="Notes") + @computed_field + @property + def hash_id(self) -> str: + return _hash(self.id) + @property def amount_signed(self) -> Decimal: return self.amount * self.sign.to_sign() @@ -67,6 +78,11 @@ class Category(BaseModel): name: str = Field(pattern=r"[^:\r\n]+") parent: Category | None = None + @computed_field + @property + def hash_id(self) -> str: + return _hash(self.name, self.parent) + @classmethod def from_str(cls, s: str) -> Category | None: if not s: @@ -124,6 +140,11 @@ class ComdirectGiro(ComdirectBase): ) text: str = Field(alias="Buchungstext") + @computed_field + @property + def hash_id(self) -> str: + return _hash(self.date_val, self.date_book, self.text) + @field_validator("date_book", mode="before") @classmethod def parse_date_book(cls, date_str: str) -> date | None: diff --git a/src/models_db.py b/src/models_db.py index aefe066..185b824 100644 --- a/src/models_db.py +++ b/src/models_db.py @@ -9,6 +9,9 @@ from sqlalchemy import ( ) from sqlalchemy.orm import DeclarativeBase, relationship +from settings import BANK_ACCOUNTS +from src.settings.models import AccountInfo + class BaseModelDb(DeclarativeBase): pass @@ -33,18 +36,24 @@ class CategoryDb(BaseModelDb): UniqueConstraint("name", "parent_id", name="uq_category_name_parent") -class B4wDb(BaseModelDb): - __tablename__ = "import_b4w" +class IngestdbBase(BaseModelDb): + __abstract__ = True id = Column(Integer, primary_key=True, autoincrement=True) - id_b4w = Column(Integer, nullable=False, unique=True) + hash_id = Column(String, nullable=False) account_iban = Column(String, nullable=False) account_currency = Column(String(3), nullable=False) date_valid = Column(Date, nullable=False) - date_book = Column(Date, nullable=True) amount = Column(Numeric(12, 2), nullable=False) amount_currency = Column(String(3), nullable=False) - reference = Column(String, nullable=True) + text = Column(String, nullable=True) + + +class B4wDb(IngestdbBase): + __tablename__ = "import_b4w" + + id_b4w = Column(Integer, nullable=False, unique=True) + date_book = Column(Date, nullable=True) other_name = Column(String, nullable=True) other_iban = Column(String, nullable=True) category_id = Column(Integer, ForeignKey("category.id"), nullable=True) @@ -52,3 +61,29 @@ class B4wDb(BaseModelDb): # relationship category = relationship("CategoryDb") + + +COMDIRECT_INFO = BANK_ACCOUNTS.get("comdirect") +assert isinstance(COMDIRECT_INFO, dict) + +COMDIRECT_GIRO_INFO = COMDIRECT_INFO.get("Girokonto", {}) +assert isinstance(COMDIRECT_GIRO_INFO, AccountInfo) + + +class ComdirectBaseDb(IngestdbBase): + __abstract__ = True + + account_iban = Column(String, nullable=False, default=COMDIRECT_GIRO_INFO.iban) + account_currency = Column( + String(3), nullable=False, default=COMDIRECT_GIRO_INFO.currency + ) + amount_currency = Column( + String(3), nullable=False, default=COMDIRECT_GIRO_INFO.currency + ) + + +class ComdirectGiroDb(ComdirectBaseDb): + __tablename__ = "comdirect_giro" + + type = Column(String, nullable=True) + date_book = Column(Date, nullable=True) diff --git a/src/reader.py b/src/reader.py index 4df5906..c35bf33 100644 --- a/src/reader.py +++ b/src/reader.py @@ -4,7 +4,7 @@ from enum import Enum, auto from pathlib import Path from settings import BANK_ACCOUNTS -from src.models import B4wExport +from src.models import B4wExport, ComdirectGiro from src.settings.models import AccountInfo @@ -13,7 +13,7 @@ def _reader_raw(path: Path): return list(csv.DictReader(fid)) -def reader_b4w(path: Path): +def reader_b4w(path: Path) -> list[B4wExport]: raw = _reader_raw(path) return list(map(B4wExport.model_validate, raw)) @@ -34,7 +34,7 @@ COMDIRECT_ACCOUNTS = BANK_ACCOUNTS.get("comdirect", {}) assert isinstance(COMDIRECT_ACCOUNTS, dict) -def reader_comdirect(path: Path): +def reader_comdirect(path: Path) -> dict[str, list[ComdirectGiro]]: result = {} raw = _reader_raw_comdirect(path) for account_name, account_data in raw.items(): From 69c13f93aca6fc639513f059e6fa4224247e2ea5 Mon Sep 17 00:00:00 2001 From: Mathias Schneider Date: Fri, 11 Sep 2026 14:40:38 +0200 Subject: [PATCH 08/31] heavy refactor --- .gitignore | 3 + alembic/env.py | 2 +- settings.template.py | 80 ++++++++---- src/{settings => }/constants.py | 0 src/helper_db.py | 117 ----------------- src/{settings => ingest}/__init__.py | 0 src/ingest/_common.py | 44 +++++++ src/ingest/b4w.py | 72 +++++++++++ src/ingest/comdirect.py | 130 +++++++++++++++++++ src/main.py | 38 +++--- src/models.py | 181 --------------------------- src/models/__init__.py | 7 ++ src/models/_common.py | 54 ++++++++ src/models/b4w.py | 120 ++++++++++++++++++ src/models/comdirect.py | 88 +++++++++++++ src/models_db.py | 89 ------------- src/reader.py | 80 ------------ src/settings/models.py | 11 -- tests/test_helper_db.py | 4 +- tests/test_reader.py | 13 +- 20 files changed, 599 insertions(+), 534 deletions(-) rename src/{settings => }/constants.py (100%) delete mode 100644 src/helper_db.py rename src/{settings => ingest}/__init__.py (100%) create mode 100644 src/ingest/_common.py create mode 100644 src/ingest/b4w.py create mode 100644 src/ingest/comdirect.py delete mode 100644 src/models.py create mode 100644 src/models/__init__.py create mode 100644 src/models/_common.py create mode 100644 src/models/b4w.py create mode 100644 src/models/comdirect.py delete mode 100644 src/models_db.py delete mode 100644 src/reader.py delete mode 100644 src/settings/models.py diff --git a/.gitignore b/.gitignore index 2e81ff7..ff8b1cb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ dist/ wheels/ *.egg-info +# IDE +.idea + # Virtual environments .venv diff --git a/alembic/env.py b/alembic/env.py index c2e9aac..6dfa89b 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -6,7 +6,7 @@ from sqlalchemy import engine_from_config, pool from alembic import context from src.db import get_set_engine -from src.models_db import BaseModelDb +from src.models._common import BaseModelDb load_dotenv() # reads .env into os.environ diff --git a/settings.template.py b/settings.template.py index 10677d4..7f0e48c 100644 --- a/settings.template.py +++ b/settings.template.py @@ -1,32 +1,62 @@ +from pathlib import Path + from faker import Faker -from src.models import ComdirectCreditCard, ComdirectDepot, ComdirectGiro -from src.settings.constants import Currency -from src.settings.models import AccountInfo +from src.constants import Currency +from src.ingest.b4w import ingest_b4w, reader_b4w +from src.ingest.comdirect import reader_comdirect +from src.models import ( + ComdirectCreditCard, + ComdirectDepot, + ComdirectGiro, +) +from src.models.settings import Account, Source fake = Faker() BANK_ACCOUNTS = { - "comdirect": { - "Girokonto": AccountInfo( - iban=fake.iban(), - ingestion_function=lambda x: ComdirectGiro.model_validate(x), - currency=Currency.EUR, - ), - "Visa-Karte (Kreditkarte)": AccountInfo( - iban=fake.iban(), - ingestion_function=lambda x: ComdirectCreditCard.model_validate(x), - currency=Currency.EUR, - ), - "Tagesgeld PLUS-Konto": AccountInfo( - iban=fake.iban(), - ingestion_function=lambda x: ComdirectGiro.model_validate(x), - currency=Currency.EUR, - ), - "Depot": AccountInfo( - iban=fake.iban(), - ingestion_function=lambda x: ComdirectDepot.model_validate(x), - currency=Currency.EUR, - ), - } + "b4w": Source( + name="b4w", + source_path=Path(""), + reader_function=reader_b4w, + accounts=[ + Account( + name="b4w", + ingestion_function=ingest_b4w, + iban=fake.iban(), + currency=Currency.EUR, + ) + ], + ), + "comdirect": Source( + name="comdirect", + source_path=Path(""), + reader_function=reader_comdirect, + accounts=[ + Account( + name="Girokonto", + ingestion_function=lambda x: ComdirectGiro.model_validate(x), + iban=fake.iban(), + currency=Currency.EUR, + ), + Account( + name="Visa-Karte (Kreditkarte)", + ingestion_function=lambda x: ComdirectCreditCard.model_validate(x), + iban=fake.iban(), + currency=Currency.EUR, + ), + Account( + name="Tagesgeld PLUS-Konto", + ingestion_function=lambda x: ComdirectGiro.model_validate(x), + iban=fake.iban(), + currency=Currency.EUR, + ), + Account( + name="Depot", + ingestion_function=lambda x: ComdirectDepot.model_validate(x), + iban=fake.iban(), + currency=Currency.EUR, + ), + ], + ), } diff --git a/src/settings/constants.py b/src/constants.py similarity index 100% rename from src/settings/constants.py rename to src/constants.py diff --git a/src/helper_db.py b/src/helper_db.py deleted file mode 100644 index 2afe45a..0000000 --- a/src/helper_db.py +++ /dev/null @@ -1,117 +0,0 @@ -from typing import Any - -from sqlalchemy.orm import Session - -from src.models import B4wExport, Category, ComdirectGiro -from src.models_db import B4wDb, BaseModelDb, CategoryDb, ComdirectGiroDb - - -def _query_existing( - session: Session, DbModel: type[BaseModelDb], query_parameters: dict[str, Any] -) -> BaseModelDb | None: - query = session.query(DbModel).filter_by(**query_parameters) - return query.first() - - -def _upsert[DbModelT: BaseModelDb]( - session: Session, - DbModel: type[DbModelT], - parameters_existing: dict[str, Any], - parameters_new: dict[str, Any], - do_update: bool = False, - do_commit: bool = True, -) -> DbModelT: - existing_db_row = _query_existing(session, DbModel, parameters_existing) - - if existing_db_row: - if do_update: - for key, value in parameters_new.items(): - setattr(existing_db_row, key, value) - assert isinstance(existing_db_row, DbModel) - return existing_db_row - - new_db_row = DbModel(**parameters_new) - session.add(new_db_row) - session.flush() - if do_commit: - session.commit() - return new_db_row - - -def upsert_category( - session: Session, category: Category | None, do_commit=True -) -> CategoryDb | None: - if category is None: - return None - parameters = { - "name": category.name, - "parent": upsert_category(session, category.parent), - } - return _upsert( - session, - CategoryDb, - parameters, - parameters, - do_update=False, - do_commit=do_commit, - ) - - -def upsert_b4w( - session: Session, data: B4wExport, do_update=False, do_commit=True -) -> B4wDb | None: - category_db = upsert_category(session, data.category) - - parameters_existing = {"hash_id": data.hash_id} - - parameters_new = { - "id_b4w": data.id, - "hash_id": data.hash_id, - "account_iban": data.account_iban, - "account_currency": data.account_currency.value, - "date_valid": data.date_valid, - "date_book": data.date_book, - "amount": data.amount_signed, - "amount_currency": data.amount_currency.value, - "text": data.reference, - "other_name": data.other_name, - "other_iban": data.other_iban, - "category": category_db, - "notes": data.notes, - } - - return _upsert( - session, - B4wDb, - parameters_existing, - parameters_new, - do_update=do_update, - do_commit=do_commit, - ) - - -def upsert_comdirect_giro( - session: Session, data: ComdirectGiro, do_update=False, do_commit=True -) -> ComdirectGiroDb | None: - parameters_existing = {"hash_id": data.hash_id} - - if not data.date_val: - return - - parameters_new = { - "hash_id": data.hash_id, - "date_valid": data.date_val, - "amount": data.amount_eur, - "date_book": data.date_book, - "type": data.type, - "text": data.text, - } - - return _upsert( - session, - ComdirectGiroDb, - parameters_existing, - parameters_new, - do_update=do_update, - do_commit=do_commit, - ) diff --git a/src/settings/__init__.py b/src/ingest/__init__.py similarity index 100% rename from src/settings/__init__.py rename to src/ingest/__init__.py diff --git a/src/ingest/_common.py b/src/ingest/_common.py new file mode 100644 index 0000000..906a5b8 --- /dev/null +++ b/src/ingest/_common.py @@ -0,0 +1,44 @@ +import csv +from pathlib import Path +from typing import Any + +from sqlalchemy.orm import Session + +from src.models._common import BaseModelDb + + +def _query_existing( + session: Session, DbModel: type[BaseModelDb], query_parameters: dict[str, Any] +) -> BaseModelDb | None: + query = session.query(DbModel).filter_by(**query_parameters) + return query.first() + + +def _upsert[DbModelT: BaseModelDb]( + session: Session, + DbModel: type[DbModelT], + parameters_existing: dict[str, Any], + parameters_new: dict[str, Any], + do_update: bool = False, + do_commit: bool = True, +) -> DbModelT: + existing_db_row = _query_existing(session, DbModel, parameters_existing) + + if existing_db_row: + if do_update: + for key, value in parameters_new.items(): + setattr(existing_db_row, key, value) + assert isinstance(existing_db_row, DbModel) + return existing_db_row + + new_db_row = DbModel(**parameters_new) + session.add(new_db_row) + session.flush() + if do_commit: + session.commit() + return new_db_row + + +def _reader_raw(path: Path): + with path.open() as fid: + return list(csv.DictReader(fid)) diff --git a/src/ingest/b4w.py b/src/ingest/b4w.py new file mode 100644 index 0000000..6391289 --- /dev/null +++ b/src/ingest/b4w.py @@ -0,0 +1,72 @@ +from pathlib import Path + +from sqlalchemy.orm import Session + +from src.db import get_session +from src.ingest._common import _reader_raw, _upsert +from src.models.b4w import B4wDb, B4wExport, Category, CategoryDb + + +def upsert_category( + session: Session, category: Category | None, do_commit=True +) -> CategoryDb | None: + if category is None: + return None + parameters = { + "name": category.name, + "parent": upsert_category(session, category.parent), + } + return _upsert( + session, + CategoryDb, + parameters, + parameters, + do_update=False, + do_commit=do_commit, + ) + + +def upsert_b4w( + session: Session, data: B4wExport, do_update=False, do_commit=True +) -> B4wDb | None: + category_db = upsert_category(session, data.category) + + parameters_existing = {"hash_id": data.hash_id} + + parameters_new = { + "id_b4w": data.id, + "hash_id": data.hash_id, + "account_iban": data.account_iban, + "account_currency": data.account_currency.value, + "date_valid": data.date_valid, + "date_book": data.date_book, + "amount": data.amount_signed, + "amount_currency": data.amount_currency.value, + "text": data.reference, + "other_name": data.other_name, + "other_iban": data.other_iban, + "category": category_db, + "notes": data.notes, + } + + return _upsert( + session, + B4wDb, + parameters_existing, + parameters_new, + do_update=do_update, + do_commit=do_commit, + ) + + +def ingest_b4w(path: Path): + data = reader_b4w(path) + with get_session() as session: + for row in data: + upsert_b4w(session, row, do_commit=False) + session.commit() + + +def reader_b4w(path: Path) -> list[B4wExport]: + raw = _reader_raw(path) + return list(map(B4wExport.model_validate, raw)) diff --git a/src/ingest/comdirect.py b/src/ingest/comdirect.py new file mode 100644 index 0000000..234f8a7 --- /dev/null +++ b/src/ingest/comdirect.py @@ -0,0 +1,130 @@ +import re +from enum import Enum, auto +from pathlib import Path + +from sqlalchemy.orm import Session + +from src.db import get_session +from src.ingest._common import _upsert +from src.models.comdirect import ( + ComdirectCreditCard, + ComdirectDepot, + ComdirectGiro, + ComdirectGiroDb, +) + +PATTERN_ACCOUNT_NAME = r'^"Umsätze ([^"]+)";"Zeitraum: .*$' +PATTERN_HEADER = r'"([^"]+)"' +PATTERN_DATA = r'"([^"]+)"' + + +class EXPECT_LINE(Enum): + ACCOUNT_NAME = auto() + BLANK = auto() + HEADER = auto() + DATA = auto() + + +COMDIRECT_ACCOUNT_PARSERS = { + "Girokonto": lambda row: ComdirectGiro.model_validate(row), + "Visa-Karte (Kreditkarte)": lambda row: ComdirectCreditCard.model_validate(row), + "Tagesgeld PLUS-Konto": lambda row: ComdirectGiro.model_validate(row), + "Depot": lambda row: ComdirectDepot.model_validate(row), +} + + +def upsert_comdirect_giro( + session: Session, + data: ComdirectGiro, + account_iban: str, + account_currency: str, + do_update=False, + do_commit=True, +) -> ComdirectGiroDb | None: + parameters_existing = {"hash_id": data.hash_id} + + if not data.date_val: + return + + parameters_new = { + "hash_id": data.hash_id, + "account_iban": account_iban, + "account_currency": account_currency, + "amount_currency": account_currency, + "date_valid": data.date_val, + "amount": data.amount_eur, + "date_book": data.date_book, + "type": data.type, + "text": data.text, + } + + return _upsert( + session, + ComdirectGiroDb, + parameters_existing, + parameters_new, + do_update=do_update, + do_commit=do_commit, + ) + + +def ingest_comdirect(path: Path, account_iban: str, account_currency: str): + data = reader_comdirect(path).get("Girokonto") + assert isinstance(data, list) + with get_session() as session: + for row in data: + upsert_comdirect_giro( + session, + row, + account_iban, + account_currency, + do_commit=False, + ) + session.commit() + + +def reader_comdirect(path: Path) -> dict[str, list]: + result = {} + raw = _reader_raw_comdirect(path) + for account_name, account_data in raw.items(): + parser = COMDIRECT_ACCOUNT_PARSERS.get(account_name) + if parser is None: + continue + result[account_name] = list(map(parser, account_data)) + return result + + +def _reader_raw_comdirect(path: Path): + + with path.open(encoding="latin1") as fid: + lines = fid.readlines() + + expect_next = EXPECT_LINE.ACCOUNT_NAME + account_name = None + result = {} + for line in lines: + if expect_next == EXPECT_LINE.ACCOUNT_NAME: + account_name = re.findall(PATTERN_ACCOUNT_NAME, line) + if account_name: + account_name = account_name[0] + result[account_name] = {} + expect_next = EXPECT_LINE.BLANK + elif expect_next == EXPECT_LINE.BLANK: + if line == "\n": + expect_next = EXPECT_LINE.HEADER + elif expect_next == EXPECT_LINE.HEADER: + header = re.findall(PATTERN_HEADER, line) + if header: + result[account_name] = [] + expect_next = EXPECT_LINE.DATA + elif expect_next == EXPECT_LINE.DATA: + data = re.findall(PATTERN_DATA, line) + if data: + result[account_name].append( + {column: value for column, value in zip(header, data)} + ) + else: + expect_next = EXPECT_LINE.ACCOUNT_NAME + else: + expect_next = EXPECT_LINE.ACCOUNT_NAME + return result diff --git a/src/main.py b/src/main.py index e7a7aa1..340b7d6 100644 --- a/src/main.py +++ b/src/main.py @@ -1,26 +1,9 @@ import argparse from pathlib import Path -from src.db import get_session -from src.helper_db import upsert_b4w, upsert_comdirect_giro -from src.reader import reader_b4w, reader_comdirect - - -def ingest_b4w(path: Path): - data = reader_b4w(path) - with get_session() as session: - for row in data: - upsert_b4w(session, row, do_commit=False) - session.commit() - - -def ingest_comdirect(path: Path): - data = reader_comdirect(path).get("Girokonto") - assert isinstance(data, list) - with get_session() as session: - for row in data: - upsert_comdirect_giro(session, row, do_commit=False) - session.commit() +from src.ingest.b4w import ingest_b4w +from src.ingest.comdirect import ingest_comdirect +from src.models.settings import Account def main(): @@ -35,11 +18,18 @@ def main(): ) args = parser.parse_args() - path_b4w = Path(args.input_b4w) - path_comdirect = Path(args.input_comdirect) + if args.input_b4w: + ingest_b4w(Path(args.input_b4w)) + if args.input_comdirect: + import settings - ingest_b4w(path_b4w) - ingest_comdirect(path_comdirect) + giro_account = settings.BANK_ACCOUNTS["comdirect"].get_account("Girokonto") + assert isinstance(giro_account, Account) + ingest_comdirect( + Path(args.input_comdirect), + account_iban=giro_account.iban, + account_currency=giro_account.currency, + ) if __name__ == "__main__": diff --git a/src/models.py b/src/models.py deleted file mode 100644 index 804fc05..0000000 --- a/src/models.py +++ /dev/null @@ -1,181 +0,0 @@ -import hashlib -from datetime import date -from decimal import Decimal -from enum import Enum - -from pydantic import BaseModel, Field, computed_field, field_validator - -from src.settings.constants import IBAN_PATTERN, Currency - - -def _hash(*args): - raw = "".join(str(args)) - return hashlib.sha256(raw.encode()).hexdigest() - - -class Sign(str, Enum): - DEBIT = "DBIT" - CREDIT = "CRDT" - - def __str__(self): - return self.value - - def __repr__(self): - return f"Sign.{self.name}" - - def to_sign(self) -> int: - return -1 if self is Sign.DEBIT else 1 - - -class B4wExport(BaseModel): - id: int = Field(alias="Id1", description="internal B4w ID") - account_iban: str = Field(alias="OwnrAcctIBAN", pattern=IBAN_PATTERN) - account_currency: Currency = Field(alias="OwnrAcctCcy") - date_valid: date = Field(alias="ValDt") - date_book: date = Field(alias="BookgDt") - amount: Decimal = Field(alias="Amt") - amount_currency: Currency = Field(alias="AmtCcy") - sign: Sign = Field(alias="CdtDbtInd") - reference: str = Field(alias="RmtInf") - other_name: str = Field(alias="RmtdNm") - other_iban: str = Field(alias="RmtdAcctIBAN") - category: Category | None = Field( - alias="Category", - examples=[ - "Freizeit", - "Beruf:Gehalt:super Firma", - "Lebenshaltung:Lebensmitteleinkäuft", - "", - ], - ) - notes: str = Field(alias="Notes") - - @computed_field - @property - def hash_id(self) -> str: - return _hash(self.id) - - @property - def amount_signed(self) -> Decimal: - return self.amount * self.sign.to_sign() - - @field_validator("category", mode="before") - @classmethod - def category_str_is_parsable( - cls, category_in: str | Category | None - ) -> Category | None: - if category_in is None or category_in == "": - return None - if isinstance(category_in, Category): - return category_in - category = Category.from_str(category_in) - if category is None or category.to_str() != category_in: - raise ValueError(f"Category string cannot be parsed: {category_in!r}") - return category - - -class Category(BaseModel): - name: str = Field(pattern=r"[^:\r\n]+") - parent: Category | None = None - - @computed_field - @property - def hash_id(self) -> str: - return _hash(self.name, self.parent) - - @classmethod - def from_str(cls, s: str) -> Category | None: - if not s: - return - components = s.split(":") - name = components[-1] - rest = ":".join(components[:-1]) - return cls(name=name, parent=cls.from_str(rest)) - - def to_str(self) -> str | None: - if not self.parent: - return self.name - else: - return f"{self.parent.to_str()}:{self.name}" - - def __str__(self): - return self.to_str() - - def __repr__(self): - return f'Category.from_str("{self.__str__()}")' - - -def _parse_date_german(date_str: str) -> date | None: - if date_str in ["offen", "neu", "--"]: - return None - day, month, year = list(map(int, date_str.split("."))) - return date(day=day, month=month, year=year) - - -def _parse_decimal_german(decimal_str: str) -> Decimal: - return Decimal(decimal_str.replace(".", "").replace(",", ".")) - - -class ComdirectBase(BaseModel): - date_val: date | None = Field( - alias="Wertstellung (Valuta)", examples=["07.09.2026"] - ) - amount_eur: Decimal = Field(alias="Umsatz in EUR", examples=["32,43"]) - - @field_validator("date_val", mode="before") - @classmethod - def parse_date_val(cls, date_str: str) -> date | None: - return _parse_date_german(date_str) - - @field_validator("amount_eur", mode="before") - @classmethod - def parse_amount(cls, decimal_str: str) -> Decimal: - return _parse_decimal_german(decimal_str) - - -class ComdirectGiro(ComdirectBase): - date_book: date | None = Field(alias="Buchungstag", examples=["07.09.2026"]) - type: str = Field( - alias="Vorgang", examples=["Lastschrift / Belastung", "Kartenumsatz"] - ) - text: str = Field(alias="Buchungstext") - - @computed_field - @property - def hash_id(self) -> str: - return _hash(self.date_val, self.date_book, self.text) - - @field_validator("date_book", mode="before") - @classmethod - def parse_date_book(cls, date_str: str) -> date | None: - return _parse_date_german(date_str) - - -class ComdirectCreditCard(ComdirectGiro): - # "Buchungstag";"Umsatztag";"Vorgang";"Referenz";"Buchungstext";"Umsatz in EUR"; - date_book: date | None = Field(alias="Buchungstag", examples=["07.09.2026"]) - date_val: date | None = Field(alias="Umsatztag", default=None) - type: str = Field( - alias="Vorgang", - examples=["Lastschrift / Belastung", "Kartenumsatz"], - default="", - ) - reference: str = Field(alias="Referenz", default="") - text: str = Field(alias="Buchungstext", default="") - amount_eur: Decimal | None = Field( - alias="Umsatz in EUR", examples=["32,43"], default=None - ) - - -class ComdirectDepot(ComdirectBase): - date_val: date | None = Field(alias="Geschäftstag") - num: Decimal = Field(alias="Stück / Nom.") - name: str = Field(alias="Bezeichnung") - wkn: str = Field(alias="WKN") - currency: str = Field(alias="Währung") - spot_price: Decimal = Field(alias="Ausführungskurs") - - @field_validator("num", "spot_price", mode="before") - @classmethod - def parse_decimals(cls, decimal_in: str) -> Decimal: - return _parse_decimal_german(decimal_in) diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..d602c21 --- /dev/null +++ b/src/models/__init__.py @@ -0,0 +1,7 @@ +from src.models.b4w import B4wExport as B4wExport +from src.models.b4w import Category as Category +from src.models.comdirect import ComdirectCreditCard as ComdirectCreditCard +from src.models.comdirect import ComdirectDepot as ComdirectDepot +from src.models.comdirect import ComdirectGiro as ComdirectGiro +from src.models.settings import Account as Account +from src.models.settings import Source as Source diff --git a/src/models/_common.py b/src/models/_common.py new file mode 100644 index 0000000..75df288 --- /dev/null +++ b/src/models/_common.py @@ -0,0 +1,54 @@ +import hashlib +from datetime import date +from decimal import Decimal +from enum import Enum + +from sqlalchemy import Column, Date, Integer, Numeric, String +from sqlalchemy.orm import DeclarativeBase + + +def _hash(*args): + raw = "".join(str(args)) + return hashlib.sha256(raw.encode()).hexdigest() + + +class Sign(str, Enum): + DEBIT = "DBIT" + CREDIT = "CRDT" + + def __str__(self): + return self.value + + def __repr__(self): + return f"Sign.{self.name}" + + def to_sign(self) -> int: + return -1 if self is Sign.DEBIT else 1 + + +def _parse_date_german(date_str: str) -> date | None: + if date_str in ["offen", "neu", "--"]: + return None + day, month, year = list(map(int, date_str.split("."))) + return date(day=day, month=month, year=year) + + +def _parse_decimal_german(decimal_str: str) -> Decimal: + return Decimal(decimal_str.replace(".", "").replace(",", ".")) + + +class BaseModelDb(DeclarativeBase): + pass + + +class IngestdbBase(BaseModelDb): + __abstract__ = True + + id = Column(Integer, primary_key=True, autoincrement=True) + hash_id = Column(String, nullable=False) + account_iban = Column(String, nullable=False) + account_currency = Column(String(3), nullable=False) + date_valid = Column(Date, nullable=False) + amount = Column(Numeric(12, 2), nullable=False) + amount_currency = Column(String(3), nullable=False) + text = Column(String, nullable=True) diff --git a/src/models/b4w.py b/src/models/b4w.py new file mode 100644 index 0000000..37b66e7 --- /dev/null +++ b/src/models/b4w.py @@ -0,0 +1,120 @@ +from datetime import date +from decimal import Decimal + +from pydantic import BaseModel, Field, computed_field, field_validator +from sqlalchemy import Column, Date, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy.orm import relationship + +from src.constants import IBAN_PATTERN, Currency +from src.models._common import BaseModelDb, IngestdbBase, Sign, _hash + + +class B4wExport(BaseModel): + id: int = Field(alias="Id1", description="internal B4w ID") + account_iban: str = Field(alias="OwnrAcctIBAN", pattern=IBAN_PATTERN) + account_currency: Currency = Field(alias="OwnrAcctCcy") + date_valid: date = Field(alias="ValDt") + date_book: date = Field(alias="BookgDt") + amount: Decimal = Field(alias="Amt") + amount_currency: Currency = Field(alias="AmtCcy") + sign: Sign = Field(alias="CdtDbtInd") + reference: str = Field(alias="RmtInf") + other_name: str = Field(alias="RmtdNm") + other_iban: str = Field(alias="RmtdAcctIBAN") + category: Category | None = Field( + alias="Category", + examples=[ + "Freizeit", + "Beruf:Gehalt:super Firma", + "Lebenshaltung:Lebensmitteleinkäuft", + "", + ], + ) + notes: str = Field(alias="Notes") + + @computed_field + @property + def hash_id(self) -> str: + return _hash(self.id) + + @property + def amount_signed(self) -> Decimal: + return self.amount * self.sign.to_sign() + + @field_validator("category", mode="before") + @classmethod + def category_str_is_parsable( + cls, category_in: str | Category | None + ) -> Category | None: + if category_in is None or category_in == "": + return None + if isinstance(category_in, Category): + return category_in + category = Category.from_str(category_in) + if category is None or category.to_str() != category_in: + raise ValueError(f"Category string cannot be parsed: {category_in!r}") + return category + + +class Category(BaseModel): + name: str = Field(pattern=r"[^:\r\n]+") + parent: Category | None = None + + @computed_field + @property + def hash_id(self) -> str: + return _hash(self.name, self.parent) + + @classmethod + def from_str(cls, s: str) -> Category | None: + if not s: + return + components = s.split(":") + name = components[-1] + rest = ":".join(components[:-1]) + return cls(name=name, parent=cls.from_str(rest)) + + def to_str(self) -> str | None: + if not self.parent: + return self.name + else: + return f"{self.parent.to_str()}:{self.name}" + + def __str__(self): + return self.to_str() + + def __repr__(self): + return f'Category.from_str("{self.__str__()}")' + + +class CategoryDb(BaseModelDb): + __tablename__ = "category" + __table_args__ = ( + UniqueConstraint("name", "parent_id", name="uq_category_name_parent"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False) + parent_id = Column(Integer, ForeignKey("category.id"), nullable=True) + + # Relationships + parent = relationship( + "CategoryDb", remote_side="CategoryDb.id", back_populates="children" + ) + children = relationship("CategoryDb", back_populates="parent") + + UniqueConstraint("name", "parent_id", name="uq_category_name_parent") + + +class B4wDb(IngestdbBase): + __tablename__ = "import_b4w" + + id_b4w = Column(Integer, nullable=False, unique=True) + date_book = Column(Date, nullable=True) + other_name = Column(String, nullable=True) + other_iban = Column(String, nullable=True) + category_id = Column(Integer, ForeignKey("category.id"), nullable=True) + notes = Column(String, nullable=True) + + # relationship + category = relationship("CategoryDb") diff --git a/src/models/comdirect.py b/src/models/comdirect.py new file mode 100644 index 0000000..a25c05d --- /dev/null +++ b/src/models/comdirect.py @@ -0,0 +1,88 @@ +from datetime import date +from decimal import Decimal + +from pydantic import BaseModel, Field, computed_field, field_validator +from sqlalchemy import Column, Date, String + +from src.models._common import ( + IngestdbBase, + _hash, + _parse_date_german, + _parse_decimal_german, +) + + +class ComdirectBase(BaseModel): + date_val: date | None = Field( + alias="Wertstellung (Valuta)", examples=["07.09.2026"] + ) + amount_eur: Decimal = Field(alias="Umsatz in EUR", examples=["32,43"]) + + @field_validator("date_val", mode="before") + @classmethod + def parse_date_val(cls, date_str: str) -> date | None: + return _parse_date_german(date_str) + + @field_validator("amount_eur", mode="before") + @classmethod + def parse_amount(cls, decimal_str: str) -> Decimal: + return _parse_decimal_german(decimal_str) + + +class ComdirectGiro(ComdirectBase): + date_book: date | None = Field(alias="Buchungstag", examples=["07.09.2026"]) + type: str = Field( + alias="Vorgang", examples=["Lastschrift / Belastung", "Kartenumsatz"] + ) + text: str = Field(alias="Buchungstext") + + @computed_field + @property + def hash_id(self) -> str: + return _hash(self.date_val, self.date_book, self.text) + + @field_validator("date_book", mode="before") + @classmethod + def parse_date_book(cls, date_str: str) -> date | None: + return _parse_date_german(date_str) + + +class ComdirectCreditCard(ComdirectGiro): + # "Buchungstag";"Umsatztag";"Vorgang";"Referenz";"Buchungstext";"Umsatz in EUR"; + date_book: date | None = Field(alias="Buchungstag", examples=["07.09.2026"]) + date_val: date | None = Field(alias="Umsatztag", default=None) + type: str = Field( + alias="Vorgang", + examples=["Lastschrift / Belastung", "Kartenumsatz"], + default="", + ) + reference: str = Field(alias="Referenz", default="") + text: str = Field(alias="Buchungstext", default="") + amount_eur: Decimal | None = Field( + alias="Umsatz in EUR", examples=["32,43"], default=None + ) + + +class ComdirectDepot(ComdirectBase): + date_val: date | None = Field(alias="Geschäftstag") + num: Decimal = Field(alias="Stück / Nom.") + name: str = Field(alias="Bezeichnung") + wkn: str = Field(alias="WKN") + currency: str = Field(alias="Währung") + spot_price: Decimal = Field(alias="Ausführungskurs") + + @field_validator("num", "spot_price", mode="before") + @classmethod + def parse_decimals(cls, decimal_in: str) -> Decimal: + return _parse_decimal_german(decimal_in) + + +class ComdirectBaseDb(IngestdbBase): + __abstract__ = True + + +class ComdirectGiroDb(ComdirectBaseDb): + __tablename__ = "comdirect_giro" + + type = Column(String, nullable=True) + date_book = Column(Date, nullable=True) diff --git a/src/models_db.py b/src/models_db.py deleted file mode 100644 index 185b824..0000000 --- a/src/models_db.py +++ /dev/null @@ -1,89 +0,0 @@ -from sqlalchemy import ( - Column, - Date, - ForeignKey, - Integer, - Numeric, - String, - UniqueConstraint, -) -from sqlalchemy.orm import DeclarativeBase, relationship - -from settings import BANK_ACCOUNTS -from src.settings.models import AccountInfo - - -class BaseModelDb(DeclarativeBase): - pass - - -class CategoryDb(BaseModelDb): - __tablename__ = "category" - __table_args__ = ( - UniqueConstraint("name", "parent_id", name="uq_category_name_parent"), - ) - - id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String, nullable=False) - parent_id = Column(Integer, ForeignKey("category.id"), nullable=True) - - # Relationships - parent = relationship( - "CategoryDb", remote_side="CategoryDb.id", back_populates="children" - ) - children = relationship("CategoryDb", back_populates="parent") - - UniqueConstraint("name", "parent_id", name="uq_category_name_parent") - - -class IngestdbBase(BaseModelDb): - __abstract__ = True - - id = Column(Integer, primary_key=True, autoincrement=True) - hash_id = Column(String, nullable=False) - account_iban = Column(String, nullable=False) - account_currency = Column(String(3), nullable=False) - date_valid = Column(Date, nullable=False) - amount = Column(Numeric(12, 2), nullable=False) - amount_currency = Column(String(3), nullable=False) - text = Column(String, nullable=True) - - -class B4wDb(IngestdbBase): - __tablename__ = "import_b4w" - - id_b4w = Column(Integer, nullable=False, unique=True) - date_book = Column(Date, nullable=True) - other_name = Column(String, nullable=True) - other_iban = Column(String, nullable=True) - category_id = Column(Integer, ForeignKey("category.id"), nullable=True) - notes = Column(String, nullable=True) - - # relationship - category = relationship("CategoryDb") - - -COMDIRECT_INFO = BANK_ACCOUNTS.get("comdirect") -assert isinstance(COMDIRECT_INFO, dict) - -COMDIRECT_GIRO_INFO = COMDIRECT_INFO.get("Girokonto", {}) -assert isinstance(COMDIRECT_GIRO_INFO, AccountInfo) - - -class ComdirectBaseDb(IngestdbBase): - __abstract__ = True - - account_iban = Column(String, nullable=False, default=COMDIRECT_GIRO_INFO.iban) - account_currency = Column( - String(3), nullable=False, default=COMDIRECT_GIRO_INFO.currency - ) - amount_currency = Column( - String(3), nullable=False, default=COMDIRECT_GIRO_INFO.currency - ) - - -class ComdirectGiroDb(ComdirectBaseDb): - __tablename__ = "comdirect_giro" - - type = Column(String, nullable=True) - date_book = Column(Date, nullable=True) diff --git a/src/reader.py b/src/reader.py deleted file mode 100644 index c35bf33..0000000 --- a/src/reader.py +++ /dev/null @@ -1,80 +0,0 @@ -import csv -import re -from enum import Enum, auto -from pathlib import Path - -from settings import BANK_ACCOUNTS -from src.models import B4wExport, ComdirectGiro -from src.settings.models import AccountInfo - - -def _reader_raw(path: Path): - with path.open() as fid: - return list(csv.DictReader(fid)) - - -def reader_b4w(path: Path) -> list[B4wExport]: - raw = _reader_raw(path) - return list(map(B4wExport.model_validate, raw)) - - -PATTERN_ACCOUNT_NAME = r'^"Umsätze ([^"]+)";"Zeitraum: .*$' -PATTERN_HEADER = r'"([^"]+)"' -PATTERN_DATA = r'"([^"]+)"' - - -class EXPECT_LINE(Enum): - ACCOUNT_NAME = auto() - BLANK = auto() - HEADER = auto() - DATA = auto() - - -COMDIRECT_ACCOUNTS = BANK_ACCOUNTS.get("comdirect", {}) -assert isinstance(COMDIRECT_ACCOUNTS, dict) - - -def reader_comdirect(path: Path) -> dict[str, list[ComdirectGiro]]: - result = {} - raw = _reader_raw_comdirect(path) - for account_name, account_data in raw.items(): - account = COMDIRECT_ACCOUNTS.get(account_name) - assert isinstance(account, AccountInfo) - result[account_name] = list(map(account.ingestion_function, account_data)) - return result - - -def _reader_raw_comdirect(path: Path): - - with path.open(encoding="latin1") as fid: - lines = fid.readlines() - - expect_next = EXPECT_LINE.ACCOUNT_NAME - account_name = None - result = {} - for line in lines: - if expect_next == EXPECT_LINE.ACCOUNT_NAME: - account_name = re.findall(PATTERN_ACCOUNT_NAME, line) - if account_name: - account_name = account_name[0] - result[account_name] = {} - expect_next = EXPECT_LINE.BLANK - elif expect_next == EXPECT_LINE.BLANK: - if line == "\n": - expect_next = EXPECT_LINE.HEADER - elif expect_next == EXPECT_LINE.HEADER: - header = re.findall(PATTERN_HEADER, line) - if header: - result[account_name] = [] - expect_next = EXPECT_LINE.DATA - elif expect_next == EXPECT_LINE.DATA: - data = re.findall(PATTERN_DATA, line) - if data: - result[account_name].append( - {column: value for column, value in zip(header, data)} - ) - else: - expect_next = EXPECT_LINE.ACCOUNT_NAME - else: - expect_next = EXPECT_LINE.ACCOUNT_NAME - return result diff --git a/src/settings/models.py b/src/settings/models.py deleted file mode 100644 index 485b8db..0000000 --- a/src/settings/models.py +++ /dev/null @@ -1,11 +0,0 @@ -from collections.abc import Callable - -from pydantic import BaseModel, Field - -from src.settings.constants import IBAN_PATTERN, Currency - - -class AccountInfo(BaseModel): - iban: str = Field(pattern=IBAN_PATTERN) - ingestion_function: Callable - currency: Currency diff --git a/tests/test_helper_db.py b/tests/test_helper_db.py index a7bb12c..a25f97d 100644 --- a/tests/test_helper_db.py +++ b/tests/test_helper_db.py @@ -1,9 +1,9 @@ from sqlalchemy import select from sqlalchemy.orm import Session -from src.helper_db import upsert_category +from src.ingest.b4w import upsert_category from src.models import Category -from src.models_db import CategoryDb +from src.models.b4w import CategoryDb def test_upsert_category_returns_orm_object(db_session: Session): diff --git a/tests/test_reader.py b/tests/test_reader.py index 927af5c..e013f7d 100644 --- a/tests/test_reader.py +++ b/tests/test_reader.py @@ -1,18 +1,23 @@ from pathlib import Path +import pytest + +from src.ingest.comdirect import COMDIRECT_ACCOUNT_PARSERS, reader_comdirect from src.models import ComdirectCreditCard, ComdirectDepot, ComdirectGiro -from src.reader import COMDIRECT_ACCOUNTS, reader_comdirect TEST_DATA_PATH = ".local/data/comdirect/umsaetze_9773063005_20260906-2043.csv" +@pytest.mark.skipif( + not Path(TEST_DATA_PATH).exists(), + reason=f"Testdaten fehlen: {TEST_DATA_PATH}", +) def test_reader_comdirect(): test_path = Path(TEST_DATA_PATH) - assert test_path.exists() data = reader_comdirect(test_path) assert data - assert len(data) == len(COMDIRECT_ACCOUNTS) - assert set(data) == set(COMDIRECT_ACCOUNTS) + assert len(data) == len(COMDIRECT_ACCOUNT_PARSERS) + assert set(data) == set(COMDIRECT_ACCOUNT_PARSERS) assert all(isinstance(row, ComdirectGiro) for row in data["Girokonto"]) assert all( isinstance(row, ComdirectCreditCard) for row in data["Visa-Karte (Kreditkarte)"] From c354523ae9a80d832245f451a7bd18079786617b Mon Sep 17 00:00:00 2001 From: Mathias Schneider Date: Sat, 12 Sep 2026 10:13:59 +0200 Subject: [PATCH 09/31] comdirect ingest: overhaul --- .gitignore | 2 +- src/ingest/b4w.py | 10 +-- src/ingest/comdirect.py | 138 ++++++++++++++++++++++------------------ src/models/_common.py | 10 +++ src/models/b4w.py | 8 +-- src/models/comdirect.py | 5 +- src/models/settings.py | 26 ++++++++ 7 files changed, 125 insertions(+), 74 deletions(-) create mode 100644 src/models/settings.py diff --git a/.gitignore b/.gitignore index ff8b1cb..803269d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ wheels/ # Environment variables .env -settings.py +/settings.py # Database instances *.db diff --git a/src/ingest/b4w.py b/src/ingest/b4w.py index 6391289..0316d74 100644 --- a/src/ingest/b4w.py +++ b/src/ingest/b4w.py @@ -7,6 +7,11 @@ from src.ingest._common import _reader_raw, _upsert from src.models.b4w import B4wDb, B4wExport, Category, CategoryDb +def reader_b4w(path: Path) -> list[B4wExport]: + raw = _reader_raw(path) + return list(map(B4wExport.model_validate, raw)) + + def upsert_category( session: Session, category: Category | None, do_commit=True ) -> CategoryDb | None: @@ -65,8 +70,3 @@ def ingest_b4w(path: Path): for row in data: upsert_b4w(session, row, do_commit=False) session.commit() - - -def reader_b4w(path: Path) -> list[B4wExport]: - raw = _reader_raw(path) - return list(map(B4wExport.model_validate, raw)) diff --git a/src/ingest/comdirect.py b/src/ingest/comdirect.py index 234f8a7..6490df9 100644 --- a/src/ingest/comdirect.py +++ b/src/ingest/comdirect.py @@ -4,14 +4,17 @@ from pathlib import Path from sqlalchemy.orm import Session +from settings import BANK_ACCOUNTS from src.db import get_session from src.ingest._common import _upsert +from src.models._common import Csv2Dict, CsV2DictMulti from src.models.comdirect import ( ComdirectCreditCard, ComdirectDepot, ComdirectGiro, ComdirectGiroDb, ) +from src.models.settings import Account, Source PATTERN_ACCOUNT_NAME = r'^"Umsätze ([^"]+)";"Zeitraum: .*$' PATTERN_HEADER = r'"([^"]+)"' @@ -25,12 +28,61 @@ class EXPECT_LINE(Enum): DATA = auto() -COMDIRECT_ACCOUNT_PARSERS = { - "Girokonto": lambda row: ComdirectGiro.model_validate(row), - "Visa-Karte (Kreditkarte)": lambda row: ComdirectCreditCard.model_validate(row), - "Tagesgeld PLUS-Konto": lambda row: ComdirectGiro.model_validate(row), - "Depot": lambda row: ComdirectDepot.model_validate(row), -} +type ComdirectExportModel = ComdirectGiro | ComdirectDepot | ComdirectCreditCard + + +SOURCE_COMDIRECT = BANK_ACCOUNTS.get("Comdirect") +assert isinstance(SOURCE_COMDIRECT, Source) + +SOURCE_PATH = SOURCE_COMDIRECT.source_path +ACCOUNTS = SOURCE_COMDIRECT.accounts + + +def reader_comdirect(path: Path) -> dict[str, list[ComdirectExportModel]]: + result = {} + raw = _reader_raw_comdirect(path) + for account in ACCOUNTS: + data_raw: Csv2Dict = raw.pop(account.name) + data: list = list(map(account.model.model_validate, data_raw)) + result[account.name] = data + assert not raw + return result + + +def _reader_raw_comdirect(path: Path) -> CsV2DictMulti: + + with path.open(encoding="latin1") as fid: + lines = fid.readlines() + + expect_next = EXPECT_LINE.ACCOUNT_NAME + account_name = None + result = {} + for line in lines: + if expect_next == EXPECT_LINE.ACCOUNT_NAME: + account_name = re.findall(PATTERN_ACCOUNT_NAME, line) + if account_name: + account_name = account_name[0] + result[account_name] = {} + expect_next = EXPECT_LINE.BLANK + elif expect_next == EXPECT_LINE.BLANK: + if line == "\n": + expect_next = EXPECT_LINE.HEADER + elif expect_next == EXPECT_LINE.HEADER: + header = re.findall(PATTERN_HEADER, line) + if header: + result[account_name] = [] + expect_next = EXPECT_LINE.DATA + elif expect_next == EXPECT_LINE.DATA: + data = re.findall(PATTERN_DATA, line) + if data: + result[account_name].append( + {column: value for column, value in zip(header, data)} + ) + else: + expect_next = EXPECT_LINE.ACCOUNT_NAME + else: + expect_next = EXPECT_LINE.ACCOUNT_NAME + return result def upsert_comdirect_giro( @@ -69,62 +121,24 @@ def upsert_comdirect_giro( def ingest_comdirect(path: Path, account_iban: str, account_currency: str): - data = reader_comdirect(path).get("Girokonto") - assert isinstance(data, list) - with get_session() as session: - for row in data: - upsert_comdirect_giro( - session, - row, - account_iban, - account_currency, + assert SOURCE_COMDIRECT + data_collection = reader_comdirect(SOURCE_PATH) + for account_name, data in data_collection.items(): + account = SOURCE_COMDIRECT.get_account(account_name) + assert isinstance(account, Account) + if account_name == "Comdirect": + db_load_function = lambda session_, row_, account_: upsert_comdirect_giro( + session=session_, + data=row_, + account_iban=account_.iban, + account_currency=account_.currency, do_commit=False, ) - session.commit() - - -def reader_comdirect(path: Path) -> dict[str, list]: - result = {} - raw = _reader_raw_comdirect(path) - for account_name, account_data in raw.items(): - parser = COMDIRECT_ACCOUNT_PARSERS.get(account_name) - if parser is None: - continue - result[account_name] = list(map(parser, account_data)) - return result - - -def _reader_raw_comdirect(path: Path): - - with path.open(encoding="latin1") as fid: - lines = fid.readlines() - - expect_next = EXPECT_LINE.ACCOUNT_NAME - account_name = None - result = {} - for line in lines: - if expect_next == EXPECT_LINE.ACCOUNT_NAME: - account_name = re.findall(PATTERN_ACCOUNT_NAME, line) - if account_name: - account_name = account_name[0] - result[account_name] = {} - expect_next = EXPECT_LINE.BLANK - elif expect_next == EXPECT_LINE.BLANK: - if line == "\n": - expect_next = EXPECT_LINE.HEADER - elif expect_next == EXPECT_LINE.HEADER: - header = re.findall(PATTERN_HEADER, line) - if header: - result[account_name] = [] - expect_next = EXPECT_LINE.DATA - elif expect_next == EXPECT_LINE.DATA: - data = re.findall(PATTERN_DATA, line) - if data: - result[account_name].append( - {column: value for column, value in zip(header, data)} - ) - else: - expect_next = EXPECT_LINE.ACCOUNT_NAME else: - expect_next = EXPECT_LINE.ACCOUNT_NAME - return result + continue + with get_session() as session: + for row in data: + data_validated = account.model.model_validate(row) + assert isinstance(data_validated, account.model) + db_load_function(session, row, account) + session.commit() diff --git a/src/models/_common.py b/src/models/_common.py index 75df288..fbf530a 100644 --- a/src/models/_common.py +++ b/src/models/_common.py @@ -3,6 +3,7 @@ from datetime import date from decimal import Decimal from enum import Enum +from pydantic import BaseModel from sqlalchemy import Column, Date, Integer, Numeric, String from sqlalchemy.orm import DeclarativeBase @@ -37,6 +38,15 @@ def _parse_decimal_german(decimal_str: str) -> Decimal: return Decimal(decimal_str.replace(".", "").replace(",", ".")) +type Csv2DictRow = dict[str, str] +type Csv2Dict = list[Csv2DictRow] +type CsV2DictMulti = dict[str, Csv2Dict] + + +class ExportBaseModel(BaseModel): + pass + + class BaseModelDb(DeclarativeBase): pass diff --git a/src/models/b4w.py b/src/models/b4w.py index 37b66e7..4ecfbfc 100644 --- a/src/models/b4w.py +++ b/src/models/b4w.py @@ -1,15 +1,15 @@ from datetime import date from decimal import Decimal -from pydantic import BaseModel, Field, computed_field, field_validator +from pydantic import Field, computed_field, field_validator from sqlalchemy import Column, Date, ForeignKey, Integer, String, UniqueConstraint from sqlalchemy.orm import relationship from src.constants import IBAN_PATTERN, Currency -from src.models._common import BaseModelDb, IngestdbBase, Sign, _hash +from src.models._common import BaseModelDb, ExportBaseModel, IngestdbBase, Sign, _hash -class B4wExport(BaseModel): +class B4wExport(ExportBaseModel): id: int = Field(alias="Id1", description="internal B4w ID") account_iban: str = Field(alias="OwnrAcctIBAN", pattern=IBAN_PATTERN) account_currency: Currency = Field(alias="OwnrAcctCcy") @@ -56,7 +56,7 @@ class B4wExport(BaseModel): return category -class Category(BaseModel): +class Category(ExportBaseModel): name: str = Field(pattern=r"[^:\r\n]+") parent: Category | None = None diff --git a/src/models/comdirect.py b/src/models/comdirect.py index a25c05d..9990484 100644 --- a/src/models/comdirect.py +++ b/src/models/comdirect.py @@ -1,10 +1,11 @@ from datetime import date from decimal import Decimal -from pydantic import BaseModel, Field, computed_field, field_validator +from pydantic import Field, computed_field, field_validator from sqlalchemy import Column, Date, String from src.models._common import ( + ExportBaseModel, IngestdbBase, _hash, _parse_date_german, @@ -12,7 +13,7 @@ from src.models._common import ( ) -class ComdirectBase(BaseModel): +class ComdirectBase(ExportBaseModel): date_val: date | None = Field( alias="Wertstellung (Valuta)", examples=["07.09.2026"] ) diff --git a/src/models/settings.py b/src/models/settings.py new file mode 100644 index 0000000..85b5449 --- /dev/null +++ b/src/models/settings.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from pydantic import BaseModel, Field + +from src.constants import IBAN_PATTERN, Currency +from src.models.b4w import B4wExport +from src.models.comdirect import ComdirectCreditCard, ComdirectDepot, ComdirectGiro + + +class Account(BaseModel): + name: str + model: type[B4wExport | ComdirectGiro | ComdirectCreditCard | ComdirectDepot] + iban: str | None = Field(pattern=IBAN_PATTERN, default=None) + currency: Currency | None = None + + +class Source(BaseModel): + name: str + source_path: Path + accounts: list[Account] + + def get_account_names(self) -> list[str]: + return [a.name for a in self.accounts] + + def get_account(self, name: str) -> Account | None: + return next((a for a in self.accounts if a.name == name), None) From a0a4d6e8d985b3d0eedb602d54c2bea75d9028ce Mon Sep 17 00:00:00 2001 From: Mathias Schneider Date: Sat, 12 Sep 2026 14:26:42 +0200 Subject: [PATCH 10/31] feat(b4w): add human-readable B4wExport repr/str output; fix comdirect account lookup and export formatting helpers --- src/constants.py | 2 +- src/ingest/comdirect.py | 8 +++++--- src/models/_common.py | 17 +++++++++++++++++ src/models/b4w.py | 32 +++++++++++++++++++++++++++++++- tests/test_model_b4wexport.py | 21 +++++++++++++++++++++ 5 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/constants.py b/src/constants.py index fc2cd09..99599bb 100644 --- a/src/constants.py +++ b/src/constants.py @@ -10,7 +10,7 @@ class Currency(str, Enum): CHF = "CHF" def __str__(self): - return self.value + return f"{self.value}" def __repr__(self): return f"Currency.{self.name}" diff --git a/src/ingest/comdirect.py b/src/ingest/comdirect.py index 6490df9..783593b 100644 --- a/src/ingest/comdirect.py +++ b/src/ingest/comdirect.py @@ -31,12 +31,14 @@ class EXPECT_LINE(Enum): type ComdirectExportModel = ComdirectGiro | ComdirectDepot | ComdirectCreditCard -SOURCE_COMDIRECT = BANK_ACCOUNTS.get("Comdirect") +SOURCE_COMDIRECT = BANK_ACCOUNTS.get("comdirect") assert isinstance(SOURCE_COMDIRECT, Source) SOURCE_PATH = SOURCE_COMDIRECT.source_path ACCOUNTS = SOURCE_COMDIRECT.accounts +COMDIRECT_ACCOUNT_PARSERS = {a.name: a.model.model_validate for a in ACCOUNTS} + def reader_comdirect(path: Path) -> dict[str, list[ComdirectExportModel]]: result = {} @@ -122,11 +124,11 @@ def upsert_comdirect_giro( def ingest_comdirect(path: Path, account_iban: str, account_currency: str): assert SOURCE_COMDIRECT - data_collection = reader_comdirect(SOURCE_PATH) + data_collection = reader_comdirect(path) for account_name, data in data_collection.items(): account = SOURCE_COMDIRECT.get_account(account_name) assert isinstance(account, Account) - if account_name == "Comdirect": + if account_name == "Girokonto": db_load_function = lambda session_, row_, account_: upsert_comdirect_giro( session=session_, data=row_, diff --git a/src/models/_common.py b/src/models/_common.py index fbf530a..95b935c 100644 --- a/src/models/_common.py +++ b/src/models/_common.py @@ -34,10 +34,27 @@ def _parse_date_german(date_str: str) -> date | None: return date(day=day, month=month, year=year) +def date2str(value: date | None, default="--", date_format="%Y-%m-%d") -> str: + if value: + return value.strftime(date_format) + return default + + def _parse_decimal_german(decimal_str: str) -> Decimal: return Decimal(decimal_str.replace(".", "").replace(",", ".")) +def decimal2str(value: Decimal) -> str: + value = Decimal(f"{value:.2f}") + int_part, _, frac = str(value).partition(".") + groups: list[str] = [] + while len(int_part) > 3: + groups.insert(0, int_part[-3:]) + int_part = int_part[:-3] + groups.insert(0, int_part or "0") + return f"{'.'.join(groups)},{frac or '00'}" + + type Csv2DictRow = dict[str, str] type Csv2Dict = list[Csv2DictRow] type CsV2DictMulti = dict[str, Csv2Dict] diff --git a/src/models/b4w.py b/src/models/b4w.py index 4ecfbfc..f71ff1b 100644 --- a/src/models/b4w.py +++ b/src/models/b4w.py @@ -6,7 +6,14 @@ from sqlalchemy import Column, Date, ForeignKey, Integer, String, UniqueConstrai from sqlalchemy.orm import relationship from src.constants import IBAN_PATTERN, Currency -from src.models._common import BaseModelDb, ExportBaseModel, IngestdbBase, Sign, _hash +from src.models._common import ( + BaseModelDb, + ExportBaseModel, + IngestdbBase, + Sign, + _hash, + date2str, +) class B4wExport(ExportBaseModel): @@ -32,6 +39,29 @@ class B4wExport(ExportBaseModel): ) notes: str = Field(alias="Notes") + def parameters_to_str(self): + return ( + f"Id1='{self.id}', " + f"OwnrAcctIBAN='{self.account_iban!s}', " + f"AmtCcy='{self.amount_currency!s}', " + f"ValDt='{date2str(self.date_valid)}', " + f"BookgDt='{date2str( self.date_book )}', " + f"Amt='{self.amount!s}', " + f"OwnrAcctCcy='{self.account_currency!s}', " + f"CdtDbtInd='{self.sign!s}', " + f"RmtInf='{self.reference}', " + f"RmtdNm='{self.other_name}', " + f"RmtdAcctIBAN='{self.other_iban}', " + f"Category='{self.category.to_str() if self.category else ''}', " + f"Notes='{self.notes}'" + ) + + def __repr__(self): + return f"B4wExport({self.parameters_to_str()})" + + def __str__(self): + return f"B4wExport({self.parameters_to_str()})" + @computed_field @property def hash_id(self) -> str: diff --git a/tests/test_model_b4wexport.py b/tests/test_model_b4wexport.py index 866fd44..29dd557 100644 --- a/tests/test_model_b4wexport.py +++ b/tests/test_model_b4wexport.py @@ -1,3 +1,5 @@ +import re + import pytest from pydantic import ValidationError @@ -29,3 +31,22 @@ def test_b4w_export_account_iban_to_long(): def test_b4w_export_account_iban_to_short(): with pytest.raises(ValidationError): _ = B4wExportFactory.build(OwnrAcctIBAN="DE123") + + +def test_b4w_export_parameters_to_str(): + data = B4wExportFactory.build() + params = data.parameters_to_str() + components = [s.strip() for s in re.split(r",\s*(?=\w+=)", params)] + param_names = [component.split("=")[0].strip() for component in components] + param_values = [component.split("=")[1].strip() for component in components] + assert "OwnrAcctCcy" in param_names + assert all(s.startswith("'") and s.endswith("'") for s in param_values) + + +def test_b4w_export_repr_round_trip(): + data_batch = B4wExportFactory.batch(size=10) + assert isinstance(data_batch, list) + for data in data_batch: + data_str = str(data) + data2 = eval(data_str) + assert data == data2 From cc7ba35c4deb78eaf79cc22b6d4afc7b503ed2b0 Mon Sep 17 00:00:00 2001 From: Mathias Schneider Date: Sat, 12 Sep 2026 14:40:55 +0200 Subject: [PATCH 11/31] b4w dummy data --- src/models/b4w.py | 34 ++++++++----- .../dummy_data/generate_dummies_b4w.py | 22 ++++++++ tests/test_data/b4w_export_dummy_01.csv | 51 +++++++++++++++++++ tests/test_model_b4wexport.py | 9 ++++ 4 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 src/scripts/dummy_data/generate_dummies_b4w.py create mode 100644 tests/test_data/b4w_export_dummy_01.csv diff --git a/src/models/b4w.py b/src/models/b4w.py index f71ff1b..4e1e7ff 100644 --- a/src/models/b4w.py +++ b/src/models/b4w.py @@ -39,22 +39,28 @@ class B4wExport(ExportBaseModel): ) notes: str = Field(alias="Notes") + def parameters_to_dict(self) -> dict[str, str]: + return { + "Id1": f"{self.id}", + "OwnrAcctIBAN": f"{self.account_iban!s}", + "OwnrAcctCcy": f"{self.account_currency!s}", + "ValDt": date2str(self.date_valid), + "BookgDt": date2str(self.date_book), + "Amt": f"{self.amount!s}", + "AmtCcy": f"{self.amount_currency!s}", + "CdtDbtInd": f"{self.sign!s}", + "RmtInf": f"{self.reference}", + "RmtdNm": f"{self.other_name}", + "RmtdAcctIBAN": f"{self.other_iban}", + "Category": f"{self.category.to_str() if self.category else ''}", + "Notes": f"{self.notes}", + } + def parameters_to_str(self): - return ( - f"Id1='{self.id}', " - f"OwnrAcctIBAN='{self.account_iban!s}', " - f"AmtCcy='{self.amount_currency!s}', " - f"ValDt='{date2str(self.date_valid)}', " - f"BookgDt='{date2str( self.date_book )}', " - f"Amt='{self.amount!s}', " - f"OwnrAcctCcy='{self.account_currency!s}', " - f"CdtDbtInd='{self.sign!s}', " - f"RmtInf='{self.reference}', " - f"RmtdNm='{self.other_name}', " - f"RmtdAcctIBAN='{self.other_iban}', " - f"Category='{self.category.to_str() if self.category else ''}', " - f"Notes='{self.notes}'" + parameters = ", ".join( + f"{key}='{value}'" for key, value in self.parameters_to_dict().items() ) + return parameters def __repr__(self): return f"B4wExport({self.parameters_to_str()})" diff --git a/src/scripts/dummy_data/generate_dummies_b4w.py b/src/scripts/dummy_data/generate_dummies_b4w.py new file mode 100644 index 0000000..07cb275 --- /dev/null +++ b/src/scripts/dummy_data/generate_dummies_b4w.py @@ -0,0 +1,22 @@ +import csv +from pathlib import Path + +from tests.factories import B4wExportFactory + +NUMBER_OF_ROWS = 50 +PATH_DEFAULT = Path("tests/test_data/b4w_export_dummy_01.csv") + + +def save_csv(path: Path = PATH_DEFAULT): + data = B4wExportFactory.batch(NUMBER_OF_ROWS) + row_fields = list(data[0].parameters_to_dict()) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as fid: + writer = csv.DictWriter(fid, fieldnames=row_fields) + writer.writeheader() + for export in data: + writer.writerow(export.parameters_to_dict()) + + +if __name__ == "__main__": + save_csv() diff --git a/tests/test_data/b4w_export_dummy_01.csv b/tests/test_data/b4w_export_dummy_01.csv new file mode 100644 index 0000000..29edd8d --- /dev/null +++ b/tests/test_data/b4w_export_dummy_01.csv @@ -0,0 +1,51 @@ +Id1,OwnrAcctIBAN,OwnrAcctCcy,ValDt,BookgDt,Amt,AmtCcy,CdtDbtInd,RmtInf,RmtdNm,RmtdAcctIBAN,Category,Notes +546,BB94K6C321C502O,GBP,2025-12-29,2021-05-31,966249079068317412764571542407919514412758243437140957631412514241265507048511272714647319346008254.45380506761321740967397160412882982325923639777828329138314768657367549356366752060752331132,GBP,DBIT,xobiDcwzRKxgCdlMhOqA,tKVeYnvYCbpbogKulljk,TDgXpTthOVshHpNKRvbQ,Beruf:Gehalt:super Firma,WGCaHsVAXWVeCLsPbdrN +3130,CY01AL0ZPFKXL0Z,CHF,2020-04-27,2025-04-02,-91380510785950845491826614519754504907024428700812006803291502570873.211019996196381685494424904026971409543,EUR,CRDT,aYEtztGZMJkkjbGNhrGl,CbvOzQGjcTWWXPrKfyjp,nOBawCuxZMLTiFKuZnsx,,MVznQDrIqmCmYrTDhBbp +5356,AE43DPLDC66ENP6,USD,2026-01-09,2020-03-27,12495147566547030449243528706787437899952753255929949.510436466181518004685,EUR,DBIT,lckOKAAYRGuzCUqnXbfW,vOGbcayDxBlnejGwfLjA,xxjAuYKPhMmqaMfZDhAw,Lebenshaltung:Lebensmitteleinkäuft,iwUPRIJRxPSlRXcWDWNo +5605,NS44V0K3D40LZEC,GBP,2021-12-22,2021-06-05,0.5943269284524336922642283862359244002375250667,CHF,CRDT,joJcLPuIFvxHxnpTMRZS,LRMmWOiXpcpNkZNCotyG,WawuMyUUpqZRmIWRpzOF,Freizeit,AUSRXmzbzyZwBREOZOgi +9030,UL50MIF1W1KAS3C,USD,2024-03-13,2021-03-07,94229449126346152407566499816383306669237.8035079624692824996218720107585445554234173,USD,CRDT,AcQtetkWOZpHsvpFAFLY,sIieEkhvUFSOsLcLPzIq,nUOVSAvyyjaXaGrtwHkQ,Freizeit,xdNHKUVfoqzfwCykZIvd +5139,KN98I6KMGOBJGDQ,GBP,2021-01-08,2021-12-18,-15902928538656294489371181513645784480574990906804212434837506917428638594538599376635051278636.78520642113315033999770538105015205921897385545146693383944328,USD,CRDT,lMrbuAYxXwuHurNyFYzg,PVoYOsiYrgiycTpYqwaq,EaPFovWogTiCIIVqPnHZ,Beruf:Gehalt:super Firma,rZVnmZqzUaqgrTejRQBv +3262,VP35ANQ489KSRBJ,CHF,2022-02-11,2022-12-15,1456.3272931,EUR,DBIT,ZSDukJoElAhJnbqxCemY,gQZioaMYeNkeDIWUJQOJ,DaRRlkzDhfzwgqsmyKfI,,RmcCnGYIJwwXhUoDebtx +6370,EG06AML3VX70NBZ,CHF,2020-10-30,2026-06-24,-85985373736852359001228845964.12923,GBP,CRDT,nGcVCPmxqcCWOwLCqNcL,ELTpZKLLPxtkBQpFfcXO,QqCDEBwHcqhYqNmrIjRJ,Lebenshaltung:Lebensmitteleinkäuft,MlltKALcNRksLbrBdqHB +9610,LS00DDSRWKI2GCN,CHF,2022-12-24,2023-11-13,111302702519553016947918391140.83336651886149677841069515026199226910390124216959835264362430572949455458094205858204009469,GBP,DBIT,rIqXIlJHQUmNryiFhZgl,fmlShtEaPYxDxLPLVFtA,ZuEmBQavkZkliTgQslkX,Beruf:Gehalt:super Firma,QxxGfOhHEKlRIBtxLBzN +7958,IM49W0MERUPAEYL,GBP,2024-09-23,2026-08-07,997654494541331560967.23894678707663633993031462271276158599265860951730613416835071421468,EUR,CRDT,lIFaiRDOHpPsLZYMvSLL,COjPCyRHmQJAJjXpHtoc,wLGNwAmIoguSFiPOwhop,Beruf:Gehalt:super Firma,wIVvfgWAGpXaNtmCMdtl +81,NB13I5PF8RT1KBF,EUR,2023-10-01,2025-10-14,-623.171747883897331930600196592034302396711514091988656250527836008950413955561,EUR,CRDT,gKujLbFKPTJWNHCwLEbP,BNNSobmkOJYvruTmVpsY,JFTexNOzIHpIDYxFyxXC,Lebenshaltung:Lebensmitteleinkäuft,EXRfMRisDxjfvTFbbScl +4306,MA16EMD5TXB6W2Q,CHF,2022-03-09,2022-07-04,1967316692371140884668.697876470482728723376342302791065723059022794527986321223057196172445638187219626476457610180,EUR,CRDT,PxRyYSeHHCnLsOGvkJBR,tdUmPUqXPyVAwcMonTOz,FkhuAmZMFEYHXfIRbYNa,,hoOqFYFhQZMNFflTTRzr +1340,QC15X8QJ925LIIZ,USD,2023-05-13,2024-10-19,51002714193280774680213206683817944775146555520957438367304201512763096320304094279.5917694840241093438785611670208852758416238660282666099607713108189846992146195070603042071073,USD,CRDT,KXmqOKVhWFhNyeTrYcqi,xrXHIHUrhVotQHkfTSbR,XftIqQPwfIZaQWPgfvTq,Beruf:Gehalt:super Firma,aIBfbrbYXZILZipenuQs +6971,IU89H1O656FOARG,CHF,2023-01-05,2023-01-26,-635472806525340497640508823188167.73094955967757797936541852850962382275616205624981776791984877714957051928106426532873075,EUR,CRDT,aBFnGYtfSndKKDcuKbOA,mFDeQxsPDxRwgSxHoPar,rWitPpvexQZfYaRknvVZ,Lebenshaltung:Lebensmitteleinkäuft,SdKWfeCRdmIuQtpYXpcP +5765,YO97BHIS7F13W5T,CHF,2025-04-27,2024-09-23,-84170840.062065298147641969564638230620262747954,EUR,DBIT,PQzlBfLSMjHPhmzCNWlW,OAuifNsHLzDRYOshTltg,SrdmTqlAcjYZqspXUqWl,Beruf:Gehalt:super Firma,gdyHSZQRVGVDljyWukxF +873,CU84CRHLBLDVPG4,USD,2024-09-10,2021-04-05,31708414063036408560039191005.628517576703091956518065559718890786318411169242277168731009729,EUR,DBIT,jEqWELcluqYzeoFKWrSv,rMeRFYSEneKpjTQxTAWE,irpXsNFkucPYaXhtiGVU,Beruf:Gehalt:super Firma,tDYCusMlvSzPxJtAoGwU +7512,OK88REZY7LC3ZJO,USD,2024-06-20,2024-02-12,-91309800414850316424146855043265998301745409273389966051295090800556208516373.6106630,EUR,CRDT,RjKWAFvwkGwehDeVgAMe,ZBCofGxOxAuhCNdoRwJK,CBjHBmiutfFjnNqGNsys,Freizeit,ibeMySwyUuaKJmopdFeo +468,ZI7268MWFCVKJET,GBP,2021-07-08,2023-08-04,-9566140937946527477623537138113287213.7764645148994252143659337484894589906652837718944384583356873367,GBP,DBIT,dHnVUadgIXaQokZAaddZ,MJpKJyJXAGVFHGXqyeLb,jgWKQYgfzCjzpWDqSfMV,Beruf:Gehalt:super Firma,rRrGzQHiNWxftPiobuBl +6125,VX319YHC45XB9FI,CHF,2024-04-08,2023-11-02,-404943589.503,EUR,CRDT,DLgsRpbgvYUZiFwwfxYX,NFCMuqKchwfGolxUoszx,wToRonKwFFOWrnmfMWZd,Beruf:Gehalt:super Firma,EiYGrzzlsMweQPlFHdlx +5352,WI30AMBG4K8GC5M,USD,2022-07-28,2023-07-12,298801846977579969716883410099510711907273580263884196580644541848777146559224.91128608265378630536063943513079726365933334413452853303576298418192825,GBP,DBIT,BkBNoWLKZmNptdlkcdFy,jdCcnjNmSYMVZYBAqXpk,KdVUoLgaiQoorPtFXfuD,Lebenshaltung:Lebensmitteleinkäuft,aPilIRVyMZhDaLreCtVB +8157,AS89HYB3PJOCKGQ,USD,2021-12-11,2021-05-02,-5383257045022212194476332790.58249908408715153448695588386599010825479811059729154822,USD,CRDT,hLscFzoYamBLLTCOLtJX,mJzlPJMFlwXsvDWCdPEX,zcgAipzPSDbNvDgsUsdf,Lebenshaltung:Lebensmitteleinkäuft,KzpTvMfREqDcraLsnAFa +8562,XF55JJHCFY9VFPN,EUR,2021-10-06,2023-03-07,58513152657357786429578134004715672662808018369796246403461323977035786109578790774134514968087.32196519574363119760206835296320588360455621,EUR,CRDT,WwygiOMjDsxlmBrgMBUD,CPkjRvCOcTGVQRxdVSco,WEmukvnPtZyCmopsKLeB,Freizeit,vNxjYBDrcjIJXRYsioVn +6932,PK53O1QFHWMM80N,CHF,2021-11-22,2022-04-29,-35687720973389008791120348643747347909777063417264503383154431851328499756311481892.525588482723067939754169870785253637544209574930786950647614924757554933127740331928,USD,DBIT,ocyWAbkFrEGhLKcqIUrJ,eIdWToAESlHQGGnykQZI,urMJiqlsVuLqGbGmIOdR,Beruf:Gehalt:super Firma,wbVGbcIRqoAyhXPQFmbn +5603,LM99PWTKWNVKFUK,CHF,2020-10-03,2024-10-23,137912574664116210905504437194561515849372037.5300228797878339289857585366614065212461747,CHF,DBIT,mJHgXdjzfGTiqdPFYKJu,iCPXYqUtKwBTilCrsJTM,AqLdwtlWBsQmQaBvwHii,Lebenshaltung:Lebensmitteleinkäuft,DVEvjvPzjBahqIEOddlq +7707,LP36QSQ8LN9F6U8,EUR,2020-01-26,2026-07-22,20617377959559934081335048761128552951261867125124238167249671785612681.51317409949621954528827764556416450523039696133111537499104315164348776636640613432,GBP,CRDT,IcRLxekFBpooSGzJqqGw,rCFLSzlzvLBdEpXFPZfV,vkMfGTUYKzrINNryKNJN,,tjNBGXokHzSQvBVABxCV +7964,WB16POIGIKIRSRB,GBP,2025-01-15,2026-07-14,19212833314539080562967961976059399140703968646971623411156202797393305194593.881945095397465505661922398,USD,DBIT,CoxjaXrcDJVITkyHjZzv,IunvUBVgzwgJVzAvOyKQ,BcYtrPUKMrJkEKNNnYyD,,awUfzbbQZVwNaLaTiFsr +4725,AT598204ZYZODV1,EUR,2024-03-25,2024-08-09,82856650729233542595012846123790016186861935974808282995711867343684454016512697494.88917524421022914453765603187655206018877538527053112,EUR,DBIT,xlCBJxJOxqvLAjgICrbX,msAsDTuxgXrTntkliYmb,FXyFAuyPwdvXjTTNcPrz,Lebenshaltung:Lebensmitteleinkäuft,gwHdLpOEMZgALigVFmpM +6050,TY84APSJTGLW0Y7,GBP,2020-09-04,2025-08-15,-3472009449932706821864839468227645291697670610604942221835405160979793916196159877775819802166678286.924897290769355741386357676998033855577136669559598846,GBP,DBIT,sqcQIypoDiHETEPLtswm,bkRKycwiqnHBrCWFDWvp,IsMCNagZuWQgjbHotLkZ,Lebenshaltung:Lebensmitteleinkäuft,PmxAqaLvtdKHOqufnPEr +1636,TT56OLKXD9FFLHW,EUR,2022-04-05,2025-01-21,-8700251749693498471853131195688590000252839147762542676728724966.70275220280,CHF,CRDT,LYftAZAxwAbsKqKJJDox,AutZAoZFOVswxydiSajl,peXIkhZxuXokaHbIHulP,Freizeit,qAADExaKKWlrrLTDOeJd +7753,BS67UNQVFAG259C,CHF,2024-06-05,2021-06-24,24861162756159054957.670585366670739666042308388630102242932146348848932417907909353461828706673705,EUR,CRDT,xZhCgZOvnVzrCDKLqbhK,XhDZhAWtaSbEctUEYLjT,aClycbHxjTAgkNnTRcuY,,LmeGwybyUMOrxHkjgLaZ +6189,ZZ871MTLUU9A1RU,CHF,2021-08-01,2023-03-17,-409780077475199424796148695159372908162375055189181880910638677.248670538597146440670239645579725174792942268017121149370963751622201496,EUR,CRDT,GQlNICMrebnglDvBDTYS,CXDqKkEcjnJngYAqpByV,NHVCfnmSrnFolUjPSEIr,,uwEFhSBDVdAPtdhJavhW +9624,AU87XCMRZZWGH86,USD,2024-09-02,2022-09-19,-442193693537141866795259204406415934110882954978333632301457685242868631049543099431520126945516367.745400551449,EUR,DBIT,cbaGAkeofJGsqaiNUEIY,ljZDuEzncPYZLDWNMgnH,EcVuFQSISKGSBDNIdqXJ,Beruf:Gehalt:super Firma,KhFiMeuprVqYAZtZQwIp +9203,FX68NTN2D10VVKO,GBP,2022-02-15,2024-06-23,-534572334.4012791130107478240377375826129655674355510806820651340789640,USD,DBIT,tNihPvPVLoZiwWmdZZRZ,zJGOcjBqCiLbirDJCgVR,oeFcARYRdznzUieqiHxt,Lebenshaltung:Lebensmitteleinkäuft,ihcfdoHuUNdBWRSOyeBB +3927,WH16E7SHB62XKIH,GBP,2023-10-04,2023-01-05,415593598910615971016549932285154.643848021414472037085778014035161865336120601218617113853923085787470877370,EUR,CRDT,MreRryaDFIhSFXkthwko,AqECnwyKoevMnedwdaFc,DXMnEnltpMbzNPWinMrk,Freizeit,IDiYkQfxVozPBqInPIQV +6283,HU452U8CMEYLRNE,CHF,2022-04-25,2025-03-24,-31056127574668984711914.148062642678712152287686837162829872960394642611046974293342,CHF,CRDT,uvRVCtUcGqAYppkZfxar,VvKDwohUZmOQWHpTbbox,rGrLuSeVEZhbtgGBcOFx,Lebenshaltung:Lebensmitteleinkäuft,mNqLNmnFbTXThCWaoBpa +4041,AR48BWK3T5RZ570,GBP,2025-10-10,2020-07-06,1812531959.04051886610531349,EUR,DBIT,VICxZqioNNFBXEnZvvHZ,HHKlheyBNtageACANISB,mPbiYFXCzowXbuRckcJU,Freizeit,jAtyPevPSKJdGTVjowmo +7217,OZ17J7ZD7YG7O1A,USD,2024-01-23,2024-06-19,-968844607026826575302933681105715018281543501601087944362850708075305.66200706173538352524957865510877,GBP,CRDT,RQxftSphrPpwMKVlnKpn,uqNrspSFVXYYPveWyuNv,CWyQeuLcZPAuGlNWjGdM,Lebenshaltung:Lebensmitteleinkäuft,HAyMDEDnKqtDgfIYZDiC +2615,WX91BRBHNDBXKU0,EUR,2021-10-30,2025-11-23,-8343466178334617252604602.569949636202667336077288532506921508691215873161097,CHF,CRDT,RyhUcmsrSAXNUaXevPMB,bFIZFcQCTivRZcMYhJAx,mfRHcvsWGYIMPHtWOYpm,Lebenshaltung:Lebensmitteleinkäuft,PHOtyKuWgmyZaxJYQPjO +251,ZJ430BMO81CDA44,EUR,2025-08-08,2022-04-02,-944177666499883169717087768179297037355028367029688127.633698125531,USD,DBIT,lUaArRWPnlxRwoWKSDyZ,jFSXDqiPxpdkDkjduezx,ndEGQjdezYvakfXmSeer,Freizeit,NbDQJseGHjiMLMFmhlQX +8432,RG9882TQXT40HDH,GBP,2020-05-29,2026-06-29,-4620473059978062834290693674666409952444643118783437106239403117968074841905810680891909.179130304762746994760945084576,GBP,DBIT,QakUGBIAuAmdabucXlOf,WlRItduFSMEjCwmkbVPV,haAnIWAPZnqWKJHZubtA,Freizeit,tZHnaMEcNMMxpvFZAdjx +9952,JF78B330IL8AKEQ,CHF,2023-03-16,2023-01-31,-4493693464311613709632110286395250278421205912044580776714732064115337203268.547530,GBP,DBIT,hTIWMdfJKdCycCtcVZIa,OABZhgwiXUqfXMmSklHk,GpItXzAVpjsAvDFHxtHP,Freizeit,QbKsSKahDgJtFJQcPJyq +3604,NJ18VUU9VH26T0A,GBP,2026-08-11,2020-04-09,0.64973001968427147068,EUR,CRDT,qupJQwFywzUjbEdwPDUA,uyOHMYgdPtMhcVdnxCZx,cLMmgjuVZtoDOwxoKrFt,Lebenshaltung:Lebensmitteleinkäuft,XQYYbtGKlEBIBnEWqecs +7263,KT85HBZNJL59FFT,USD,2022-11-27,2024-07-15,-37374807.958562351366178447898590057117071635996571407457660485745838424652255349422449887484,GBP,DBIT,uHcJnYVJYKDQMoFMjGXC,PQGdgBCLypSRkHiJYdev,rGwrTRHsFHjLPYhFpMTJ,,dIxwCBlTvNJpYULlJAaS +9675,BJ715E6CD1OIU6T,CHF,2020-12-16,2024-01-07,-5040941689111955157468104725713611935085598625070764074521071414015940351382113590909197304063575.4515354590973595602470431141798115590834959037133034277462703823,GBP,DBIT,HGLztVTGOpGnChJsjprC,qLFITvGssIbHlFBfuZyg,SWQbyfBlwfdSCnoAtwmn,Beruf:Gehalt:super Firma,jtabNPVEZOlBVnaIvamZ +2631,BB0000NISDKBWM1,USD,2023-12-09,2021-09-25,35870065450695.1976,GBP,DBIT,gtwRCGANaKlcNLbLqXCW,qhkoHtHuLrwxdDjUitUM,SMAvrVhBeuNflTTfByZh,Lebenshaltung:Lebensmitteleinkäuft,JodnUWDKlXidVgYIIkZo +7105,FM81K0IHYG2B1K8,CHF,2022-07-17,2021-04-08,7605971335219411809496337191273512429.19144246140951276630353263005965894263583159362498576671567919,GBP,DBIT,bvkiJapOkQoCqDQaeSsj,wcUfBTvFUoxaSRsaNbOk,wzNzdPXmfLEXEgEvtXBP,Lebenshaltung:Lebensmitteleinkäuft,mQFwQMQRmhadGjzUhyIP +4791,XO29KPUGVXKV7P4,GBP,2025-05-09,2025-03-06,-3047323302463055726328888340075326228246153418725869094694662399.9369795962317797302779791691824533,CHF,CRDT,WPBXOTiXiJFuZyjStJxI,bKFooYlWkOIHkeDlcJxu,OXIINcOhpetOqPvcLwkf,Freizeit,jHrZgYXhlOxQlibgCCRq +9872,NX87N16NYF88GY3,CHF,2024-09-17,2022-11-07,-7503175603096494776785070745277206265109071257232396346239801093970295294378754146262745092564314724.58147937995295429167115163695901432938,USD,DBIT,PskmmmqmEWvvjVwdTbpa,IKnFvpHdkVECQXMJgPEb,XgxsPRqDemqlLkHurTtC,Lebenshaltung:Lebensmitteleinkäuft,iDHDfFpVGofjugPlXzMa +2685,DI50CT1EAUXO5ZQ,CHF,2020-11-27,2022-08-17,804255947854847664075204915880058282080805548244876707562721818851111658361963075718333712782.054580103972864189422158609687925786112724117046925451325120196326468662088148238188624,GBP,DBIT,EUSdiqhdVykkRxbeknBL,aUXqpaJrSZvchOtFcDZK,ruepxYDTWMuSxXoNiEBQ,Beruf:Gehalt:super Firma,tSCewecchqoQJuQXVXEr +6119,MJ453EJXQAF5KF9,USD,2020-06-02,2020-07-17,18687868432897828853760232671664003846966802581954654987871.542014558313629353382101420030766,EUR,DBIT,HhAovEIGHJoxAVgFeCVG,iUoROrwSAhKLpKuJqQZl,xHvmkmSMiiuCtJVFZDpN,Freizeit,GgDhdWxjgwwJUmNQOEzl diff --git a/tests/test_model_b4wexport.py b/tests/test_model_b4wexport.py index 29dd557..1e88230 100644 --- a/tests/test_model_b4wexport.py +++ b/tests/test_model_b4wexport.py @@ -33,6 +33,15 @@ def test_b4w_export_account_iban_to_short(): _ = B4wExportFactory.build(OwnrAcctIBAN="DE123") +def test_b4w_export_parameters_to_dict(): + data = B4wExportFactory.build() + params = data.parameters_to_dict() + expected = sorted(field.alias for field in B4wExport.model_fields.values()) + assert sorted(params) == expected + assert "OwnrAcctCcy" in params + assert all(isinstance(value, str) for value in params.values()) + + def test_b4w_export_parameters_to_str(): data = B4wExportFactory.build() params = data.parameters_to_str() From 72c2ee00cdd30542bddc39d129632723309e31ab Mon Sep 17 00:00:00 2001 From: Mathias Schneider Date: Sat, 12 Sep 2026 14:45:23 +0200 Subject: [PATCH 12/31] moved scripts --- {src/scripts => scripts}/dummy_data/generate_dummies_b4w.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {src/scripts => scripts}/dummy_data/generate_dummies_b4w.py (100%) diff --git a/src/scripts/dummy_data/generate_dummies_b4w.py b/scripts/dummy_data/generate_dummies_b4w.py similarity index 100% rename from src/scripts/dummy_data/generate_dummies_b4w.py rename to scripts/dummy_data/generate_dummies_b4w.py From 821fa1bd97e1bd2b41043ee8ff0c5869426ac885 Mon Sep 17 00:00:00 2001 From: Mathias Schneider Date: Sat, 12 Sep 2026 18:10:34 +0200 Subject: [PATCH 13/31] b4w dummy data improved --- src/models/b4w.py | 4 +- tests/factories.py | 36 +++++++++ tests/test_data/b4w_export_dummy_01.csv | 100 ++++++++++++------------ tests/test_model_b4wexport.py | 12 +++ 4 files changed, 100 insertions(+), 52 deletions(-) diff --git a/src/models/b4w.py b/src/models/b4w.py index 4e1e7ff..1e6d6f8 100644 --- a/src/models/b4w.py +++ b/src/models/b4w.py @@ -22,12 +22,12 @@ class B4wExport(ExportBaseModel): account_currency: Currency = Field(alias="OwnrAcctCcy") date_valid: date = Field(alias="ValDt") date_book: date = Field(alias="BookgDt") - amount: Decimal = Field(alias="Amt") + amount: Decimal = Field(alias="Amt", gt=0, decimal_places=2) amount_currency: Currency = Field(alias="AmtCcy") sign: Sign = Field(alias="CdtDbtInd") reference: str = Field(alias="RmtInf") other_name: str = Field(alias="RmtdNm") - other_iban: str = Field(alias="RmtdAcctIBAN") + other_iban: str = Field(alias="RmtdAcctIBAN", pattern=IBAN_PATTERN) category: Category | None = Field( alias="Category", examples=[ diff --git a/tests/factories.py b/tests/factories.py index f31a6fb..0ab356f 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,9 +1,45 @@ +from decimal import Decimal + +from faker import Faker from polyfactory.factories.pydantic_factory import ModelFactory +from polyfactory.fields import Use from src.models import B4wExport DEFAULT_CATEGORY = "root_category:parent_category:leaf_category" +def _amount() -> Decimal: + return B4wExportFactory.__faker__.pydecimal( + left_digits=6, + right_digits=2, + positive=True, + min_value=Decimal("0.01"), + ) + + +def _sentence() -> str: + return B4wExportFactory.__faker__.sentence( + nb_words=8, + variable_nb_words=False, + ) + + +def _names() -> str: + return B4wExportFactory.__faker__.company() + + +def _iban() -> str: + return B4wExportFactory.__faker__.iban() + + class B4wExportFactory(ModelFactory[B4wExport]): + __faker__ = Faker("de_DE") __use_examples__ = True + + Amt = Use(_amount) + RmtInf = Use(_sentence) + Notes = Use(_sentence) + RmtdNm = Use(_names) + OwnrAcctIBAN = Use(_iban) + RmtdAcctIBAN = Use(_iban) diff --git a/tests/test_data/b4w_export_dummy_01.csv b/tests/test_data/b4w_export_dummy_01.csv index 29edd8d..0bc2040 100644 --- a/tests/test_data/b4w_export_dummy_01.csv +++ b/tests/test_data/b4w_export_dummy_01.csv @@ -1,51 +1,51 @@ Id1,OwnrAcctIBAN,OwnrAcctCcy,ValDt,BookgDt,Amt,AmtCcy,CdtDbtInd,RmtInf,RmtdNm,RmtdAcctIBAN,Category,Notes -546,BB94K6C321C502O,GBP,2025-12-29,2021-05-31,966249079068317412764571542407919514412758243437140957631412514241265507048511272714647319346008254.45380506761321740967397160412882982325923639777828329138314768657367549356366752060752331132,GBP,DBIT,xobiDcwzRKxgCdlMhOqA,tKVeYnvYCbpbogKulljk,TDgXpTthOVshHpNKRvbQ,Beruf:Gehalt:super Firma,WGCaHsVAXWVeCLsPbdrN -3130,CY01AL0ZPFKXL0Z,CHF,2020-04-27,2025-04-02,-91380510785950845491826614519754504907024428700812006803291502570873.211019996196381685494424904026971409543,EUR,CRDT,aYEtztGZMJkkjbGNhrGl,CbvOzQGjcTWWXPrKfyjp,nOBawCuxZMLTiFKuZnsx,,MVznQDrIqmCmYrTDhBbp -5356,AE43DPLDC66ENP6,USD,2026-01-09,2020-03-27,12495147566547030449243528706787437899952753255929949.510436466181518004685,EUR,DBIT,lckOKAAYRGuzCUqnXbfW,vOGbcayDxBlnejGwfLjA,xxjAuYKPhMmqaMfZDhAw,Lebenshaltung:Lebensmitteleinkäuft,iwUPRIJRxPSlRXcWDWNo -5605,NS44V0K3D40LZEC,GBP,2021-12-22,2021-06-05,0.5943269284524336922642283862359244002375250667,CHF,CRDT,joJcLPuIFvxHxnpTMRZS,LRMmWOiXpcpNkZNCotyG,WawuMyUUpqZRmIWRpzOF,Freizeit,AUSRXmzbzyZwBREOZOgi -9030,UL50MIF1W1KAS3C,USD,2024-03-13,2021-03-07,94229449126346152407566499816383306669237.8035079624692824996218720107585445554234173,USD,CRDT,AcQtetkWOZpHsvpFAFLY,sIieEkhvUFSOsLcLPzIq,nUOVSAvyyjaXaGrtwHkQ,Freizeit,xdNHKUVfoqzfwCykZIvd -5139,KN98I6KMGOBJGDQ,GBP,2021-01-08,2021-12-18,-15902928538656294489371181513645784480574990906804212434837506917428638594538599376635051278636.78520642113315033999770538105015205921897385545146693383944328,USD,CRDT,lMrbuAYxXwuHurNyFYzg,PVoYOsiYrgiycTpYqwaq,EaPFovWogTiCIIVqPnHZ,Beruf:Gehalt:super Firma,rZVnmZqzUaqgrTejRQBv -3262,VP35ANQ489KSRBJ,CHF,2022-02-11,2022-12-15,1456.3272931,EUR,DBIT,ZSDukJoElAhJnbqxCemY,gQZioaMYeNkeDIWUJQOJ,DaRRlkzDhfzwgqsmyKfI,,RmcCnGYIJwwXhUoDebtx -6370,EG06AML3VX70NBZ,CHF,2020-10-30,2026-06-24,-85985373736852359001228845964.12923,GBP,CRDT,nGcVCPmxqcCWOwLCqNcL,ELTpZKLLPxtkBQpFfcXO,QqCDEBwHcqhYqNmrIjRJ,Lebenshaltung:Lebensmitteleinkäuft,MlltKALcNRksLbrBdqHB -9610,LS00DDSRWKI2GCN,CHF,2022-12-24,2023-11-13,111302702519553016947918391140.83336651886149677841069515026199226910390124216959835264362430572949455458094205858204009469,GBP,DBIT,rIqXIlJHQUmNryiFhZgl,fmlShtEaPYxDxLPLVFtA,ZuEmBQavkZkliTgQslkX,Beruf:Gehalt:super Firma,QxxGfOhHEKlRIBtxLBzN -7958,IM49W0MERUPAEYL,GBP,2024-09-23,2026-08-07,997654494541331560967.23894678707663633993031462271276158599265860951730613416835071421468,EUR,CRDT,lIFaiRDOHpPsLZYMvSLL,COjPCyRHmQJAJjXpHtoc,wLGNwAmIoguSFiPOwhop,Beruf:Gehalt:super Firma,wIVvfgWAGpXaNtmCMdtl -81,NB13I5PF8RT1KBF,EUR,2023-10-01,2025-10-14,-623.171747883897331930600196592034302396711514091988656250527836008950413955561,EUR,CRDT,gKujLbFKPTJWNHCwLEbP,BNNSobmkOJYvruTmVpsY,JFTexNOzIHpIDYxFyxXC,Lebenshaltung:Lebensmitteleinkäuft,EXRfMRisDxjfvTFbbScl -4306,MA16EMD5TXB6W2Q,CHF,2022-03-09,2022-07-04,1967316692371140884668.697876470482728723376342302791065723059022794527986321223057196172445638187219626476457610180,EUR,CRDT,PxRyYSeHHCnLsOGvkJBR,tdUmPUqXPyVAwcMonTOz,FkhuAmZMFEYHXfIRbYNa,,hoOqFYFhQZMNFflTTRzr -1340,QC15X8QJ925LIIZ,USD,2023-05-13,2024-10-19,51002714193280774680213206683817944775146555520957438367304201512763096320304094279.5917694840241093438785611670208852758416238660282666099607713108189846992146195070603042071073,USD,CRDT,KXmqOKVhWFhNyeTrYcqi,xrXHIHUrhVotQHkfTSbR,XftIqQPwfIZaQWPgfvTq,Beruf:Gehalt:super Firma,aIBfbrbYXZILZipenuQs -6971,IU89H1O656FOARG,CHF,2023-01-05,2023-01-26,-635472806525340497640508823188167.73094955967757797936541852850962382275616205624981776791984877714957051928106426532873075,EUR,CRDT,aBFnGYtfSndKKDcuKbOA,mFDeQxsPDxRwgSxHoPar,rWitPpvexQZfYaRknvVZ,Lebenshaltung:Lebensmitteleinkäuft,SdKWfeCRdmIuQtpYXpcP -5765,YO97BHIS7F13W5T,CHF,2025-04-27,2024-09-23,-84170840.062065298147641969564638230620262747954,EUR,DBIT,PQzlBfLSMjHPhmzCNWlW,OAuifNsHLzDRYOshTltg,SrdmTqlAcjYZqspXUqWl,Beruf:Gehalt:super Firma,gdyHSZQRVGVDljyWukxF -873,CU84CRHLBLDVPG4,USD,2024-09-10,2021-04-05,31708414063036408560039191005.628517576703091956518065559718890786318411169242277168731009729,EUR,DBIT,jEqWELcluqYzeoFKWrSv,rMeRFYSEneKpjTQxTAWE,irpXsNFkucPYaXhtiGVU,Beruf:Gehalt:super Firma,tDYCusMlvSzPxJtAoGwU -7512,OK88REZY7LC3ZJO,USD,2024-06-20,2024-02-12,-91309800414850316424146855043265998301745409273389966051295090800556208516373.6106630,EUR,CRDT,RjKWAFvwkGwehDeVgAMe,ZBCofGxOxAuhCNdoRwJK,CBjHBmiutfFjnNqGNsys,Freizeit,ibeMySwyUuaKJmopdFeo -468,ZI7268MWFCVKJET,GBP,2021-07-08,2023-08-04,-9566140937946527477623537138113287213.7764645148994252143659337484894589906652837718944384583356873367,GBP,DBIT,dHnVUadgIXaQokZAaddZ,MJpKJyJXAGVFHGXqyeLb,jgWKQYgfzCjzpWDqSfMV,Beruf:Gehalt:super Firma,rRrGzQHiNWxftPiobuBl -6125,VX319YHC45XB9FI,CHF,2024-04-08,2023-11-02,-404943589.503,EUR,CRDT,DLgsRpbgvYUZiFwwfxYX,NFCMuqKchwfGolxUoszx,wToRonKwFFOWrnmfMWZd,Beruf:Gehalt:super Firma,EiYGrzzlsMweQPlFHdlx -5352,WI30AMBG4K8GC5M,USD,2022-07-28,2023-07-12,298801846977579969716883410099510711907273580263884196580644541848777146559224.91128608265378630536063943513079726365933334413452853303576298418192825,GBP,DBIT,BkBNoWLKZmNptdlkcdFy,jdCcnjNmSYMVZYBAqXpk,KdVUoLgaiQoorPtFXfuD,Lebenshaltung:Lebensmitteleinkäuft,aPilIRVyMZhDaLreCtVB -8157,AS89HYB3PJOCKGQ,USD,2021-12-11,2021-05-02,-5383257045022212194476332790.58249908408715153448695588386599010825479811059729154822,USD,CRDT,hLscFzoYamBLLTCOLtJX,mJzlPJMFlwXsvDWCdPEX,zcgAipzPSDbNvDgsUsdf,Lebenshaltung:Lebensmitteleinkäuft,KzpTvMfREqDcraLsnAFa -8562,XF55JJHCFY9VFPN,EUR,2021-10-06,2023-03-07,58513152657357786429578134004715672662808018369796246403461323977035786109578790774134514968087.32196519574363119760206835296320588360455621,EUR,CRDT,WwygiOMjDsxlmBrgMBUD,CPkjRvCOcTGVQRxdVSco,WEmukvnPtZyCmopsKLeB,Freizeit,vNxjYBDrcjIJXRYsioVn -6932,PK53O1QFHWMM80N,CHF,2021-11-22,2022-04-29,-35687720973389008791120348643747347909777063417264503383154431851328499756311481892.525588482723067939754169870785253637544209574930786950647614924757554933127740331928,USD,DBIT,ocyWAbkFrEGhLKcqIUrJ,eIdWToAESlHQGGnykQZI,urMJiqlsVuLqGbGmIOdR,Beruf:Gehalt:super Firma,wbVGbcIRqoAyhXPQFmbn -5603,LM99PWTKWNVKFUK,CHF,2020-10-03,2024-10-23,137912574664116210905504437194561515849372037.5300228797878339289857585366614065212461747,CHF,DBIT,mJHgXdjzfGTiqdPFYKJu,iCPXYqUtKwBTilCrsJTM,AqLdwtlWBsQmQaBvwHii,Lebenshaltung:Lebensmitteleinkäuft,DVEvjvPzjBahqIEOddlq -7707,LP36QSQ8LN9F6U8,EUR,2020-01-26,2026-07-22,20617377959559934081335048761128552951261867125124238167249671785612681.51317409949621954528827764556416450523039696133111537499104315164348776636640613432,GBP,CRDT,IcRLxekFBpooSGzJqqGw,rCFLSzlzvLBdEpXFPZfV,vkMfGTUYKzrINNryKNJN,,tjNBGXokHzSQvBVABxCV -7964,WB16POIGIKIRSRB,GBP,2025-01-15,2026-07-14,19212833314539080562967961976059399140703968646971623411156202797393305194593.881945095397465505661922398,USD,DBIT,CoxjaXrcDJVITkyHjZzv,IunvUBVgzwgJVzAvOyKQ,BcYtrPUKMrJkEKNNnYyD,,awUfzbbQZVwNaLaTiFsr -4725,AT598204ZYZODV1,EUR,2024-03-25,2024-08-09,82856650729233542595012846123790016186861935974808282995711867343684454016512697494.88917524421022914453765603187655206018877538527053112,EUR,DBIT,xlCBJxJOxqvLAjgICrbX,msAsDTuxgXrTntkliYmb,FXyFAuyPwdvXjTTNcPrz,Lebenshaltung:Lebensmitteleinkäuft,gwHdLpOEMZgALigVFmpM -6050,TY84APSJTGLW0Y7,GBP,2020-09-04,2025-08-15,-3472009449932706821864839468227645291697670610604942221835405160979793916196159877775819802166678286.924897290769355741386357676998033855577136669559598846,GBP,DBIT,sqcQIypoDiHETEPLtswm,bkRKycwiqnHBrCWFDWvp,IsMCNagZuWQgjbHotLkZ,Lebenshaltung:Lebensmitteleinkäuft,PmxAqaLvtdKHOqufnPEr -1636,TT56OLKXD9FFLHW,EUR,2022-04-05,2025-01-21,-8700251749693498471853131195688590000252839147762542676728724966.70275220280,CHF,CRDT,LYftAZAxwAbsKqKJJDox,AutZAoZFOVswxydiSajl,peXIkhZxuXokaHbIHulP,Freizeit,qAADExaKKWlrrLTDOeJd -7753,BS67UNQVFAG259C,CHF,2024-06-05,2021-06-24,24861162756159054957.670585366670739666042308388630102242932146348848932417907909353461828706673705,EUR,CRDT,xZhCgZOvnVzrCDKLqbhK,XhDZhAWtaSbEctUEYLjT,aClycbHxjTAgkNnTRcuY,,LmeGwybyUMOrxHkjgLaZ -6189,ZZ871MTLUU9A1RU,CHF,2021-08-01,2023-03-17,-409780077475199424796148695159372908162375055189181880910638677.248670538597146440670239645579725174792942268017121149370963751622201496,EUR,CRDT,GQlNICMrebnglDvBDTYS,CXDqKkEcjnJngYAqpByV,NHVCfnmSrnFolUjPSEIr,,uwEFhSBDVdAPtdhJavhW -9624,AU87XCMRZZWGH86,USD,2024-09-02,2022-09-19,-442193693537141866795259204406415934110882954978333632301457685242868631049543099431520126945516367.745400551449,EUR,DBIT,cbaGAkeofJGsqaiNUEIY,ljZDuEzncPYZLDWNMgnH,EcVuFQSISKGSBDNIdqXJ,Beruf:Gehalt:super Firma,KhFiMeuprVqYAZtZQwIp -9203,FX68NTN2D10VVKO,GBP,2022-02-15,2024-06-23,-534572334.4012791130107478240377375826129655674355510806820651340789640,USD,DBIT,tNihPvPVLoZiwWmdZZRZ,zJGOcjBqCiLbirDJCgVR,oeFcARYRdznzUieqiHxt,Lebenshaltung:Lebensmitteleinkäuft,ihcfdoHuUNdBWRSOyeBB -3927,WH16E7SHB62XKIH,GBP,2023-10-04,2023-01-05,415593598910615971016549932285154.643848021414472037085778014035161865336120601218617113853923085787470877370,EUR,CRDT,MreRryaDFIhSFXkthwko,AqECnwyKoevMnedwdaFc,DXMnEnltpMbzNPWinMrk,Freizeit,IDiYkQfxVozPBqInPIQV -6283,HU452U8CMEYLRNE,CHF,2022-04-25,2025-03-24,-31056127574668984711914.148062642678712152287686837162829872960394642611046974293342,CHF,CRDT,uvRVCtUcGqAYppkZfxar,VvKDwohUZmOQWHpTbbox,rGrLuSeVEZhbtgGBcOFx,Lebenshaltung:Lebensmitteleinkäuft,mNqLNmnFbTXThCWaoBpa -4041,AR48BWK3T5RZ570,GBP,2025-10-10,2020-07-06,1812531959.04051886610531349,EUR,DBIT,VICxZqioNNFBXEnZvvHZ,HHKlheyBNtageACANISB,mPbiYFXCzowXbuRckcJU,Freizeit,jAtyPevPSKJdGTVjowmo -7217,OZ17J7ZD7YG7O1A,USD,2024-01-23,2024-06-19,-968844607026826575302933681105715018281543501601087944362850708075305.66200706173538352524957865510877,GBP,CRDT,RQxftSphrPpwMKVlnKpn,uqNrspSFVXYYPveWyuNv,CWyQeuLcZPAuGlNWjGdM,Lebenshaltung:Lebensmitteleinkäuft,HAyMDEDnKqtDgfIYZDiC -2615,WX91BRBHNDBXKU0,EUR,2021-10-30,2025-11-23,-8343466178334617252604602.569949636202667336077288532506921508691215873161097,CHF,CRDT,RyhUcmsrSAXNUaXevPMB,bFIZFcQCTivRZcMYhJAx,mfRHcvsWGYIMPHtWOYpm,Lebenshaltung:Lebensmitteleinkäuft,PHOtyKuWgmyZaxJYQPjO -251,ZJ430BMO81CDA44,EUR,2025-08-08,2022-04-02,-944177666499883169717087768179297037355028367029688127.633698125531,USD,DBIT,lUaArRWPnlxRwoWKSDyZ,jFSXDqiPxpdkDkjduezx,ndEGQjdezYvakfXmSeer,Freizeit,NbDQJseGHjiMLMFmhlQX -8432,RG9882TQXT40HDH,GBP,2020-05-29,2026-06-29,-4620473059978062834290693674666409952444643118783437106239403117968074841905810680891909.179130304762746994760945084576,GBP,DBIT,QakUGBIAuAmdabucXlOf,WlRItduFSMEjCwmkbVPV,haAnIWAPZnqWKJHZubtA,Freizeit,tZHnaMEcNMMxpvFZAdjx -9952,JF78B330IL8AKEQ,CHF,2023-03-16,2023-01-31,-4493693464311613709632110286395250278421205912044580776714732064115337203268.547530,GBP,DBIT,hTIWMdfJKdCycCtcVZIa,OABZhgwiXUqfXMmSklHk,GpItXzAVpjsAvDFHxtHP,Freizeit,QbKsSKahDgJtFJQcPJyq -3604,NJ18VUU9VH26T0A,GBP,2026-08-11,2020-04-09,0.64973001968427147068,EUR,CRDT,qupJQwFywzUjbEdwPDUA,uyOHMYgdPtMhcVdnxCZx,cLMmgjuVZtoDOwxoKrFt,Lebenshaltung:Lebensmitteleinkäuft,XQYYbtGKlEBIBnEWqecs -7263,KT85HBZNJL59FFT,USD,2022-11-27,2024-07-15,-37374807.958562351366178447898590057117071635996571407457660485745838424652255349422449887484,GBP,DBIT,uHcJnYVJYKDQMoFMjGXC,PQGdgBCLypSRkHiJYdev,rGwrTRHsFHjLPYhFpMTJ,,dIxwCBlTvNJpYULlJAaS -9675,BJ715E6CD1OIU6T,CHF,2020-12-16,2024-01-07,-5040941689111955157468104725713611935085598625070764074521071414015940351382113590909197304063575.4515354590973595602470431141798115590834959037133034277462703823,GBP,DBIT,HGLztVTGOpGnChJsjprC,qLFITvGssIbHlFBfuZyg,SWQbyfBlwfdSCnoAtwmn,Beruf:Gehalt:super Firma,jtabNPVEZOlBVnaIvamZ -2631,BB0000NISDKBWM1,USD,2023-12-09,2021-09-25,35870065450695.1976,GBP,DBIT,gtwRCGANaKlcNLbLqXCW,qhkoHtHuLrwxdDjUitUM,SMAvrVhBeuNflTTfByZh,Lebenshaltung:Lebensmitteleinkäuft,JodnUWDKlXidVgYIIkZo -7105,FM81K0IHYG2B1K8,CHF,2022-07-17,2021-04-08,7605971335219411809496337191273512429.19144246140951276630353263005965894263583159362498576671567919,GBP,DBIT,bvkiJapOkQoCqDQaeSsj,wcUfBTvFUoxaSRsaNbOk,wzNzdPXmfLEXEgEvtXBP,Lebenshaltung:Lebensmitteleinkäuft,mQFwQMQRmhadGjzUhyIP -4791,XO29KPUGVXKV7P4,GBP,2025-05-09,2025-03-06,-3047323302463055726328888340075326228246153418725869094694662399.9369795962317797302779791691824533,CHF,CRDT,WPBXOTiXiJFuZyjStJxI,bKFooYlWkOIHkeDlcJxu,OXIINcOhpetOqPvcLwkf,Freizeit,jHrZgYXhlOxQlibgCCRq -9872,NX87N16NYF88GY3,CHF,2024-09-17,2022-11-07,-7503175603096494776785070745277206265109071257232396346239801093970295294378754146262745092564314724.58147937995295429167115163695901432938,USD,DBIT,PskmmmqmEWvvjVwdTbpa,IKnFvpHdkVECQXMJgPEb,XgxsPRqDemqlLkHurTtC,Lebenshaltung:Lebensmitteleinkäuft,iDHDfFpVGofjugPlXzMa -2685,DI50CT1EAUXO5ZQ,CHF,2020-11-27,2022-08-17,804255947854847664075204915880058282080805548244876707562721818851111658361963075718333712782.054580103972864189422158609687925786112724117046925451325120196326468662088148238188624,GBP,DBIT,EUSdiqhdVykkRxbeknBL,aUXqpaJrSZvchOtFcDZK,ruepxYDTWMuSxXoNiEBQ,Beruf:Gehalt:super Firma,tSCewecchqoQJuQXVXEr -6119,MJ453EJXQAF5KF9,USD,2020-06-02,2020-07-17,18687868432897828853760232671664003846966802581954654987871.542014558313629353382101420030766,EUR,DBIT,HhAovEIGHJoxAVgFeCVG,iUoROrwSAhKLpKuJqQZl,xHvmkmSMiiuCtJVFZDpN,Freizeit,GgDhdWxjgwwJUmNQOEzl +2032,DE19808033335256432098,EUR,2024-05-10,2022-07-19,477957.82,EUR,DBIT,Gesund Zimmer sein krank kann man etwas im.,Christoph Mentzel GmbH,DE94692210239520911940,Freizeit,Wollen Apfel Tisch Haus Gesicht ohne jeder neun. +4080,DE28150336732274077295,GBP,2021-02-25,2025-01-07,769387.23,EUR,DBIT,Draußen gar hoch antworten wahr neun arbeiten Buch.,Weinhage Kühnert Stiftung & Co. KG,DE14961563763270251565,Freizeit,Natürlich erzählen Herr weiter dort Frau fünf beißen. +9367,DE40027196887628946719,USD,2026-07-18,2022-03-09,791669.93,GBP,CRDT,Weiß und Klasse wünschen mein spät gestern dazu.,Keudel,DE33391900322679112266,,Jeder verkaufen schön tun hören fehlen Leute heraus. +2361,DE67300770777005335370,EUR,2025-11-08,2026-04-05,390306.30,CHF,CRDT,Sicher führen Berg hinein Wald Ding stark gestern.,Hendriks AG,DE39870780016882226947,,Liegen öffnen Wohnung Weihnachten drei jung las nämlich. +9761,DE87243182231193227494,GBP,2022-04-25,2023-09-06,670374.21,CHF,CRDT,Nacht Tag springen vielleicht warten Glas glauben wünschen.,Drub,DE83303433691604416148,,Wissen später Zeitung Bett alle es Straße las. +455,DE14818846077831291312,USD,2025-05-06,2021-05-04,280035.12,EUR,CRDT,Traurig aber selbst Glas See davon Hase nehmen.,Jungfer,DE28870736962903551052,Beruf:Gehalt:super Firma,Onkel was schreien vor richtig wahr ihn sicher. +4285,DE50477297338180580879,USD,2021-08-19,2023-02-20,596594.78,EUR,CRDT,Erst sich ist lassen Zeitung ziehen Woche wieder.,Neureuther GbR,DE36230929812846899707,,Eis Freund dich sich ganz scheinen Land Wiese. +8432,DE85496829713784528780,CHF,2026-07-01,2024-12-26,300513.80,GBP,CRDT,Bis dann traurig Winter tief ließ Polizei denn.,Gotthard Nohlmans GmbH,DE14524057948727945455,Freizeit,Besser gleich Ding leben Stunde aber sprechen mögen. +372,DE64700135524319240949,GBP,2020-01-09,2023-07-18,182843.44,USD,DBIT,Fliegen las schenken Leben wenn Kind Loch Teller.,Hauffer e.G.,DE52586382530700013061,Beruf:Gehalt:super Firma,Was dem rund Finger helfen treffen Mama oben. +6018,DE78250779752870449879,USD,2022-06-20,2020-02-07,666776.36,EUR,DBIT,An erklären heißen Apfel mein früher oft Nacht.,Fliegner Hermighausen Stiftung & Co. KGaA,DE91726700833327322444,Lebenshaltung:Lebensmitteleinkäuft,Dafür Sache Arbeit Zeit weinen plötzlich auch sitzen. +4464,DE05587092142576135694,USD,2023-04-14,2025-01-20,357344.06,GBP,CRDT,Dabei schön hinter Bruder Name heraus geben Freund.,Riehl,DE58482277931550514013,Beruf:Gehalt:super Firma,Vier kalt Küche Haus Schüler Fußball Hund Minutenmir. +1479,DE82962048196319756400,CHF,2025-10-07,2022-01-30,586943.05,GBP,DBIT,Den turnen mich dabei schlimm immer Weihnachten Tier.,Mies,DE41653677901428085866,,Genau wenn hören beim Wiese dein gestern weinen. +9193,DE21182566895433374746,EUR,2021-12-13,2025-05-15,430822.96,CHF,CRDT,Glück deshalb Beispiel sprechen hin gerade müde Musik.,Textor KG,DE87584327160880455611,Lebenshaltung:Lebensmitteleinkäuft,Hat fast Mensch öffnen gerade fliegen Schwester wenn. +2824,DE64197421420322158940,EUR,2025-04-14,2022-12-07,889912.62,CHF,DBIT,Beim nehmen leben hören genau fest Feuer heiß.,Fröhlich,DE75211062367600230013,Lebenshaltung:Lebensmitteleinkäuft,Zeitung lustig geben Finger Loch freuen ich vor. +8924,DE42126072718960162711,USD,2024-01-15,2021-12-23,944745.70,USD,CRDT,Sofort Mama Brot nennen Welt werden Wagen deshalb.,Pruschke GmbH,DE75308826295762345281,Freizeit,Nur viel wohl fast leben Herr spielen voll. +2509,DE57930750759289971608,EUR,2023-07-28,2025-08-08,360311.42,USD,CRDT,Wie Tante schreien kann Minute erst mein Monate.,Möchlichen GmbH & Co. KG,DE17231305869365595866,Beruf:Gehalt:super Firma,Nach Frage Winter nun neun öffnen groß singen. +6255,DE10567598489780010187,CHF,2020-03-30,2024-12-23,252109.20,EUR,CRDT,Lange kein Minute packen her rechnen traurig damit.,Schulz,DE29330437928686341607,Freizeit,Ende Arbeit lange vier Angst ziehen halten bin. +1121,DE94920479014178182173,USD,2022-08-29,2020-05-26,397816.95,USD,CRDT,Lassen erklären leben gehören Monat frei oft fangen.,Koch II,DE68541504580807864984,Freizeit,Schenken sie bringen groß Woche alt Luft neun. +6374,DE75942041340422354630,CHF,2025-12-27,2026-08-07,587541.88,EUR,CRDT,Was Haare sein heraus früh was sechs holen.,Bolzmann,DE22771518850584481354,Freizeit,Aus Polizei alle böse danach zehn dazu der. +511,DE43921575912945524899,USD,2020-06-09,2024-03-05,166813.06,GBP,CRDT,Freuen den Wissen leise Uhr lernen deshalb Oma.,Scholz,DE66505425703594182780,,Jahr beißen vor frei Tag leicht dunkel schauen. +8158,DE77412175136594843031,EUR,2021-10-10,2021-04-16,926940.66,EUR,DBIT,Trinken gern Angst hängen laufen warten Minute gerade.,Kade Hövel AG & Co. KGaA,DE97881910315088563084,,Selbst Schnee Winter Ball kochen wenn rufen ihm. +6754,DE09636290023508283629,USD,2026-02-20,2023-01-20,460951.01,GBP,CRDT,Herz Papa Tante wo fliegen warum lesen also.,Linke KG,DE89662305695252592872,Lebenshaltung:Lebensmitteleinkäuft,See Geburtstag aus Buch Glück Sommer nichts alle. +1216,DE53353450282840484851,CHF,2021-09-08,2025-10-12,199630.85,GBP,DBIT,Spiel vom sich wahr wichtig können drei schicken.,Nerger,DE04715474230705914230,,Führen lachen Loch Sohn heraus früher Glas später. +9340,DE91809093005191920688,CHF,2025-09-09,2026-07-30,425019.55,GBP,DBIT,Drei beißen Gott mein seit baden Maus reiten.,Franke Zahn AG & Co. KGaA,DE07689475049941441136,Lebenshaltung:Lebensmitteleinkäuft,Hase gefährlich ließ sehen beißen acht Leben Tisch. +4951,DE97118644159431619469,CHF,2025-04-06,2026-03-20,330808.64,CHF,DBIT,Können Stelle Rad dich lachen kein so Erde.,Schenk Faust e.G.,DE82654811844560944325,Freizeit,Erde Platz Finger Seite eigentlich oben Wiese warten. +6903,DE68368212682303266136,EUR,2025-04-05,2026-01-22,336982.68,CHF,DBIT,Pferd anfangen wünschen Schuh gehören Wohnung hinein lassen.,Weinhage Juncken Stiftung & Co. KGaA,DE71417495971804567911,Freizeit,Vom schlecht Name wir brauchen überall sprechen Flasche. +7410,DE27052166428249878010,CHF,2026-01-30,2022-03-22,720689.48,USD,CRDT,Hoch ja Baum Mensch Tante nun an kaufen.,Schottin GmbH,DE57760275392105618486,Beruf:Gehalt:super Firma,Müssen haben treffen vorbei mehr setzen sein Land. +4867,DE58799284124136070280,CHF,2024-07-19,2021-03-08,254079.38,EUR,DBIT,Sofort Schuh wenig nichts fertig gehen fährt einfach.,Hesse,DE19648447949103706433,Freizeit,Gut unser stehen so gestern Straße Beispiel daran. +4361,DE42442778011385588866,GBP,2025-11-22,2023-08-07,203518.70,USD,CRDT,Neben bis Licht rufen fressen warum bringen Stelle.,Gude,DE55132266801893474601,Lebenshaltung:Lebensmitteleinkäuft,Essen Stein freuen Rad halten Licht etwas Katze. +560,DE40475588184568131358,CHF,2020-02-27,2022-12-18,514260.57,CHF,CRDT,Schlagen Bein beide machen sofort Vogel oben Arzt.,Kobelt,DE58145236780761541141,Freizeit,Antworten offen jung Leute schicken wirklich Musik Mädchen. +8169,DE62988808737936594461,USD,2024-12-06,2024-04-02,150408.43,USD,CRDT,Rufen wo Stück versuchen wir Sohn hinein leicht.,Bauer Kramer GmbH & Co. KGaA,DE45719424413951709627,Beruf:Gehalt:super Firma,Dorf ihn lernen wir unter Sohn wird Nase. +1051,DE78853173636543491619,USD,2025-08-07,2022-02-21,441511.02,USD,CRDT,Um Himmel Katze lachen hier war sind brauchen.,Schäfer Zobel Stiftung & Co. KGaA,DE42307015037562554193,Freizeit,Rad Eis schön gesund mehr Zeit reiten warm. +5873,DE96735708905385148270,CHF,2022-10-16,2022-08-21,421265.96,EUR,DBIT,Waschen vergessen singen gehören fressen Fußball Blume weit.,Kobelt,DE94740760467111232470,Lebenshaltung:Lebensmitteleinkäuft,Milch schwimmen einigen darin Gott doch las Ende. +2172,DE22158601632262516142,GBP,2020-11-26,2025-12-25,191861.50,USD,CRDT,Dauern Nacht weiter bleiben Haare Garten mögen alt.,Speer Förster GmbH & Co. OHG,DE59577418330840153755,Beruf:Gehalt:super Firma,Ein dafür nie verlieren sprechen ging gestern zum. +5572,DE58032300666892592059,USD,2024-07-24,2023-09-10,906216.64,GBP,DBIT,Zahl gefährlich wollen wünschen schaffen aber also gerade.,Trommler,DE13561237857459928066,Freizeit,Darauf mit ist endlich Minute Hilfe Seite arbeiten. +313,DE92651300507452853254,USD,2020-10-31,2025-04-14,866696.48,GBP,DBIT,Fehlen aber stark bin ging auch vier voll.,Mangold Ziegert AG,DE18157663477859322472,Freizeit,Draußen schauen Eltern ihm das können versuchen sieben. +1876,DE86461387038565521160,GBP,2020-09-08,2020-06-30,243203.56,CHF,CRDT,Kalt Gott sehr Monat drei Berg baden Zug.,Wende GbR,DE43291855772890172820,,Aber Mutter beide liegen sollen beide Fußball fertig. +5865,DE33605312020410210095,EUR,2021-08-26,2023-10-05,340544.57,USD,CRDT,Ohne sehr schauen Lehrerin führen gegen wird blau.,Koch,DE70628578310176310668,Beruf:Gehalt:super Firma,Verkaufen Wald war lieb dem las Buch hart. +3505,DE37289547312669494332,CHF,2026-01-12,2022-11-28,614459.45,CHF,CRDT,Apfel oft sich etwas Geld das Mann warten.,Mülichen Binner AG & Co. KG,DE33960492633225313318,Beruf:Gehalt:super Firma,Ferien Winter Schüler sagen reich gestern was nun. +8920,DE80390213921792676656,USD,2024-03-03,2022-04-04,820884.89,EUR,CRDT,Warum hinter unser Mama warum Frage Zug oben.,Dietz GbR,DE62154682672948525232,,Ihm einfach brauchen Herz Himmel Sache oder kein. +761,DE84946891435941245657,USD,2024-07-26,2022-04-21,480806.40,CHF,CRDT,Fußball versuchen tief Schluss vergessen Leute also die.,Niemeier,DE96236239552739085942,Beruf:Gehalt:super Firma,Küche kann fressen Kopf schreien weiß schreien nimmt. +6921,DE64065293639991914593,GBP,2026-08-09,2022-07-03,808254.32,USD,CRDT,Wasser verlieren laut Schwester müssen kochen setzen bin.,Plath Mosemann GmbH,DE93965440822266769867,Freizeit,Junge gar brauchen Zahl sein durch las Nacht. +6324,DE23592807095269847064,CHF,2025-12-11,2020-04-04,730322.57,CHF,CRDT,Gewinnen gern bei baden daran rufen ziehen Bein.,Mülichen GmbH & Co. KG,DE64053886780792810305,Freizeit,Immer vor Essen laut Rad Licht sieben wo. +5435,DE14840061169639249372,GBP,2021-10-25,2020-11-04,844305.50,GBP,DBIT,Ihm kann jeder bin rennen dunkel Mädchen turnen.,Hörle Höfig Stiftung & Co. KG,DE91970255854053308679,Beruf:Gehalt:super Firma,Schwester öffnen sollen leise Zeit was Monate Tisch. +3022,DE84651674225021983381,EUR,2023-11-30,2020-11-08,976082.14,GBP,DBIT,Nein gesund sein ging Küche kann Stelle bald.,Pärtzelt GbR,DE38737090659432749722,Freizeit,Bin geben Mädchen neu Angst das wirklich spielen. +9346,DE69679562016853727805,GBP,2022-02-04,2022-02-26,491633.47,EUR,CRDT,Weinen Zeitung schreien klettern uns Boden Baum Opa.,Geißler GmbH,DE22027168066139552284,Lebenshaltung:Lebensmitteleinkäuft,Dazu ins arbeiten helfen Finger Name fahren Tag. +1502,DE84767043572002123777,EUR,2023-12-16,2020-01-08,704801.13,CHF,DBIT,Sagen Rad Mann allein Stelle wohl Welt zusammen.,Oderwald KG,DE71393302813716414796,,Seite langsam heißen nein Meer nun setzen Bein. +3110,DE61157657569102045205,EUR,2023-10-06,2026-08-24,374209.78,USD,CRDT,Haus weg Familie brauchen weinen zusammen Haus kochen.,Budig Graf GmbH & Co. KG,DE28518045480715343061,Freizeit,Gehen Wagen Maus von sieht gesund Uhr darin. +6491,DE68036766833654634331,CHF,2025-05-13,2021-03-15,605790.76,EUR,DBIT,Ist wir kam nein Mann reiten anfangen wahr.,Karz,DE09211671711270990746,,Platz Ding Himmel darin steigen an lernen nennen. +5420,DE51383009536339913416,CHF,2025-06-07,2023-02-11,675240.92,EUR,DBIT,Davon Mann Welt wichtig kennen ob da genau.,Seip,DE02445793934707639611,Lebenshaltung:Lebensmitteleinkäuft,Richtig in Weihnachten Platz spielen dumm halbe weiß. diff --git a/tests/test_model_b4wexport.py b/tests/test_model_b4wexport.py index 1e88230..dd7a437 100644 --- a/tests/test_model_b4wexport.py +++ b/tests/test_model_b4wexport.py @@ -59,3 +59,15 @@ def test_b4w_export_repr_round_trip(): data_str = str(data) data2 = eval(data_str) assert data == data2 + + +def test_b4w_export_amount_two_decimal_places(): + data = B4wExportFactory.build() + assert data.amount > 0 + assert data.amount.as_tuple().exponent >= -2 + + +def test_b4w_export_text_fields_non_empty(): + data = B4wExportFactory.build() + assert data.reference + assert data.notes From 75fd76b0d96fcc48d8765ed38ac4496b03c19777 Mon Sep 17 00:00:00 2001 From: Mathias Schneider Date: Sat, 12 Sep 2026 20:07:08 +0200 Subject: [PATCH 14/31] comdirect faker --- src/models/comdirect.py | 13 ++- tests/factories.py | 21 ++++- tests/test_data/b4w_export_dummy_01.csv | 100 ++++++++++++------------ 3 files changed, 82 insertions(+), 52 deletions(-) diff --git a/src/models/comdirect.py b/src/models/comdirect.py index 9990484..3637e67 100644 --- a/src/models/comdirect.py +++ b/src/models/comdirect.py @@ -31,6 +31,9 @@ class ComdirectBase(ExportBaseModel): class ComdirectGiro(ComdirectBase): + # # Fields from parents: + # date_val + # amount_eur date_book: date | None = Field(alias="Buchungstag", examples=["07.09.2026"]) type: str = Field( alias="Vorgang", examples=["Lastschrift / Belastung", "Kartenumsatz"] @@ -49,7 +52,12 @@ class ComdirectGiro(ComdirectBase): class ComdirectCreditCard(ComdirectGiro): - # "Buchungstag";"Umsatztag";"Vorgang";"Referenz";"Buchungstext";"Umsatz in EUR"; + # # Fields from parents: + # date_val + # amount_eur + # date_book + # type + # text date_book: date | None = Field(alias="Buchungstag", examples=["07.09.2026"]) date_val: date | None = Field(alias="Umsatztag", default=None) type: str = Field( @@ -65,6 +73,9 @@ class ComdirectCreditCard(ComdirectGiro): class ComdirectDepot(ComdirectBase): + # # Fields from parents: + # date_val + # amount_eur date_val: date | None = Field(alias="Geschäftstag") num: Decimal = Field(alias="Stück / Nom.") name: str = Field(alias="Bezeichnung") diff --git a/tests/factories.py b/tests/factories.py index 0ab356f..e54af89 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -4,7 +4,8 @@ from faker import Faker from polyfactory.factories.pydantic_factory import ModelFactory from polyfactory.fields import Use -from src.models import B4wExport +from src.models.b4w import B4wExport +from src.models.comdirect import ComdirectCreditCard, ComdirectDepot, ComdirectGiro DEFAULT_CATEGORY = "root_category:parent_category:leaf_category" @@ -43,3 +44,21 @@ class B4wExportFactory(ModelFactory[B4wExport]): RmtdNm = Use(_names) OwnrAcctIBAN = Use(_iban) RmtdAcctIBAN = Use(_iban) + + +class ComdirectGiroFactory(ModelFactory[ComdirectGiro]): + __faker__ = Faker("de_DE") + __use_example__ = True + + Buchungstext = Use(_sentence) + + +class ComdirectCreditCardFactory(ModelFactory[ComdirectCreditCard]): + __faker__ = Faker("de_DE") + __use_example__ = True + + +class ComdirectDepotFactory(ModelFactory[ComdirectDepot]): + __faker__ = Faker("de_DE") + __use_example__ = True + diff --git a/tests/test_data/b4w_export_dummy_01.csv b/tests/test_data/b4w_export_dummy_01.csv index 0bc2040..e99af15 100644 --- a/tests/test_data/b4w_export_dummy_01.csv +++ b/tests/test_data/b4w_export_dummy_01.csv @@ -1,51 +1,51 @@ Id1,OwnrAcctIBAN,OwnrAcctCcy,ValDt,BookgDt,Amt,AmtCcy,CdtDbtInd,RmtInf,RmtdNm,RmtdAcctIBAN,Category,Notes -2032,DE19808033335256432098,EUR,2024-05-10,2022-07-19,477957.82,EUR,DBIT,Gesund Zimmer sein krank kann man etwas im.,Christoph Mentzel GmbH,DE94692210239520911940,Freizeit,Wollen Apfel Tisch Haus Gesicht ohne jeder neun. -4080,DE28150336732274077295,GBP,2021-02-25,2025-01-07,769387.23,EUR,DBIT,Draußen gar hoch antworten wahr neun arbeiten Buch.,Weinhage Kühnert Stiftung & Co. KG,DE14961563763270251565,Freizeit,Natürlich erzählen Herr weiter dort Frau fünf beißen. -9367,DE40027196887628946719,USD,2026-07-18,2022-03-09,791669.93,GBP,CRDT,Weiß und Klasse wünschen mein spät gestern dazu.,Keudel,DE33391900322679112266,,Jeder verkaufen schön tun hören fehlen Leute heraus. -2361,DE67300770777005335370,EUR,2025-11-08,2026-04-05,390306.30,CHF,CRDT,Sicher führen Berg hinein Wald Ding stark gestern.,Hendriks AG,DE39870780016882226947,,Liegen öffnen Wohnung Weihnachten drei jung las nämlich. -9761,DE87243182231193227494,GBP,2022-04-25,2023-09-06,670374.21,CHF,CRDT,Nacht Tag springen vielleicht warten Glas glauben wünschen.,Drub,DE83303433691604416148,,Wissen später Zeitung Bett alle es Straße las. -455,DE14818846077831291312,USD,2025-05-06,2021-05-04,280035.12,EUR,CRDT,Traurig aber selbst Glas See davon Hase nehmen.,Jungfer,DE28870736962903551052,Beruf:Gehalt:super Firma,Onkel was schreien vor richtig wahr ihn sicher. -4285,DE50477297338180580879,USD,2021-08-19,2023-02-20,596594.78,EUR,CRDT,Erst sich ist lassen Zeitung ziehen Woche wieder.,Neureuther GbR,DE36230929812846899707,,Eis Freund dich sich ganz scheinen Land Wiese. -8432,DE85496829713784528780,CHF,2026-07-01,2024-12-26,300513.80,GBP,CRDT,Bis dann traurig Winter tief ließ Polizei denn.,Gotthard Nohlmans GmbH,DE14524057948727945455,Freizeit,Besser gleich Ding leben Stunde aber sprechen mögen. -372,DE64700135524319240949,GBP,2020-01-09,2023-07-18,182843.44,USD,DBIT,Fliegen las schenken Leben wenn Kind Loch Teller.,Hauffer e.G.,DE52586382530700013061,Beruf:Gehalt:super Firma,Was dem rund Finger helfen treffen Mama oben. -6018,DE78250779752870449879,USD,2022-06-20,2020-02-07,666776.36,EUR,DBIT,An erklären heißen Apfel mein früher oft Nacht.,Fliegner Hermighausen Stiftung & Co. KGaA,DE91726700833327322444,Lebenshaltung:Lebensmitteleinkäuft,Dafür Sache Arbeit Zeit weinen plötzlich auch sitzen. -4464,DE05587092142576135694,USD,2023-04-14,2025-01-20,357344.06,GBP,CRDT,Dabei schön hinter Bruder Name heraus geben Freund.,Riehl,DE58482277931550514013,Beruf:Gehalt:super Firma,Vier kalt Küche Haus Schüler Fußball Hund Minutenmir. -1479,DE82962048196319756400,CHF,2025-10-07,2022-01-30,586943.05,GBP,DBIT,Den turnen mich dabei schlimm immer Weihnachten Tier.,Mies,DE41653677901428085866,,Genau wenn hören beim Wiese dein gestern weinen. -9193,DE21182566895433374746,EUR,2021-12-13,2025-05-15,430822.96,CHF,CRDT,Glück deshalb Beispiel sprechen hin gerade müde Musik.,Textor KG,DE87584327160880455611,Lebenshaltung:Lebensmitteleinkäuft,Hat fast Mensch öffnen gerade fliegen Schwester wenn. -2824,DE64197421420322158940,EUR,2025-04-14,2022-12-07,889912.62,CHF,DBIT,Beim nehmen leben hören genau fest Feuer heiß.,Fröhlich,DE75211062367600230013,Lebenshaltung:Lebensmitteleinkäuft,Zeitung lustig geben Finger Loch freuen ich vor. -8924,DE42126072718960162711,USD,2024-01-15,2021-12-23,944745.70,USD,CRDT,Sofort Mama Brot nennen Welt werden Wagen deshalb.,Pruschke GmbH,DE75308826295762345281,Freizeit,Nur viel wohl fast leben Herr spielen voll. -2509,DE57930750759289971608,EUR,2023-07-28,2025-08-08,360311.42,USD,CRDT,Wie Tante schreien kann Minute erst mein Monate.,Möchlichen GmbH & Co. KG,DE17231305869365595866,Beruf:Gehalt:super Firma,Nach Frage Winter nun neun öffnen groß singen. -6255,DE10567598489780010187,CHF,2020-03-30,2024-12-23,252109.20,EUR,CRDT,Lange kein Minute packen her rechnen traurig damit.,Schulz,DE29330437928686341607,Freizeit,Ende Arbeit lange vier Angst ziehen halten bin. -1121,DE94920479014178182173,USD,2022-08-29,2020-05-26,397816.95,USD,CRDT,Lassen erklären leben gehören Monat frei oft fangen.,Koch II,DE68541504580807864984,Freizeit,Schenken sie bringen groß Woche alt Luft neun. -6374,DE75942041340422354630,CHF,2025-12-27,2026-08-07,587541.88,EUR,CRDT,Was Haare sein heraus früh was sechs holen.,Bolzmann,DE22771518850584481354,Freizeit,Aus Polizei alle böse danach zehn dazu der. -511,DE43921575912945524899,USD,2020-06-09,2024-03-05,166813.06,GBP,CRDT,Freuen den Wissen leise Uhr lernen deshalb Oma.,Scholz,DE66505425703594182780,,Jahr beißen vor frei Tag leicht dunkel schauen. -8158,DE77412175136594843031,EUR,2021-10-10,2021-04-16,926940.66,EUR,DBIT,Trinken gern Angst hängen laufen warten Minute gerade.,Kade Hövel AG & Co. KGaA,DE97881910315088563084,,Selbst Schnee Winter Ball kochen wenn rufen ihm. -6754,DE09636290023508283629,USD,2026-02-20,2023-01-20,460951.01,GBP,CRDT,Herz Papa Tante wo fliegen warum lesen also.,Linke KG,DE89662305695252592872,Lebenshaltung:Lebensmitteleinkäuft,See Geburtstag aus Buch Glück Sommer nichts alle. -1216,DE53353450282840484851,CHF,2021-09-08,2025-10-12,199630.85,GBP,DBIT,Spiel vom sich wahr wichtig können drei schicken.,Nerger,DE04715474230705914230,,Führen lachen Loch Sohn heraus früher Glas später. -9340,DE91809093005191920688,CHF,2025-09-09,2026-07-30,425019.55,GBP,DBIT,Drei beißen Gott mein seit baden Maus reiten.,Franke Zahn AG & Co. KGaA,DE07689475049941441136,Lebenshaltung:Lebensmitteleinkäuft,Hase gefährlich ließ sehen beißen acht Leben Tisch. -4951,DE97118644159431619469,CHF,2025-04-06,2026-03-20,330808.64,CHF,DBIT,Können Stelle Rad dich lachen kein so Erde.,Schenk Faust e.G.,DE82654811844560944325,Freizeit,Erde Platz Finger Seite eigentlich oben Wiese warten. -6903,DE68368212682303266136,EUR,2025-04-05,2026-01-22,336982.68,CHF,DBIT,Pferd anfangen wünschen Schuh gehören Wohnung hinein lassen.,Weinhage Juncken Stiftung & Co. KGaA,DE71417495971804567911,Freizeit,Vom schlecht Name wir brauchen überall sprechen Flasche. -7410,DE27052166428249878010,CHF,2026-01-30,2022-03-22,720689.48,USD,CRDT,Hoch ja Baum Mensch Tante nun an kaufen.,Schottin GmbH,DE57760275392105618486,Beruf:Gehalt:super Firma,Müssen haben treffen vorbei mehr setzen sein Land. -4867,DE58799284124136070280,CHF,2024-07-19,2021-03-08,254079.38,EUR,DBIT,Sofort Schuh wenig nichts fertig gehen fährt einfach.,Hesse,DE19648447949103706433,Freizeit,Gut unser stehen so gestern Straße Beispiel daran. -4361,DE42442778011385588866,GBP,2025-11-22,2023-08-07,203518.70,USD,CRDT,Neben bis Licht rufen fressen warum bringen Stelle.,Gude,DE55132266801893474601,Lebenshaltung:Lebensmitteleinkäuft,Essen Stein freuen Rad halten Licht etwas Katze. -560,DE40475588184568131358,CHF,2020-02-27,2022-12-18,514260.57,CHF,CRDT,Schlagen Bein beide machen sofort Vogel oben Arzt.,Kobelt,DE58145236780761541141,Freizeit,Antworten offen jung Leute schicken wirklich Musik Mädchen. -8169,DE62988808737936594461,USD,2024-12-06,2024-04-02,150408.43,USD,CRDT,Rufen wo Stück versuchen wir Sohn hinein leicht.,Bauer Kramer GmbH & Co. KGaA,DE45719424413951709627,Beruf:Gehalt:super Firma,Dorf ihn lernen wir unter Sohn wird Nase. -1051,DE78853173636543491619,USD,2025-08-07,2022-02-21,441511.02,USD,CRDT,Um Himmel Katze lachen hier war sind brauchen.,Schäfer Zobel Stiftung & Co. KGaA,DE42307015037562554193,Freizeit,Rad Eis schön gesund mehr Zeit reiten warm. -5873,DE96735708905385148270,CHF,2022-10-16,2022-08-21,421265.96,EUR,DBIT,Waschen vergessen singen gehören fressen Fußball Blume weit.,Kobelt,DE94740760467111232470,Lebenshaltung:Lebensmitteleinkäuft,Milch schwimmen einigen darin Gott doch las Ende. -2172,DE22158601632262516142,GBP,2020-11-26,2025-12-25,191861.50,USD,CRDT,Dauern Nacht weiter bleiben Haare Garten mögen alt.,Speer Förster GmbH & Co. OHG,DE59577418330840153755,Beruf:Gehalt:super Firma,Ein dafür nie verlieren sprechen ging gestern zum. -5572,DE58032300666892592059,USD,2024-07-24,2023-09-10,906216.64,GBP,DBIT,Zahl gefährlich wollen wünschen schaffen aber also gerade.,Trommler,DE13561237857459928066,Freizeit,Darauf mit ist endlich Minute Hilfe Seite arbeiten. -313,DE92651300507452853254,USD,2020-10-31,2025-04-14,866696.48,GBP,DBIT,Fehlen aber stark bin ging auch vier voll.,Mangold Ziegert AG,DE18157663477859322472,Freizeit,Draußen schauen Eltern ihm das können versuchen sieben. -1876,DE86461387038565521160,GBP,2020-09-08,2020-06-30,243203.56,CHF,CRDT,Kalt Gott sehr Monat drei Berg baden Zug.,Wende GbR,DE43291855772890172820,,Aber Mutter beide liegen sollen beide Fußball fertig. -5865,DE33605312020410210095,EUR,2021-08-26,2023-10-05,340544.57,USD,CRDT,Ohne sehr schauen Lehrerin führen gegen wird blau.,Koch,DE70628578310176310668,Beruf:Gehalt:super Firma,Verkaufen Wald war lieb dem las Buch hart. -3505,DE37289547312669494332,CHF,2026-01-12,2022-11-28,614459.45,CHF,CRDT,Apfel oft sich etwas Geld das Mann warten.,Mülichen Binner AG & Co. KG,DE33960492633225313318,Beruf:Gehalt:super Firma,Ferien Winter Schüler sagen reich gestern was nun. -8920,DE80390213921792676656,USD,2024-03-03,2022-04-04,820884.89,EUR,CRDT,Warum hinter unser Mama warum Frage Zug oben.,Dietz GbR,DE62154682672948525232,,Ihm einfach brauchen Herz Himmel Sache oder kein. -761,DE84946891435941245657,USD,2024-07-26,2022-04-21,480806.40,CHF,CRDT,Fußball versuchen tief Schluss vergessen Leute also die.,Niemeier,DE96236239552739085942,Beruf:Gehalt:super Firma,Küche kann fressen Kopf schreien weiß schreien nimmt. -6921,DE64065293639991914593,GBP,2026-08-09,2022-07-03,808254.32,USD,CRDT,Wasser verlieren laut Schwester müssen kochen setzen bin.,Plath Mosemann GmbH,DE93965440822266769867,Freizeit,Junge gar brauchen Zahl sein durch las Nacht. -6324,DE23592807095269847064,CHF,2025-12-11,2020-04-04,730322.57,CHF,CRDT,Gewinnen gern bei baden daran rufen ziehen Bein.,Mülichen GmbH & Co. KG,DE64053886780792810305,Freizeit,Immer vor Essen laut Rad Licht sieben wo. -5435,DE14840061169639249372,GBP,2021-10-25,2020-11-04,844305.50,GBP,DBIT,Ihm kann jeder bin rennen dunkel Mädchen turnen.,Hörle Höfig Stiftung & Co. KG,DE91970255854053308679,Beruf:Gehalt:super Firma,Schwester öffnen sollen leise Zeit was Monate Tisch. -3022,DE84651674225021983381,EUR,2023-11-30,2020-11-08,976082.14,GBP,DBIT,Nein gesund sein ging Küche kann Stelle bald.,Pärtzelt GbR,DE38737090659432749722,Freizeit,Bin geben Mädchen neu Angst das wirklich spielen. -9346,DE69679562016853727805,GBP,2022-02-04,2022-02-26,491633.47,EUR,CRDT,Weinen Zeitung schreien klettern uns Boden Baum Opa.,Geißler GmbH,DE22027168066139552284,Lebenshaltung:Lebensmitteleinkäuft,Dazu ins arbeiten helfen Finger Name fahren Tag. -1502,DE84767043572002123777,EUR,2023-12-16,2020-01-08,704801.13,CHF,DBIT,Sagen Rad Mann allein Stelle wohl Welt zusammen.,Oderwald KG,DE71393302813716414796,,Seite langsam heißen nein Meer nun setzen Bein. -3110,DE61157657569102045205,EUR,2023-10-06,2026-08-24,374209.78,USD,CRDT,Haus weg Familie brauchen weinen zusammen Haus kochen.,Budig Graf GmbH & Co. KG,DE28518045480715343061,Freizeit,Gehen Wagen Maus von sieht gesund Uhr darin. -6491,DE68036766833654634331,CHF,2025-05-13,2021-03-15,605790.76,EUR,DBIT,Ist wir kam nein Mann reiten anfangen wahr.,Karz,DE09211671711270990746,,Platz Ding Himmel darin steigen an lernen nennen. -5420,DE51383009536339913416,CHF,2025-06-07,2023-02-11,675240.92,EUR,DBIT,Davon Mann Welt wichtig kennen ob da genau.,Seip,DE02445793934707639611,Lebenshaltung:Lebensmitteleinkäuft,Richtig in Weihnachten Platz spielen dumm halbe weiß. +1036,DE07271846982018433140,GBP,2026-07-04,2023-10-12,608251.74,CHF,CRDT,Wohl Straße als gut Zeit laufen wenn Wissen.,Scholtz AG,DE05340442071223320995,Beruf:Gehalt:super Firma,Krank mich erschrecken Leute den nehmen fahren man. +4849,DE42187219246703133408,CHF,2023-09-10,2025-08-05,725899.19,USD,CRDT,Berg im Bruder fröhlich Herr wahr wer leben.,Oestrovsky,DE58802771240736400847,Freizeit,Maus wohnen vergessen Vater plötzlich Schule Meer fallen. +1914,DE70452496991202055362,USD,2025-04-15,2025-12-24,984194.63,GBP,CRDT,Schreiben sollen voll ab Wort kochen schlimm wohl.,Beckmann,DE59790637683034706744,Freizeit,Anfangen fünf weg Woche Boden schreiben hier hängen. +6930,DE67943298499847570990,USD,2025-11-09,2021-09-08,156898.87,USD,DBIT,Verlieren sein dich Papa fragen ließ denn packen.,Heinz,DE54265758309837023543,Beruf:Gehalt:super Firma,Halbe so Leute Bild Schuh an früh traurig. +1699,DE84149253738055473900,EUR,2026-08-01,2021-07-20,818063.51,EUR,CRDT,Rad grün schlecht Pferd beide noch erschrecken Uhr.,Hofmann Steinberg Stiftung & Co. KGaA,DE21220975806758335585,,Neun man kam sechs blau Sache langsam Blume. +7046,DE82274490061461449020,EUR,2023-01-23,2024-05-14,669696.87,USD,DBIT,Vogel hinein Tante brauchen Weihnachten nicht klein braun.,Hübel GmbH,DE39283738756876306318,Freizeit,Ja Loch Winter rufen laufen Woche gelb hinter. +366,DE25389044843010198038,USD,2024-02-09,2023-03-08,220167.30,USD,DBIT,Gefährlich nein Mann nie schlecht Arbeit treffen damit.,Scheuermann,DE77031936189338919289,Lebenshaltung:Lebensmitteleinkäuft,Sofort Blume ging warten gewinnen kaufen Wissen später. +6922,DE73307102808802733774,USD,2025-03-20,2023-01-30,129827.89,EUR,DBIT,Rennen jetzt damit Brief See dumm stellen legen.,Reuter auch Schlauchin GmbH & Co. KG,DE22372707001113794894,Lebenshaltung:Lebensmitteleinkäuft,Hart ins sagen fest eigentlich wo schlimm ganz. +9114,DE16057368814344996724,USD,2021-03-08,2020-10-16,725225.13,EUR,DBIT,Kam waschen Wohnung Sache sprechen kaufen gern spielen.,Hentschel Wilms KG,DE41311487016568247161,Beruf:Gehalt:super Firma,Haare Winter bauen Mädchen gesund Teller hoch deshalb. +6739,DE05970178849203811396,GBP,2020-05-13,2020-08-18,333023.84,GBP,CRDT,Bin schicken unser Tier will endlich dem gefährlich.,Heuser Wiek KG,DE75493228729510129693,Beruf:Gehalt:super Firma,Versuchen etwas fertig den schnell fast Bein Freude. +1278,DE96528552228817399257,CHF,2024-07-25,2024-11-16,385205.95,EUR,CRDT,Auge darin ohne Schnee Frage stark mein oft.,Scholl,DE70979826731102142893,Lebenshaltung:Lebensmitteleinkäuft,Spät Minutenmir Brief Essen geben heiß nah warm. +406,DE89506092918989440859,EUR,2024-11-13,2026-03-18,978962.38,CHF,DBIT,Kaufen turnen Weg dick ging vom rund Sommer.,Thies,DE36862593824516545526,Beruf:Gehalt:super Firma,Für Zeit Fenster Abend warten Geschichte dann stellen. +2026,DE14445771047668409127,GBP,2020-05-06,2024-02-20,967493.86,EUR,CRDT,Glück verlieren Wetter Bauer turnen baden Freude spät.,Mohaupt GmbH & Co. KG,DE52407611264263373889,,Winter nicht bis beide hängen Rad Meer lassen. +3041,DE58304085373743696607,USD,2024-03-10,2026-09-08,807803.15,EUR,CRDT,Auch schlimm groß bauen Welt das alt alle.,Hartmann AG,DE37816718253864239200,Lebenshaltung:Lebensmitteleinkäuft,Spiel erst nicht gelb Vogel über fahren Hilfe. +5405,DE05257576358009350425,GBP,2024-05-30,2022-05-29,611553.21,EUR,DBIT,Darin gut ihn reich mit mögen dauern Ferien.,Rosenow,DE29315257044202664260,Lebenshaltung:Lebensmitteleinkäuft,Tief Tag Polizei Zahl Sommer Abend neun vom. +6543,DE91371884570994329173,CHF,2025-07-16,2022-09-05,762232.56,USD,DBIT,Monate Glas waschen Brief fliegen gesund Essen kam.,Jäntsch Ullrich AG & Co. KG,DE33181542563621737977,,Versuchen waschen gestern Fahrrad Arbeit zwischen können ich. +5190,DE13303497125457728906,CHF,2020-06-26,2022-01-18,944585.95,EUR,DBIT,Das Sonne Klasse frei beide dort Vogel fiel.,Margraf,DE67573080640653441068,Beruf:Gehalt:super Firma,Reich fiel führen Tisch fertig gesund Stein Wagen. +6694,DE32400010286267483201,GBP,2024-02-15,2022-09-05,896628.93,EUR,CRDT,Genau Wiese danach auf Land Fuß Zahl braun.,Weinhold,DE82843035570517646373,Beruf:Gehalt:super Firma,Fehlen Licht hinein Frage möglich Hund sechs danach. +2153,DE70374437913789686109,USD,2024-02-23,2020-07-19,809680.27,USD,CRDT,Schon ließ Arzt Arbeit müde hoch Tag auch.,Roskoth,DE61342749791049237138,Beruf:Gehalt:super Firma,Zu weit Freund Brief noch durch seit sofort. +1462,DE07599923076063186293,USD,2022-03-05,2021-11-29,563454.79,CHF,CRDT,Bei stark über denn wo Lehrer gibt las.,Gehringer AG,DE71382372368571415704,Beruf:Gehalt:super Firma,Schwarz finden laut werden Zug Vater aber schreiben. +4268,DE75725070066084697801,USD,2023-12-21,2025-06-24,461694.56,GBP,DBIT,Schule Küche Erde letzte Schnee halten und sechs.,Scholtz Pärtzelt AG & Co. KG,DE63255914448215384283,Lebenshaltung:Lebensmitteleinkäuft,Zurück spät trinken Tür Stunde Boden Licht Ende. +3951,DE05688473582233220472,GBP,2022-08-15,2023-01-18,998250.23,GBP,DBIT,Weit draußen Feuer eigentlich wünschen Wagen fertig tragen.,Binner AG & Co. KGaA,DE73947638506756550295,Beruf:Gehalt:super Firma,Dumm verlieren nehmen erschrecken fressen zurück hängen weit. +1124,DE90298843179938014585,GBP,2023-11-14,2023-07-05,371813.63,USD,DBIT,Platz fragen wer weinen Geburtstag ganz einigen gar.,Boucsein Stiftung & Co. KG,DE05290733575870594578,Lebenshaltung:Lebensmitteleinkäuft,Weg andere wohnen steigen Hilfe schaffen mehr das. +1744,DE13238979080075655943,GBP,2022-12-14,2025-02-19,579578.97,EUR,DBIT,Immer fiel wieder überall bald legen Tisch wahr.,Heser Hahn GmbH,DE68405054791747194888,Lebenshaltung:Lebensmitteleinkäuft,Fünf See neu früh wird fehlen sonst wer. +5892,DE04011963778496207452,USD,2025-05-20,2023-05-10,648012.28,EUR,DBIT,Polizei weinen gut Kind immer Ding oder Licht.,Jacobi Jäckel,DE13372573108475954117,Lebenshaltung:Lebensmitteleinkäuft,Fragen immer acht Sache heute nämlich Platz warum. +362,DE92124261075456158918,EUR,2022-10-26,2025-08-31,299548.47,CHF,DBIT,Laufen ihr lassen Pferd ruhig müssen Hund unten.,Sölzer Meyer OHG mbH,DE93416735067530701655,Beruf:Gehalt:super Firma,Schwimmen voll neun beim sehr dich Ferien Nase. +9327,DE56968036606213916258,EUR,2022-09-12,2025-06-10,669422.17,EUR,CRDT,Reich kochen Ende früher denken Gott bauen Wagen.,Rogge Carsten AG & Co. KG,DE17055312400520689014,,Schwer verkaufen oder gefährlich sprechen Hunger rot ins. +680,DE41089047416450094816,GBP,2025-06-19,2020-11-14,514360.52,EUR,DBIT,Essen schlimm schnell sich vor drei Ding schwimmen.,Stroh Margraf GmbH,DE30951508691902962996,Freizeit,Drei gelb Meer mit rot Hand da führen. +4786,DE42895215744806933501,USD,2024-10-12,2021-06-23,421775.78,USD,DBIT,Rennen rund und acht werden wieder richtig Tür.,Pieper KG,DE50031826122338744320,Lebenshaltung:Lebensmitteleinkäuft,Monate Licht bis Himmel nächste von Stück ab. +5155,DE48350990582715356385,EUR,2024-01-16,2020-07-12,132026.65,CHF,DBIT,Freude setzen offen bauen offen aus Bruder wirklich.,Oestrovsky Mülichen Stiftung & Co. KG,DE31183689182369696496,,Hund legen Schiff einige Brief schlecht jeder voll. +9202,DE79223829316538123746,CHF,2024-10-23,2026-09-07,683650.17,GBP,CRDT,Platz gelb heiß uns Abend schreien bis ziehen.,Meyer OHG mbH,DE85492444746577878500,Lebenshaltung:Lebensmitteleinkäuft,Angst schwarz war Auge darauf Lehrerin gerade oft. +4370,DE96935887403881776524,CHF,2022-03-09,2025-06-12,238525.09,GBP,DBIT,Sitzen Katze vergessen schicken laufen spielen deshalb weiter.,Müller,DE64513317359137578745,,Gesicht fangen klein Glas ein ohne seit Gesicht. +6861,DE53091086333961111752,EUR,2022-01-18,2020-04-09,949477.16,GBP,DBIT,Gerade genau dürfen gelb klettern nächste zum Bauer.,Schleich,DE55073850335806125294,Beruf:Gehalt:super Firma,Mit weil rechnen Hund Wiese einmal ja gern. +1968,DE98454978214248264491,GBP,2024-01-14,2022-09-19,435993.30,CHF,CRDT,Apfel setzen Polizei einfach Minutenmir schnell zwischen vom.,Schulz GmbH,DE08932615790658012392,Beruf:Gehalt:super Firma,Schwer spielen Leben sind brauchen Finger dabei Essen. +934,DE72179461629364066237,GBP,2020-12-07,2023-12-31,936759.29,GBP,CRDT,Selbst aber treffen springen setzen hoch lesen zusammen.,Textor Schaaf AG & Co. KG,DE68736100578473881809,Lebenshaltung:Lebensmitteleinkäuft,Warm unser gehören Zug früher schlagen stehen langsam. +288,DE36079718826004443058,USD,2025-03-20,2024-07-16,410828.73,GBP,DBIT,Bauen Geschenk Hund beide Stück Sohn ab denken.,Drewes,DE37024088386878351977,Lebenshaltung:Lebensmitteleinkäuft,Dürfen jetzt danach Oma See alt Flasche sofort. +909,DE07992119720440262287,CHF,2024-02-20,2020-10-05,713491.26,GBP,CRDT,Ende krank Arzt Wohnung finden vom Blume Monat.,Kohl Ortmann AG,DE46570978124442579293,,Heute Haus nach ab heißen versuchen Schüler wahr. +6200,DE65667575255962659468,USD,2024-12-29,2023-07-30,859574.20,EUR,CRDT,Erklären schreiben Frau kann leise Papa Arzt fliegen.,Wulf Grein Groth AG & Co. KG,DE45122342340503421393,Freizeit,Pferd gut schenken spät wirklich Kind bauen lange. +5198,DE82737311374514326219,USD,2022-09-14,2025-12-23,899940.80,EUR,CRDT,Glauben wissen ab sehr zwischen Frau essen Minutenmir.,Schwital Mohaupt Stiftung & Co. KGaA,DE31111358336117661206,Lebenshaltung:Lebensmitteleinkäuft,Ihm rechnen drehen etwas jeder Gesicht zusammen vorbei. +2088,DE84805952945908475488,GBP,2020-06-23,2023-03-19,706456.94,CHF,CRDT,Gerade zeigen Stück ziehen dumm Minutenmir alt Brief.,Thies GbR,DE30848702698273936264,,Fröhlich kaufen im gefährlich gar Zeitung leben voll. +4094,DE96513254882011465283,USD,2021-03-12,2023-08-10,844148.26,GBP,DBIT,Fußball führen Tier nichts hoch Lehrerin brauchen grün.,Meister,DE39122919808000752227,Beruf:Gehalt:super Firma,Müde schauen rechnen Geschichte Apfel kann daran weinen. +407,DE34550143031579571279,GBP,2020-03-01,2021-10-24,802001.66,EUR,CRDT,Scheinen wahr Luft merken Hilfe Sommer Weg sprechen.,Sager AG,DE95396414587780784446,Freizeit,Wasser jeder los werfen Loch Kopf Sommer stehen. +1882,DE93756550001006525563,GBP,2020-05-22,2020-04-02,255936.18,CHF,CRDT,Schüler Wetter unser Nase lesen erklären Herr damit.,Bohlander e.G.,DE33912765450255003824,Lebenshaltung:Lebensmitteleinkäuft,Vielleicht Papa Sonntag Glück dem krank Ende Lehrer. +674,DE34636975346401640379,GBP,2025-04-21,2023-07-13,107958.15,GBP,CRDT,Um rufen Schnee nämlich leise Tag gesund hin.,Ruppersberger Förster AG & Co. KG,DE82042132477947420315,Beruf:Gehalt:super Firma,Ich wo Oma Leute gegen früh Herz hoch. +4938,DE18280828921382275721,CHF,2024-09-06,2026-06-01,307489.25,USD,CRDT,Zehn los stark wissen vor dein an Schule.,Holsten Bien e.G.,DE54634488965756369503,Freizeit,Er merken Seite von früher schlimm weit gestern. +7493,DE75969937757392474988,EUR,2024-07-01,2025-05-15,769646.98,USD,CRDT,Wollen kalt alt uns acht heißen deshalb Licht.,Losekann Mentzel AG,DE06861731830241040674,Beruf:Gehalt:super Firma,Sehen los Herr dort ließ fährt gibt als. +7741,DE27930829328207938603,USD,2023-12-10,2021-12-25,332130.35,CHF,DBIT,Unser Essen Ferien ließ Lehrerin bringen trinken wichtig.,Beer Dehmel OHG mbH,DE56877468032283033000,Lebenshaltung:Lebensmitteleinkäuft,Weil Dorf verlieren deshalb wirklich Himmel dein schreien. +8664,DE17107663775980137420,USD,2020-12-02,2026-04-02,695808.54,EUR,CRDT,Da aber frei schlafen sicher Ding schreiben endlich.,Steckel,DE34731945010152636545,Beruf:Gehalt:super Firma,Plötzlich beißen bringen Boden auch verstecken lieb vorbei. +7929,DE32942674407704323077,CHF,2020-09-06,2026-09-09,165324.34,EUR,CRDT,Sehen ob Bruder drei Kind dumm Hilfe auf.,Spieß Fröhlich KGaA,DE68174009519788157086,Freizeit,Stunde Hilfe ganz nie las kam oft voll. +8900,DE03689805605701261449,USD,2021-03-03,2020-11-12,186334.15,USD,CRDT,Brief gern der Eis Freude lange schlagen Flasche.,Radisch Keudel GbR,DE49885385858776558412,Lebenshaltung:Lebensmitteleinkäuft,Dafür singen ein sonst uns Mensch ins Ding. From e9dcb88da5192f7832845943f67874e5204471db Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sat, 12 Sep 2026 21:00:00 +0200 Subject: [PATCH 15/31] fix settings template --- settings.template.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/settings.template.py b/settings.template.py index 7f0e48c..af27e8e 100644 --- a/settings.template.py +++ b/settings.template.py @@ -3,13 +3,12 @@ from pathlib import Path from faker import Faker from src.constants import Currency -from src.ingest.b4w import ingest_b4w, reader_b4w -from src.ingest.comdirect import reader_comdirect from src.models import ( ComdirectCreditCard, ComdirectDepot, ComdirectGiro, ) +from src.models.b4w import B4wExport from src.models.settings import Account, Source fake = Faker() @@ -18,11 +17,10 @@ BANK_ACCOUNTS = { "b4w": Source( name="b4w", source_path=Path(""), - reader_function=reader_b4w, accounts=[ Account( name="b4w", - ingestion_function=ingest_b4w, + model=B4wExport, iban=fake.iban(), currency=Currency.EUR, ) @@ -31,29 +29,28 @@ BANK_ACCOUNTS = { "comdirect": Source( name="comdirect", source_path=Path(""), - reader_function=reader_comdirect, accounts=[ Account( name="Girokonto", - ingestion_function=lambda x: ComdirectGiro.model_validate(x), + model=ComdirectGiro, iban=fake.iban(), currency=Currency.EUR, ), Account( name="Visa-Karte (Kreditkarte)", - ingestion_function=lambda x: ComdirectCreditCard.model_validate(x), + model=ComdirectCreditCard, iban=fake.iban(), currency=Currency.EUR, ), Account( name="Tagesgeld PLUS-Konto", - ingestion_function=lambda x: ComdirectGiro.model_validate(x), + model=ComdirectGiro, iban=fake.iban(), currency=Currency.EUR, ), Account( name="Depot", - ingestion_function=lambda x: ComdirectDepot.model_validate(x), + model=ComdirectDepot, iban=fake.iban(), currency=Currency.EUR, ), From 500b76b8a81076c168df3a81f8de7704875cae2e Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sat, 12 Sep 2026 21:00:26 +0200 Subject: [PATCH 16/31] fix b4w real-world import --- src/main.py | 4 ++-- src/models/b4w.py | 11 +++++++++-- tests/factories.py | 10 ++++++++-- tests/test_model_b4wexport.py | 21 +++++++++++++++++++++ 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/main.py b/src/main.py index 340b7d6..593626b 100644 --- a/src/main.py +++ b/src/main.py @@ -27,8 +27,8 @@ def main(): assert isinstance(giro_account, Account) ingest_comdirect( Path(args.input_comdirect), - account_iban=giro_account.iban, - account_currency=giro_account.currency, + account_iban=giro_account.iban or "", + account_currency=giro_account.currency or "", ) diff --git a/src/models/b4w.py b/src/models/b4w.py index 1e6d6f8..4e21961 100644 --- a/src/models/b4w.py +++ b/src/models/b4w.py @@ -27,7 +27,7 @@ class B4wExport(ExportBaseModel): sign: Sign = Field(alias="CdtDbtInd") reference: str = Field(alias="RmtInf") other_name: str = Field(alias="RmtdNm") - other_iban: str = Field(alias="RmtdAcctIBAN", pattern=IBAN_PATTERN) + other_iban: str | None = Field(alias="RmtdAcctIBAN", pattern=IBAN_PATTERN) category: Category | None = Field( alias="Category", examples=[ @@ -51,7 +51,7 @@ class B4wExport(ExportBaseModel): "CdtDbtInd": f"{self.sign!s}", "RmtInf": f"{self.reference}", "RmtdNm": f"{self.other_name}", - "RmtdAcctIBAN": f"{self.other_iban}", + "RmtdAcctIBAN": f"{self.other_iban or ''}", "Category": f"{self.category.to_str() if self.category else ''}", "Notes": f"{self.notes}", } @@ -77,6 +77,13 @@ class B4wExport(ExportBaseModel): def amount_signed(self) -> Decimal: return self.amount * self.sign.to_sign() + @field_validator("other_iban", mode="before") + @classmethod + def other_iban_blank_is_none(cls, iban_in: str | None) -> str | None: + if iban_in is None or (isinstance(iban_in, str) and not iban_in.strip()): + return None + return iban_in + @field_validator("category", mode="before") @classmethod def category_str_is_parsable( diff --git a/tests/factories.py b/tests/factories.py index e54af89..b4f9c73 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -34,6 +34,13 @@ def _iban() -> str: return B4wExportFactory.__faker__.iban() +def _other_iban() -> str: + faker = B4wExportFactory.__faker__ + if faker.pyint(min_value=0, max_value=100) < 50: + return "" + return faker.iban() + + class B4wExportFactory(ModelFactory[B4wExport]): __faker__ = Faker("de_DE") __use_examples__ = True @@ -43,7 +50,7 @@ class B4wExportFactory(ModelFactory[B4wExport]): Notes = Use(_sentence) RmtdNm = Use(_names) OwnrAcctIBAN = Use(_iban) - RmtdAcctIBAN = Use(_iban) + RmtdAcctIBAN = Use(_other_iban) class ComdirectGiroFactory(ModelFactory[ComdirectGiro]): @@ -61,4 +68,3 @@ class ComdirectCreditCardFactory(ModelFactory[ComdirectCreditCard]): class ComdirectDepotFactory(ModelFactory[ComdirectDepot]): __faker__ = Faker("de_DE") __use_example__ = True - diff --git a/tests/test_model_b4wexport.py b/tests/test_model_b4wexport.py index dd7a437..e8ab431 100644 --- a/tests/test_model_b4wexport.py +++ b/tests/test_model_b4wexport.py @@ -33,6 +33,27 @@ def test_b4w_export_account_iban_to_short(): _ = B4wExportFactory.build(OwnrAcctIBAN="DE123") +def test_b4w_export_other_iban_empty_is_none(): + data = B4wExportFactory.build(RmtdAcctIBAN="") + assert data.other_iban is None + + +def test_b4w_export_other_iban_blank_is_none(): + data = B4wExportFactory.build(RmtdAcctIBAN=" ") + assert data.other_iban is None + + +def test_b4w_export_other_iban_invalid(): + with pytest.raises(ValidationError): + _ = B4wExportFactory.build(RmtdAcctIBAN="not an IBAN") + + +def test_b4w_export_other_iban_repr_round_trip_empty(): + data = B4wExportFactory.build(RmtdAcctIBAN="") + data2 = eval(str(data)) + assert data == data2 + + def test_b4w_export_parameters_to_dict(): data = B4wExportFactory.build() params = data.parameters_to_dict() From 7ab7276db55baff281b3631daac1ce6d70e0d8cc Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sat, 12 Sep 2026 22:58:22 +0200 Subject: [PATCH 17/31] AGENTS.md --- AGENTS.md | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..91fde59 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,107 @@ +# finanz-cockpit Agent Instructions + +## Setup Commands + +```bash +# Install dependencies +uv sync + +# Run migrations +alembic upgrade head + +# Run all tests +pytest + +# Run a single test file +pytest tests/test_model_b4wexport.py -v +``` + +## Project Structure + +- **`src/`**: Application code + - `main.py`: CLI entrypoint for CSV ingestion + - `db.py`: Database connection management (uses global `_engine` pattern) + - `models/`: Pydantic models + SQLAlchemy tables for b4w and comdirect imports + - `ingest/`: Data ingestion logic per source +- **`tests/`**: Tests with fixtures in `conftest.py` +- **`alembic/versions/`**: Database migrations + +## Key Patterns & Gotchas + +### Database Configuration + +```python +from src.db import get_engine, get_session, set_engine + +# Development: use sqlite:// +os.environ["DATABASE_URL"] = "sqlite://" + +# Production/default: sqlite:///finanz.db +engine = get_engine() # reads DATABASE_URL or uses default +with get_session() as session: + session.query(...).all() +``` + +**Critical**: Tests override engine via `conftest.py`. Never assume global state - always use `get_session()` context manager. + +### CSV Format Expectations + +- **b4w**: German bank export (CSV) from `/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/b4w_export_dummy_01.csv` +- **comdirect**: German bank export (CSV) with fields like `Buchungstag`, `Umsatz in EUR`, `Vorgang` + +### Testing Workflow + +```bash +# Tests automatically run migrations via conftest fixture +pytest tests/ -v --tb=short + +# To test without migrations (in-memory SQLite), use DATABASE_URL="sqlite://" +export DATABASE_URL="sqlite://" pytest tests/test_db.py +``` + +### Environment Variables + +- `DATABASE_URL`: Database connection string (default: `sqlite:///finanz.db`) + +## Data Models + +Two sources with shared patterns: + +- **b4w**: `B4wExport`, `Category`, transactional import to `import_b4w` table + `category` table +- **comdirect**: `ComdirectGiro`, `ComdirectCreditCard`, `ComdirectDepot`, import to `comdirect_giro` table + +All models inherit from common base in `src/models/_common.py`: +- `_hash()`: SHA256-based deduplication key +- `_parse_date_german()`: Handles "07.09.2026" format and values like "offen", "--" +- `_parse_decimal_german()`: Converts "1,23" to Decimal + +## Factory Pattern for Testing + +Import from `tests/factories.py`: + +```python +from tests.factories import B4wExportFactory, ComdirectGiroFactory +from src.constants import Currency + +B4wExportFactory.__faker__ = Faker("de_DE") +ComdirectGiroFactory.__faker__ = Faker("de_DE") + +# Use in tests: +item = B4wExportFactory.build( # build() for partial, create() for full + Amt="50.00", + RmtInf="Supermarket", +) +``` + +## Common Mistakes to Avoid + +1. **Assuming global engine state**: Always use `get_session()` context manager +2. **Ignoring German date formats**: Use `_parse_date_german()` for CSV parsing +3. **Forgetting migration setup in tests**: Tests auto-migrate, but integration tests with real db need explicit upgrade +4. **Wrong Faker locale**: Use `Faker("de_DE")` for realistic names/IBANs + +## Testing Tips + +- Run single test: `pytest tests/test_model_b4wexport.py -v` +- View migrations: `ls alembic/versions/` +- Test data location: `/scripts/dummy_data/` From 58dc585e7e4398a7af4c167c6c7f8c37f0ee2f24 Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 13 Sep 2026 07:31:20 +0200 Subject: [PATCH 18/31] b4w faker: occasionally empty other_iban --- tests/factories.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/factories.py b/tests/factories.py index b4f9c73..a46e685 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,4 +1,6 @@ +from collections.abc import Callable from decimal import Decimal +from typing import Any from faker import Faker from polyfactory.factories.pydantic_factory import ModelFactory @@ -10,6 +12,12 @@ from src.models.comdirect import ComdirectCreditCard, ComdirectDepot, ComdirectG DEFAULT_CATEGORY = "root_category:parent_category:leaf_category" +def _faker_or_none(faker: Faker, non_default: Any, default: Any = None, p_value=0.5): + if faker.pyfloat(min_value=0.0, max_value=1.0) < p_value: + return default + return non_default + + def _amount() -> Decimal: return B4wExportFactory.__faker__.pydecimal( left_digits=6, @@ -34,11 +42,9 @@ def _iban() -> str: return B4wExportFactory.__faker__.iban() -def _other_iban() -> str: +def _iban_or_none() -> str: faker = B4wExportFactory.__faker__ - if faker.pyint(min_value=0, max_value=100) < 50: - return "" - return faker.iban() + return _faker_or_none(faker, faker.iban(), default="") class B4wExportFactory(ModelFactory[B4wExport]): @@ -50,7 +56,7 @@ class B4wExportFactory(ModelFactory[B4wExport]): Notes = Use(_sentence) RmtdNm = Use(_names) OwnrAcctIBAN = Use(_iban) - RmtdAcctIBAN = Use(_other_iban) + RmtdAcctIBAN = Use(_iban_or_none) class ComdirectGiroFactory(ModelFactory[ComdirectGiro]): From 46bdd5e68c6d77199156c15cfe89804467cf7a6f Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 13 Sep 2026 07:55:24 +0200 Subject: [PATCH 19/31] ingestion models: less code repetition, more inheritance --- src/models/_common.py | 20 +++++++++++++++++++- src/models/b4w.py | 12 ------------ src/models/comdirect.py | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/models/_common.py b/src/models/_common.py index 95b935c..6fb3e14 100644 --- a/src/models/_common.py +++ b/src/models/_common.py @@ -61,7 +61,25 @@ type CsV2DictMulti = dict[str, Csv2Dict] class ExportBaseModel(BaseModel): - pass + + @classmethod + def _my_name(cls): + return cls.__name__ + + def parameters_to_dict(self) -> dict[str, str]: + return {} + + def parameters_to_str(self): + parameters = ", ".join( + f"{key}='{value}'" for key, value in self.parameters_to_dict().items() + ) + return parameters + + def __repr__(self): + return f"{self._my_name()}({self.parameters_to_str()})" + + def __str__(self): + return f"{self._my_name()}({self.parameters_to_str()})" class BaseModelDb(DeclarativeBase): diff --git a/src/models/b4w.py b/src/models/b4w.py index 4e21961..4f8189c 100644 --- a/src/models/b4w.py +++ b/src/models/b4w.py @@ -56,18 +56,6 @@ class B4wExport(ExportBaseModel): "Notes": f"{self.notes}", } - def parameters_to_str(self): - parameters = ", ".join( - f"{key}='{value}'" for key, value in self.parameters_to_dict().items() - ) - return parameters - - def __repr__(self): - return f"B4wExport({self.parameters_to_str()})" - - def __str__(self): - return f"B4wExport({self.parameters_to_str()})" - @computed_field @property def hash_id(self) -> str: diff --git a/src/models/comdirect.py b/src/models/comdirect.py index 3637e67..b205f77 100644 --- a/src/models/comdirect.py +++ b/src/models/comdirect.py @@ -10,6 +10,7 @@ from src.models._common import ( _hash, _parse_date_german, _parse_decimal_german, + date2str, ) @@ -29,6 +30,14 @@ class ComdirectBase(ExportBaseModel): def parse_amount(cls, decimal_str: str) -> Decimal: return _parse_decimal_german(decimal_str) + def parameters_to_dict(self) -> dict[str, str]: ... + + def parameters_to_str(self, sep=", ", str_qualifier=False): ... + + def __repr__(self): ... + + def __str__(self): ... + class ComdirectGiro(ComdirectBase): # # Fields from parents: @@ -50,6 +59,22 @@ class ComdirectGiro(ComdirectBase): def parse_date_book(cls, date_str: str) -> date | None: return _parse_date_german(date_str) + def parameters_to_dict(self) -> dict[str, str]: + return { + "Buchungstag": date2str( + self.date_book, default="--", date_format="%d.%m.%Y" + ), + "Vorgang": self.type, + "Text": self.text, + } + + def parameters_to_str(self, sep=", ", str_qualifier=False): + ... + + def __repr__(self): ... + + def __str__(self): ... + class ComdirectCreditCard(ComdirectGiro): # # Fields from parents: @@ -71,6 +96,14 @@ class ComdirectCreditCard(ComdirectGiro): alias="Umsatz in EUR", examples=["32,43"], default=None ) + def parameters_to_dict(self) -> dict[str, str]: ... + + def parameters_to_str(self, sep=", ", str_qualifier=False): ... + + def __repr__(self): ... + + def __str__(self): ... + class ComdirectDepot(ComdirectBase): # # Fields from parents: @@ -88,6 +121,14 @@ class ComdirectDepot(ComdirectBase): def parse_decimals(cls, decimal_in: str) -> Decimal: return _parse_decimal_german(decimal_in) + def parameters_to_dict(self) -> dict[str, str]: ... + + def parameters_to_str(self, sep=", ", str_qualifier=False): ... + + def __repr__(self): ... + + def __str__(self): ... + class ComdirectBaseDb(IngestdbBase): __abstract__ = True From afe2478cc704b481c4b627dfe4f0d5d6c2d48f86 Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 13 Sep 2026 09:45:16 +0200 Subject: [PATCH 20/31] comdirect stringification --- src/models/comdirect.py | 92 ++++++++++++++++++++++------------- tests/factories.py | 22 +++++++-- tests/test_model_comdirect.py | 24 +++++++++ 3 files changed, 100 insertions(+), 38 deletions(-) create mode 100644 tests/test_model_comdirect.py diff --git a/src/models/comdirect.py b/src/models/comdirect.py index b205f77..4b9f967 100644 --- a/src/models/comdirect.py +++ b/src/models/comdirect.py @@ -1,7 +1,7 @@ from datetime import date from decimal import Decimal -from pydantic import Field, computed_field, field_validator +from pydantic import ConfigDict, Field, computed_field, field_validator from sqlalchemy import Column, Date, String from src.models._common import ( @@ -11,10 +11,12 @@ from src.models._common import ( _parse_date_german, _parse_decimal_german, date2str, + decimal2str, ) class ComdirectBase(ExportBaseModel): + model_config = ConfigDict(populate_by_name=True) date_val: date | None = Field( alias="Wertstellung (Valuta)", examples=["07.09.2026"] ) @@ -22,7 +24,9 @@ class ComdirectBase(ExportBaseModel): @field_validator("date_val", mode="before") @classmethod - def parse_date_val(cls, date_str: str) -> date | None: + def parse_date_val(cls, date_str: str | None) -> date | None: + if date_str is None: + return return _parse_date_german(date_str) @field_validator("amount_eur", mode="before") @@ -30,13 +34,28 @@ class ComdirectBase(ExportBaseModel): def parse_amount(cls, decimal_str: str) -> Decimal: return _parse_decimal_german(decimal_str) - def parameters_to_dict(self) -> dict[str, str]: ... + @classmethod + def _date2str(cls, date_value: date | None) -> str: + return date2str(date_value, default="--", date_format="%d.%m.%Y") - def parameters_to_str(self, sep=", ", str_qualifier=False): ... + def parameters_to_dict(self, by_name=True) -> dict[str, str]: + return { + "date_val" if by_name else "Wertstellung (Valuta)": self._date2str( + self.date_val + ), + "amount_eur" if by_name else "Umsatz in EUR": decimal2str(self.amount_eur), + } - def __repr__(self): ... + def get_orig_headers(self, sep: str = ";", bracket: str = '"'): + return sep.join( + f"{bracket}{txt}{bracket}" for txt in self.parameters_to_dict(by_name=False) + ) - def __str__(self): ... + def get_orig_row(self, sep: str = ";", bracket: str = '"'): + return sep.join( + f"{bracket}{txt}{bracket}" + for txt in self.parameters_to_dict(by_name=False).values() + ) class ComdirectGiro(ComdirectBase): @@ -59,21 +78,18 @@ class ComdirectGiro(ComdirectBase): def parse_date_book(cls, date_str: str) -> date | None: return _parse_date_german(date_str) - def parameters_to_dict(self) -> dict[str, str]: - return { - "Buchungstag": date2str( - self.date_book, default="--", date_format="%d.%m.%Y" - ), - "Vorgang": self.type, - "Text": self.text, - } - - def parameters_to_str(self, sep=", ", str_qualifier=False): - ... - - def __repr__(self): ... - - def __str__(self): ... + def parameters_to_dict(self, by_name=True) -> dict[str, str]: + parameters = dict(**super().parameters_to_dict(by_name=by_name)) + parameters.update( + { + "date_book" if by_name else "Buchungstag": self._date2str( + self.date_book + ), + "type" if by_name else "Vorgang": self.type, + "text" if by_name else "Buchungstext": self.text, + } + ) + return parameters class ComdirectCreditCard(ComdirectGiro): @@ -96,13 +112,17 @@ class ComdirectCreditCard(ComdirectGiro): alias="Umsatz in EUR", examples=["32,43"], default=None ) - def parameters_to_dict(self) -> dict[str, str]: ... - def parameters_to_str(self, sep=", ", str_qualifier=False): ... - def __repr__(self): ... - - def __str__(self): ... + def parameters_to_dict(self) -> dict[str, str]: + parameters = dict(**super().parameters_to_dict()) + parameters.update( + { + "Umsatztag": self._date2str(self.date_val), + "Referenz": self.reference, + } + ) + return parameters class ComdirectDepot(ComdirectBase): @@ -121,13 +141,19 @@ class ComdirectDepot(ComdirectBase): def parse_decimals(cls, decimal_in: str) -> Decimal: return _parse_decimal_german(decimal_in) - def parameters_to_dict(self) -> dict[str, str]: ... - - def parameters_to_str(self, sep=", ", str_qualifier=False): ... - - def __repr__(self): ... - - def __str__(self): ... + def parameters_to_dict(self) -> dict[str, str]: + parameters = dict(**super().parameters_to_dict()) + parameters.update( + { + "Geschäftstag": self._date2str(self.date_val), + "Stück / Nom.": decimal2str(self.num), + "Bezeichnung": self.name, + "WKN": self.wkn, + "Währung": self.currency, + "Ausführungskurs": decimal2str(self.spot_price), + } + ) + return parameters class ComdirectBaseDb(IngestdbBase): diff --git a/tests/factories.py b/tests/factories.py index a46e685..d2d9726 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,4 +1,3 @@ -from collections.abc import Callable from decimal import Decimal from typing import Any @@ -27,6 +26,18 @@ def _amount() -> Decimal: ) +def _amount_eur() -> str: + """Generate German-formatted decimal string (e.g., '32,43').""" + val = B4wExportFactory.__faker__.pydecimal( + left_digits=6, + right_digits=2, + positive=True, + min_value=Decimal("0.01"), + ) + # Format as German string with comma decimal separator + return f"{val:.2f}".replace(".", ",").strip() + + def _sentence() -> str: return B4wExportFactory.__faker__.sentence( nb_words=8, @@ -61,16 +72,17 @@ class B4wExportFactory(ModelFactory[B4wExport]): class ComdirectGiroFactory(ModelFactory[ComdirectGiro]): __faker__ = Faker("de_DE") - __use_example__ = True + __use_examples__ = True - Buchungstext = Use(_sentence) + text = Use(_sentence) + amount_eur = Use(_amount_eur) class ComdirectCreditCardFactory(ModelFactory[ComdirectCreditCard]): __faker__ = Faker("de_DE") - __use_example__ = True + __use_examples__ = True class ComdirectDepotFactory(ModelFactory[ComdirectDepot]): __faker__ = Faker("de_DE") - __use_example__ = True + __use_examples__ = True diff --git a/tests/test_model_comdirect.py b/tests/test_model_comdirect.py new file mode 100644 index 0000000..0660caa --- /dev/null +++ b/tests/test_model_comdirect.py @@ -0,0 +1,24 @@ +from src.models.comdirect import ComdirectGiro +from tests.factories import ComdirectGiroFactory + + +def test_comdirect_giro_basic_and_stringification(): + data = ComdirectGiroFactory.build() + assert data + assert isinstance(data, ComdirectGiro) + orig_headers = data.get_orig_headers() + assert orig_headers + assert isinstance(orig_headers, str) + header_list = orig_headers.split(";") + assert len(header_list) > 0 + assert set(header_list) == { + '"Wertstellung (Valuta)"', + '"Umsatz in EUR"', + '"Buchungstag"', + '"Vorgang"', + '"Buchungstext"', + } + orig_row = data.get_orig_row() + assert orig_row + assert isinstance(orig_row, str) + assert len(orig_row.split(";")) > 0 From 61d7eb54a327c0b1791d55b1219a18397ae8cfc9 Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 13 Sep 2026 10:03:28 +0200 Subject: [PATCH 21/31] scripts/dummy_data/generate_dummies_b4w.py --- .../dummy_data/comdirect_export_template.csv | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 scripts/dummy_data/comdirect_export_template.csv diff --git a/scripts/dummy_data/comdirect_export_template.csv b/scripts/dummy_data/comdirect_export_template.csv new file mode 100644 index 0000000..deeb0bb --- /dev/null +++ b/scripts/dummy_data/comdirect_export_template.csv @@ -0,0 +1,25 @@ +; +"Umsätze Girokonto";"Zeitraum: 180 Tage"; +"Neuer Kontostand";"2.363,30 EUR"; + +{table_giro} + +"Alter Kontostand";"1.632,48 EUR"; + +"Umsätze Visa-Karte (Kreditkarte)";"Zeitraum: 180 Tage"; +"Neuer Kontostand";"0,00 EUR"; + +{table_credit_card} + +"Alter Kontostand";"0,00 EUR"; + +"Umsätze Tagesgeld PLUS-Konto";"Zeitraum: 180 Tage"; +"Neuer Kontostand";"1.673,90 EUR"; + +{table_tagesgeld} + +"Alter Kontostand";"6.686,20 EUR"; + +"Umsätze Depot";"Zeitraum: 180 Tage"; + +{table_depot} From ef07fa5a2e0b9eaf4f484635f8e553cd72b7444b Mon Sep 17 00:00:00 2001 From: Mathias Schneider Date: Sun, 13 Sep 2026 12:11:41 +0200 Subject: [PATCH 22/31] comdirect complete dummy generation --- ...late.csv => comdirect_export_template.txt} | 0 scripts/dummy_data/generate_dummies_b4w.py | 4 +- .../dummy_data/generate_dummies_comdirect.py | 61 ++++++++++ src/models/comdirect.py | 18 +-- tests/factories.py | 26 ++-- tests/test_data/comdirect_export_dummy_01.txt | 115 ++++++++++++++++++ 6 files changed, 202 insertions(+), 22 deletions(-) rename scripts/dummy_data/{comdirect_export_template.csv => comdirect_export_template.txt} (100%) create mode 100644 scripts/dummy_data/generate_dummies_comdirect.py create mode 100644 tests/test_data/comdirect_export_dummy_01.txt diff --git a/scripts/dummy_data/comdirect_export_template.csv b/scripts/dummy_data/comdirect_export_template.txt similarity index 100% rename from scripts/dummy_data/comdirect_export_template.csv rename to scripts/dummy_data/comdirect_export_template.txt diff --git a/scripts/dummy_data/generate_dummies_b4w.py b/scripts/dummy_data/generate_dummies_b4w.py index 07cb275..fa9006b 100644 --- a/scripts/dummy_data/generate_dummies_b4w.py +++ b/scripts/dummy_data/generate_dummies_b4w.py @@ -4,10 +4,10 @@ from pathlib import Path from tests.factories import B4wExportFactory NUMBER_OF_ROWS = 50 -PATH_DEFAULT = Path("tests/test_data/b4w_export_dummy_01.csv") +DEFAULT_PATH = Path("tests/test_data/b4w_export_dummy_01.csv") -def save_csv(path: Path = PATH_DEFAULT): +def save_csv(path: Path = DEFAULT_PATH): data = B4wExportFactory.batch(NUMBER_OF_ROWS) row_fields = list(data[0].parameters_to_dict()) path.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/dummy_data/generate_dummies_comdirect.py b/scripts/dummy_data/generate_dummies_comdirect.py new file mode 100644 index 0000000..ecba557 --- /dev/null +++ b/scripts/dummy_data/generate_dummies_comdirect.py @@ -0,0 +1,61 @@ +from pathlib import Path + +from tests.factories import ( + ComdirectCreditCardFactory, + ComdirectDepotFactory, + ComdirectGiroFactory, +) + +DEFAULT_PATH = Path("tests/test_data/comdirect_export_dummy_01.txt") +TEMPLATE_PATH = Path("scripts/dummy_data/comdirect_export_template.txt") + +NUMBER_OF_ROWS_GIRO = 50 +NUMBER_OF_ROWS_CREDIT = 20 +NUMBER_OF_ROWS_TAGESGELD = 10 +NUMBER_OF_ROWS_DEPOT = 10 + + +def save(path: Path = DEFAULT_PATH): + with TEMPLATE_PATH.open("r", encoding="latin-1") as fid: + txt_temp = fid.read() + assert isinstance(txt_temp, str) + + batches = { + "giro": { + "data": ComdirectGiroFactory.batch(NUMBER_OF_ROWS_GIRO), + "table_txt": "", + }, + "credit": { + "data": ComdirectCreditCardFactory.batch(NUMBER_OF_ROWS_CREDIT), + "table_txt": "", + }, + "tagesgled": { + "data": ComdirectGiroFactory.batch(NUMBER_OF_ROWS_TAGESGELD), + "table_txt": "", + }, + "depot": { + "data": ComdirectDepotFactory.batch(NUMBER_OF_ROWS_DEPOT), + "table_txt": "", + }, + } + + for batch in batches.values(): + data = batch.get("data") + assert isinstance(data, list) + lines = [data[0].get_orig_headers()] + lines = lines + [row.get_orig_row() for row in data] + batch["table_txt"] = "\n".join(lines) + + txt = txt_temp.format( + table_giro=batches["giro"]["table_txt"], + table_credit_card=batches["credit"]["table_txt"], + table_tagesgeld=batches["tagesgled"]["table_txt"], + table_depot=batches["depot"]["table_txt"], + ) + + with path.open("w", newline="") as fid: + fid.write(txt) + + +if __name__ == "__main__": + save() diff --git a/src/models/comdirect.py b/src/models/comdirect.py index 4b9f967..2578e71 100644 --- a/src/models/comdirect.py +++ b/src/models/comdirect.py @@ -78,7 +78,7 @@ class ComdirectGiro(ComdirectBase): def parse_date_book(cls, date_str: str) -> date | None: return _parse_date_german(date_str) - def parameters_to_dict(self, by_name=True) -> dict[str, str]: + def parameters_to_dict(self, by_name: bool = True) -> dict[str, str]: parameters = dict(**super().parameters_to_dict(by_name=by_name)) parameters.update( { @@ -94,11 +94,6 @@ class ComdirectGiro(ComdirectBase): class ComdirectCreditCard(ComdirectGiro): # # Fields from parents: - # date_val - # amount_eur - # date_book - # type - # text date_book: date | None = Field(alias="Buchungstag", examples=["07.09.2026"]) date_val: date | None = Field(alias="Umsatztag", default=None) type: str = Field( @@ -112,10 +107,8 @@ class ComdirectCreditCard(ComdirectGiro): alias="Umsatz in EUR", examples=["32,43"], default=None ) - def parameters_to_str(self, sep=", ", str_qualifier=False): ... - - def parameters_to_dict(self) -> dict[str, str]: - parameters = dict(**super().parameters_to_dict()) + def parameters_to_dict(self, by_name: bool = True) -> dict[str, str]: + parameters = dict(**super().parameters_to_dict(by_name=by_name)) parameters.update( { "Umsatztag": self._date2str(self.date_val), @@ -127,7 +120,6 @@ class ComdirectCreditCard(ComdirectGiro): class ComdirectDepot(ComdirectBase): # # Fields from parents: - # date_val # amount_eur date_val: date | None = Field(alias="Geschäftstag") num: Decimal = Field(alias="Stück / Nom.") @@ -141,8 +133,8 @@ class ComdirectDepot(ComdirectBase): def parse_decimals(cls, decimal_in: str) -> Decimal: return _parse_decimal_german(decimal_in) - def parameters_to_dict(self) -> dict[str, str]: - parameters = dict(**super().parameters_to_dict()) + def parameters_to_dict(self, by_name: bool = True) -> dict[str, str]: + parameters = dict(**super().parameters_to_dict(by_name=by_name)) parameters.update( { "Geschäftstag": self._date2str(self.date_val), diff --git a/tests/factories.py b/tests/factories.py index d2d9726..a96fbd4 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -28,16 +28,17 @@ def _amount() -> Decimal: def _amount_eur() -> str: """Generate German-formatted decimal string (e.g., '32,43').""" - val = B4wExportFactory.__faker__.pydecimal( - left_digits=6, - right_digits=2, - positive=True, - min_value=Decimal("0.01"), + val = Faker().pydecimal( + right_digits=2, min_value=Decimal("-4000.00"), max_value=Decimal("4000.00") ) # Format as German string with comma decimal separator return f"{val:.2f}".replace(".", ",").strip() +def _german_date() -> str: + return Faker().date("%d.%m.%Y") + + def _sentence() -> str: return B4wExportFactory.__faker__.sentence( nb_words=8, @@ -54,8 +55,7 @@ def _iban() -> str: def _iban_or_none() -> str: - faker = B4wExportFactory.__faker__ - return _faker_or_none(faker, faker.iban(), default="") + return _faker_or_none(Faker(), Faker().iban(), default="") class B4wExportFactory(ModelFactory[B4wExport]): @@ -76,13 +76,25 @@ class ComdirectGiroFactory(ModelFactory[ComdirectGiro]): text = Use(_sentence) amount_eur = Use(_amount_eur) + date_val = Use(_german_date) + date_book = Use(_german_date) class ComdirectCreditCardFactory(ModelFactory[ComdirectCreditCard]): __faker__ = Faker("de_DE") __use_examples__ = True + text = Use(_sentence) + amount_eur = Use(_amount_eur) + date_val = Use(_german_date) + date_book = Use(_german_date) + class ComdirectDepotFactory(ModelFactory[ComdirectDepot]): __faker__ = Faker("de_DE") __use_examples__ = True + + amount_eur = Use(_amount_eur) + date_val = Use(_german_date) + num = Use(_amount_eur) + spot_price = Use(_amount_eur) diff --git a/tests/test_data/comdirect_export_dummy_01.txt b/tests/test_data/comdirect_export_dummy_01.txt new file mode 100644 index 0000000..6c35df9 --- /dev/null +++ b/tests/test_data/comdirect_export_dummy_01.txt @@ -0,0 +1,115 @@ +; +"Umsätze Girokonto";"Zeitraum: 180 Tage"; +"Neuer Kontostand";"2.363,30 EUR"; + +"Wertstellung (Valuta)";"Umsatz in EUR";"Buchungstag";"Vorgang";"Buchungstext" +"12.01.2023";"-2.494,62";"28.09.2013";"Kartenumsatz";"Jeder Feuer fünf Hand Kind Jahr plötzlich leben." +"03.11.2014";"3.840,37";"12.02.2023";"Kartenumsatz";"Nehmen Name schnell Tier schreien gefährlich Arzt Monate." +"07.09.2008";"-1.895,07";"05.07.1999";"Kartenumsatz";"Frau stehen bleiben Minute Hund ein Fisch gern." +"23.06.1971";"1.996,11";"27.05.2015";"Kartenumsatz";"Glück wie doch tief gefährlich rennen schlafen Milch." +"28.05.1998";"3.183,63";"23.08.1993";"Lastschrift / Belastung";"Vom braun Seite fiel arbeiten bin kommen wichtig." +"20.08.1972";"707,94";"22.10.2001";"Kartenumsatz";"Auch letzte böse geben kurz nämlich es nass." +"21.09.2024";"-3.019,63";"25.12.1989";"Lastschrift / Belastung";"Boden böse denken oder sie denken später Kind." +"09.06.2016";"-2.908,98";"30.01.1978";"Kartenumsatz";"Stadt sofort nein Lehrerin fünf reiten gehen Haare." +"09.03.1994";"-2.972,60";"21.11.2009";"Lastschrift / Belastung";"Laut Weihnachten wenig dauern Schuh schwer alle nächste." +"30.11.2017";"1.994,76";"29.09.2011";"Lastschrift / Belastung";"Früher ob darauf krank wo Leute nach für." +"18.08.2013";"2.925,64";"16.02.2011";"Lastschrift / Belastung";"Weg wirklich Freude reich da erst man sind." +"19.10.1973";"-.837,10";"04.09.1987";"Lastschrift / Belastung";"Los mögen Stunde Weihnachten nass wo drei ohne." +"15.09.1975";"3.691,37";"19.01.2010";"Kartenumsatz";"Treffen glauben Spiel gab will vom waschen antworten." +"10.07.2011";"3.650,99";"16.02.2012";"Lastschrift / Belastung";"Katze seit freuen freuen den Eis die traurig." +"07.05.2022";"722,12";"12.01.1989";"Lastschrift / Belastung";"Tun an als finden wie klein aber nun." +"06.02.2004";"1.499,05";"20.09.1986";"Lastschrift / Belastung";"Oder natürlich Essen zur rennen Platz spielen Monat." +"26.10.2009";"-3.454,94";"21.06.2024";"Kartenumsatz";"Es Erde Hand bauen Winter gerade böse vom." +"03.07.2013";"2.746,42";"23.01.1988";"Kartenumsatz";"Natürlich schon Tier Opa Fußball Stadt brauchen kochen." +"25.06.2014";"1.487,38";"26.11.1979";"Lastschrift / Belastung";"Dunkel her möglich essen Pferd Stadt ziehen seit." +"16.05.2009";"1.866,68";"21.12.1994";"Lastschrift / Belastung";"Genau wer glauben erklären nennen schicken Hilfe stellen." +"26.12.1998";"-1.925,12";"06.02.1992";"Lastschrift / Belastung";"Katze gegen wo stellen uns sprechen fertig nah." +"24.01.1996";"-1.089,91";"30.03.1992";"Lastschrift / Belastung";"Mich los Essen Lehrerin hinter heißen danach weit." +"21.07.1973";"-3.206,61";"27.08.1984";"Kartenumsatz";"Dein Schuh Garten glauben Vater Opa die Gott." +"22.11.1981";"1.112,51";"25.06.1974";"Lastschrift / Belastung";"Krank fast Papa verlieren Kopf Monate endlich ruhig." +"01.11.1973";"-2.077,99";"05.08.2017";"Lastschrift / Belastung";"Ein liegen See jung wie Name Sonntag ihm." +"26.08.1972";"-4.000,00";"25.01.2025";"Lastschrift / Belastung";"Darauf in dauern schön las jeder Freude Auto." +"21.06.1989";"-3.639,78";"04.08.1982";"Kartenumsatz";"Wissen merken nie Essen Rad sitzen wo letzte." +"02.12.1982";"-3.371,26";"19.07.2005";"Lastschrift / Belastung";"Setzen so dabei wenn es Hunger Meer Hase." +"16.05.1987";"-1.715,06";"15.10.1984";"Lastschrift / Belastung";"Feuer Wald vor wo auf wohl fressen Haare." +"05.04.2024";"322,78";"11.07.1988";"Lastschrift / Belastung";"Her Pferd zusammen arbeiten schenken gar Lehrer hinter." +"25.12.1984";"397,36";"15.03.1978";"Kartenumsatz";"Essen blau deshalb laufen für am sieht weinen." +"19.07.1970";"-2.245,70";"22.05.1991";"Kartenumsatz";"Vorbei wird braun bis Tier als erschrecken Ende." +"21.11.2019";"732,25";"19.06.1993";"Lastschrift / Belastung";"Acht Minutenmir man böse natürlich rund immer heißen." +"28.10.2003";"2.397,64";"21.07.2026";"Kartenumsatz";"Arzt kommen haben lassen fünf wer gar kochen." +"16.10.1973";"2.271,59";"27.02.1987";"Lastschrift / Belastung";"Blume Auge weiter böse fallen Essen Erde kam." +"12.01.2005";"1.472,05";"02.12.2006";"Kartenumsatz";"Lehrerin verkaufen Wald ziehen hin lesen Hilfe klettern." +"02.05.2015";"2.284,43";"08.08.1991";"Lastschrift / Belastung";"Zu Freude Geld will Glück die seit vielleicht." +"18.09.2012";"-1.940,73";"16.04.2016";"Lastschrift / Belastung";"So ein Mensch schenken am wir wollen merken." +"13.03.2010";"1.899,59";"10.02.1997";"Kartenumsatz";"Das auch glauben Tür gewinnen schreien fangen Flasche." +"01.03.1983";"3.566,27";"27.03.2015";"Kartenumsatz";"Bekommen warum ja wohnen gefährlich fährt langsam Monat." +"14.01.2011";"-2.962,06";"07.03.2010";"Lastschrift / Belastung";"Gesund Minutenmir etwas fallen Nacht schaffen nach ihr." +"30.05.2000";"-1.915,23";"29.08.1979";"Kartenumsatz";"Nennen leicht Name Schuh Bild reiten laut letzte." +"11.10.1970";"-3.359,95";"31.05.2025";"Lastschrift / Belastung";"Den singen Wald Geschenk Wald durch Stück Oma." +"10.09.2012";"3.953,53";"15.03.1993";"Kartenumsatz";"Um Opa fragen müssen endlich Bein leben zum." +"21.11.2012";"-1.197,06";"17.04.1999";"Kartenumsatz";"Ist also wo Eis zum Stück kommen Hunger." +"12.07.1996";"2.381,22";"23.04.2010";"Lastschrift / Belastung";"Dich Auge können Stück seit fliegen trinken können." +"13.03.1988";"1.748,03";"01.06.1996";"Lastschrift / Belastung";"Berg Leben steigen Licht nehmen vom Hilfe Kind." +"29.12.1979";"1.727,59";"26.07.1998";"Lastschrift / Belastung";"Nämlich laufen Stelle nächste noch schwimmen Wagen grün." +"09.02.1973";"-.393,09";"28.12.1979";"Lastschrift / Belastung";"Reich Onkel schön danach Flasche klein am wahr." +"12.11.1982";"727,08";"26.04.2001";"Kartenumsatz";"Lustig dem zehn mich fressen Straße Auto langsam." + +"Alter Kontostand";"1.632,48 EUR"; + +"Umsätze Visa-Karte (Kreditkarte)";"Zeitraum: 180 Tage"; +"Neuer Kontostand";"0,00 EUR"; + +"Wertstellung (Valuta)";"Umsatz in EUR";"Buchungstag";"Vorgang";"Buchungstext";"Umsatztag";"Referenz" +"10.08.2009";"846,45";"19.02.1980";"Kartenumsatz";"Zehn stehen zusammen liegen weil neu sollen Monat.";"10.08.2009";"awdexfhWsPiVoDLETNyk" +"16.07.2025";"-3.343,34";"19.07.2000";"Kartenumsatz";"Ihm Wagen Apfel Sommer sechs Rad Finger Wetter.";"16.07.2025";"KDPhAroRFDegPTQBwXZF" +"21.09.2011";"-3.094,59";"24.02.1998";"Kartenumsatz";"Bauen dick Opa wollen gegen See Tür erklären.";"21.09.2011";"TYCRvkfvRnKAZZHyjMJf" +"22.11.2019";"2.501,72";"18.09.2023";"Kartenumsatz";"Wiese Tür Beispiel als richtig oft hören Glück.";"22.11.2019";"MRXprlArMtGEOYyrivvb" +"25.06.1972";"-.797,60";"20.12.2000";"Kartenumsatz";"Minute alt sollen arbeiten Wissen ihn viel hören.";"25.06.1972";"WLNlXtEPMVPWNQinqVoX" +"23.06.1997";"3.688,65";"24.07.1983";"Kartenumsatz";"Glas sieben Freund bleiben Geld als laufen gelb.";"23.06.1997";"kMtKBlMZfVzaQpFJVrdf" +"07.08.1970";"236,47";"26.10.2025";"Lastschrift / Belastung";"Können sehr los Klasse gern Glas dabei baden.";"07.08.1970";"XcTWEhERXSrKhTpwxKwq" +"05.03.1995";"-3.699,34";"28.04.2022";"Lastschrift / Belastung";"Neu traurig damit gar plötzlich Küche kam erzählen.";"05.03.1995";"DCSPqPoExMJVIXDJEmba" +"13.05.2014";"-2.301,46";"02.02.1993";"Kartenumsatz";"Wiese Blume von verlieren trinken Lehrerin reiten dick.";"13.05.2014";"FyjQDvaDRthPmTPacdcN" +"12.07.2006";"-1.002,95";"16.06.1990";"Kartenumsatz";"Kam bin frei setzen Lehrer er Schnee ziehen.";"12.07.2006";"CxGyzurWIlEVpidlaHgz" +"03.01.1989";"-1.538,17";"17.11.2025";"Kartenumsatz";"Merken Geschichte nein sprechen holen kein Stein müde.";"03.01.1989";"LUBzVEGaOaXicGKGRXLK" +"27.10.1995";"-1.588,75";"09.08.1998";"Kartenumsatz";"Überall genau spielen es dort sechs ich es.";"27.10.1995";"IlEguSkSvqMwjWjgrPJJ" +"21.05.1976";"771,43";"05.02.2023";"Kartenumsatz";"Schüler früh Tür Dorf traurig er Ende der.";"21.05.1976";"OkireOnJlianvjBgiKLP" +"10.11.1991";"-3.682,38";"11.12.1976";"Kartenumsatz";"Mädchen der Tag lachen Spaß singen nehmen fangen.";"10.11.1991";"EtrQVskkdZQgaxCnaNuX" +"27.05.2005";"-2.049,77";"18.05.1992";"Lastschrift / Belastung";"Stehen Stadt kein Stein einige wir dürfen Monate.";"27.05.2005";"yayAivkfmnKnKjaEeqHx" +"28.09.2009";"2.950,65";"07.10.2017";"Kartenumsatz";"Einfach warm Spaß er wird sonst Seite jung.";"28.09.2009";"xTmXkYMLxHlUWVgzDurB" +"26.09.1978";"691,87";"09.04.2015";"Kartenumsatz";"Böse zusammen an jung Land spät Brot so.";"26.09.1978";"OZCVRrNQxLzDaTJaYgir" +"07.01.1986";"1.899,21";"16.06.1975";"Kartenumsatz";"Wird offen ruhig Teller Arbeit sofort bleiben Weihnachten.";"07.01.1986";"YexccmpQrHfXPEaNqjYN" +"22.11.1970";"-1.088,74";"22.06.1979";"Lastschrift / Belastung";"Kopf Arzt bei jeder krank Haus Haare laut.";"22.11.1970";"uiRZmISGnUnmICslCQKR" +"06.10.2011";"2.627,51";"06.09.2018";"Lastschrift / Belastung";"Berg Auge auch die Milch packen Junge Bett.";"06.10.2011";"HjQTAhQreZPzYhSlyoDp" + +"Alter Kontostand";"0,00 EUR"; + +"Umsätze Tagesgeld PLUS-Konto";"Zeitraum: 180 Tage"; +"Neuer Kontostand";"1.673,90 EUR"; + +"Wertstellung (Valuta)";"Umsatz in EUR";"Buchungstag";"Vorgang";"Buchungstext" +"02.09.2000";"-2.652,98";"13.12.1985";"Lastschrift / Belastung";"Uhr wohl führen See setzen mal krank hoch." +"27.02.1976";"-2.302,68";"11.05.1997";"Kartenumsatz";"Ohne gut Stein wirklich gar Winter Welt nämlich." +"28.05.1986";"-3.343,51";"24.04.2026";"Kartenumsatz";"Dich wenig Oma oft denn Ferien hinter halten." +"18.08.1980";"3.519,25";"08.06.1985";"Lastschrift / Belastung";"Erklären tragen See ging versuchen gern heiß vielleicht." +"04.10.1981";"3.268,02";"25.07.2014";"Kartenumsatz";"Nein Junge Tier beide noch Wasser hinein gefährlich." +"09.01.1991";"-3.052,80";"13.06.2023";"Kartenumsatz";"Fisch Schwester viel sicher bis dürfen es Opa." +"05.07.2011";"-.633,49";"27.04.1994";"Kartenumsatz";"Frei fährt hat mich gleich gibt er Mutter." +"22.02.2015";"-1.146,44";"16.04.2010";"Lastschrift / Belastung";"Tag stellen in nichts Winter unser Eltern traurig." +"17.03.1989";"350,28";"24.03.2000";"Kartenumsatz";"Ding fröhlich fangen Herz allein tief weiter ihn." +"26.10.1994";"3.490,67";"10.05.1988";"Kartenumsatz";"Gibt Zeit Zug doch hören Frau Zimmer Onkel." + +"Alter Kontostand";"6.686,20 EUR"; + +"Umsätze Depot";"Zeitraum: 180 Tage"; + +"Wertstellung (Valuta)";"Umsatz in EUR";"Geschäftstag";"Stück / Nom.";"Bezeichnung";"WKN";"Währung";"Ausführungskurs" +"28.10.1974";"1.685,37";"28.10.1974";"3.782,02";"zVQTjfeGndFUgHRYoOJV";"UIKWcmkLpKvlEaxsJNAY";"IVIQpMQspcGkoCQUMKKQ";"-1.169,05" +"23.03.1994";"1.559,84";"23.03.1994";"-.507,97";"rWidmLRRjtCtJbGCxxiJ";"clkDytzVOZfNqNqNqwpq";"vinALLmPCGosxxYbxQOT";"-.170,74" +"21.07.1997";"1.885,70";"21.07.1997";"3.199,90";"zbFJlhZJhFyVckyTkbWF";"NCLFEpfFrQAsoRLpEdNB";"BdqBcCsARBsTONKUEMUG";"2.167,34" +"04.11.2007";"-2.336,08";"04.11.2007";"3.374,10";"UmhrGDbbJhTbtBVnMEWU";"kfrvJSzEonSnYSvSwhzR";"WsyqHHvMGewNQWnQVOab";"-44,87" +"10.05.1995";"2.777,21";"10.05.1995";"-.466,66";"QBcTBCTJDuIWJKthZKKN";"DOBQNhJNlHjRdTYQfihc";"tTdPJSTffQjGknRiARpp";"1.108,37" +"02.08.2021";"95,64";"02.08.2021";"-2.473,15";"sURiquSzaJuCrwWdnuBf";"VltMSrMBshpZqfZltkJz";"mDBoFnnxoaMnkJcJwXtE";"1.989,84" +"08.01.2006";"723,60";"08.01.2006";"-3.788,97";"lENmCDzBnAoOlUWoIEOh";"vsTfHGBstXTwHpVQQIMN";"woeYzPNzwxhbsVuBQSzW";"723,80" +"24.08.1972";"-2.752,39";"24.08.1972";"121,81";"QmboZenDJYKYJZHrwBzg";"TOOOVZqQAxZLvZBqhttK";"iCRZdDaRZbJzKjDwweZz";"1.508,94" +"17.10.2004";"-3.983,82";"17.10.2004";"-2.862,12";"tBoMSqznKQiDGPEaZgtK";"KCxxVLPJcUdCHWRHiLuG";"MuwYMYDVjiZdGPPchFan";"2.170,06" +"13.01.2003";"1.172,75";"13.01.2003";"3.559,62";"DZUeNsJNwFiobKvjqbFE";"lKBJTcOkTowTcbtEDnHC";"DDMOrfHrxGjnjDQplaXe";"515,78" From 8440dd2ea792dec214931bc69418b63ed001e02f Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 13 Sep 2026 12:57:05 +0200 Subject: [PATCH 23/31] b4w ingest e2e test --- src/ingest/b4w.py | 10 ++++----- tests/test_ingest_b4w.py | 45 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 tests/test_ingest_b4w.py diff --git a/src/ingest/b4w.py b/src/ingest/b4w.py index 0316d74..9562884 100644 --- a/src/ingest/b4w.py +++ b/src/ingest/b4w.py @@ -2,7 +2,6 @@ from pathlib import Path from sqlalchemy.orm import Session -from src.db import get_session from src.ingest._common import _reader_raw, _upsert from src.models.b4w import B4wDb, B4wExport, Category, CategoryDb @@ -64,9 +63,8 @@ def upsert_b4w( ) -def ingest_b4w(path: Path): +def ingest_b4w(path: Path, session: Session): data = reader_b4w(path) - with get_session() as session: - for row in data: - upsert_b4w(session, row, do_commit=False) - session.commit() + for row in data: + upsert_b4w(session, row, do_commit=False) + session.commit() diff --git a/tests/test_ingest_b4w.py b/tests/test_ingest_b4w.py new file mode 100644 index 0000000..41f089c --- /dev/null +++ b/tests/test_ingest_b4w.py @@ -0,0 +1,45 @@ +import re +from datetime import date +from decimal import Decimal +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from src.constants import IBAN_PATTERN, Currency +from src.ingest.b4w import ingest_b4w +from src.models.b4w import B4wDb + +b4w_db_column_names = [col.key for col in B4wDb.__mapper__.columns] + + +# TODO: Unit tests for other funtions in src/ingest/b4w.py + +# TODO: Similar file for Comdirect (more work due to several tables) + + +def test_ingest_b4w_e2e(db_session: Session): + path = Path("tests/test_data/b4w_export_dummy_01.csv") + ingest_b4w(path, db_session) + result = db_session.execute(select(B4wDb)).scalars().all() + assert isinstance(result, list) + assert all(isinstance(row.id_b4w, int) for row in result) + assert all(isinstance(row.date_book, date) for row in result) + assert all(isinstance(row.other_name, str) for row in result) + assert all(isinstance(row.other_iban, str) for row in result) + assert all(re.findall(IBAN_PATTERN, str(row.other_iban)) for row in result) + assert all( + (isinstance(row.category_id, int) or row.category_id is None) for row in result + ) + assert all(isinstance(row.notes, str) for row in result) + assert all(isinstance(row.id, int) for row in result) + assert all(isinstance(row.hash_id, str) for row in result) + assert all(isinstance(row.account_iban, str) for row in result) + assert all(re.findall(IBAN_PATTERN, str(row.account_iban)) for row in result) + assert all(isinstance(row.account_currency, str) for row in result) + assert all(isinstance(row.date_valid, date) for row in result) + assert all(isinstance(row.amount, Decimal) for row in result) + assert all(isinstance(row.amount_currency, str) for row in result) + assert all(row.amount_currency in list(Currency) for row in result) + assert all(isinstance(row.text, str) for row in result) + print(b4w_db_column_names) From c11c168134cea8dc17cd0f50b84e293254d6f5dc Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 13 Sep 2026 13:16:28 +0200 Subject: [PATCH 24/31] pipeline --- .github/workflows/test.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d821f4a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: | + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Run pytest + run: pytest From 75a787aecebda18a00fa46f0c7d3f10bdfe2dd90 Mon Sep 17 00:00:00 2001 From: Mathias Schneider Date: Sun, 13 Sep 2026 20:46:40 +0200 Subject: [PATCH 25/31] comdirect ingest tests (tbc) --- src/ingest/comdirect.py | 16 ++++++++-------- tests/test_ingest_comdirect.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 tests/test_ingest_comdirect.py diff --git a/src/ingest/comdirect.py b/src/ingest/comdirect.py index 783593b..e9ea3a1 100644 --- a/src/ingest/comdirect.py +++ b/src/ingest/comdirect.py @@ -44,6 +44,7 @@ def reader_comdirect(path: Path) -> dict[str, list[ComdirectExportModel]]: result = {} raw = _reader_raw_comdirect(path) for account in ACCOUNTS: + assert account.name in raw data_raw: Csv2Dict = raw.pop(account.name) data: list = list(map(account.model.model_validate, data_raw)) result[account.name] = data @@ -53,7 +54,7 @@ def reader_comdirect(path: Path) -> dict[str, list[ComdirectExportModel]]: def _reader_raw_comdirect(path: Path) -> CsV2DictMulti: - with path.open(encoding="latin1") as fid: + with path.open("r", encoding="utf-8") as fid: lines = fid.readlines() expect_next = EXPECT_LINE.ACCOUNT_NAME @@ -122,7 +123,7 @@ def upsert_comdirect_giro( ) -def ingest_comdirect(path: Path, account_iban: str, account_currency: str): +def ingest_comdirect(session: Session, path: Path, account_iban: str, account_currency: str): assert SOURCE_COMDIRECT data_collection = reader_comdirect(path) for account_name, data in data_collection.items(): @@ -138,9 +139,8 @@ def ingest_comdirect(path: Path, account_iban: str, account_currency: str): ) else: continue - with get_session() as session: - for row in data: - data_validated = account.model.model_validate(row) - assert isinstance(data_validated, account.model) - db_load_function(session, row, account) - session.commit() + for row in data: + data_validated = account.model.model_validate(row) + assert isinstance(data_validated, account.model) + db_load_function(session, row, account) + session.commit() diff --git a/tests/test_ingest_comdirect.py b/tests/test_ingest_comdirect.py new file mode 100644 index 0000000..862dc25 --- /dev/null +++ b/tests/test_ingest_comdirect.py @@ -0,0 +1,33 @@ +from pathlib import Path + +import pytest +from faker import Faker +from sqlalchemy.orm import Session + +from src.constants import Currency +from src.ingest.comdirect import ( + _reader_raw_comdirect, + ingest_comdirect, + reader_comdirect, +) + +TEST_DATA_PATH = Path("tests/test_data/comdirect_export_dummy_01.txt") + + +def test_reader_comdirect_raw(): + result = _reader_raw_comdirect(TEST_DATA_PATH) + assert result + +def test_reader_comdirect(): + result = reader_comdirect(TEST_DATA_PATH) + assert result + assert isinstance(result, dict) + +@pytest.mark.skip +def test_ingest_comdirect(db_session: Session): + ingest_comdirect( + session=db_session, + path=TEST_DATA_PATH, + account_iban=Faker().iban(), + account_currency=Currency.EUR, + ) From c4bc8d427ff51fa9b423acddec98ca9b90a4ed88 Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 13 Sep 2026 21:18:46 +0200 Subject: [PATCH 26/31] pipeline --- .gitea/workflows/test.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .gitea/workflows/test.yml diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..1b3ad6b --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,19 @@ +name: Tests + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + - name: Install dependencies + run: | + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Run pytest + run: pytest From 4046c00b1b84d4f9dc277e0130660268c9f47ab2 Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 13 Sep 2026 21:56:05 +0200 Subject: [PATCH 27/31] pipeline fix dependencies --- .gitea/workflows/test.yml | 10 +++++----- .github/workflows/test.yml | 27 --------------------------- 2 files changed, 5 insertions(+), 32 deletions(-) delete mode 100644 .github/workflows/test.yml diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 1b3ad6b..6d6aab3 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -10,10 +10,10 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" - cache: "pip" + python-version: "3.14" + - name: Install uv + run: pip install uv - name: Install dependencies - run: | - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + run: uv sync --frozen - name: Run pytest - run: pytest + run: uv run pytest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index d821f4a..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - cache: 'pip' - - - name: Install dependencies - run: | - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - - name: Run pytest - run: pytest From 551d8ffbdeb48dd5c0785e712f83e707b3c898f7 Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 13 Sep 2026 22:06:20 +0200 Subject: [PATCH 28/31] flexible encoding when reading comdirect data --- src/ingest/comdirect.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/ingest/comdirect.py b/src/ingest/comdirect.py index e9ea3a1..c7b81c6 100644 --- a/src/ingest/comdirect.py +++ b/src/ingest/comdirect.py @@ -5,7 +5,6 @@ from pathlib import Path from sqlalchemy.orm import Session from settings import BANK_ACCOUNTS -from src.db import get_session from src.ingest._common import _upsert from src.models._common import Csv2Dict, CsV2DictMulti from src.models.comdirect import ( @@ -52,10 +51,18 @@ def reader_comdirect(path: Path) -> dict[str, list[ComdirectExportModel]]: return result +def _open_and_readlines(path: Path, encoding: str) -> list: + with path.open("r", encoding=encoding) as fid: + lines = fid.readlines() + return lines + + def _reader_raw_comdirect(path: Path) -> CsV2DictMulti: - with path.open("r", encoding="utf-8") as fid: - lines = fid.readlines() + try: + lines = _open_and_readlines(path, encoding="utf-8") + except UnicodeDecodeError: + lines = _open_and_readlines(path, encoding="latin-1") expect_next = EXPECT_LINE.ACCOUNT_NAME account_name = None @@ -123,7 +130,9 @@ def upsert_comdirect_giro( ) -def ingest_comdirect(session: Session, path: Path, account_iban: str, account_currency: str): +def ingest_comdirect( + session: Session, path: Path, account_iban: str, account_currency: str +): assert SOURCE_COMDIRECT data_collection = reader_comdirect(path) for account_name, data in data_collection.items(): From a482f38cb66d8dcb81b8e62e7744e98188058ef9 Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 13 Sep 2026 22:06:28 +0200 Subject: [PATCH 29/31] black --- tests/test_ingest_comdirect.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_ingest_comdirect.py b/tests/test_ingest_comdirect.py index 862dc25..23c685b 100644 --- a/tests/test_ingest_comdirect.py +++ b/tests/test_ingest_comdirect.py @@ -18,11 +18,13 @@ def test_reader_comdirect_raw(): result = _reader_raw_comdirect(TEST_DATA_PATH) assert result + def test_reader_comdirect(): result = reader_comdirect(TEST_DATA_PATH) assert result assert isinstance(result, dict) + @pytest.mark.skip def test_ingest_comdirect(db_session: Session): ingest_comdirect( From 3245b25e9dee622fd3d9f5c7a0786e9f82d35edb Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 13 Sep 2026 22:11:36 +0200 Subject: [PATCH 30/31] pipeline: copy default settings --- .gitea/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 6d6aab3..c3df3dc 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -15,5 +15,7 @@ jobs: run: pip install uv - name: Install dependencies run: uv sync --frozen + - name: Copy default settings + run: cp settings.template.py settings.py - name: Run pytest run: uv run pytest From 61cb7cb591e18c6e81caec33c4cde4a26a44fb4c Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 13 Sep 2026 22:20:20 +0200 Subject: [PATCH 31/31] pipeline: some more checks --- .gitea/workflows/test.yml | 19 +++++++++++++++++++ pyproject.toml | 7 +++++++ uv.lock | 27 +++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index c3df3dc..5bcc4c8 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -19,3 +19,22 @@ jobs: run: cp settings.template.py settings.py - name: Run pytest run: uv run pytest + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + - name: Install uv + run: pip install uv + - name: Install dependencies + run: uv sync --frozen + - name: Copy default settings + run: cp settings.template.py settings.py + - name: Check formatting (black) + run: uv run black --check . --extend-exclude .venv + - name: Lint (ruff) + run: uv run ruff check . diff --git a/pyproject.toml b/pyproject.toml index e236405..4710623 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,10 +28,17 @@ module-root = "" requires = ["uv_build>=0.12.9,<0.13.0"] build-backend = "uv_build" +[tool.ruff] +target-version = "py314" + +[tool.ruff.lint] +ignore = ["EXE"] + [dependency-groups] dev = [ "black>=26.5.1", "debugpy>=1.8.21", "faker>=40.38.0", "polyfactory>=3.3.0", + "ruff>=0.16.7", ] diff --git a/uv.lock b/uv.lock index d235a00..5fdcc12 100644 --- a/uv.lock +++ b/uv.lock @@ -130,6 +130,7 @@ dev = [ { name = "debugpy" }, { name = "faker" }, { name = "polyfactory" }, + { name = "ruff" }, ] [package.metadata] @@ -149,6 +150,7 @@ dev = [ { name = "debugpy", specifier = ">=1.8.21" }, { name = "faker", specifier = ">=40.38.0" }, { name = "polyfactory", specifier = ">=3.3.0" }, + { name = "ruff", specifier = ">=0.16.7" }, ] [[package]] @@ -408,6 +410,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] +[[package]] +name = "ruff" +version = "0.16.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.52"