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:
co-authored by
Claude Opus 5
parent
152483707e
commit
38697f828e
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "backend",
|
||||
"runtimeExecutable": "cmd",
|
||||
"runtimeArgs": [
|
||||
"/c",
|
||||
"cd backend && .venv\\Scripts\\python.exe -m uvicorn app.main:app --port 8000"
|
||||
],
|
||||
"port": 8000
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"runtimeExecutable": "cmd",
|
||||
"runtimeArgs": ["/c", "cd web && npm run dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -313,6 +313,20 @@ class Account(Base):
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f51b5")
|
||||
opening_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
opening_date: Mapped[date_t | None] = mapped_column(Date, nullable=True)
|
||||
# Was die Karte ausgeben DARF – nicht, was auf ihr liegt.
|
||||
#
|
||||
# Eine Kreditkarte ist kein Guthaben. Abbezahlt steht sie auf null, jede
|
||||
# Ausgabe drueckt sie ins Minus, die Rechnungszahlung holt sie zurueck.
|
||||
# Der Rahmen ist eine Eigenschaft des Vertrags mit der Bank und hat im
|
||||
# Saldo nichts zu suchen – wer ihn dort eintraegt, sieht Geld, das er
|
||||
# nicht hat.
|
||||
#
|
||||
# Deshalb ein eigenes Feld: Die Zahl ist es wert, aufgehoben zu werden
|
||||
# („noch 1.900 von 2.500 frei"), nur eben nicht als Vermoegen.
|
||||
#
|
||||
# Optional, und nur bei Kreditkarten sinnvoll. Ohne Eintrag aendert sich
|
||||
# nichts.
|
||||
credit_limit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Wer hierher ueberweist, spart – und zwar gegen DIESE Kategorie.
|
||||
#
|
||||
# Sparen ist eine Entscheidung ueber ein KONTO, nicht ueber jede einzelne
|
||||
|
||||
@@ -67,6 +67,7 @@ def to_out(db: Session, acc: Account, base: str, on: date,
|
||||
color=acc.color,
|
||||
opening_balance_cents=acc.opening_balance_cents,
|
||||
opening_date=acc.opening_date,
|
||||
credit_limit_cents=acc.credit_limit_cents,
|
||||
is_archived=acc.is_archived,
|
||||
position=acc.position,
|
||||
savings_category_id=acc.savings_category_id,
|
||||
@@ -162,6 +163,7 @@ def create_account(
|
||||
color=payload.color,
|
||||
opening_balance_cents=payload.opening_balance_cents,
|
||||
opening_date=payload.opening_date,
|
||||
credit_limit_cents=payload.credit_limit_cents,
|
||||
savings_category_id=payload.savings_category_id,
|
||||
position=(max_pos[0] + 1) if max_pos else 0,
|
||||
)
|
||||
@@ -196,8 +198,12 @@ def update_account(
|
||||
_pruefe_sparziel(db, user, data["savings_category_id"])
|
||||
for key, value in data.items():
|
||||
# "savings_category_id" gehoert in die Ausnahme: Ohne sie liesse sich
|
||||
# ein einmal gesetztes Sparziel nie wieder loesen.
|
||||
if value is not None or key in ("opening_date", "savings_category_id"):
|
||||
# ein einmal gesetztes Sparziel nie wieder loesen. Fuer den
|
||||
# Kreditrahmen gilt dasselbe – wer ein Konto vom Typ Kreditkarte auf
|
||||
# Girokonto umstellt, muss den Rahmen wieder loswerden koennen.
|
||||
if value is not None or key in (
|
||||
"opening_date", "savings_category_id", "credit_limit_cents"
|
||||
):
|
||||
setattr(acc, key, value)
|
||||
if identifiers is not None:
|
||||
_set_identifiers(acc, payload.identifiers)
|
||||
|
||||
@@ -162,6 +162,8 @@ class AccountCreate(BaseModel):
|
||||
color: str = "#3f51b5"
|
||||
opening_balance_cents: int = 0
|
||||
opening_date: date_t | None = None
|
||||
# Was die Karte ausgeben darf – kein Guthaben. Nur bei Kreditkarten sinnvoll.
|
||||
credit_limit_cents: int | None = Field(default=None, ge=0)
|
||||
# Ueberweisungen hierher zaehlen als Sparen gegen diese Ausgabe-Kategorie.
|
||||
savings_category_id: int | None = None
|
||||
identifiers: list[AccountIdentifierIn] = Field(default_factory=list)
|
||||
@@ -186,6 +188,7 @@ class AccountUpdate(BaseModel):
|
||||
color: str | None = None
|
||||
opening_balance_cents: int | None = None
|
||||
opening_date: date_t | None = None
|
||||
credit_limit_cents: int | None = Field(default=None, ge=0)
|
||||
is_archived: bool | None = None
|
||||
position: int | None = None
|
||||
savings_category_id: int | None = None
|
||||
@@ -207,6 +210,7 @@ class AccountOut(BaseModel):
|
||||
color: str
|
||||
opening_balance_cents: int
|
||||
opening_date: date_t | None
|
||||
credit_limit_cents: int | None = None
|
||||
is_archived: bool
|
||||
position: int
|
||||
savings_category_id: int | None = None
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Der Kreditrahmen bekommt einen eigenen Platz.
|
||||
|
||||
Bisher hatte niemand einen Ort fuer die Zahl, die eine Kreditkarte ausmacht:
|
||||
wie viel sie ausgeben darf. Wer sie trotzdem festhalten wollte, trug sie als
|
||||
Startsaldo ein – und sah fortan ein Guthaben, das es nicht gibt.
|
||||
|
||||
Eine Karte ist kein Konto mit Geld darauf. Abbezahlt steht sie auf null, jede
|
||||
Ausgabe drueckt sie ins Minus, die Rechnung holt sie zurueck. Der Rahmen ist
|
||||
eine Zusage der Bank, kein Besitz – und gehoert deshalb neben den Saldo, nicht
|
||||
hinein.
|
||||
|
||||
Optional: Konten ohne Eintrag verhalten sich wie bisher.
|
||||
|
||||
Revision ID: 0035
|
||||
Revises: 0034
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0035"
|
||||
down_revision = "0034"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Ohne batch_alter_table, aus demselben Grund wie in 0034: Eine Spalte
|
||||
# anhaengen kann SQLite direkt, der Tabellen-Umbau risse ueber ON DELETE
|
||||
# CASCADE haengende Zeilen mit (tests/test_migrations.py).
|
||||
op.execute(sa.text("ALTER TABLE accounts ADD COLUMN credit_limit_cents INTEGER"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(sa.text("ALTER TABLE accounts DROP COLUMN credit_limit_cents"))
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Eine Kreditkarte ist eine Schuld, kein Guthaben.
|
||||
|
||||
Abbezahlt steht sie auf null. Jede Ausgabe drueckt sie ins Minus, die Zahlung
|
||||
der Rechnung holt sie zurueck. Der Kreditrahmen ist, was sie ausgeben darf –
|
||||
nicht, was auf ihr liegt.
|
||||
|
||||
Die Rechenkette war dafuer schon vorbereitet (balances.py rechnet
|
||||
vorzeichenoffen). Diese Tests halten fest, dass sie es bleibt.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
|
||||
def _karte(c, **kw):
|
||||
body = {"name": "Visa", "type": "kreditkarte", "currency": "EUR",
|
||||
"opening_balance_cents": 0}
|
||||
body.update(kw)
|
||||
r = c.post("/api/accounts", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_kreditrahmen_wird_gespeichert_und_zurueckgegeben(admin):
|
||||
karte = _karte(admin, credit_limit_cents=250000)
|
||||
assert karte["credit_limit_cents"] == 250000
|
||||
assert admin.get(f"/api/accounts/{karte['id']}").json()["credit_limit_cents"] == 250000
|
||||
|
||||
|
||||
def test_kreditrahmen_laesst_sich_aendern_und_wieder_loeschen(admin):
|
||||
"""Ohne die Loesch-Moeglichkeit haengt ein einmal gesetzter Rahmen fuer
|
||||
immer am Konto – auch wenn daraus laengst ein Girokonto geworden ist."""
|
||||
karte = _karte(admin, credit_limit_cents=250000)
|
||||
|
||||
r = admin.patch(f"/api/accounts/{karte['id']}", json={"credit_limit_cents": 300000})
|
||||
assert r.json()["credit_limit_cents"] == 300000
|
||||
|
||||
r = admin.patch(f"/api/accounts/{karte['id']}", json={"credit_limit_cents": None})
|
||||
assert r.json()["credit_limit_cents"] is None
|
||||
|
||||
|
||||
def test_kontoname_aendern_laesst_den_rahmen_stehen(admin):
|
||||
karte = _karte(admin, credit_limit_cents=250000)
|
||||
r = admin.patch(f"/api/accounts/{karte['id']}", json={"name": "Visa Gold"})
|
||||
assert r.json()["credit_limit_cents"] == 250000
|
||||
|
||||
|
||||
def test_negativer_rahmen_wird_abgelehnt(admin):
|
||||
r = admin.post("/api/accounts", json={
|
||||
"name": "Unsinn", "type": "kreditkarte", "credit_limit_cents": -100})
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
def test_karte_geht_ins_minus_und_die_rechnung_holt_sie_zurueck(admin):
|
||||
"""Der ganze Lebenslauf einer Karte in einem Monat."""
|
||||
giro = admin.post("/api/accounts", json={
|
||||
"name": "Giro", "currency": "EUR", "opening_balance_cents": 100000}).json()
|
||||
karte = _karte(admin, credit_limit_cents=250000)
|
||||
heute = str(date.today())
|
||||
|
||||
def saldo(acc):
|
||||
return admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"]
|
||||
|
||||
assert saldo(karte) == 0, "abbezahlt = null, nicht der Kreditrahmen"
|
||||
|
||||
for betrag in (600, 2400):
|
||||
admin.post("/api/transactions", json={
|
||||
"type": "expense", "date": heute, "account_id": karte["id"],
|
||||
"amount_cents": betrag, "payee": "Laden"})
|
||||
assert saldo(karte) == -3000, "Ausgaben druecken die Karte ins Minus"
|
||||
|
||||
# Die Rechnung: Geld geht vom Giro auf die Karte.
|
||||
admin.post("/api/transactions", json={
|
||||
"type": "transfer", "date": heute, "account_id": giro["id"],
|
||||
"to_account_id": karte["id"], "amount_cents": 3000, "payee": "Kartenrechnung"})
|
||||
|
||||
assert saldo(karte) == 0, "bezahlt = wieder null"
|
||||
assert saldo(giro) == 97000, "und das Geld ist wirklich vom Giro weg"
|
||||
|
||||
|
||||
def test_vermoegen_zieht_die_kartenschuld_ab(admin):
|
||||
"""Eine Schuld darf das Vermoegen nicht erhoehen – der Fehler, der
|
||||
entsteht, wenn man den Kreditrahmen als Startsaldo eintraegt.
|
||||
|
||||
Gemessen wird die Veraenderung, nicht der absolute Wert: Konten aus
|
||||
anderen Tests dieser Datei zaehlen sonst mit.
|
||||
"""
|
||||
from app.database import SessionLocal
|
||||
from app.models import Account, User
|
||||
from app.services import balances
|
||||
|
||||
def vermoegen() -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.username == "admin").one()
|
||||
konten = db.query(Account).filter(Account.owner_id == user.id).all()
|
||||
return balances.net_worth_cents(db, konten, "EUR", date.today())
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
vorher = vermoegen()
|
||||
|
||||
admin.post("/api/accounts", json={
|
||||
"name": "Giro Vermoegen", "currency": "EUR", "opening_balance_cents": 100000})
|
||||
assert vermoegen() == vorher + 100000
|
||||
|
||||
karte = _karte(admin, name="Visa Vermoegen", credit_limit_cents=250000)
|
||||
assert vermoegen() == vorher + 100000, "ein Kreditrahmen ist kein Vermoegen"
|
||||
|
||||
admin.post("/api/transactions", json={
|
||||
"type": "expense", "date": str(date.today()), "account_id": karte["id"],
|
||||
"amount_cents": 3000, "payee": "Laden"})
|
||||
assert vermoegen() == vorher + 97000, "die Schuld wird abgezogen, nicht addiert"
|
||||
|
||||
|
||||
def test_kreditrahmen_ueberlebt_das_backup(admin):
|
||||
"""Ein Feld, das die Rueckspielung nicht ueberlebt, ist kein Feld."""
|
||||
karte = _karte(admin, credit_limit_cents=250000)
|
||||
|
||||
daten = json.loads(admin.get("/api/export/backup.json").content)
|
||||
gesichert = [a for a in daten["data"]["accounts"] if a["name"] == "Visa"]
|
||||
assert gesichert and gesichert[0]["credit_limit_cents"] == 250000
|
||||
|
||||
admin.delete(f"/api/accounts/{karte['id']}")
|
||||
r = admin.post(
|
||||
"/api/backup/restore",
|
||||
files={"file": ("backup.json", json.dumps(daten).encode("utf-8"),
|
||||
"application/json")},
|
||||
data={"mode": "replace"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
zurueck = [a for a in admin.get("/api/accounts").json() if a["name"] == "Visa"]
|
||||
assert zurueck and zurueck[0]["credit_limit_cents"] == 250000
|
||||
@@ -49,7 +49,9 @@ function AccountBalances() {
|
||||
))}
|
||||
<div className="dash-total">
|
||||
<span className="grow">{t("cards.total")}</span>
|
||||
<strong>{formatMoney(data.total, data.currency)}</strong>
|
||||
{/* Mit einer Kreditkartenschuld kann diese Summe negativ werden.
|
||||
formatMoney allein faerbt nicht – das Minus stuende still da. */}
|
||||
<strong><Money cents={data.total} currency={data.currency} /></strong>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1164,6 +1164,31 @@ tr.row-dup {
|
||||
.saldo-ok { color: var(--ok); }
|
||||
.saldo-abweichung { color: var(--warn); font-weight: 560; }
|
||||
|
||||
/* ---------- Kreditrahmen ----------
|
||||
Wie viel von der Karte noch uebrig ist. Der Balken faellt bewusst schmal
|
||||
aus: Er begleitet den Saldo, er ersetzt ihn nicht. Neutral gefaerbt, denn
|
||||
einen Rahmen auszunutzen ist nicht falsch – erst ihn zu ueberziehen. */
|
||||
.kreditrahmen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.rahmen-balken {
|
||||
flex: 0 0 64px;
|
||||
height: 5px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rahmen-fuellung {
|
||||
height: 100%;
|
||||
background: var(--muted);
|
||||
border-radius: 3px;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
.rahmen-balken.ueber .rahmen-fuellung { background: var(--warn); }
|
||||
|
||||
/* ---------- Übertrags-Vorschläge ---------- */
|
||||
.uebertrag-vorschlag {
|
||||
padding: var(--sp-3) 0;
|
||||
|
||||
@@ -180,6 +180,11 @@
|
||||
"empty": "Noch keine Konten. Lege dein erstes Konto an.",
|
||||
"deleteConfirm": "Dieses Konto und alle zugehörigen Buchungen löschen?",
|
||||
"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.",
|
||||
"creditLimit": "Kreditrahmen",
|
||||
"creditLimitNone": "kein Rahmen hinterlegt",
|
||||
"creditLimitHint": "Was die Karte ausgeben darf – kein Guthaben. Der Kontostand gehört nicht hierher: Eine abbezahlte Karte steht auf 0, jede Ausgabe drückt sie ins Minus, die Rechnung holt sie zurück.",
|
||||
"creditLimitLeft": "noch {{left}} von {{limit}} frei",
|
||||
"creditLimitExceeded": "{{over}} über dem Rahmen von {{limit}}",
|
||||
"savingsCategory": "Sparen gegen Kategorie",
|
||||
"savingsCategoryNone": "Kein Sparziel",
|
||||
"savingsCategoryHint": "Überweisungen auf dieses Konto zählen im Budget als Sparen gegen diese Kategorie – auch dann, wenn sie über die Bank als zwei verknüpfte Buchungen hereinkommen. Einzelne Buchungen lassen sich davon ausnehmen.",
|
||||
|
||||
@@ -180,6 +180,11 @@
|
||||
"empty": "No accounts yet. Create your first account.",
|
||||
"deleteConfirm": "Delete this account and all its transactions?",
|
||||
"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.",
|
||||
"creditLimit": "Credit limit",
|
||||
"creditLimitNone": "no limit set",
|
||||
"creditLimitHint": "What the card may spend — not money you have. The balance does not belong here: a paid-off card sits at 0, every purchase pushes it negative, and the bill brings it back.",
|
||||
"creditLimitLeft": "{{left}} of {{limit}} left",
|
||||
"creditLimitExceeded": "{{over}} over the {{limit}} limit",
|
||||
"savingsCategory": "Count savings towards",
|
||||
"savingsCategoryNone": "Not a savings account",
|
||||
"savingsCategoryHint": "Transfers into this account count as savings against this category in the budget – including those that arrive from the bank as two linked transactions. Individual transactions can be excluded.",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user