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)} +
+ ))} +
+
+ )}
)) )}