feat: Ein Konto kann sagen, dass Ueberweisungen darauf Sparen sind

Wer auf ein Pocket spart, budgetiert dafuer eine Ausgabe – im Budget stand
davon aber nichts. Zwei Gruende:

Erstens gibt es zwei Formen von Uebertrag. Die eine ist EINE Buchung mit
Zielkonto, wie sie das Formular anlegt; nur fuer die gab es die Sparen-Logik.
Die andere sind ZWEI verknuepfte Buchungen, und genau so kommt ein Uebertrag
ueber die Bankanbindung herein. Beide Seiten uebersprang die Auswertung
ausnahmslos.

Zweitens hing das Sparen am Kontotyp. Ein Revolut-Pocket ist bei der Bank ein
ganz normales Konto und kommt als "girokonto" herein – am Typ ist nicht
abzulesen, dass man darauf spart.

Jetzt sagt man es dem Konto einmal ("Sparen gegen Kategorie X"), und es gilt
fuer beide Formen. Wer eine einzelne Bewegung anders sieht, nimmt sie an der
Buchung heraus oder gibt ihr eine eigene Kategorie – dieselben zwei Felder wie
bisher.

Der Normalfall aendert sich nicht: Ein Uebertrag auf ein Konto ohne Sparziel
ist weiterhin bloss verschobenes Geld.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 13:53:26 +02:00
co-authored by Claude Opus 5
parent 991a8e4fc2
commit 25392b9d08
11 changed files with 407 additions and 16 deletions
+3
View File
@@ -168,6 +168,9 @@
"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.",
"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.",
"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",
+3
View File
@@ -168,6 +168,9 @@
"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.",
"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.",
"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",
+27 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import { useSettings } from "../settings";
@@ -9,6 +9,8 @@ import IconChip from "../components/IconChip";
import Money from "../components/Money";
import Modal from "../components/Modal";
import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker";
import CategorySelect from "../components/CategorySelect";
import { gruppenFuer } from "../kategorien";
import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format";
const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"];
@@ -30,6 +32,7 @@ function emptyForm() {
color: "#3f51b5",
opening_balance: "",
opening_date: "",
savings_category_id: "",
identifiers: [],
};
}
@@ -88,6 +91,10 @@ function TransferKeywords({ accounts }) {
catch (err) { toast(err.message, "error"); }
}
useEffect(() => { laden(); }, []);
useEffect(() => {
api.listCategories().then(setGroups).catch(() => setGroups([]));
}, []);
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
async function hinzufuegen() {
const w = input.trim();
@@ -178,6 +185,9 @@ export default function Accounts() {
const toast = useToast();
const [accounts, setAccounts] = useState([]);
// Nur fuer die Auswahl des Sparziels. Ausgaben-Gruppen, denn Sparen zaehlt im
// Budget wie eine Ausgabe.
const [groups, setGroups] = useState([]);
const [editing, setEditing] = useState(null); // Account-Objekt oder null
const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen)
const [busy, setBusy] = useState(false);
@@ -236,6 +246,7 @@ export default function Accounts() {
color: acc.color,
opening_balance: centsToInput(acc.opening_balance_cents),
opening_date: acc.opening_date || "",
savings_category_id: acc.savings_category_id ? String(acc.savings_category_id) : "",
identifiers: acc.identifiers.map((i) => ({ kind: i.kind, value: i.value })),
});
}
@@ -260,6 +271,7 @@ export default function Accounts() {
color: form.color,
opening_balance_cents: parseAmountToCents(form.opening_balance) ?? 0,
opening_date: form.opening_date || null,
savings_category_id: form.savings_category_id ? Number(form.savings_category_id) : null,
identifiers: form.identifiers
.filter((i) => i.value.trim())
.map((i) => ({ kind: i.kind, value: i.value.trim() })),
@@ -466,6 +478,20 @@ export default function Accounts() {
</label>
</div>
<p className="muted small">{t("accounts.openingDateHint")}</p>
<label>
<span>{t("accounts.savingsCategory")}</span>
<CategorySelect
groups={expenseGroups}
value={form.savings_category_id}
onChange={(id) => setForm({ ...form, savings_category_id: id != null ? String(id) : "" })}
allowNone
noneLabel={t("accounts.savingsCategoryNone")}
placeholder={t("accounts.savingsCategoryNone")}
/>
</label>
<p className="muted small">{t("accounts.savingsCategoryHint")}</p>
<label>
<span>{t("accounts.icon")}</span>
<IconPicker icons={ACCOUNT_ICONS} value={form.icon} color={form.color}
+9 -2
View File
@@ -154,16 +154,23 @@ export default function Transactions() {
const fromAcc = form && accountById[Number(form.account_id)];
const toAcc = form && accountById[Number(form.to_account_id)];
const crossCurrency = form?.type === "transfer" && fromAcc && toAcc && fromAcc.currency !== toAcc.currency;
const savingsDest = form?.type === "transfer" && toAcc && SAVINGS_TYPES.includes(toAcc.type);
// Sparziel ist das Konto, wenn es eine Spar-Kategorie traegt – oder, als
// Vorgabe, wenn sein Typ dafuer steht. Am Typ allein laesst es sich nicht
// ablesen: Ein Revolut-Pocket kommt als ganz normales Girokonto herein.
const savingsDest = form?.type === "transfer" && toAcc
&& (toAcc.savings_category_id != null || SAVINGS_TYPES.includes(toAcc.type));
// Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden).
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]);
const sparenCatId = useMemo(() => {
// Was am Zielkonto steht, schlaegt jede Vermutung: Dort hat es jemand
// ausdruecklich gesagt.
if (toAcc?.savings_category_id != null) return String(toAcc.savings_category_id);
for (const g of expenseGroups)
for (const c of g.categories) if (c.name === "Sparen & Investments") return String(c.id);
return expenseGroups[0]?.categories?.[0] ? String(expenseGroups[0].categories[0].id) : "";
}, [expenseGroups]);
}, [expenseGroups, toAcc]);
// Bei Spar-/Depot-Ziel eine Budget-Kategorie vorbelegen.
useEffect(() => {