feat: Kontostand von der Bank – die eigene Rechnung wird geprueft

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.

Der von der Bank gemeldete Saldo wird jetzt mitgefuehrt und danebengestellt.
Weicht er ab, fehlt oder doppelt eine Buchung – und man sieht es auf der
Kontenseite und in den Einstellungen sofort, statt es zu suchen.

Banken melden mehrere Saldi nebeneinander; ausgewaehlt wird der gebuchte, denn
nur der ist mit Certains Rechnung vergleichbar. Ein verfuegbarer Saldo enthaelt
Vorgemerktes oder einen Ueberziehungsrahmen.

Die Saldi kommen auch fuer noch nicht zugeordnete Konten: Bei mehreren
Revolut-Pockets ist der Betrag oft das Einzige, woran man sie unterscheidet.
Ein Fehler bei einem Konto beendet den Durchlauf nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-26 23:08:56 +02:00
co-authored by Claude Opus 5
parent f684c752d8
commit 63de8368f0
13 changed files with 342 additions and 18 deletions
+2
View File
@@ -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) =>
+6
View File
@@ -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; }
+9 -2
View File
@@ -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",
+9 -2
View File
@@ -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",
+23 -1
View File
@@ -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")}`}
</div>
{/* 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 && (
<div className="named-sub">
{a.bank_balance_cents === a.balance_cents ? (
<span className="saldo-ok">
{t("accounts.bankBalanceMatches")}
{a.bank_balance_at ? ` · ${formatDate(a.bank_balance_at)}` : ""}
</span>
) : (
<span className="saldo-abweichung">
{t("accounts.bankBalanceDiffers", {
bank: formatMoney(a.bank_balance_cents, a.currency),
diff: formatMoney(a.balance_cents - a.bank_balance_cents,
a.currency),
})}
</span>
)}
</div>
)}
</div>
<Money cents={a.balance_cents} currency={a.currency}
baseCents={a.balance_base_cents} baseCurrency={baseCurrency} />
+42 -1
View File
@@ -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 })}
</p>
)}
{l.balance_cents != null && (
<p className="muted small" style={{ margin: "4px 0 0" }}>
{t("bank.bankBalance")}:{" "}
<strong>{formatMoney(l.balance_cents, l.currency)}</strong>
{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 ? (
<span className="saldo-ok"> · {t("bank.balanceMatches")}</span>
) : (
<span className="saldo-abweichung">
{" "}· {t("bank.balanceDiffers", {
diff: formatMoney(diff, l.currency),
})}
</span>
);
})()
)}
</p>
)}
{Object.keys(l.bank_felder || {}).length > 0 && (
<details className="muted small" style={{ marginTop: 4 }}>
<summary>{t("bank.rawFields")}</summary>
@@ -254,6 +292,9 @@ export default function Bankanbindung() {
<button className="btn sm primary" disabled={busy} onClick={() => abrufen(v)}>
{t("bank.sync")}
</button>
<button className="btn sm" disabled={busy} onClick={() => saldenHolen(v)}>
{t("bank.fetchBalances")}
</button>
<button className="btn sm danger" onClick={() => trennen(v)}>
<Icon name="trash" size={15} />
</button>