Transactions table (#7)
Tests / test (push) Successful in 10s
Tests / lint (push) Successful in 10s

Reviewed-on: #7
Co-authored-by: Mathias <mathias2784@web.de>
This commit was merged in pull request #7.
This commit is contained in:
2026-10-10 20:47:51 +00:00
committed by mathias2784
parent fdc66037ce
commit 6d98939024
17 changed files with 729 additions and 67 deletions
+2
View File
@@ -5,3 +5,5 @@ 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
from src.models.transactions import Transaction as Transaction
from src.models.transactions import TransactionDb as TransactionDb
+10 -10
View File
@@ -4,8 +4,8 @@ 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
from sqlalchemy import Date, Numeric, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
def _hash(*args):
@@ -89,11 +89,11 @@ class BaseModelDb(DeclarativeBase):
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)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
hash_id: Mapped[str] = mapped_column(String, nullable=False)
account_iban: Mapped[str] = mapped_column(String, nullable=False)
account_currency: Mapped[str] = mapped_column(String(3), nullable=False)
date_valid: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
amount_currency: Mapped[str] = mapped_column(String(3), nullable=False)
text: Mapped[str | None] = mapped_column(String, nullable=True)
+18 -16
View File
@@ -2,8 +2,8 @@ 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 sqlalchemy import Date, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.constants import IBAN_PATTERN, Currency
from src.models._common import (
@@ -56,7 +56,7 @@ class B4wExport(ExportBaseModel):
"Notes": f"{self.notes}",
}
@computed_field
@computed_field # type: ignore[prop-decorator]
@property
def hash_id(self) -> str:
return _hash(self.id)
@@ -91,7 +91,7 @@ class Category(ExportBaseModel):
name: str = Field(pattern=r"[^:\r\n]+")
parent: Category | None = None
@computed_field
@computed_field # type: ignore[prop-decorator]
@property
def hash_id(self) -> str:
return _hash(self.name, self.parent)
@@ -99,7 +99,7 @@ class Category(ExportBaseModel):
@classmethod
def from_str(cls, s: str) -> Category | None:
if not s:
return
return None
components = s.split(":")
name = components[-1]
rest = ":".join(components[:-1])
@@ -124,9 +124,11 @@ class CategoryDb(BaseModelDb):
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)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, nullable=False)
parent_id: Mapped[int | None] = mapped_column(
ForeignKey("category.id"), nullable=True
)
# Relationships
parent = relationship(
@@ -134,18 +136,18 @@ class CategoryDb(BaseModelDb):
)
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)
id_b4w: Mapped[int] = mapped_column(Integer, nullable=False, unique=True)
date_book: Mapped[date | None] = mapped_column(Date, nullable=True)
other_name: Mapped[str | None] = mapped_column(String, nullable=True)
other_iban: Mapped[str | None] = mapped_column(String, nullable=True)
category_id: Mapped[int | None] = mapped_column(
ForeignKey("category.id"), nullable=True
)
notes: Mapped[str | None] = mapped_column(String, nullable=True)
# relationship
category = relationship("CategoryDb")
+6 -8
View File
@@ -2,7 +2,8 @@ from datetime import date
from decimal import Decimal
from pydantic import ConfigDict, Field, computed_field, field_validator
from sqlalchemy import Column, Date, String
from sqlalchemy import Date, String
from sqlalchemy.orm import Mapped, mapped_column
from src.models._common import (
ExportBaseModel,
@@ -26,7 +27,7 @@ class ComdirectBase(ExportBaseModel):
@classmethod
def parse_date_val(cls, date_str: str | None) -> date | None:
if date_str is None:
return
return None
return _parse_date_german(date_str)
@field_validator("amount_eur", mode="before")
@@ -68,7 +69,7 @@ class ComdirectGiro(ComdirectBase):
)
text: str = Field(alias="Buchungstext")
@computed_field
@computed_field # type: ignore[prop-decorator]
@property
def hash_id(self) -> str:
return _hash(self.date_val, self.date_book, self.text)
@@ -103,9 +104,6 @@ class ComdirectCreditCard(ComdirectGiro):
)
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))
@@ -155,5 +153,5 @@ class ComdirectBaseDb(IngestdbBase):
class ComdirectGiroDb(ComdirectBaseDb):
__tablename__ = "comdirect_giro"
type = Column(String, nullable=True)
date_book = Column(Date, nullable=True)
type: Mapped[str | None] = mapped_column(String, nullable=True)
date_book: Mapped[date | None] = mapped_column(Date, nullable=True)
+78
View File
@@ -0,0 +1,78 @@
from datetime import date
from decimal import Decimal
from pydantic import Field, computed_field
from sqlalchemy import Date, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models._common import ExportBaseModel, IngestdbBase, _hash, date2str
class Transaction(ExportBaseModel):
"""Canonical, source-agnostic representation of a bank transaction.
Both input sources (b4w, comdirect giro) are projected into this model.
The deduplication key is a business-key hash of the transaction
attributes, so re-importing an export is idempotent even if
source-internal ids change between exports.
"""
source: str = Field(description="Source name", examples=["b4w", "comdirect_giro"])
source_row_id: int = Field(description="Primary key of the staging table row")
account_iban: str
account_currency: str
date_valid: date
amount: Decimal = Field(description="Signed amount; negative = money out")
amount_currency: str
date_book: date | None = None
text: str | None = None
other_name: str | None = None
other_iban: str | None = None
category_id: int | None = None
@computed_field # type: ignore[prop-decorator]
@property
def hash_id(self) -> str:
return _hash(
self.source,
self.account_iban,
self.date_valid,
f"{self.amount:.2f}",
self.text,
self.other_name,
)
def parameters_to_dict(self) -> dict[str, str]:
return {
"source": f"{self.source}",
"source_row_id": f"{self.source_row_id}",
"account_iban": f"{self.account_iban}",
"account_currency": f"{self.account_currency}",
"date_valid": date2str(self.date_valid),
"amount": f"{self.amount}",
"amount_currency": f"{self.amount_currency}",
"date_book": date2str(self.date_book),
"text": f"{self.text or ''}",
"other_name": f"{self.other_name or ''}",
"other_iban": f"{self.other_iban or ''}",
"category_id": f"{self.category_id}",
}
class TransactionDb(IngestdbBase):
__tablename__ = "transactions"
__table_args__ = (
UniqueConstraint("hash_id", name="uq_transactions_hash"),
Index("ix_transactions_source_row_id", "source", "source_row_id"),
)
source: Mapped[str] = mapped_column(String, nullable=False)
source_row_id: Mapped[int] = mapped_column(Integer, nullable=False)
date_book: Mapped[date | None] = mapped_column(Date, nullable=True)
other_name: Mapped[str | None] = mapped_column(String, nullable=True)
other_iban: Mapped[str | None] = mapped_column(String, nullable=True)
category_id: Mapped[int | None] = mapped_column(
ForeignKey("category.id"), nullable=True
)
category = relationship("CategoryDb")