diff --git a/backend/app/models.py b/backend/app/models.py index aa85184..24d1af5 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -914,6 +914,13 @@ class BankAccountLink(Base): # 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") + # 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( ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True ) diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py index 797ae99..bfc75cb 100644 --- a/backend/app/routers/accounts.py +++ b/backend/app/routers/accounts.py @@ -7,7 +7,15 @@ from sqlalchemy.orm import Session from ..database import get_db 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 ..services import balances, currency 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" -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( id=acc.id, 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_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) .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) @@ -115,7 +144,8 @@ def create_account( db.add(acc) db.commit() 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) @@ -123,7 +153,8 @@ def get_account( account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_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) @@ -143,7 +174,8 @@ def update_account( _set_identifiers(acc, payload.identifiers) db.commit() 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) diff --git a/backend/app/routers/bank.py b/backend/app/routers/bank.py index 0faabfe..88b52a2 100644 --- a/backend/app/routers/bank.py +++ b/backend/app/routers/bank.py @@ -127,6 +127,23 @@ def sync(connection_id: int, db: Session = Depends(get_db), 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) def delete_connection(connection_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 7561238..dec3e4f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -200,6 +200,10 @@ class AccountOut(BaseModel): identifiers: list[AccountIdentifierOut] balance_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 ------------------------------------------------------------ @@ -656,6 +660,9 @@ class BankAccountLinkOut(BaseModel): other_id: str | None = None bank_felder: dict = {} currency: str + balance_cents: int | None = None + balance_type: str | None = None + balance_at: datetime | None = None account_id: int | None model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/bank_sync.py b/backend/app/services/bank_sync.py index 61dd218..423a4c9 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -319,8 +319,86 @@ def _text(umsatz: dict, ausgabe: bool) -> tuple[str | None, str | 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: """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 = [ v for v in db.query(BankAccountLink).filter( 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 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)} seit = (verbindung.last_sync_at.date() - timedelta(days=3) 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_error = None 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: diff --git a/backend/migrations/versions/0023_bank_saldo.py b/backend/migrations/versions/0023_bank_saldo.py new file mode 100644 index 0000000..02d44e2 --- /dev/null +++ b/backend/migrations/versions/0023_bank_saldo.py @@ -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") diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index 6d741a9..62f7882 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -31,8 +31,11 @@ PEM = SCHLUESSEL.private_bytes( class FakeBank: """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.salden = salden if salden is not None else [ + {"balance_amount": {"amount": "100.00", "currency": "EUR"}}, + ] self.konten = konten if konten is not None else [{ "uid": "acc-1", "name": "Girokonto", "currency": "EUR", "account_id": {"iban": "DE02120300000000202051"}, @@ -60,8 +63,7 @@ class FakeBank: return 200, {"session_id": "sess-1", "accounts": self.konten, "access": {"valid_until": "2027-01-01T00:00:00+00:00"}} if "/balances" in pfad: - return 200, {"balances": [{"balance_amount": {"amount": "100.00", - "currency": "EUR"}}]} + return 200, {"balances": self.salden} if "/transactions" in pfad: # Blättern nachstellen: erst mit Fortsetzungsschlüssel, dann ohne. 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] assert danach["product"] == "Savings" 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 diff --git a/web/src/api.js b/web/src/api.js index e63685e..6054b81 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -206,6 +206,8 @@ export const api = { { method: "POST" }), listBankConnections: () => request("/bank/connections"), 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" }), listBankLinks: () => request("/bank/links"), setBankLinkAccount: (linkId, accountId) => diff --git a/web/src/finance.css b/web/src/finance.css index 66f9f3b..9e3b0a5 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1147,3 +1147,9 @@ tr.row-dup { text-overflow: ellipsis; 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; } diff --git a/web/src/locales/de.json b/web/src/locales/de.json index e2c6cb7..e8e556c 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -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.", "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", - "moveDown": "Nach unten" + "moveDown": "Nach unten", + "bankBalanceMatches": "Saldo stimmt mit der Bank überein", + "bankBalanceDiffers": "Bank: {{bank}} · Certain weicht um {{diff}} ab" }, "accountTypes": { "girokonto": "Girokonto", @@ -612,7 +614,12 @@ "sameLetter": "Alles, was in der Liste mit „{{buchstabe}}“ beginnt:", "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" + "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": { "title": "Rückkehr von der Bank", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 8318d53..c508441 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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.", "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", - "moveDown": "Move down" + "moveDown": "Move down", + "bankBalanceMatches": "Balance matches the bank", + "bankBalanceDiffers": "Bank: {{bank}} · Certain differs by {{diff}}" }, "accountTypes": { "girokonto": "Checking", @@ -612,7 +614,12 @@ "sameLetter": "Everything in the list starting with “{{buchstabe}}”:", "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" + "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": { "title": "Back from your bank", diff --git a/web/src/pages/Accounts.jsx b/web/src/pages/Accounts.jsx index f910de0..5174995 100644 --- a/web/src/pages/Accounts.jsx +++ b/web/src/pages/Accounts.jsx @@ -9,7 +9,7 @@ import IconChip from "../components/IconChip"; import Money from "../components/Money"; import Modal from "../components/Modal"; 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 TYPE_ICON = { @@ -248,6 +248,28 @@ export default function Accounts() { {t(`accountTypes.${a.type}`)} · {a.currency} {a.identifiers.length > 0 && ` · ${a.identifiers.length} ${t("accounts.identShort")}`} + {/* 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 && ( +
+ {a.bank_balance_cents === a.balance_cents ? ( + + {t("accounts.bankBalanceMatches")} + {a.bank_balance_at ? ` · ${formatDate(a.bank_balance_at)}` : ""} + + ) : ( + + {t("accounts.bankBalanceDiffers", { + bank: formatMoney(a.bank_balance_cents, a.currency), + diff: formatMoney(a.balance_cents - a.bank_balance_cents, + a.currency), + })} + + )} +
+ )} diff --git a/web/src/pages/settings/Bankanbindung.jsx b/web/src/pages/settings/Bankanbindung.jsx index 4747d78..91f18d0 100644 --- a/web/src/pages/settings/Bankanbindung.jsx +++ b/web/src/pages/settings/Bankanbindung.jsx @@ -5,7 +5,7 @@ import { useToast } from "../../toast"; import { useConfirm } from "../../confirm"; import Icon from "../../components/Icon"; 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 // 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) { setBusy(true); try { @@ -231,6 +244,31 @@ export default function Bankanbindung() { : t("bank.noAccountForCurrency", { currency: l.currency })}

)} + {l.balance_cents != null && ( +

+ {t("bank.bankBalance")}:{" "} + {formatMoney(l.balance_cents, l.currency)} + {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 ? ( + · {t("bank.balanceMatches")} + ) : ( + + {" "}· {t("bank.balanceDiffers", { + diff: formatMoney(diff, l.currency), + })} + + ); + })() + )} +

+ )} {Object.keys(l.bank_felder || {}).length > 0 && (
{t("bank.rawFields")} @@ -254,6 +292,9 @@ export default function Bankanbindung() { +