From f684c752d8060e719ab9b2cb096b99cd0c7f198e Mon Sep 17 00:00:00 2001 From: Guido Date: Wed, 26 Aug 2026 23:02:25 +0200 Subject: [PATCH] feat: Pockets sind erkennbar und zuordenbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Revolut-Pocket bringt weder eigene IBAN noch eigenen Namen mit. Certain hielt bisher nur uid, IBAN, Name und Waehrung fest – mehrere Konten derselben Bank sahen damit gleich aus, und in der Zeile stand die rohe Kennung. Jetzt werden Produktbezeichnung und die Kennung fuer Konten ohne IBAN mitgefuehrt, der angezeigte Name faellt der Reihe nach auf Name, Details, Produkt, Kennung, IBAN zurueck statt auf die uid, und der uebermittelte Datensatz laesst sich aufklappen – nachsehen statt raten, wenn zwei Konten gleich aussehen. Beim Neuverbinden werden die Merkmale aufgefrischt, ohne die Zuordnung anzutasten. Fehlt ein Konto in der Waehrung des Bankkontos, steht das jetzt dabei; vorher war die Auswahl einfach leer. Co-Authored-By: Claude Opus 5 --- backend/app/models.py | 24 ++++++++ backend/app/schemas.py | 3 + backend/app/services/bank_sync.py | 56 ++++++++++++++++--- .../versions/0022_bank_konto_merkmale.py | 36 ++++++++++++ backend/tests/test_bank.py | 56 +++++++++++++++++++ web/src/locales/de.json | 4 +- web/src/locales/en.json | 4 +- web/src/pages/settings/Bankanbindung.jsx | 27 ++++++++- 8 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 backend/migrations/versions/0022_bank_konto_merkmale.py diff --git a/backend/app/models.py b/backend/app/models.py index a82f716..aa85184 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -12,6 +12,7 @@ hinzu und werden dann alle über ``owner_id`` je Benutzer getrennt. from __future__ import annotations import enum +import json # date/time unter Alias, weil es Spalten namens "date"/"time" gibt, die die # gleichnamigen Typen sonst in den (späten) Annotationen beschatten. from datetime import date as date_t, datetime, time as time_t, timezone @@ -904,6 +905,14 @@ class BankAccountLink(Base): remote_uid: Mapped[str] = mapped_column(String(64), nullable=False) iban: Mapped[str | None] = mapped_column(String(64), nullable=True) remote_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + # Produktbezeichnung der Bank. Bei Revolut unterscheidet sie das Konto von + # seinen Pockets – die haben weder eigene IBAN noch eigenen Namen. + product: Mapped[str | None] = mapped_column(String(120), nullable=True) + # Kennung fuer Konten ohne IBAN (PSD2 fuehrt sie unter ``other``). + other_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + # Der uebermittelte Datensatz, wie er kam. Damit laesst sich nachsehen, + # 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") account_id: Mapped[int | None] = mapped_column( ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True @@ -913,3 +922,18 @@ class BankAccountLink(Base): __table_args__ = ( UniqueConstraint("owner_id", "remote_uid", name="uq_bank_link_remote"), ) + + @property + def bank_felder(self) -> dict: + """Der uebermittelte Datensatz als Feld-Liste. + + Wird in den Einstellungen aufklappbar gezeigt. Wenn zwei Konten gleich + aussehen, steht der Unterschied hier – nachsehen schlaegt raten. + """ + if not self.raw_json: + return {} + try: + daten = json.loads(self.raw_json) + except ValueError: + return {} + return daten if isinstance(daten, dict) else {} diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ef784d8..7561238 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -652,6 +652,9 @@ class BankAccountLinkOut(BaseModel): remote_uid: str iban: str | None remote_name: str | None + product: str | None = None + other_id: str | None = None + bank_felder: dict = {} currency: str account_id: int | None diff --git a/backend/app/services/bank_sync.py b/backend/app/services/bank_sync.py index 2694161..61dd218 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -15,6 +15,7 @@ Sonderbehandlung greifen. Eine dritte Quelle, kein dritter Sonderweg. from __future__ import annotations +import json import logging import secrets from datetime import date, datetime, timedelta, timezone @@ -174,6 +175,47 @@ def starte_verbindung(db: Session, user: User, aspsp_name: str, land: str, return {"url": antwort.get("url"), "connection_id": verbindung.id, "state": state} +# Wie viel des uebermittelten Datensatzes aufgehoben wird. Ein Konto sind ein +# paar hundert Zeichen; die Grenze ist nur da, damit eine ungewoehnlich +# gespraechige Bank die Zeile nicht sprengt. +ROHDATEN_MAX = 4000 + + +def _merkmale(konto: dict) -> dict: + """Woran sich ein Bankkonto erkennen laesst. + + Ein Revolut-Pocket bringt weder eigene IBAN noch eigenen Namen mit. Ohne + Produktbezeichnung und Ersatzkennung sehen mehrere Konten derselben Bank + identisch aus – man kann sie dann weder unterscheiden noch zuordnen. + """ + kennung = konto.get("account_id") or {} + iban = kennung.get("iban") + + # PSD2 fuehrt Konten ohne IBAN unter ``other``; manche Banken liefern + # stattdessen nur die Sammelliste. + andere = (kennung.get("other") or {}).get("identification") + if not andere: + for eintrag in konto.get("all_account_ids") or []: + wert = (eintrag or {}).get("identification") + if wert and wert != iban: + andere = wert + break + + produkt = konto.get("product") or konto.get("cash_account_type") + # Reihenfolge nach Aussagekraft. Die uid steht bewusst am Ende: Sie ist + # eindeutig, sagt aber niemandem, welches Konto gemeint ist. + name = (konto.get("name") or konto.get("details") or produkt + or andere or iban or konto.get("uid")) + return { + "iban": str(iban)[:64] if iban else None, + "other_id": str(andere)[:128] if andere else None, + "product": str(produkt)[:120] if produkt else None, + "remote_name": str(name)[:255] if name else None, + "currency": (konto.get("currency") or "EUR").upper()[:3], + "raw_json": json.dumps(konto, ensure_ascii=False, default=str)[:ROHDATEN_MAX], + } + + def schliesse_verbindung(db: Session, user: User, state: str, code: str) -> dict: """Code gegen die Sitzung tauschen und die gefundenen Konten ablegen. @@ -216,18 +258,18 @@ def schliesse_verbindung(db: Session, user: User, state: str, code: str) -> dict .filter(BankAccountLink.owner_id == user.id, BankAccountLink.remote_uid == uid) .first() ) + merkmale = _merkmale(konto) if vorhanden is not None: - # Schon bekannt – die Zuordnung bleibt bewusst erhalten. + # Schon bekannt – die Zuordnung bleibt bewusst erhalten. Die + # Merkmale werden aber aufgefrischt, sonst muesste man loeschen, + # um an eine spaetere Produktbezeichnung zu kommen. vorhanden.connection_id = verbindung.id + for feld, wert in merkmale.items(): + setattr(vorhanden, feld, wert) bekannt += 1 continue db.add(BankAccountLink( - owner_id=user.id, - connection_id=verbindung.id, - remote_uid=uid, - iban=(konto.get("account_id") or {}).get("iban"), - remote_name=konto.get("name"), - currency=(konto.get("currency") or "EUR").upper(), + owner_id=user.id, connection_id=verbindung.id, remote_uid=uid, **merkmale, )) neu += 1 diff --git a/backend/migrations/versions/0022_bank_konto_merkmale.py b/backend/migrations/versions/0022_bank_konto_merkmale.py new file mode 100644 index 0000000..9caba7d --- /dev/null +++ b/backend/migrations/versions/0022_bank_konto_merkmale.py @@ -0,0 +1,36 @@ +"""Merkmale, an denen sich ein Bankkonto erkennen laesst. + +Ein Revolut-Pocket hat keine eigene IBAN und oft keinen eigenen Namen. Bisher +hielt Certain nur uid, IBAN, Name und Waehrung fest – drei Pockets sahen damit +gleich aus und liessen sich nicht auseinanderhalten, geschweige denn zuordnen. + +``product`` ist die Produktbezeichnung der Bank (bei Revolut unterscheidet sie +Konto und Pocket), ``other_id`` die Kennung fuer Konten ohne IBAN, und +``raw_json`` alles Uebrige, wie es kam – damit man nachsehen kann, statt zu +raten, was die Bank eigentlich geschickt hat. + +Revision ID: 0022 +Revises: 0021 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0022" +down_revision = "0021" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("bank_account_links") as b: + b.add_column(sa.Column("product", sa.String(length=120), nullable=True)) + b.add_column(sa.Column("other_id", sa.String(length=128), nullable=True)) + b.add_column(sa.Column("raw_json", sa.Text(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("bank_account_links") as b: + b.drop_column("raw_json") + b.drop_column("other_id") + b.drop_column("product") diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index b2d6090..6d741a9 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -555,3 +555,59 @@ def test_erneutes_verbinden_behaelt_die_kontozuordnung(admin, fake_bank): passende = [c for c in admin.get("/api/bank/connections").json() if c["aspsp_name"] == "Testbank"] assert len(passende) == 1 + + +def test_pocket_ohne_iban_und_namen_bleibt_unterscheidbar(admin, fake_bank): + """Ein Revolut-Pocket bringt weder IBAN noch eigenen Namen mit. Ohne + Produktbezeichnung und Ersatzkennung sähen mehrere Konten gleich aus.""" + _app(admin) + haupt = f"acc-{secrets.token_hex(4)}" + pocket = f"acc-{secrets.token_hex(4)}" + fake_bank(konten=[ + {"uid": haupt, "name": "Revolut", "currency": "CHF", + "account_id": {"iban": "CH9300762011623852957"}, "product": "Current"}, + # So kommt ein Pocket an: keine IBAN, kein Name, nur Produkt und + # eine Kennung unter ``other``. + {"uid": pocket, "currency": "CHF", "product": "Steuern", + "account_id": {"other": {"identification": "POCKET-7788"}}}, + ]) + state = admin.post("/api/bank/connect", + json={"aspsp_name": "Revolut", "country": "LT"}).json()["state"] + r = admin.post(f"/api/bank/callback?state={state}&code=guter-code") + assert r.status_code == 200, r.text + assert r.json()["konten"] == 2 + + links = {v["remote_uid"]: v for v in admin.get("/api/bank/links").json()} + p = links[pocket] + assert p["product"] == "Steuern" + assert p["other_id"] == "POCKET-7788" + assert p["iban"] is None + # Der Name faellt auf etwas Sprechendes zurueck, nicht auf die uid. + assert p["remote_name"] == "Steuern" + assert p["remote_name"] != p["remote_uid"] + assert p["bank_felder"]["product"] == "Steuern" + + # Und die beiden sind auseinanderzuhalten. + assert links[haupt]["remote_name"] != p["remote_name"] + + +def test_merkmale_werden_beim_neuverbinden_aufgefrischt(admin, fake_bank): + """Sonst muesste man loeschen, um an eine spaetere Produktbezeichnung zu + kommen – und verloere dabei die Zuordnung.""" + _app(admin) + uid = f"acc-{secrets.token_hex(4)}" + bank = f"Testbank-{secrets.token_hex(3)}" + + def verbinde(konten): + fake_bank(konten=konten) + state = admin.post("/api/bank/connect", + json={"aspsp_name": bank, "country": "DE"}).json()["state"] + return admin.post(f"/api/bank/callback?state={state}&code=guter-code") + + verbinde([{"uid": uid, "currency": "EUR"}]) + assert admin.get("/api/bank/links").json()[-1]["product"] is None + + verbinde([{"uid": uid, "currency": "EUR", "name": "Urlaub", "product": "Savings"}]) + danach = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0] + assert danach["product"] == "Savings" + assert danach["remote_name"] == "Urlaub" diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 3cfae14..e2c6cb7 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -610,7 +610,9 @@ "letters": "Vorhandene Anfangsbuchstaben", "truncatedHint": "Fehlen ganze Buchstaben, ist die Liste unvollständig. Fehlt nur deine Bank, sieh unten nach, was unter demselben Anfangsbuchstaben steht.", "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.", + "rawFields": "Was die Bank geschickt hat" }, "bankReturn": { "title": "Rückkehr von der Bank", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index b12b5d9..8318d53 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -610,7 +610,9 @@ "letters": "Initial letters present", "truncatedHint": "If whole letters are missing, the list is incomplete. If only your bank is missing, check below what is listed under the same initial letter.", "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.", + "rawFields": "What the bank sent" }, "bankReturn": { "title": "Back from your bank", diff --git a/web/src/pages/settings/Bankanbindung.jsx b/web/src/pages/settings/Bankanbindung.jsx index 103746f..4747d78 100644 --- a/web/src/pages/settings/Bankanbindung.jsx +++ b/web/src/pages/settings/Bankanbindung.jsx @@ -208,7 +208,12 @@ export default function Bankanbindung() {
{l.remote_name || l.iban || l.remote_uid} - {l.iban ? ` · ${l.iban}` : ""} · {l.currency} + {/* Produktbezeichnung und Ersatzkennung sind bei Pockets das + Einzige, was sie unterscheidet – ohne IBAN, oft ohne + eigenen Namen sähen mehrere Konten sonst gleich aus. */} + {l.product ? ` · ${l.product}` : ""} + {l.iban ? ` · ${l.iban}` : l.other_id ? ` · ${l.other_id}` : ""} + {` · ${l.currency}`}
{l.account_id ? null : (

- {t("bank.mapHint")} + {/* Ohne Konto in dieser Währung ist die Auswahl leer, und + man sucht den Fehler bei der Bank statt bei sich. */} + {konten.some((k) => k.currency === l.currency) + ? t("bank.mapHint") + : t("bank.noAccountForCurrency", { currency: l.currency })}

)} + {Object.keys(l.bank_felder || {}).length > 0 && ( +
+ {t("bank.rawFields")} +
+ {Object.entries(l.bank_felder).map(([feld, wert]) => ( +
+ {feld}: {typeof wert === "object" && wert !== null + ? JSON.stringify(wert) + : String(wert)} +
+ ))} +
+
+ )}
)) )}