diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..5bcc4c8 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,40 @@ +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.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: 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/.gitignore b/.gitignore index 2a04809..803269d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,11 +6,15 @@ dist/ wheels/ *.egg-info +# IDE +.idea + # Virtual environments .venv # Environment variables .env +/settings.py # Database instances *.db 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/` 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/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/pyproject.toml b/pyproject.toml index 79d3bd4..4710623 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,8 +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/scripts/dummy_data/comdirect_export_template.txt b/scripts/dummy_data/comdirect_export_template.txt new file mode 100644 index 0000000..deeb0bb --- /dev/null +++ b/scripts/dummy_data/comdirect_export_template.txt @@ -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} diff --git a/scripts/dummy_data/generate_dummies_b4w.py b/scripts/dummy_data/generate_dummies_b4w.py new file mode 100644 index 0000000..fa9006b --- /dev/null +++ b/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 +DEFAULT_PATH = Path("tests/test_data/b4w_export_dummy_01.csv") + + +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) + 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/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/settings.template.py b/settings.template.py new file mode 100644 index 0000000..af27e8e --- /dev/null +++ b/settings.template.py @@ -0,0 +1,59 @@ +from pathlib import Path + +from faker import Faker + +from src.constants import Currency +from src.models import ( + ComdirectCreditCard, + ComdirectDepot, + ComdirectGiro, +) +from src.models.b4w import B4wExport +from src.models.settings import Account, Source + +fake = Faker() + +BANK_ACCOUNTS = { + "b4w": Source( + name="b4w", + source_path=Path(""), + accounts=[ + Account( + name="b4w", + model=B4wExport, + iban=fake.iban(), + currency=Currency.EUR, + ) + ], + ), + "comdirect": Source( + name="comdirect", + source_path=Path(""), + accounts=[ + Account( + name="Girokonto", + model=ComdirectGiro, + iban=fake.iban(), + currency=Currency.EUR, + ), + Account( + name="Visa-Karte (Kreditkarte)", + model=ComdirectCreditCard, + iban=fake.iban(), + currency=Currency.EUR, + ), + Account( + name="Tagesgeld PLUS-Konto", + model=ComdirectGiro, + iban=fake.iban(), + currency=Currency.EUR, + ), + Account( + name="Depot", + model=ComdirectDepot, + iban=fake.iban(), + currency=Currency.EUR, + ), + ], + ), +} diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 0000000..99599bb --- /dev/null +++ b/src/constants.py @@ -0,0 +1,16 @@ +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 f"{self.value}" + + def __repr__(self): + return f"Currency.{self.name}" diff --git a/src/ingest/__init__.py b/src/ingest/__init__.py new file mode 100644 index 0000000..e69de29 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/helper_db.py b/src/ingest/b4w.py similarity index 52% rename from src/helper_db.py rename to src/ingest/b4w.py index 7e38db0..9562884 100644 --- a/src/helper_db.py +++ b/src/ingest/b4w.py @@ -1,41 +1,14 @@ -from typing import Any +from pathlib import Path from sqlalchemy.orm import Session -from src.models import B4wExport, Category -from src.models_db import B4wDb, BaseModelDb, CategoryDb +from src.ingest._common import _reader_raw, _upsert +from src.models.b4w import B4wDb, B4wExport, Category, CategoryDb -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_b4w(path: Path) -> list[B4wExport]: + raw = _reader_raw(path) + return list(map(B4wExport.model_validate, raw)) def upsert_category( @@ -62,17 +35,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 +61,10 @@ def upsert_b4w( do_update=do_update, do_commit=do_commit, ) + + +def ingest_b4w(path: Path, session: Session): + data = reader_b4w(path) + for row in data: + upsert_b4w(session, row, do_commit=False) + session.commit() diff --git a/src/ingest/comdirect.py b/src/ingest/comdirect.py new file mode 100644 index 0000000..c7b81c6 --- /dev/null +++ b/src/ingest/comdirect.py @@ -0,0 +1,155 @@ +import re +from enum import Enum, auto +from pathlib import Path + +from sqlalchemy.orm import Session + +from settings import BANK_ACCOUNTS +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'"([^"]+)"' +PATTERN_DATA = r'"([^"]+)"' + + +class EXPECT_LINE(Enum): + ACCOUNT_NAME = auto() + BLANK = auto() + HEADER = auto() + DATA = auto() + + +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 + +COMDIRECT_ACCOUNT_PARSERS = {a.name: a.model.model_validate for a in ACCOUNTS} + + +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 + assert not raw + 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: + + 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 + 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( + 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( + 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(): + account = SOURCE_COMDIRECT.get_account(account_name) + assert isinstance(account, Account) + if account_name == "Girokonto": + 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, + ) + else: + continue + 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/main.py b/src/main.py index 8f7f77f..593626b 100644 --- a/src/main.py +++ b/src/main.py @@ -1,29 +1,35 @@ 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 - - -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() +from src.ingest.b4w import ingest_b4w +from src.ingest.comdirect import ingest_comdirect +from src.models.settings import Account 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) + if args.input_b4w: + ingest_b4w(Path(args.input_b4w)) + if args.input_comdirect: + import settings - ingest_b4w(path) + 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 or "", + account_currency=giro_account.currency or "", + ) if __name__ == "__main__": diff --git a/src/models.py b/src/models.py deleted file mode 100644 index e3e8c04..0000000 --- a/src/models.py +++ /dev/null @@ -1,102 +0,0 @@ -from datetime import date -from decimal import Decimal -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}" - - -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") - - @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 - - @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__()}")' 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..6fb3e14 --- /dev/null +++ b/src/models/_common.py @@ -0,0 +1,99 @@ +import hashlib +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 + + +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 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] + + +class ExportBaseModel(BaseModel): + + @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): + 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..4f8189c --- /dev/null +++ b/src/models/b4w.py @@ -0,0 +1,151 @@ +from datetime import date +from decimal import Decimal + +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, + ExportBaseModel, + IngestdbBase, + Sign, + _hash, + date2str, +) + + +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") + date_valid: date = Field(alias="ValDt") + date_book: date = Field(alias="BookgDt") + 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 | None = Field(alias="RmtdAcctIBAN", pattern=IBAN_PATTERN) + category: Category | None = Field( + alias="Category", + examples=[ + "Freizeit", + "Beruf:Gehalt:super Firma", + "Lebenshaltung:Lebensmitteleinkäuft", + "", + ], + ) + 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 or ''}", + "Category": f"{self.category.to_str() if self.category else ''}", + "Notes": f"{self.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("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( + 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(ExportBaseModel): + 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..2578e71 --- /dev/null +++ b/src/models/comdirect.py @@ -0,0 +1,159 @@ +from datetime import date +from decimal import Decimal + +from pydantic import ConfigDict, Field, computed_field, field_validator +from sqlalchemy import Column, Date, String + +from src.models._common import ( + ExportBaseModel, + IngestdbBase, + _hash, + _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"] + ) + 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 | None) -> date | None: + if date_str is None: + return + 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) + + @classmethod + def _date2str(cls, date_value: date | None) -> str: + return date2str(date_value, default="--", date_format="%d.%m.%Y") + + 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 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 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): + # # 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"] + ) + 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) + + def parameters_to_dict(self, by_name: bool = 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): + # # Fields from parents: + 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 + ) + + 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), + "Referenz": self.reference, + } + ) + return parameters + + +class ComdirectDepot(ComdirectBase): + # # Fields from parents: + # amount_eur + 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) + + 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), + "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): + __abstract__ = True + + +class ComdirectGiroDb(ComdirectBaseDb): + __tablename__ = "comdirect_giro" + + type = Column(String, nullable=True) + date_book = Column(Date, nullable=True) 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) diff --git a/src/models_db.py b/src/models_db.py deleted file mode 100644 index aefe066..0000000 --- a/src/models_db.py +++ /dev/null @@ -1,54 +0,0 @@ -from sqlalchemy import ( - Column, - Date, - ForeignKey, - Integer, - Numeric, - String, - UniqueConstraint, -) -from sqlalchemy.orm import DeclarativeBase, relationship - - -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 B4wDb(BaseModelDb): - __tablename__ = "import_b4w" - - id = Column(Integer, primary_key=True, autoincrement=True) - id_b4w = Column(Integer, nullable=False, unique=True) - 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) - 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/reader.py b/src/reader.py deleted file mode 100644 index 64ec4e9..0000000 --- a/src/reader.py +++ /dev/null @@ -1,61 +0,0 @@ -import csv -import re -from enum import Enum, auto -from pathlib import Path - -from src.models import B4wExport - - -def reader_raw(path: Path): - with path.open() as fid: - return list(csv.DictReader(fid)) - - -def reader_b4w(path: Path): - raw = reader_raw(path) - return list(map(B4wExport.model_validate, raw)) - - -def reader_comdirect(path: Path): - - 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() - - 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]["data"] = [] - expect_next = EXPECT_LINE.DATA - elif expect_next == EXPECT_LINE.DATA: - data = re.findall(PATTERN_DATA, line) - if data: - result[account_name]["data"].append( - {column: value for column, value in zip(header, data)} - ) - expect_next = EXPECT_LINE.ACCOUNT_NAME - else: - expect_next = EXPECT_LINE.ACCOUNT_NAME - return result diff --git a/tests/factories.py b/tests/factories.py index f31a6fb..a96fbd4 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,9 +1,100 @@ -from polyfactory.factories.pydantic_factory import ModelFactory +from decimal import Decimal +from typing import Any -from src.models import B4wExport +from faker import Faker +from polyfactory.factories.pydantic_factory import ModelFactory +from polyfactory.fields import Use + +from src.models.b4w import B4wExport +from src.models.comdirect import ComdirectCreditCard, ComdirectDepot, ComdirectGiro 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, + right_digits=2, + positive=True, + min_value=Decimal("0.01"), + ) + + +def _amount_eur() -> str: + """Generate German-formatted decimal string (e.g., '32,43').""" + 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, + variable_nb_words=False, + ) + + +def _names() -> str: + return B4wExportFactory.__faker__.company() + + +def _iban() -> str: + return B4wExportFactory.__faker__.iban() + + +def _iban_or_none() -> str: + return _faker_or_none(Faker(), Faker().iban(), default="") + + 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_or_none) + + +class ComdirectGiroFactory(ModelFactory[ComdirectGiro]): + __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 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/b4w_export_dummy_01.csv b/tests/test_data/b4w_export_dummy_01.csv new file mode 100644 index 0000000..e99af15 --- /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 +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. 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" 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_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) diff --git a/tests/test_ingest_comdirect.py b/tests/test_ingest_comdirect.py new file mode 100644 index 0000000..23c685b --- /dev/null +++ b/tests/test_ingest_comdirect.py @@ -0,0 +1,35 @@ +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, + ) diff --git a/tests/test_model_b4wexport.py b/tests/test_model_b4wexport.py index 866fd44..e8ab431 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,64 @@ 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_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() + 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() + 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 + + +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 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 diff --git a/tests/test_reader.py b/tests/test_reader.py index 5b0727e..e013f7d 100644 --- a/tests/test_reader.py +++ b/tests/test_reader.py @@ -1,10 +1,26 @@ from pathlib import Path -from src.reader import reader_comdirect +import pytest + +from src.ingest.comdirect import COMDIRECT_ACCOUNT_PARSERS, reader_comdirect +from src.models import ComdirectCreditCard, ComdirectDepot, ComdirectGiro + +TEST_DATA_PATH = ".local/data/comdirect/umsaetze_9773063005_20260906-2043.csv" -def test_reader(): - test_path = Path(".local/data/comdirect/umsaetze_9773063005_20260906-2043.csv") - assert test_path.exists() +@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) data = reader_comdirect(test_path) assert data + 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)"] + ) + assert all(isinstance(row, ComdirectGiro) for row in data["Tagesgeld PLUS-Konto"]) + assert all(isinstance(row, ComdirectDepot) for row in data["Depot"]) diff --git a/uv.lock b/uv.lock index 7f52dba..5fdcc12 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,7 +127,10 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "black" }, + { name = "debugpy" }, + { name = "faker" }, { name = "polyfactory" }, + { name = "ruff" }, ] [package.metadata] @@ -131,7 +147,10 @@ requires-dist = [ [package.metadata.requires-dev] 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" }, + { name = "ruff", specifier = ">=0.16.7" }, ] [[package]] @@ -391,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"