feat: Pockets sind erkennbar und zuordenbar

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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-26 23:02:25 +02:00
co-authored by Claude Opus 5
parent d8995116cb
commit f684c752d8
8 changed files with 199 additions and 11 deletions
+24
View File
@@ -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 {}
+3
View File
@@ -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
+49 -7
View File
@@ -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
@@ -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")
+56
View File
@@ -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"
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+25 -2
View File
@@ -208,7 +208,12 @@ export default function Bankanbindung() {
<div className="sender-row" key={l.id}>
<div className="muted small selectable">
<strong>{l.remote_name || l.iban || l.remote_uid}</strong>
{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}`}
</div>
<select value={l.account_id || ""}
onChange={(e) => zuordnen(l, e.target.value)}>
@@ -219,9 +224,27 @@ export default function Bankanbindung() {
</select>
{l.account_id ? null : (
<p className="muted small" style={{ margin: "4px 0 0" }}>
{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 })}
</p>
)}
{Object.keys(l.bank_felder || {}).length > 0 && (
<details className="muted small" style={{ marginTop: 4 }}>
<summary>{t("bank.rawFields")}</summary>
<div className="selectable" style={{ marginTop: 4 }}>
{Object.entries(l.bank_felder).map(([feld, wert]) => (
<div key={feld}>
{feld}: {typeof wert === "object" && wert !== null
? JSON.stringify(wert)
: String(wert)}
</div>
))}
</div>
</details>
)}
</div>
))
)}