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:
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user