Compare commits

...
2 Commits
Author SHA1 Message Date
mathias2784 f815ff2c0a first Durchstich: comdirect Giro to DB 2026-09-11 11:36:25 +02:00
mathias2784 0ca784fd80 black 2026-09-11 10:17:35 +02:00
9 changed files with 184 additions and 28 deletions
+52
View File
@@ -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 ###
+32 -4
View File
@@ -2,8 +2,8 @@ from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.models import B4wExport, Category from src.models import B4wExport, Category, ComdirectGiro
from src.models_db import B4wDb, BaseModelDb, CategoryDb from src.models_db import B4wDb, BaseModelDb, CategoryDb, ComdirectGiroDb
def _query_existing( def _query_existing(
@@ -62,17 +62,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 +88,30 @@ def upsert_b4w(
do_update=do_update, do_update=do_update,
do_commit=do_commit, do_commit=do_commit,
) )
def upsert_comdirect_giro(
session: Session, data: ComdirectGiro, do_update=False, do_commit=True
) -> ComdirectGiroDb | None:
parameters_existing = {"hash_id": data.hash_id}
if not data.date_val:
return
parameters_new = {
"hash_id": data.hash_id,
"date_valid": data.date_val,
"amount": data.amount_eur,
"date_book": data.date_book,
"type": data.type,
"text": data.text,
}
return _upsert(
session,
ComdirectGiroDb,
parameters_existing,
parameters_new,
do_update=do_update,
do_commit=do_commit,
)
+21 -5
View File
@@ -2,8 +2,8 @@ import argparse
from pathlib import Path from pathlib import Path
from src.db import get_session from src.db import get_session
from src.helper_db import upsert_b4w from src.helper_db import upsert_b4w, upsert_comdirect_giro
from src.reader import reader_b4w from src.reader import reader_b4w, reader_comdirect
def ingest_b4w(path: Path): def ingest_b4w(path: Path):
@@ -14,16 +14,32 @@ def ingest_b4w(path: Path):
session.commit() session.commit()
def ingest_comdirect(path: Path):
data = reader_comdirect(path).get("Girokonto")
assert isinstance(data, list)
with get_session() as session:
for row in data:
upsert_comdirect_giro(session, row, do_commit=False)
session.commit()
def main(): 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) path_b4w = Path(args.input_b4w)
path_comdirect = Path(args.input_comdirect)
ingest_b4w(path) ingest_b4w(path_b4w)
ingest_comdirect(path_comdirect)
if __name__ == "__main__": if __name__ == "__main__":
+29 -3
View File
@@ -1,12 +1,18 @@
import hashlib
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, computed_field, field_validator
from src.settings.constants import IBAN_PATTERN, Currency from src.settings.constants import IBAN_PATTERN, Currency
def _hash(*args):
raw = "".join(str(args))
return hashlib.sha256(raw.encode()).hexdigest()
class Sign(str, Enum): class Sign(str, Enum):
DEBIT = "DBIT" DEBIT = "DBIT"
CREDIT = "CRDT" CREDIT = "CRDT"
@@ -44,6 +50,11 @@ class B4wExport(BaseModel):
) )
notes: str = Field(alias="Notes") notes: str = Field(alias="Notes")
@computed_field
@property
def hash_id(self) -> str:
return _hash(self.id)
@property @property
def amount_signed(self) -> Decimal: def amount_signed(self) -> Decimal:
return self.amount * self.sign.to_sign() return self.amount * self.sign.to_sign()
@@ -67,6 +78,11 @@ class Category(BaseModel):
name: str = Field(pattern=r"[^:\r\n]+") name: str = Field(pattern=r"[^:\r\n]+")
parent: Category | None = None parent: Category | None = None
@computed_field
@property
def hash_id(self) -> str:
return _hash(self.name, self.parent)
@classmethod @classmethod
def from_str(cls, s: str) -> Category | None: def from_str(cls, s: str) -> Category | None:
if not s: if not s:
@@ -95,12 +111,15 @@ def _parse_date_german(date_str: str) -> date | None:
day, month, year = list(map(int, date_str.split("."))) day, month, year = list(map(int, date_str.split(".")))
return date(day=day, month=month, year=year) return date(day=day, month=month, year=year)
def _parse_decimal_german(decimal_str: str) -> Decimal: def _parse_decimal_german(decimal_str: str) -> Decimal:
return Decimal(decimal_str.replace(".", "").replace(",", ".")) return Decimal(decimal_str.replace(".", "").replace(",", "."))
class ComdirectBase(BaseModel): class ComdirectBase(BaseModel):
date_val: date | None = Field(alias="Wertstellung (Valuta)", examples=["07.09.2026"]) date_val: date | None = Field(
alias="Wertstellung (Valuta)", examples=["07.09.2026"]
)
amount_eur: Decimal = Field(alias="Umsatz in EUR", examples=["32,43"]) amount_eur: Decimal = Field(alias="Umsatz in EUR", examples=["32,43"])
@field_validator("date_val", mode="before") @field_validator("date_val", mode="before")
@@ -121,6 +140,11 @@ class ComdirectGiro(ComdirectBase):
) )
text: str = Field(alias="Buchungstext") 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") @field_validator("date_book", mode="before")
@classmethod @classmethod
def parse_date_book(cls, date_str: str) -> date | None: def parse_date_book(cls, date_str: str) -> date | None:
@@ -138,7 +162,9 @@ class ComdirectCreditCard(ComdirectGiro):
) )
reference: str = Field(alias="Referenz", default="") reference: str = Field(alias="Referenz", default="")
text: str = Field(alias="Buchungstext", default="") text: str = Field(alias="Buchungstext", default="")
amount_eur: Decimal | None = Field(alias="Umsatz in EUR", examples=["32,43"], default=None) amount_eur: Decimal | None = Field(
alias="Umsatz in EUR", examples=["32,43"], default=None
)
class ComdirectDepot(ComdirectBase): class ComdirectDepot(ComdirectBase):
+40 -5
View File
@@ -9,6 +9,9 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import DeclarativeBase, relationship from sqlalchemy.orm import DeclarativeBase, relationship
from settings import BANK_ACCOUNTS
from src.settings.models import AccountInfo
class BaseModelDb(DeclarativeBase): class BaseModelDb(DeclarativeBase):
pass pass
@@ -33,18 +36,24 @@ class CategoryDb(BaseModelDb):
UniqueConstraint("name", "parent_id", name="uq_category_name_parent") UniqueConstraint("name", "parent_id", name="uq_category_name_parent")
class B4wDb(BaseModelDb): class IngestdbBase(BaseModelDb):
__tablename__ = "import_b4w" __abstract__ = True
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
id_b4w = Column(Integer, nullable=False, unique=True) hash_id = Column(String, nullable=False)
account_iban = Column(String, nullable=False) account_iban = Column(String, nullable=False)
account_currency = Column(String(3), nullable=False) account_currency = Column(String(3), nullable=False)
date_valid = Column(Date, nullable=False) date_valid = Column(Date, nullable=False)
date_book = Column(Date, nullable=True)
amount = Column(Numeric(12, 2), nullable=False) amount = Column(Numeric(12, 2), nullable=False)
amount_currency = Column(String(3), nullable=False) amount_currency = Column(String(3), nullable=False)
reference = Column(String, nullable=True) text = Column(String, nullable=True)
class B4wDb(IngestdbBase):
__tablename__ = "import_b4w"
id_b4w = Column(Integer, nullable=False, unique=True)
date_book = Column(Date, nullable=True)
other_name = Column(String, nullable=True) other_name = Column(String, nullable=True)
other_iban = Column(String, nullable=True) other_iban = Column(String, nullable=True)
category_id = Column(Integer, ForeignKey("category.id"), nullable=True) category_id = Column(Integer, ForeignKey("category.id"), nullable=True)
@@ -52,3 +61,29 @@ class B4wDb(BaseModelDb):
# relationship # relationship
category = relationship("CategoryDb") category = relationship("CategoryDb")
COMDIRECT_INFO = BANK_ACCOUNTS.get("comdirect")
assert isinstance(COMDIRECT_INFO, dict)
COMDIRECT_GIRO_INFO = COMDIRECT_INFO.get("Girokonto", {})
assert isinstance(COMDIRECT_GIRO_INFO, AccountInfo)
class ComdirectBaseDb(IngestdbBase):
__abstract__ = True
account_iban = Column(String, nullable=False, default=COMDIRECT_GIRO_INFO.iban)
account_currency = Column(
String(3), nullable=False, default=COMDIRECT_GIRO_INFO.currency
)
amount_currency = Column(
String(3), nullable=False, default=COMDIRECT_GIRO_INFO.currency
)
class ComdirectGiroDb(ComdirectBaseDb):
__tablename__ = "comdirect_giro"
type = Column(String, nullable=True)
date_book = Column(Date, nullable=True)
+6 -4
View File
@@ -4,7 +4,7 @@ from enum import Enum, auto
from pathlib import Path from pathlib import Path
from settings import BANK_ACCOUNTS from settings import BANK_ACCOUNTS
from src.models import B4wExport from src.models import B4wExport, ComdirectGiro
from src.settings.models import AccountInfo from src.settings.models import AccountInfo
@@ -13,16 +13,16 @@ def _reader_raw(path: Path):
return list(csv.DictReader(fid)) return list(csv.DictReader(fid))
def reader_b4w(path: Path): def reader_b4w(path: Path) -> list[B4wExport]:
raw = _reader_raw(path) raw = _reader_raw(path)
return list(map(B4wExport.model_validate, raw)) return list(map(B4wExport.model_validate, raw))
PATTERN_ACCOUNT_NAME = r'^"Umsätze ([^"]+)";"Zeitraum: .*$' PATTERN_ACCOUNT_NAME = r'^"Umsätze ([^"]+)";"Zeitraum: .*$'
PATTERN_HEADER = r'"([^"]+)"' PATTERN_HEADER = r'"([^"]+)"'
PATTERN_DATA = r'"([^"]+)"' PATTERN_DATA = r'"([^"]+)"'
class EXPECT_LINE(Enum): class EXPECT_LINE(Enum):
ACCOUNT_NAME = auto() ACCOUNT_NAME = auto()
BLANK = auto() BLANK = auto()
@@ -33,7 +33,8 @@ class EXPECT_LINE(Enum):
COMDIRECT_ACCOUNTS = BANK_ACCOUNTS.get("comdirect", {}) COMDIRECT_ACCOUNTS = BANK_ACCOUNTS.get("comdirect", {})
assert isinstance(COMDIRECT_ACCOUNTS, dict) assert isinstance(COMDIRECT_ACCOUNTS, dict)
def reader_comdirect(path: Path):
def reader_comdirect(path: Path) -> dict[str, list[ComdirectGiro]]:
result = {} result = {}
raw = _reader_raw_comdirect(path) raw = _reader_raw_comdirect(path)
for account_name, account_data in raw.items(): for account_name, account_data in raw.items():
@@ -42,6 +43,7 @@ def reader_comdirect(path: Path):
result[account_name] = list(map(account.ingestion_function, account_data)) result[account_name] = list(map(account.ingestion_function, account_data))
return result return result
def _reader_raw_comdirect(path: Path): def _reader_raw_comdirect(path: Path):
with path.open(encoding="latin1") as fid: with path.open(encoding="latin1") as fid:
+1 -3
View File
@@ -1,8 +1,8 @@
from enum import Enum from enum import Enum
IBAN_PATTERN = r"^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$" IBAN_PATTERN = r"^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$"
class Currency(str, Enum): class Currency(str, Enum):
EUR = "EUR" EUR = "EUR"
USD = "USD" USD = "USD"
@@ -14,5 +14,3 @@ class Currency(str, Enum):
def __repr__(self): def __repr__(self):
return f"Currency.{self.name}" return f"Currency.{self.name}"
-3
View File
@@ -9,6 +9,3 @@ class AccountInfo(BaseModel):
iban: str = Field(pattern=IBAN_PATTERN) iban: str = Field(pattern=IBAN_PATTERN)
ingestion_function: Callable ingestion_function: Callable
currency: Currency currency: Currency
+3 -1
View File
@@ -14,6 +14,8 @@ def test_reader_comdirect():
assert len(data) == len(COMDIRECT_ACCOUNTS) assert len(data) == len(COMDIRECT_ACCOUNTS)
assert set(data) == set(COMDIRECT_ACCOUNTS) assert set(data) == set(COMDIRECT_ACCOUNTS)
assert all(isinstance(row, ComdirectGiro) for row in data["Girokonto"]) assert all(isinstance(row, ComdirectGiro) for row in data["Girokonto"])
assert all(isinstance(row, ComdirectCreditCard) for row in data["Visa-Karte (Kreditkarte)"]) assert all(
isinstance(row, ComdirectCreditCard) for row in data["Visa-Karte (Kreditkarte)"]
)
assert all(isinstance(row, ComdirectGiro) for row in data["Tagesgeld PLUS-Konto"]) assert all(isinstance(row, ComdirectGiro) for row in data["Tagesgeld PLUS-Konto"])
assert all(isinstance(row, ComdirectDepot) for row in data["Depot"]) assert all(isinstance(row, ComdirectDepot) for row in data["Depot"])