diff --git a/alembic/versions/8822a28b01ca_.py b/alembic/versions/8822a28b01ca_.py new file mode 100644 index 0000000..6a39905 --- /dev/null +++ b/alembic/versions/8822a28b01ca_.py @@ -0,0 +1,52 @@ +"""empty message + +Revision ID: 8822a28b01ca +Revises: 605e46acc08e +Create Date: 2026-09-11 11:13:48.650768 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "8822a28b01ca" +down_revision: str | Sequence[str] | None = "605e46acc08e" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "comdirect_giro", + sa.Column("type", sa.String(), nullable=True), + sa.Column("date_book", sa.Date(), nullable=True), + sa.Column("account_iban", sa.String(), nullable=False), + sa.Column("account_currency", sa.String(length=3), nullable=False), + sa.Column("amount_currency", sa.String(length=3), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("hash_id", sa.String(), nullable=False), + sa.Column("date_valid", sa.Date(), nullable=False), + sa.Column("amount", sa.Numeric(precision=12, scale=2), nullable=False), + sa.Column("text", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column("import_b4w", sa.Column("hash_id", sa.String(), nullable=False)) + op.add_column("import_b4w", sa.Column("text", sa.String(), nullable=True)) + op.drop_column("import_b4w", "reference") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("import_b4w", sa.Column("reference", sa.VARCHAR(), nullable=True)) + op.drop_column("import_b4w", "text") + op.drop_column("import_b4w", "hash_id") + op.drop_table("comdirect_giro") + # ### end Alembic commands ### diff --git a/src/helper_db.py b/src/helper_db.py index 7e38db0..2afe45a 100644 --- a/src/helper_db.py +++ b/src/helper_db.py @@ -2,8 +2,8 @@ from typing import Any from sqlalchemy.orm import Session -from src.models import B4wExport, Category -from src.models_db import B4wDb, BaseModelDb, CategoryDb +from src.models import B4wExport, Category, ComdirectGiro +from src.models_db import B4wDb, BaseModelDb, CategoryDb, ComdirectGiroDb def _query_existing( @@ -62,17 +62,18 @@ def upsert_b4w( ) -> B4wDb | None: category_db = upsert_category(session, data.category) - parameters_existing = {"id_b4w": data.id} + parameters_existing = {"hash_id": data.hash_id} parameters_new = { "id_b4w": data.id, + "hash_id": data.hash_id, "account_iban": data.account_iban, "account_currency": data.account_currency.value, "date_valid": data.date_valid, "date_book": data.date_book, "amount": data.amount_signed, "amount_currency": data.amount_currency.value, - "reference": data.reference, + "text": data.reference, "other_name": data.other_name, "other_iban": data.other_iban, "category": category_db, @@ -87,3 +88,30 @@ def upsert_b4w( do_update=do_update, do_commit=do_commit, ) + + +def upsert_comdirect_giro( + session: Session, data: ComdirectGiro, do_update=False, do_commit=True +) -> ComdirectGiroDb | None: + parameters_existing = {"hash_id": data.hash_id} + + if not data.date_val: + return + + parameters_new = { + "hash_id": data.hash_id, + "date_valid": data.date_val, + "amount": data.amount_eur, + "date_book": data.date_book, + "type": data.type, + "text": data.text, + } + + return _upsert( + session, + ComdirectGiroDb, + parameters_existing, + parameters_new, + do_update=do_update, + do_commit=do_commit, + ) diff --git a/src/main.py b/src/main.py index 8f7f77f..e7a7aa1 100644 --- a/src/main.py +++ b/src/main.py @@ -2,8 +2,8 @@ import argparse from pathlib import Path from src.db import get_session -from src.helper_db import upsert_b4w -from src.reader import reader_b4w +from src.helper_db import upsert_b4w, upsert_comdirect_giro +from src.reader import reader_b4w, reader_comdirect def ingest_b4w(path: Path): @@ -14,16 +14,32 @@ def ingest_b4w(path: Path): session.commit() +def ingest_comdirect(path: Path): + data = reader_comdirect(path).get("Girokonto") + assert isinstance(data, list) + with get_session() as session: + for row in data: + upsert_comdirect_giro(session, row, do_commit=False) + session.commit() + + def main(): parser = argparse.ArgumentParser( description="Eine App um den Überblick über Finanzen zu behalten" ) - parser.add_argument("input", help="Pfad einer einzulesenden Umsatzdatei (CSV)") + parser.add_argument( + "--input_b4w", help="Pfad einer einzulesenden Umsatzdatei (CSV)" + ) + parser.add_argument( + "--input_comdirect", help="Pfad einer einzulesenden Umsatzdatei (CSV)" + ) args = parser.parse_args() - path = Path(args.input) + path_b4w = Path(args.input_b4w) + path_comdirect = Path(args.input_comdirect) - ingest_b4w(path) + ingest_b4w(path_b4w) + ingest_comdirect(path_comdirect) if __name__ == "__main__": diff --git a/src/models.py b/src/models.py index 8d03906..804fc05 100644 --- a/src/models.py +++ b/src/models.py @@ -1,12 +1,18 @@ +import hashlib from datetime import date from decimal import Decimal from enum import Enum -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, computed_field, field_validator from src.settings.constants import IBAN_PATTERN, Currency +def _hash(*args): + raw = "".join(str(args)) + return hashlib.sha256(raw.encode()).hexdigest() + + class Sign(str, Enum): DEBIT = "DBIT" CREDIT = "CRDT" @@ -44,6 +50,11 @@ class B4wExport(BaseModel): ) notes: str = Field(alias="Notes") + @computed_field + @property + def hash_id(self) -> str: + return _hash(self.id) + @property def amount_signed(self) -> Decimal: return self.amount * self.sign.to_sign() @@ -67,6 +78,11 @@ class Category(BaseModel): name: str = Field(pattern=r"[^:\r\n]+") parent: Category | None = None + @computed_field + @property + def hash_id(self) -> str: + return _hash(self.name, self.parent) + @classmethod def from_str(cls, s: str) -> Category | None: if not s: @@ -124,6 +140,11 @@ class ComdirectGiro(ComdirectBase): ) text: str = Field(alias="Buchungstext") + @computed_field + @property + def hash_id(self) -> str: + return _hash(self.date_val, self.date_book, self.text) + @field_validator("date_book", mode="before") @classmethod def parse_date_book(cls, date_str: str) -> date | None: diff --git a/src/models_db.py b/src/models_db.py index aefe066..185b824 100644 --- a/src/models_db.py +++ b/src/models_db.py @@ -9,6 +9,9 @@ from sqlalchemy import ( ) from sqlalchemy.orm import DeclarativeBase, relationship +from settings import BANK_ACCOUNTS +from src.settings.models import AccountInfo + class BaseModelDb(DeclarativeBase): pass @@ -33,18 +36,24 @@ class CategoryDb(BaseModelDb): UniqueConstraint("name", "parent_id", name="uq_category_name_parent") -class B4wDb(BaseModelDb): - __tablename__ = "import_b4w" +class IngestdbBase(BaseModelDb): + __abstract__ = True id = Column(Integer, primary_key=True, autoincrement=True) - id_b4w = Column(Integer, nullable=False, unique=True) + hash_id = Column(String, nullable=False) account_iban = Column(String, nullable=False) account_currency = Column(String(3), nullable=False) date_valid = Column(Date, nullable=False) - date_book = Column(Date, nullable=True) amount = Column(Numeric(12, 2), nullable=False) amount_currency = Column(String(3), nullable=False) - reference = Column(String, nullable=True) + text = Column(String, nullable=True) + + +class B4wDb(IngestdbBase): + __tablename__ = "import_b4w" + + id_b4w = Column(Integer, nullable=False, unique=True) + date_book = Column(Date, nullable=True) other_name = Column(String, nullable=True) other_iban = Column(String, nullable=True) category_id = Column(Integer, ForeignKey("category.id"), nullable=True) @@ -52,3 +61,29 @@ class B4wDb(BaseModelDb): # relationship category = relationship("CategoryDb") + + +COMDIRECT_INFO = BANK_ACCOUNTS.get("comdirect") +assert isinstance(COMDIRECT_INFO, dict) + +COMDIRECT_GIRO_INFO = COMDIRECT_INFO.get("Girokonto", {}) +assert isinstance(COMDIRECT_GIRO_INFO, AccountInfo) + + +class ComdirectBaseDb(IngestdbBase): + __abstract__ = True + + account_iban = Column(String, nullable=False, default=COMDIRECT_GIRO_INFO.iban) + account_currency = Column( + String(3), nullable=False, default=COMDIRECT_GIRO_INFO.currency + ) + amount_currency = Column( + String(3), nullable=False, default=COMDIRECT_GIRO_INFO.currency + ) + + +class ComdirectGiroDb(ComdirectBaseDb): + __tablename__ = "comdirect_giro" + + type = Column(String, nullable=True) + date_book = Column(Date, nullable=True) diff --git a/src/reader.py b/src/reader.py index 4df5906..c35bf33 100644 --- a/src/reader.py +++ b/src/reader.py @@ -4,7 +4,7 @@ from enum import Enum, auto from pathlib import Path from settings import BANK_ACCOUNTS -from src.models import B4wExport +from src.models import B4wExport, ComdirectGiro from src.settings.models import AccountInfo @@ -13,7 +13,7 @@ def _reader_raw(path: Path): return list(csv.DictReader(fid)) -def reader_b4w(path: Path): +def reader_b4w(path: Path) -> list[B4wExport]: raw = _reader_raw(path) return list(map(B4wExport.model_validate, raw)) @@ -34,7 +34,7 @@ COMDIRECT_ACCOUNTS = BANK_ACCOUNTS.get("comdirect", {}) assert isinstance(COMDIRECT_ACCOUNTS, dict) -def reader_comdirect(path: Path): +def reader_comdirect(path: Path) -> dict[str, list[ComdirectGiro]]: result = {} raw = _reader_raw_comdirect(path) for account_name, account_data in raw.items():