feat: Kontostand von der Bank – die eigene Rechnung wird geprueft

Certain rechnet den Saldo bisher ausschliesslich selbst aus: Anfangsbestand
plus alle Buchungen. Stimmt eine Buchung nicht, faellt das erst auf, wenn man
von Hand mit dem Auszug vergleicht.

Der von der Bank gemeldete Saldo wird jetzt mitgefuehrt und danebengestellt.
Weicht er ab, fehlt oder doppelt eine Buchung – und man sieht es auf der
Kontenseite und in den Einstellungen sofort, statt es zu suchen.

Banken melden mehrere Saldi nebeneinander; ausgewaehlt wird der gebuchte, denn
nur der ist mit Certains Rechnung vergleichbar. Ein verfuegbarer Saldo enthaelt
Vorgemerktes oder einen Ueberziehungsrahmen.

Die Saldi kommen auch fuer noch nicht zugeordnete Konten: Bei mehreren
Revolut-Pockets ist der Betrag oft das Einzige, woran man sie unterscheidet.
Ein Fehler bei einem Konto beendet den Durchlauf nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-26 23:08:56 +02:00
co-authored by Claude Opus 5
parent f684c752d8
commit 63de8368f0
13 changed files with 342 additions and 18 deletions
+7
View File
@@ -914,6 +914,13 @@ class BankAccountLink(Base):
# statt zu raten, wenn zwei Konten gleich aussehen.
raw_json: Mapped[str | None] = mapped_column(Text, nullable=True)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
# Saldo, wie ihn die Bank fuehrt. Er ersetzt Certains eigene Rechnung
# nicht, er prueft sie – weichen beide ab, fehlt oder doppelt eine Buchung.
balance_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
balance_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
balance_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
+38 -6
View File
@@ -7,7 +7,15 @@ from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import Account, AccountIdentifier, IdentifierKind, Transaction, TxnType, User
from ..models import (
Account,
AccountIdentifier,
BankAccountLink,
IdentifierKind,
Transaction,
TxnType,
User,
)
from ..schemas import AccountCreate, AccountIdentifierOut, AccountOrder, AccountOut, AccountUpdate
from ..services import balances, currency
from ..services.csv_import import normalize_text
@@ -29,7 +37,25 @@ def base_currency(db: Session, user: User) -> str:
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
def to_out(db: Session, acc: Account, base: str, on: date) -> AccountOut:
def _bank_saldi(db: Session, user: User) -> dict:
"""Bank-Saldo je Konto, einmal nachgeschlagen.
Als Sammelabfrage und nicht je Konto: Die Kontenliste wird auf jeder Seite
geladen, eine Abfrage pro Zeile summierte sich schnell.
"""
return {
v.account_id: v
for v in db.query(BankAccountLink).filter(
BankAccountLink.owner_id == user.id,
BankAccountLink.account_id.isnot(None),
BankAccountLink.balance_cents.isnot(None),
)
}
def to_out(db: Session, acc: Account, base: str, on: date,
bank_saldi: dict | None = None) -> AccountOut:
bank = (bank_saldi or {}).get(acc.id)
return AccountOut(
id=acc.id,
name=acc.name,
@@ -46,6 +72,8 @@ def to_out(db: Session, acc: Account, base: str, on: date) -> AccountOut:
],
balance_cents=balances.account_balance_cents(db, acc),
balance_base_cents=balances.account_balance_base_cents(db, acc, base, on),
bank_balance_cents=bank.balance_cents if bank else None,
bank_balance_at=bank.balance_at if bank else None,
)
@@ -87,7 +115,8 @@ def list_accounts(db: Session = Depends(get_db), user: User = Depends(get_curren
.order_by(Account.position, Account.name)
.all()
)
return [to_out(db, a, base, today) for a in accounts]
saldi = _bank_saldi(db, user)
return [to_out(db, a, base, today, saldi) for a in accounts]
@router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED)
@@ -115,7 +144,8 @@ def create_account(
db.add(acc)
db.commit()
db.refresh(acc)
return to_out(db, acc, base_currency(db, user), date.today())
return to_out(db, acc, base_currency(db, user), date.today(),
_bank_saldi(db, user))
@router.get("/{account_id}", response_model=AccountOut)
@@ -123,7 +153,8 @@ def get_account(
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
acc = get_owned(db, Account, account_id, user)
return to_out(db, acc, base_currency(db, user), date.today())
return to_out(db, acc, base_currency(db, user), date.today(),
_bank_saldi(db, user))
@router.patch("/{account_id}", response_model=AccountOut)
@@ -143,7 +174,8 @@ def update_account(
_set_identifiers(acc, payload.identifiers)
db.commit()
db.refresh(acc)
return to_out(db, acc, base_currency(db, user), date.today())
return to_out(db, acc, base_currency(db, user), date.today(),
_bank_saldi(db, user))
@router.put("/order", status_code=status.HTTP_204_NO_CONTENT)
+17
View File
@@ -127,6 +127,23 @@ def sync(connection_id: int, db: Session = Depends(get_db),
raise _fehler(e) from e
@router.post("/connections/{connection_id}/salden")
def refresh_balances(connection_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Nur die Saldi auffrischen, ohne Umsätze zu holen.
Eigener Endpunkt, weil das auch vor jeder Zuordnung nützt: Bei mehreren
Pockets ist der Betrag oft das Einzige, woran man sie unterscheidet.
"""
verbindung = get_owned(db, BankConnection, connection_id, user)
try:
return {"saldi": bank_sync.hole_salden(db, user, verbindung)}
except BankFehler as e:
verbindung.last_error = str(e)[:500]
db.commit()
raise _fehler(e) from e
@router.delete("/connections/{connection_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_connection(connection_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
+7
View File
@@ -200,6 +200,10 @@ class AccountOut(BaseModel):
identifiers: list[AccountIdentifierOut]
balance_cents: int
balance_base_cents: int
# Saldo, wie ihn die Bank fuehrt – nur bei angebundenen Konten gesetzt. Er
# ersetzt die eigene Rechnung nicht, er prueft sie.
bank_balance_cents: int | None = None
bank_balance_at: datetime | None = None
# --- Kategorien ------------------------------------------------------------
@@ -656,6 +660,9 @@ class BankAccountLinkOut(BaseModel):
other_id: str | None = None
bank_felder: dict = {}
currency: str
balance_cents: int | None = None
balance_type: str | None = None
balance_at: datetime | None = None
account_id: int | None
model_config = ConfigDict(from_attributes=True)
+80 -3
View File
@@ -319,8 +319,86 @@ def _text(umsatz: dict, ausgabe: bool) -> tuple[str | None, str | None]:
str(zweck)[:255] if zweck else None)
# Saldo-Arten nach Aussagekraft. Gebucht schlaegt verfuegbar: Certain rechnet
# mit gebuchten Bewegungen. Ein "verfuegbarer" Saldo enthaelt Vorgemerktes oder
# einen Ueberziehungsrahmen und waere mit der eigenen Rechnung nicht vergleichbar.
SALDO_RANG = ("CLBD", "ITBD", "PRCD", "ITAV", "XPCD", "FWAV", "OTHR")
def _zeitpunkt(saldo: dict) -> datetime | None:
"""Wann der Saldo galt. Ohne Zeitpunkt ist eine Abweichung nicht deutbar –
ein Saldo von gestern darf von heute abweichen."""
roh = saldo.get("last_change_date_time") or saldo.get("reference_date")
if not roh:
return None
try:
wert = datetime.fromisoformat(str(roh).replace("Z", "+00:00"))
except ValueError:
return None
return wert if wert.tzinfo else wert.replace(tzinfo=timezone.utc)
def _saldo(salden: list[dict]) -> tuple[int | None, str | None, datetime | None]:
"""Der aussagekraeftigste der gemeldeten Saldi.
Banken liefern mehrere nebeneinander. Welcher gemeint ist, steht in
``balance_type``; einfach den ersten zu nehmen waere Zufall.
"""
beste = None
for eintrag in salden or []:
art = str(eintrag.get("balance_type") or "").upper()
betrag = (eintrag.get("balance_amount") or {}).get("amount")
if betrag is None:
continue
cents = csv_import.parse_amount(str(betrag), ".")
if cents is None:
continue
rang = SALDO_RANG.index(art) if art in SALDO_RANG else len(SALDO_RANG)
if beste is None or rang < beste[0]:
beste = (rang, cents, art or None, _zeitpunkt(eintrag))
if beste is None:
return None, None, None
return beste[1], beste[2], beste[3]
def hole_salden(db: Session, user: User, verbindung: BankConnection,
client: BankClient | None = None) -> int:
"""Den von der Bank gefuehrten Saldo je Konto auffrischen.
Auch fuer noch nicht zugeordnete Konten: Bei Revolut-Pockets ist der Betrag
oft das Einzige, woran man erkennt, welches Pocket man vor sich hat.
Ein Fehler bei einem Konto beendet den Durchlauf nicht. Sonst blieben wegen
eines nicht freigegebenen Kontos alle uebrigen Saldi leer.
"""
client = client or _client(db, user)
aktualisiert = 0
for verknuepfung in db.query(BankAccountLink).filter(
BankAccountLink.connection_id == verbindung.id,
BankAccountLink.owner_id == user.id,
):
try:
cents, art, stand = _saldo(client.salden(verknuepfung.remote_uid))
except BankFehler as e:
log.warning("Saldo fuer %s nicht abrufbar: %s", verknuepfung.remote_uid, e)
continue
if cents is None:
continue
verknuepfung.balance_cents = cents
verknuepfung.balance_type = art
verknuepfung.balance_at = stand
aktualisiert += 1
db.commit()
return aktualisiert
def hole_umsaetze(db: Session, user: User, verbindung: BankConnection) -> dict:
"""Umsätze aller zugeordneten Konten dieser Verbindung in den Eingang holen."""
client = _client(db, user)
# Zuerst die Saldi – auch fuer nicht zugeordnete Konten. Sonst saehe man
# bei drei gleich aussehenden Pockets nie, welches welches ist.
saldi = hole_salden(db, user, verbindung, client)
verknuepfungen = [
v for v in db.query(BankAccountLink).filter(
BankAccountLink.connection_id == verbindung.id,
@@ -329,9 +407,8 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection) -> dict:
if v.account_id is not None
]
if not verknuepfungen:
return {"geholt": 0, "neu": 0, "ohne_zuordnung": True}
return {"geholt": 0, "neu": 0, "saldi": saldi, "ohne_zuordnung": True}
client = _client(db, user)
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
seit = (verbindung.last_sync_at.date() - timedelta(days=3)
if verbindung.last_sync_at
@@ -395,7 +472,7 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection) -> dict:
verbindung.last_sync_at = datetime.now(timezone.utc)
verbindung.last_error = None
db.commit()
return {"geholt": geholt, "neu": neu, "ohne_zuordnung": False}
return {"geholt": geholt, "neu": neu, "saldi": saldi, "ohne_zuordnung": False}
def _datum(umsatz: dict) -> date | None: