Ingest comdirect #5

Merged
mathias2784 merged 31 commits from ingest-comdirect into master 2026-09-13 20:27:16 +00:00
6 changed files with 170 additions and 18 deletions
Showing only changes of commit f815ff2c0a - Show all commits
+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__":
+22 -1
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:
@@ -124,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:
+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)
+3 -3
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,7 +13,7 @@ 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))
@@ -34,7 +34,7 @@ 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():