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:
@@ -914,6 +914,13 @@ class BankAccountLink(Base):
|
|||||||
# statt zu raten, wenn zwei Konten gleich aussehen.
|
# statt zu raten, wenn zwei Konten gleich aussehen.
|
||||||
raw_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
raw_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
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(
|
account_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, get_owned
|
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 ..schemas import AccountCreate, AccountIdentifierOut, AccountOrder, AccountOut, AccountUpdate
|
||||||
from ..services import balances, currency
|
from ..services import balances, currency
|
||||||
from ..services.csv_import import normalize_text
|
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"
|
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(
|
return AccountOut(
|
||||||
id=acc.id,
|
id=acc.id,
|
||||||
name=acc.name,
|
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_cents=balances.account_balance_cents(db, acc),
|
||||||
balance_base_cents=balances.account_balance_base_cents(db, acc, base, on),
|
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)
|
.order_by(Account.position, Account.name)
|
||||||
.all()
|
.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)
|
@router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -115,7 +144,8 @@ def create_account(
|
|||||||
db.add(acc)
|
db.add(acc)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(acc)
|
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)
|
@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)
|
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
acc = get_owned(db, Account, account_id, 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)
|
@router.patch("/{account_id}", response_model=AccountOut)
|
||||||
@@ -143,7 +174,8 @@ def update_account(
|
|||||||
_set_identifiers(acc, payload.identifiers)
|
_set_identifiers(acc, payload.identifiers)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(acc)
|
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)
|
@router.put("/order", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -127,6 +127,23 @@ def sync(connection_id: int, db: Session = Depends(get_db),
|
|||||||
raise _fehler(e) from e
|
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)
|
@router.delete("/connections/{connection_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_connection(connection_id: int, db: Session = Depends(get_db),
|
def delete_connection(connection_id: int, db: Session = Depends(get_db),
|
||||||
user: User = Depends(get_current_user)):
|
user: User = Depends(get_current_user)):
|
||||||
|
|||||||
@@ -200,6 +200,10 @@ class AccountOut(BaseModel):
|
|||||||
identifiers: list[AccountIdentifierOut]
|
identifiers: list[AccountIdentifierOut]
|
||||||
balance_cents: int
|
balance_cents: int
|
||||||
balance_base_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 ------------------------------------------------------------
|
# --- Kategorien ------------------------------------------------------------
|
||||||
@@ -656,6 +660,9 @@ class BankAccountLinkOut(BaseModel):
|
|||||||
other_id: str | None = None
|
other_id: str | None = None
|
||||||
bank_felder: dict = {}
|
bank_felder: dict = {}
|
||||||
currency: str
|
currency: str
|
||||||
|
balance_cents: int | None = None
|
||||||
|
balance_type: str | None = None
|
||||||
|
balance_at: datetime | None = None
|
||||||
account_id: int | None
|
account_id: int | None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|||||||
@@ -319,8 +319,86 @@ def _text(umsatz: dict, ausgabe: bool) -> tuple[str | None, str | None]:
|
|||||||
str(zweck)[:255] if zweck else 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:
|
def hole_umsaetze(db: Session, user: User, verbindung: BankConnection) -> dict:
|
||||||
"""Umsätze aller zugeordneten Konten dieser Verbindung in den Eingang holen."""
|
"""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 = [
|
verknuepfungen = [
|
||||||
v for v in db.query(BankAccountLink).filter(
|
v for v in db.query(BankAccountLink).filter(
|
||||||
BankAccountLink.connection_id == verbindung.id,
|
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 v.account_id is not None
|
||||||
]
|
]
|
||||||
if not verknuepfungen:
|
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)}
|
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)
|
seit = (verbindung.last_sync_at.date() - timedelta(days=3)
|
||||||
if verbindung.last_sync_at
|
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_sync_at = datetime.now(timezone.utc)
|
||||||
verbindung.last_error = None
|
verbindung.last_error = None
|
||||||
db.commit()
|
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:
|
def _datum(umsatz: dict) -> date | None:
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Kontostand, wie ihn die Bank fuehrt.
|
||||||
|
|
||||||
|
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 – genau daran ist hier schon ein Abend
|
||||||
|
draufgegangen.
|
||||||
|
|
||||||
|
Der von der Bank gemeldete Saldo wird deshalb mitgefuehrt. Er ersetzt die
|
||||||
|
eigene Rechnung nicht, er prueft sie: Weichen beide voneinander ab, fehlt oder
|
||||||
|
doppelt eine Buchung, und man sieht es sofort.
|
||||||
|
|
||||||
|
Revision ID: 0023
|
||||||
|
Revises: 0022
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0023"
|
||||||
|
down_revision = "0022"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("bank_account_links") as b:
|
||||||
|
b.add_column(sa.Column("balance_cents", sa.Integer(), nullable=True))
|
||||||
|
b.add_column(sa.Column("balance_type", sa.String(length=16), nullable=True))
|
||||||
|
b.add_column(sa.Column("balance_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("bank_account_links") as b:
|
||||||
|
b.drop_column("balance_at")
|
||||||
|
b.drop_column("balance_type")
|
||||||
|
b.drop_column("balance_cents")
|
||||||
@@ -31,8 +31,11 @@ PEM = SCHLUESSEL.private_bytes(
|
|||||||
class FakeBank:
|
class FakeBank:
|
||||||
"""Minimale Gegenstelle: nur was ``BankClient`` tatsächlich aufruft."""
|
"""Minimale Gegenstelle: nur was ``BankClient`` tatsächlich aufruft."""
|
||||||
|
|
||||||
def __init__(self, umsaetze=None, konten=None, seiten=1):
|
def __init__(self, umsaetze=None, konten=None, seiten=1, salden=None):
|
||||||
self.umsaetze = umsaetze or []
|
self.umsaetze = umsaetze or []
|
||||||
|
self.salden = salden if salden is not None else [
|
||||||
|
{"balance_amount": {"amount": "100.00", "currency": "EUR"}},
|
||||||
|
]
|
||||||
self.konten = konten if konten is not None else [{
|
self.konten = konten if konten is not None else [{
|
||||||
"uid": "acc-1", "name": "Girokonto", "currency": "EUR",
|
"uid": "acc-1", "name": "Girokonto", "currency": "EUR",
|
||||||
"account_id": {"iban": "DE02120300000000202051"},
|
"account_id": {"iban": "DE02120300000000202051"},
|
||||||
@@ -60,8 +63,7 @@ class FakeBank:
|
|||||||
return 200, {"session_id": "sess-1", "accounts": self.konten,
|
return 200, {"session_id": "sess-1", "accounts": self.konten,
|
||||||
"access": {"valid_until": "2027-01-01T00:00:00+00:00"}}
|
"access": {"valid_until": "2027-01-01T00:00:00+00:00"}}
|
||||||
if "/balances" in pfad:
|
if "/balances" in pfad:
|
||||||
return 200, {"balances": [{"balance_amount": {"amount": "100.00",
|
return 200, {"balances": self.salden}
|
||||||
"currency": "EUR"}}]}
|
|
||||||
if "/transactions" in pfad:
|
if "/transactions" in pfad:
|
||||||
# Blättern nachstellen: erst mit Fortsetzungsschlüssel, dann ohne.
|
# Blättern nachstellen: erst mit Fortsetzungsschlüssel, dann ohne.
|
||||||
hat_schluessel = "continuation_key=" in pfad
|
hat_schluessel = "continuation_key=" in pfad
|
||||||
@@ -611,3 +613,64 @@ def test_merkmale_werden_beim_neuverbinden_aufgefrischt(admin, fake_bank):
|
|||||||
danach = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0]
|
danach = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0]
|
||||||
assert danach["product"] == "Savings"
|
assert danach["product"] == "Savings"
|
||||||
assert danach["remote_name"] == "Urlaub"
|
assert danach["remote_name"] == "Urlaub"
|
||||||
|
|
||||||
|
|
||||||
|
def test_saldo_der_bank_wird_gefuehrt_und_gebuchter_bevorzugt(admin, fake_bank):
|
||||||
|
"""Banken melden mehrere Saldi nebeneinander. Den ersten zu nehmen waere
|
||||||
|
Zufall – vergleichbar mit Certains eigener Rechnung ist nur der gebuchte."""
|
||||||
|
_app(admin)
|
||||||
|
uid = f"acc-{secrets.token_hex(4)}"
|
||||||
|
fake_bank(
|
||||||
|
konten=[{"uid": uid, "name": "Girokonto", "currency": "EUR",
|
||||||
|
"account_id": {"iban": "DE02120300000000202051"}}],
|
||||||
|
salden=[
|
||||||
|
# Verfuegbar steht zuerst und ist hoeher – er enthaelt den
|
||||||
|
# Ueberziehungsrahmen und darf gerade NICHT gewinnen.
|
||||||
|
{"balance_type": "ITAV", "balance_amount": {"amount": "1500.00",
|
||||||
|
"currency": "EUR"}},
|
||||||
|
{"balance_type": "CLBD", "balance_amount": {"amount": "342.15",
|
||||||
|
"currency": "EUR"},
|
||||||
|
"reference_date": "2026-08-25"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
state = admin.post("/api/bank/connect",
|
||||||
|
json={"aspsp_name": f"Saldobank-{secrets.token_hex(3)}",
|
||||||
|
"country": "DE"}).json()["state"]
|
||||||
|
# Die Kennung aus der Rueckmeldung nehmen, nicht die zuletzt angelegte
|
||||||
|
# Verbindung: Die Testdatenbank lebt ueber den ganzen Lauf.
|
||||||
|
verbindung_id = admin.post(
|
||||||
|
f"/api/bank/callback?state={state}&code=guter-code").json()["connection_id"]
|
||||||
|
|
||||||
|
r = admin.post(f"/api/bank/connections/{verbindung_id}/salden")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["saldi"] == 1
|
||||||
|
|
||||||
|
link = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0]
|
||||||
|
assert link["balance_cents"] == 34215
|
||||||
|
assert link["balance_type"] == "CLBD"
|
||||||
|
assert link["balance_at"].startswith("2026-08-25")
|
||||||
|
|
||||||
|
|
||||||
|
def test_saldo_kommt_auch_ohne_zuordnung(admin, fake_bank):
|
||||||
|
"""Genau dann braucht man ihn: Bei mehreren Pockets ist der Betrag oft das
|
||||||
|
Einzige, woran man erkennt, welches man vor sich hat."""
|
||||||
|
_app(admin)
|
||||||
|
uid = f"acc-{secrets.token_hex(4)}"
|
||||||
|
fake_bank(
|
||||||
|
konten=[{"uid": uid, "currency": "CHF", "product": "Steuern"}],
|
||||||
|
salden=[{"balance_type": "CLBD",
|
||||||
|
"balance_amount": {"amount": "2480.00", "currency": "CHF"}}],
|
||||||
|
)
|
||||||
|
state = admin.post("/api/bank/connect",
|
||||||
|
json={"aspsp_name": f"Revolut-{secrets.token_hex(3)}",
|
||||||
|
"country": "LT"}).json()["state"]
|
||||||
|
verbindung_id = admin.post(
|
||||||
|
f"/api/bank/callback?state={state}&code=guter-code").json()["connection_id"]
|
||||||
|
|
||||||
|
r = admin.post(f"/api/bank/connections/{verbindung_id}/sync")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
# Kein Konto zugeordnet – trotzdem ist der Saldo da.
|
||||||
|
assert r.json()["ohne_zuordnung"] is True
|
||||||
|
assert r.json()["saldi"] == 1
|
||||||
|
link = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0]
|
||||||
|
assert link["balance_cents"] == 248000
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ export const api = {
|
|||||||
{ method: "POST" }),
|
{ method: "POST" }),
|
||||||
listBankConnections: () => request("/bank/connections"),
|
listBankConnections: () => request("/bank/connections"),
|
||||||
syncBankConnection: (id) => request(`/bank/connections/${id}/sync`, { method: "POST" }),
|
syncBankConnection: (id) => request(`/bank/connections/${id}/sync`, { method: "POST" }),
|
||||||
|
refreshBankBalances: (id) =>
|
||||||
|
request(`/bank/connections/${id}/salden`, { method: "POST" }),
|
||||||
deleteBankConnection: (id) => request(`/bank/connections/${id}`, { method: "DELETE" }),
|
deleteBankConnection: (id) => request(`/bank/connections/${id}`, { method: "DELETE" }),
|
||||||
listBankLinks: () => request("/bank/links"),
|
listBankLinks: () => request("/bank/links"),
|
||||||
setBankLinkAccount: (linkId, accountId) =>
|
setBankLinkAccount: (linkId, accountId) =>
|
||||||
|
|||||||
@@ -1147,3 +1147,9 @@ tr.row-dup {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Abgleich des selbst gerechneten Saldos mit dem, den die Bank fuehrt.
|
||||||
|
Eine Abweichung ist kein Fehler der Bank, sondern ein Hinweis auf eine
|
||||||
|
fehlende oder doppelte Buchung – deshalb warnend, nicht rot. */
|
||||||
|
.saldo-ok { color: var(--ok); }
|
||||||
|
.saldo-abweichung { color: var(--warn); font-weight: 560; }
|
||||||
|
|||||||
@@ -171,7 +171,9 @@
|
|||||||
"openingDateHint": "Stand am Ende dieses Tages. Buchungen bis dahin zählen nicht zum Kontostand, bleiben aber in Liste und Auswertungen. Ohne Stichtag zählt alles.",
|
"openingDateHint": "Stand am Ende dieses Tages. Buchungen bis dahin zählen nicht zum Kontostand, bleiben aber in Liste und Auswertungen. Ohne Stichtag zählt alles.",
|
||||||
"shortIdentWarning": "Kurze Erkennungstexte treffen leicht Fremdes: „Euro“ steckt auch in „Europa-Park“ oder „PayPal Europe“. Solche Buchungen werden dann fälschlich zu Überträgen. Nimm lieber etwas Eindeutiges wie die IBAN oder „Revolut**5064*“.",
|
"shortIdentWarning": "Kurze Erkennungstexte treffen leicht Fremdes: „Euro“ steckt auch in „Europa-Park“ oder „PayPal Europe“. Solche Buchungen werden dann fälschlich zu Überträgen. Nimm lieber etwas Eindeutiges wie die IBAN oder „Revolut**5064*“.",
|
||||||
"moveUp": "Nach oben",
|
"moveUp": "Nach oben",
|
||||||
"moveDown": "Nach unten"
|
"moveDown": "Nach unten",
|
||||||
|
"bankBalanceMatches": "Saldo stimmt mit der Bank überein",
|
||||||
|
"bankBalanceDiffers": "Bank: {{bank}} · Certain weicht um {{diff}} ab"
|
||||||
},
|
},
|
||||||
"accountTypes": {
|
"accountTypes": {
|
||||||
"girokonto": "Girokonto",
|
"girokonto": "Girokonto",
|
||||||
@@ -612,7 +614,12 @@
|
|||||||
"sameLetter": "Alles, was in der Liste mit „{{buchstabe}}“ beginnt:",
|
"sameLetter": "Alles, was in der Liste mit „{{buchstabe}}“ beginnt:",
|
||||||
"pickBankMore": "Die besten {{n}} von {{treffer}} Treffern – such genauer, wenn deine Bank nicht dabei ist:",
|
"pickBankMore": "Die besten {{n}} von {{treffer}} Treffern – such genauer, wenn deine Bank nicht dabei ist:",
|
||||||
"noAccountForCurrency": "Kein Konto in {{currency}} vorhanden. Leg zuerst eines an, dann lässt sich dieses Bankkonto zuordnen.",
|
"noAccountForCurrency": "Kein Konto in {{currency}} vorhanden. Leg zuerst eines an, dann lässt sich dieses Bankkonto zuordnen.",
|
||||||
"rawFields": "Was die Bank geschickt hat"
|
"rawFields": "Was die Bank geschickt hat",
|
||||||
|
"bankBalance": "Saldo laut Bank",
|
||||||
|
"balanceMatches": "stimmt mit Certain überein",
|
||||||
|
"balanceDiffers": "Certain weicht um {{diff}} ab",
|
||||||
|
"fetchBalances": "Salden holen",
|
||||||
|
"balancesFetched": "{{n}} Salden aktualisiert."
|
||||||
},
|
},
|
||||||
"bankReturn": {
|
"bankReturn": {
|
||||||
"title": "Rückkehr von der Bank",
|
"title": "Rückkehr von der Bank",
|
||||||
|
|||||||
@@ -171,7 +171,9 @@
|
|||||||
"openingDateHint": "Balance at the end of that day. Bookings up to then don't count toward the balance but stay in the list and reports. Without a date, everything counts.",
|
"openingDateHint": "Balance at the end of that day. Bookings up to then don't count toward the balance but stay in the list and reports. Without a date, everything counts.",
|
||||||
"shortIdentWarning": "Short match texts hit unrelated bookings: “Euro” also occurs in “Europa-Park” or “PayPal Europe”, turning those into transfers by mistake. Prefer something unique such as the IBAN.",
|
"shortIdentWarning": "Short match texts hit unrelated bookings: “Euro” also occurs in “Europa-Park” or “PayPal Europe”, turning those into transfers by mistake. Prefer something unique such as the IBAN.",
|
||||||
"moveUp": "Move up",
|
"moveUp": "Move up",
|
||||||
"moveDown": "Move down"
|
"moveDown": "Move down",
|
||||||
|
"bankBalanceMatches": "Balance matches the bank",
|
||||||
|
"bankBalanceDiffers": "Bank: {{bank}} · Certain differs by {{diff}}"
|
||||||
},
|
},
|
||||||
"accountTypes": {
|
"accountTypes": {
|
||||||
"girokonto": "Checking",
|
"girokonto": "Checking",
|
||||||
@@ -612,7 +614,12 @@
|
|||||||
"sameLetter": "Everything in the list starting with “{{buchstabe}}”:",
|
"sameLetter": "Everything in the list starting with “{{buchstabe}}”:",
|
||||||
"pickBankMore": "The best {{n}} of {{treffer}} matches – search more precisely if yours is not among them:",
|
"pickBankMore": "The best {{n}} of {{treffer}} matches – search more precisely if yours is not among them:",
|
||||||
"noAccountForCurrency": "No {{currency}} account yet. Create one first, then this bank account can be mapped.",
|
"noAccountForCurrency": "No {{currency}} account yet. Create one first, then this bank account can be mapped.",
|
||||||
"rawFields": "What the bank sent"
|
"rawFields": "What the bank sent",
|
||||||
|
"bankBalance": "Balance per bank",
|
||||||
|
"balanceMatches": "matches Certain",
|
||||||
|
"balanceDiffers": "Certain differs by {{diff}}",
|
||||||
|
"fetchBalances": "Fetch balances",
|
||||||
|
"balancesFetched": "{{n}} balances updated."
|
||||||
},
|
},
|
||||||
"bankReturn": {
|
"bankReturn": {
|
||||||
"title": "Back from your bank",
|
"title": "Back from your bank",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import IconChip from "../components/IconChip";
|
|||||||
import Money from "../components/Money";
|
import Money from "../components/Money";
|
||||||
import Modal from "../components/Modal";
|
import Modal from "../components/Modal";
|
||||||
import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker";
|
import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker";
|
||||||
import { centsToInput, formatMoney, parseAmountToCents } from "../format";
|
import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format";
|
||||||
|
|
||||||
const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"];
|
const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"];
|
||||||
const TYPE_ICON = {
|
const TYPE_ICON = {
|
||||||
@@ -248,6 +248,28 @@ export default function Accounts() {
|
|||||||
{t(`accountTypes.${a.type}`)} · {a.currency}
|
{t(`accountTypes.${a.type}`)} · {a.currency}
|
||||||
{a.identifiers.length > 0 && ` · ${a.identifiers.length} ${t("accounts.identShort")}`}
|
{a.identifiers.length > 0 && ` · ${a.identifiers.length} ${t("accounts.identShort")}`}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Bei angebundenen Konten der Saldo, den die Bank fuehrt.
|
||||||
|
Er ersetzt die eigene Rechnung nicht, er prueft sie: Eine
|
||||||
|
Abweichung heisst, dass eine Buchung fehlt oder doppelt
|
||||||
|
ist – sonst faellt das erst beim Vergleich von Hand auf. */}
|
||||||
|
{a.bank_balance_cents != null && (
|
||||||
|
<div className="named-sub">
|
||||||
|
{a.bank_balance_cents === a.balance_cents ? (
|
||||||
|
<span className="saldo-ok">
|
||||||
|
{t("accounts.bankBalanceMatches")}
|
||||||
|
{a.bank_balance_at ? ` · ${formatDate(a.bank_balance_at)}` : ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="saldo-abweichung">
|
||||||
|
{t("accounts.bankBalanceDiffers", {
|
||||||
|
bank: formatMoney(a.bank_balance_cents, a.currency),
|
||||||
|
diff: formatMoney(a.balance_cents - a.bank_balance_cents,
|
||||||
|
a.currency),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Money cents={a.balance_cents} currency={a.currency}
|
<Money cents={a.balance_cents} currency={a.currency}
|
||||||
baseCents={a.balance_base_cents} baseCurrency={baseCurrency} />
|
baseCents={a.balance_base_cents} baseCurrency={baseCurrency} />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useToast } from "../../toast";
|
|||||||
import { useConfirm } from "../../confirm";
|
import { useConfirm } from "../../confirm";
|
||||||
import Icon from "../../components/Icon";
|
import Icon from "../../components/Icon";
|
||||||
import Modal from "../../components/Modal";
|
import Modal from "../../components/Modal";
|
||||||
import { formatDate } from "../../format";
|
import { formatDate, formatMoney } from "../../format";
|
||||||
|
|
||||||
// Länder, in denen die Banken dieses Nutzers liegen können. Die Schweiz fehlt
|
// Länder, in denen die Banken dieses Nutzers liegen können. Die Schweiz fehlt
|
||||||
// bewusst: Sie fällt nicht unter PSD2, dort gibt es keine Schnittstelle.
|
// bewusst: Sie fällt nicht unter PSD2, dort gibt es keine Schnittstelle.
|
||||||
@@ -100,6 +100,19 @@ export default function Bankanbindung() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saldenHolen(v) {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await api.refreshBankBalances(v.id);
|
||||||
|
toast(t("bank.balancesFetched", { n: r.saldi }));
|
||||||
|
laden();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function abrufen(v) {
|
async function abrufen(v) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -231,6 +244,31 @@ export default function Bankanbindung() {
|
|||||||
: t("bank.noAccountForCurrency", { currency: l.currency })}
|
: t("bank.noAccountForCurrency", { currency: l.currency })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{l.balance_cents != null && (
|
||||||
|
<p className="muted small" style={{ margin: "4px 0 0" }}>
|
||||||
|
{t("bank.bankBalance")}:{" "}
|
||||||
|
<strong>{formatMoney(l.balance_cents, l.currency)}</strong>
|
||||||
|
{l.balance_at ? ` · ${formatDate(l.balance_at)}` : ""}
|
||||||
|
{/* Der eigentliche Nutzen: Certain rechnet den Saldo selbst
|
||||||
|
aus. Weicht er ab, fehlt oder doppelt eine Buchung –
|
||||||
|
das sieht man sonst erst beim Vergleich von Hand. */}
|
||||||
|
{l.account_id && kontoById[l.account_id] && (
|
||||||
|
(() => {
|
||||||
|
const diff = kontoById[l.account_id].balance_cents
|
||||||
|
- l.balance_cents;
|
||||||
|
return diff === 0 ? (
|
||||||
|
<span className="saldo-ok"> · {t("bank.balanceMatches")}</span>
|
||||||
|
) : (
|
||||||
|
<span className="saldo-abweichung">
|
||||||
|
{" "}· {t("bank.balanceDiffers", {
|
||||||
|
diff: formatMoney(diff, l.currency),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{Object.keys(l.bank_felder || {}).length > 0 && (
|
{Object.keys(l.bank_felder || {}).length > 0 && (
|
||||||
<details className="muted small" style={{ marginTop: 4 }}>
|
<details className="muted small" style={{ marginTop: 4 }}>
|
||||||
<summary>{t("bank.rawFields")}</summary>
|
<summary>{t("bank.rawFields")}</summary>
|
||||||
@@ -254,6 +292,9 @@ export default function Bankanbindung() {
|
|||||||
<button className="btn sm primary" disabled={busy} onClick={() => abrufen(v)}>
|
<button className="btn sm primary" disabled={busy} onClick={() => abrufen(v)}>
|
||||||
{t("bank.sync")}
|
{t("bank.sync")}
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn sm" disabled={busy} onClick={() => saldenHolen(v)}>
|
||||||
|
{t("bank.fetchBalances")}
|
||||||
|
</button>
|
||||||
<button className="btn sm danger" onClick={() => trennen(v)}>
|
<button className="btn sm danger" onClick={() => trennen(v)}>
|
||||||
<Icon name="trash" size={15} />
|
<Icon name="trash" size={15} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user