heavy refactor

This commit is contained in:
2026-09-11 14:40:38 +02:00
parent f815ff2c0a
commit 69c13f93ac
20 changed files with 599 additions and 534 deletions
+7
View File
@@ -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
+54
View File
@@ -0,0 +1,54 @@
import hashlib
from datetime import date
from decimal import Decimal
from enum import Enum
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 _parse_decimal_german(decimal_str: str) -> Decimal:
return Decimal(decimal_str.replace(".", "").replace(",", "."))
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)
+120
View File
@@ -0,0 +1,120 @@
from datetime import date
from decimal import Decimal
from pydantic import BaseModel, 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, IngestdbBase, Sign, _hash
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")
@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("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
@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")
+88
View File
@@ -0,0 +1,88 @@
from datetime import date
from decimal import Decimal
from pydantic import BaseModel, Field, computed_field, field_validator
from sqlalchemy import Column, Date, String
from src.models._common import (
IngestdbBase,
_hash,
_parse_date_german,
_parse_decimal_german,
)
class ComdirectBase(BaseModel):
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) -> date | None:
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)
class ComdirectGiro(ComdirectBase):
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)
class ComdirectCreditCard(ComdirectGiro):
# "Buchungstag";"Umsatztag";"Vorgang";"Referenz";"Buchungstext";"Umsatz in EUR";
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
)
class ComdirectDepot(ComdirectBase):
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)
class ComdirectBaseDb(IngestdbBase):
__abstract__ = True
class ComdirectGiroDb(ComdirectBaseDb):
__tablename__ = "comdirect_giro"
type = Column(String, nullable=True)
date_book = Column(Date, nullable=True)