Merge pull request 'Ingest comdirect' (#5) from ingest-comdirect into master
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -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 .
|
||||||
@@ -6,11 +6,15 @@ dist/
|
|||||||
wheels/
|
wheels/
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
|
/settings.py
|
||||||
|
|
||||||
# Database instances
|
# Database instances
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -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/`
|
||||||
+1
-1
@@ -6,7 +6,7 @@ from sqlalchemy import engine_from_config, pool
|
|||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from src.db import get_set_engine
|
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
|
load_dotenv() # reads .env into os.environ
|
||||||
|
|
||||||
|
|||||||
@@ -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 ###
|
||||||
@@ -28,8 +28,17 @@ module-root = ""
|
|||||||
requires = ["uv_build>=0.12.9,<0.13.0"]
|
requires = ["uv_build>=0.12.9,<0.13.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py314"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
ignore = ["EXE"]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"black>=26.5.1",
|
"black>=26.5.1",
|
||||||
|
"debugpy>=1.8.21",
|
||||||
|
"faker>=40.38.0",
|
||||||
"polyfactory>=3.3.0",
|
"polyfactory>=3.3.0",
|
||||||
|
"ruff>=0.16.7",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
@@ -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))
|
||||||
@@ -1,41 +1,14 @@
|
|||||||
from typing import Any
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from src.models import B4wExport, Category
|
from src.ingest._common import _reader_raw, _upsert
|
||||||
from src.models_db import B4wDb, BaseModelDb, CategoryDb
|
from src.models.b4w import B4wDb, B4wExport, Category, CategoryDb
|
||||||
|
|
||||||
|
|
||||||
def _query_existing(
|
def reader_b4w(path: Path) -> list[B4wExport]:
|
||||||
session: Session, DbModel: type[BaseModelDb], query_parameters: dict[str, Any]
|
raw = _reader_raw(path)
|
||||||
) -> BaseModelDb | None:
|
return list(map(B4wExport.model_validate, raw))
|
||||||
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(
|
def upsert_category(
|
||||||
@@ -62,17 +35,18 @@ def upsert_b4w(
|
|||||||
) -> B4wDb | None:
|
) -> B4wDb | None:
|
||||||
category_db = upsert_category(session, data.category)
|
category_db = upsert_category(session, data.category)
|
||||||
|
|
||||||
parameters_existing = {"id_b4w": data.id}
|
parameters_existing = {"hash_id": data.hash_id}
|
||||||
|
|
||||||
parameters_new = {
|
parameters_new = {
|
||||||
"id_b4w": data.id,
|
"id_b4w": data.id,
|
||||||
|
"hash_id": data.hash_id,
|
||||||
"account_iban": data.account_iban,
|
"account_iban": data.account_iban,
|
||||||
"account_currency": data.account_currency.value,
|
"account_currency": data.account_currency.value,
|
||||||
"date_valid": data.date_valid,
|
"date_valid": data.date_valid,
|
||||||
"date_book": data.date_book,
|
"date_book": data.date_book,
|
||||||
"amount": data.amount_signed,
|
"amount": data.amount_signed,
|
||||||
"amount_currency": data.amount_currency.value,
|
"amount_currency": data.amount_currency.value,
|
||||||
"reference": data.reference,
|
"text": data.reference,
|
||||||
"other_name": data.other_name,
|
"other_name": data.other_name,
|
||||||
"other_iban": data.other_iban,
|
"other_iban": data.other_iban,
|
||||||
"category": category_db,
|
"category": category_db,
|
||||||
@@ -87,3 +61,10 @@ def upsert_b4w(
|
|||||||
do_update=do_update,
|
do_update=do_update,
|
||||||
do_commit=do_commit,
|
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()
|
||||||
@@ -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()
|
||||||
+20
-14
@@ -1,29 +1,35 @@
|
|||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.db import get_session
|
from src.ingest.b4w import ingest_b4w
|
||||||
from src.helper_db import upsert_b4w
|
from src.ingest.comdirect import ingest_comdirect
|
||||||
from src.reader import reader_b4w
|
from src.models.settings import Account
|
||||||
|
|
||||||
|
|
||||||
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 main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Eine App um den Überblick über Finanzen zu behalten"
|
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()
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
-102
@@ -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__()}")'
|
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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")
|
|
||||||
@@ -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
|
|
||||||
+93
-2
@@ -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"
|
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]):
|
class B4wExportFactory(ModelFactory[B4wExport]):
|
||||||
|
__faker__ = Faker("de_DE")
|
||||||
__use_examples__ = True
|
__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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
@@ -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"
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
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 import Category
|
||||||
from src.models_db import CategoryDb
|
from src.models.b4w import CategoryDb
|
||||||
|
|
||||||
|
|
||||||
def test_upsert_category_returns_orm_object(db_session: Session):
|
def test_upsert_category_returns_orm_object(db_session: Session):
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
@@ -29,3 +31,64 @@ def test_b4w_export_account_iban_to_long():
|
|||||||
def test_b4w_export_account_iban_to_short():
|
def test_b4w_export_account_iban_to_short():
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
_ = B4wExportFactory.build(OwnrAcctIBAN="DE123")
|
_ = 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
|
||||||
|
|||||||
@@ -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
|
||||||
+20
-4
@@ -1,10 +1,26 @@
|
|||||||
from pathlib import Path
|
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():
|
@pytest.mark.skipif(
|
||||||
test_path = Path(".local/data/comdirect/umsaetze_9773063005_20260906-2043.csv")
|
not Path(TEST_DATA_PATH).exists(),
|
||||||
assert test_path.exists()
|
reason=f"Testdaten fehlen: {TEST_DATA_PATH}",
|
||||||
|
)
|
||||||
|
def test_reader_comdirect():
|
||||||
|
test_path = Path(TEST_DATA_PATH)
|
||||||
data = reader_comdirect(test_path)
|
data = reader_comdirect(test_path)
|
||||||
assert data
|
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"])
|
||||||
|
|||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "dotenv"
|
name = "dotenv"
|
||||||
version = "0.9.9"
|
version = "0.9.9"
|
||||||
@@ -114,7 +127,10 @@ dependencies = [
|
|||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "black" },
|
{ name = "black" },
|
||||||
|
{ name = "debugpy" },
|
||||||
|
{ name = "faker" },
|
||||||
{ name = "polyfactory" },
|
{ name = "polyfactory" },
|
||||||
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -131,7 +147,10 @@ requires-dist = [
|
|||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "black", specifier = ">=26.5.1" },
|
{ 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 = "polyfactory", specifier = ">=3.3.0" },
|
||||||
|
{ name = "ruff", specifier = ">=0.16.7" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.52"
|
version = "2.0.52"
|
||||||
|
|||||||
Reference in New Issue
Block a user