feat: Eine Kreditkarte ist eine Schuld – der Rahmen ist kein Guthaben

Wer seinen Kreditrahmen festhalten wollte, hatte dafuer nur den
Anfangssaldo. Also standen dort 2.500, und Certain meldete ein Vermoegen,
das es nicht gibt: Das Geld ist nicht da, es darf nur ausgegeben werden.

Jetzt hat der Rahmen ein eigenes Feld – sichtbar nur bei Kreditkarten,
denn an einem Girokonto gaebe es nichts zu begrenzen. Die Kontokarte
zeigt daneben, was noch geht ("noch 1.754,50 von 2.500,00 frei") und
warnt, wenn der Rahmen ueberzogen ist.

Der Saldo selbst bleibt, was er ist: abbezahlt null, jede Ausgabe ins
Minus, die Rechnung holt ihn zurueck. Gerechnet werden musste dafuer
nichts neu – die Saldenrechnung war schon vorzeichenoffen.

Zwei Dinge fielen dabei auf und sind miterledigt:

- Die Summe "Gesamt" auf der Kontenseite zaehlte archivierte Konten mit,
  das Backend nicht. Zwei Zahlen fuer dieselbe Sache.
- "Gesamt" wurde ungefaerbt ausgegeben. Solange nur Guthaben
  zusammenkamen, fiel das nicht auf; mit einer Kartenschuld stuende ein
  negatives Gesamt still und unauffaellig da.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-30 20:27:30 +02:00
co-authored by Claude Opus 5
parent 152483707e
commit 38697f828e
11 changed files with 320 additions and 5 deletions
+67 -2
View File
@@ -33,6 +33,7 @@ function emptyForm() {
color: "#3f51b5",
opening_balance: "",
opening_date: "",
credit_limit: "",
savings_category_id: "",
identifiers: [],
};
@@ -257,6 +258,8 @@ export default function Accounts() {
color: acc.color,
opening_balance: centsToInput(acc.opening_balance_cents),
opening_date: acc.opening_date || "",
credit_limit: acc.credit_limit_cents != null
? centsToInput(acc.credit_limit_cents) : "",
savings_category_id: acc.savings_category_id ? String(acc.savings_category_id) : "",
identifiers: acc.identifiers.map((i) => ({ kind: i.kind, value: i.value })),
});
@@ -282,6 +285,10 @@ export default function Accounts() {
color: form.color,
opening_balance_cents: parseAmountToCents(form.opening_balance) ?? 0,
opening_date: form.opening_date || null,
// Ausdruecklich null bei jedem anderen Typ: Wer eine Kreditkarte zum
// Girokonto macht, soll den Rahmen nicht unsichtbar mitschleppen.
credit_limit_cents: form.type === "kreditkarte"
? (parseAmountToCents(form.credit_limit) ?? null) : null,
savings_category_id: form.savings_category_id ? Number(form.savings_category_id) : null,
identifiers: form.identifiers
.filter((i) => i.value.trim())
@@ -326,7 +333,12 @@ export default function Accounts() {
}
}
const total = accounts.reduce((s, a) => s + a.balance_base_cents, 0);
// Archivierte bleiben draussen – das Backend rechnet ebenso
// (balances.net_worth_cents, dashboard.py). Zwei Zahlen fuer dieselbe Sache
// sind eine Zahl zu viel.
const total = accounts
.filter((a) => !a.is_archived)
.reduce((s, a) => s + a.balance_base_cents, 0);
return (
<div>
@@ -343,7 +355,7 @@ export default function Accounts() {
<div className="card">
<div className="account-total">
<span className="muted">{t("accounts.total")}</span>
<strong>{formatMoney(total, baseCurrency)}</strong>
<strong><Money cents={total} currency={baseCurrency} /></strong>
</div>
<div className="account-list">
{accounts.map((a, i) => (
@@ -359,6 +371,7 @@ export default function Accounts() {
{a.identifiers.length > 0
&& ` · ${t("accounts.identShort", { count: a.identifiers.length })}`}
</div>
<Kreditrahmen account={a} t={t} />
{/* 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
@@ -488,6 +501,20 @@ export default function Accounts() {
</div>
<p className="muted small">{t("accounts.openingDateHint")}</p>
{/* Nur bei Kreditkarten: An einem Girokonto gaebe es nichts zu
begrenzen, und das Feld waere nur ein weiteres Bedienelement. */}
{form.type === "kreditkarte" && (
<>
<label>
<span>{t("accounts.creditLimit")} ({form.currency})</span>
<input inputMode="decimal" value={form.credit_limit}
onChange={(e) => setForm({ ...form, credit_limit: e.target.value })}
placeholder={t("accounts.creditLimitNone")} />
</label>
<p className="muted small">{t("accounts.creditLimitHint")}</p>
</>
)}
<label>
<span>{t("accounts.savingsCategory")}</span>
<CategorySelect
@@ -563,6 +590,44 @@ export default function Accounts() {
// Woraus sich ein Kontostand zusammensetzt. Stimmt er nicht, liegt es fast
// immer an einer dieser Zahlen oder an einem Übertrag, der zu viel oder zu
// wenig verschiebt – dann sieht man hier sofort, welcher Teil nicht passt.
// Was von der Karte noch uebrig ist.
//
// Eine Kreditkarte ist kein Konto mit Geld darauf: Abbezahlt steht sie auf
// null, jede Ausgabe drueckt sie ins Minus. Der Saldo allein sagt deshalb
// nicht, wie viel noch geht – dafuer braucht es den Rahmen daneben.
//
// Gerechnet wird in der KONTOWAEHRUNG. Der umgerechnete Saldo
// (balance_base_cents) passt nicht zu einem Rahmen, den die Bank in Franken
// zugesagt hat.
function Kreditrahmen({ account, t }) {
const rahmen = account.credit_limit_cents;
if (account.type !== "kreditkarte" || !rahmen) return null;
// Ein positiver Saldo heisst ueberzahlt – dann ist der Rahmen voll da.
const genutzt = Math.max(0, -account.balance_cents);
const ueberzogen = genutzt > rahmen;
const anteil = Math.min(100, Math.round((genutzt / rahmen) * 100));
return (
<div className="named-sub kreditrahmen">
<div className={`rahmen-balken${ueberzogen ? " ueber" : ""}`}>
<div className="rahmen-fuellung" style={{ width: `${anteil}%` }} />
</div>
<span className={ueberzogen ? "saldo-abweichung" : "muted"}>
{ueberzogen
? t("accounts.creditLimitExceeded", {
over: formatMoney(genutzt - rahmen, account.currency),
limit: formatMoney(rahmen, account.currency),
})
: t("accounts.creditLimitLeft", {
left: formatMoney(rahmen - genutzt, account.currency),
limit: formatMoney(rahmen, account.currency),
})}
</span>
</div>
);
}
function Aufschluesselung({ daten, t }) {
const { teile, uebertraege, bank, waehrung } = daten;
const zeile = (schluessel, vorzeichen) => {