feat: Abos & Vertraege – was regelmaessig abgeht, an einer Stelle

Netflix, Handyvertrag, Krankenkasse, Halbtax. Bisher sah man sie nur als
einzelne Buchungen im Auszug; die Frage "welche Abos habe ich eigentlich, und
was kosten die zusammen im Monat" liess sich nicht beantworten. Genau die
steht jetzt oben auf der Seite.

Der Bereich heisst "Abos & Vertraege", nicht "Abos": Ein Handyvertrag ist kein
Abo und die Versicherung erst recht nicht – niemand soll ueberlegen muessen,
wo sie hingehoert.

Der Monatswert wird gerechnet, nicht gespeichert (Betrag durch Intervall).
Gespeichert veraltete er beim naechsten Preisaufschlag stillschweigend.

Bei laengerem Intervall klappt "Ich lege monatlich zurueck auf" auf, mit dem
gerechneten Monatsbetrag daneben. Bei monatlicher Abrechnung gibt es nichts
anzusparen, also steht es da auch nicht.

DAZU: Waehrungen sind offen.

Die Rechen-Maschinerie konnte immer schon beliebige Waehrungen – sie rechnet
ueber den Euro als Drehpunkt. Beschraenkt haben es zwei Konstanten: eine
Erlaubnisliste mit EUR und CHF, und ein Filter, der aus dem EZB-Feed alles
ausser CHF wegwarf, obwohl er dreissig Waehrungen liefert.

Massgeblich ist jetzt die Liste der EZB. Frei erfundene Waehrungen bleiben
draussen, und das ist kein Geiz: Ohne Kurs liesse sich der Betrag nie
umrechnen – er stuende in keiner Summe und in keinem Budget, und das faellt
erst spaet auf. Ein Vertrag in einer Waehrung ohne Kurs wird deshalb genannt,
statt die Summe stillschweigend zu klein anzuzeigen.

Erkannt und gebucht wird noch nichts; der Erkennungstext wird nur erfasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 15:45:59 +02:00
co-authored by Claude Opus 5
parent ff621984dc
commit f99be47587
18 changed files with 1297 additions and 10 deletions
+481
View File
@@ -0,0 +1,481 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import { useSettings } from "../settings";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
import Modal from "../components/Modal";
import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker";
import CategorySelect from "../components/CategorySelect";
import AccountSelect from "../components/AccountSelect";
import { gruppenFuer } from "../kategorien";
import { WAEHRUNGEN } from "../waehrungen";
import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format";
// Muss zu INTERVALL_MONATE im Server passen.
const INTERVALLE = [1, 2, 3, 6, 12];
// Ab wann eine Fälligkeit als „demnächst" gilt.
const BALD_TAGE = 30;
function leeresFormular() {
return {
name: "",
icon: "refresh-ccw",
color: "#6b7480",
amount: "",
currency: "EUR",
interval_months: "1",
account_id: "",
category_id: "",
next_due: "",
payee_pattern: "",
savings_account_id: "",
notes: "",
is_active: true,
};
}
export default function Abos() {
const { t } = useTranslation();
const { locale } = useSettings();
const confirm = useConfirm();
const toast = useToast();
const [daten, setDaten] = useState(null); // { subscriptions, summary }
const [konten, setKonten] = useState([]);
const [groups, setGroups] = useState([]);
const [form, setForm] = useState(null); // Formularzustand oder null
const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false);
async function laden() {
try {
setDaten(await api.listSubscriptions());
} catch (e) {
toast(e.message, "error");
}
}
useEffect(() => {
laden();
api.listAccounts().then(setKonten).catch(() => setKonten([]));
api.listCategories().then(setGroups).catch(() => setGroups([]));
}, []);
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
const kontoById = useMemo(
() => Object.fromEntries(konten.map((a) => [a.id, a])),
[konten],
);
const katById = useMemo(() => {
const m = {};
for (const g of groups) for (const c of g.categories) m[c.id] = c;
return m;
}, [groups]);
// Ansparen ergibt nur Sinn, wenn nicht ohnehin jeden Monat abgebucht wird.
const spartAn = form && Number(form.interval_months) > 1;
function neu() {
setEditing(null);
setForm(leeresFormular());
}
function bearbeiten(s) {
setEditing(s);
setForm({
name: s.name,
icon: s.icon,
color: s.color,
amount: centsToInput(s.amount_cents),
currency: s.currency,
interval_months: String(s.interval_months),
account_id: s.account_id ? String(s.account_id) : "",
category_id: s.category_id ? String(s.category_id) : "",
next_due: s.next_due || "",
payee_pattern: s.payee_pattern || "",
savings_account_id: s.savings_account_id ? String(s.savings_account_id) : "",
notes: s.notes || "",
is_active: s.is_active,
});
}
async function speichern(e) {
e.preventDefault();
setBusy(true);
const body = {
name: form.name.trim(),
icon: form.icon,
color: form.color,
amount_cents: parseAmountToCents(form.amount) ?? 0,
currency: form.currency,
interval_months: Number(form.interval_months),
account_id: form.account_id ? Number(form.account_id) : null,
category_id: form.category_id ? Number(form.category_id) : null,
next_due: form.next_due || null,
payee_pattern: form.payee_pattern.trim() || null,
// Bei monatlicher Abrechnung gibt es nichts anzusparen – ein aus einer
// früheren Eingabe stehengebliebenes Konto wäre sonst still wirksam.
savings_account_id:
spartAn && form.savings_account_id ? Number(form.savings_account_id) : null,
notes: form.notes.trim() || null,
is_active: form.is_active,
};
try {
if (editing) await api.updateSubscription(editing.id, body);
else await api.createSubscription(body);
setForm(null);
toast(t("settings.saved"));
laden();
} catch (err) {
toast(err.message, "error");
} finally {
setBusy(false);
}
}
async function loeschen(s) {
const ok = await confirm({
title: t("abos.delete"),
message: t("abos.deleteConfirm", { name: s.name }),
confirmLabel: t("abos.delete"),
danger: true,
});
if (!ok) return;
try {
await api.deleteSubscription(s.id);
laden();
} catch (e) {
toast(e.message, "error");
}
}
if (!daten) return null;
const { subscriptions: liste, summary } = daten;
const aktiv = liste.filter((s) => s.is_active);
const gekuendigt = liste.filter((s) => !s.is_active);
const bald = aktiv
.filter((s) => s.tage_bis_faellig != null && s.tage_bis_faellig <= BALD_TAGE)
.sort((a, b) => a.tage_bis_faellig - b.tage_bis_faellig);
function faelligText(s) {
if (s.tage_bis_faellig == null) return null;
if (s.tage_bis_faellig < 0) return t("abos.overdue", { tage: -s.tage_bis_faellig });
if (s.tage_bis_faellig === 0) return t("abos.dueToday");
return t("abos.dueIn", { tage: s.tage_bis_faellig });
}
function Zeile({ s }) {
const konto = s.account_id ? kontoById[s.account_id] : null;
const kat = s.category_id ? katById[s.category_id] : null;
const pocket = s.savings_account_id ? kontoById[s.savings_account_id] : null;
const beendet = s.is_active ? "" : " abo-beendet";
const ueberfaellig = s.tage_bis_faellig < 0 ? " warn" : "";
return (
<div className={"abo-zeile" + beendet}>
<IconChip name={s.icon} color={s.color} size={30} />
<div className="abo-mitte">
<div className="abo-kopf">
<strong className="ellipsis">{s.name}</strong>
{s.tage_bis_faellig != null && s.is_active && (
<span className={"abo-faellig small" + ueberfaellig}>{faelligText(s)}</span>
)}
</div>
<div className="abo-unten muted small">
{kat && <span className="ellipsis">{kat.name}</span>}
{konto && <span className="ellipsis">· {konto.name}</span>}
{pocket && (
<span className="ellipsis">· {t("abos.savingInto", { name: pocket.name })}</span>
)}
{s.next_due && <span>· {formatDate(s.next_due, locale)}</span>}
</div>
</div>
<div className="abo-betrag">
<strong>{formatMoney(s.amount_cents, s.currency)}</strong>
<span className="muted small">{t("abos.every." + s.interval_months)}</span>
{s.interval_months > 1 && (
<span className="muted small">
{t("abos.perMonth", { betrag: formatMoney(s.monatlich_cents, s.currency) })}
</span>
)}
</div>
<div className="abo-knoepfe">
<button className="btn-icon" title={t("common.edit")} onClick={() => bearbeiten(s)}>
<Icon name="edit" size={16} />
</button>
<button className="btn-icon" title={t("abos.delete")} onClick={() => loeschen(s)}>
<Icon name="trash" size={16} className="icon-danger" />
</button>
</div>
</div>
);
}
return (
<>
<div className="page-head">
<h1>{t("abos.title")}</h1>
<button className="btn primary" onClick={neu}>
<Icon name="plus" size={15} /> {t("abos.new")}
</button>
</div>
{/* Die Zahl, wegen der man die Seite öffnet – ganz oben, nicht unten. */}
<div className="card abo-summe">
<div>
<span className="muted small">{t("abos.monthlyTotal")}</span>
<div className="abo-summe-zahl">
{formatMoney(summary.monatlich_cents, summary.currency)}
</div>
</div>
<div>
<span className="muted small">{t("abos.yearlyTotal")}</span>
<div className="abo-summe-zahl">
{formatMoney(summary.jaehrlich_cents, summary.currency)}
</div>
</div>
<div>
<span className="muted small">{t("abos.activeCount")}</span>
<div className="abo-summe-zahl">{summary.anzahl_aktiv}</div>
</div>
</div>
{/* Eine zu kleine Summe wäre schlimmer als gar keine. */}
{summary.ohne_kurs.length > 0 && (
<p className="muted warn small">
{t("abos.noRate", { waehrungen: summary.ohne_kurs.join(", ") })}
</p>
)}
{bald.length > 0 && (
<div className="card">
<div className="card-head">
<Icon name="calendar" size={18} />
<h2>{t("abos.dueSoon")}</h2>
</div>
<div className="abo-liste">
{bald.map((s) => (
<Zeile key={s.id} s={s} />
))}
</div>
</div>
)}
<div className="card">
<div className="card-head">
<Icon name="refresh-ccw" size={18} />
<h2>{t("abos.all")}</h2>
</div>
{aktiv.length === 0 ? (
<p className="muted">{t("abos.empty")}</p>
) : (
<div className="abo-liste">
{aktiv.map((s) => (
<Zeile key={s.id} s={s} />
))}
</div>
)}
</div>
{gekuendigt.length > 0 && (
<div className="card">
<div className="card-head">
<Icon name="close" size={18} />
<h2>{t("abos.cancelled")}</h2>
</div>
<p className="muted small">{t("abos.cancelledHint")}</p>
<div className="abo-liste">
{gekuendigt.map((s) => (
<Zeile key={s.id} s={s} />
))}
</div>
</div>
)}
{form && (
<Modal
title={editing ? t("abos.edit") : t("abos.new")}
onClose={() => setForm(null)}
footer={
<>
<button type="button" className="btn" onClick={() => setForm(null)}>
{t("common.cancel")}
</button>
<button type="submit" form="abo-form" className="btn primary" disabled={busy}>
{t("common.save")}
</button>
</>
}
>
<form id="abo-form" onSubmit={speichern}>
<label>
<span>{t("abos.name")}</span>
<input
autoFocus
value={form.name}
required
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</label>
<div className="row">
<label className="grow">
<span>{t("abos.amount")}</span>
<input
inputMode="decimal"
value={form.amount}
placeholder="0,00"
required
onChange={(e) => setForm({ ...form, amount: e.target.value })}
/>
</label>
<label style={{ minWidth: 110 }}>
<span>{t("abos.currency")}</span>
<select
value={form.currency}
onChange={(e) => setForm({ ...form, currency: e.target.value })}
>
{WAEHRUNGEN.map((w) => (
<option key={w} value={w}>
{w}
</option>
))}
</select>
</label>
</div>
<label>
<span>{t("abos.interval")}</span>
<select
value={form.interval_months}
onChange={(e) => setForm({ ...form, interval_months: e.target.value })}
>
{INTERVALLE.map((n) => (
<option key={n} value={String(n)}>
{t("abos.every." + n)}
</option>
))}
</select>
</label>
{/* Nur bei längerem Intervall: Monatlich abgebucht gibt es nichts
anzusparen. */}
{spartAn && (
<div className="abo-ansparen">
<label>
<span>{t("abos.savingsAccount")}</span>
<AccountSelect
accounts={konten}
value={form.savings_account_id}
onChange={(id) =>
setForm({ ...form, savings_account_id: id != null ? String(id) : "" })
}
allowNone
noneLabel={t("abos.savingsNone")}
placeholder={t("abos.savingsNone")}
/>
</label>
<p className="muted small">
{t("abos.savingsHint", {
betrag: formatMoney(
Math.round(
(parseAmountToCents(form.amount) ?? 0) / Number(form.interval_months),
),
form.currency,
),
})}
</p>
</div>
)}
<label>
<span>{t("abos.paidFrom")}</span>
<AccountSelect
accounts={konten}
value={form.account_id}
onChange={(id) => setForm({ ...form, account_id: id != null ? String(id) : "" })}
allowNone
noneLabel={t("abos.accountNone")}
placeholder={t("abos.accountNone")}
/>
</label>
<label>
<span>{t("abos.category")}</span>
<CategorySelect
groups={expenseGroups}
value={form.category_id}
onChange={(id) => setForm({ ...form, category_id: id != null ? String(id) : "" })}
allowNone
noneLabel={t("abos.categoryNone")}
placeholder={t("abos.categoryNone")}
/>
</label>
<label>
<span>{t("abos.nextDue")}</span>
<input
type="date"
value={form.next_due}
onChange={(e) => setForm({ ...form, next_due: e.target.value })}
/>
</label>
<label>
<span>{t("abos.pattern")}</span>
<input
value={form.payee_pattern}
placeholder={form.name}
onChange={(e) => setForm({ ...form, payee_pattern: e.target.value })}
/>
</label>
<p className="muted small">{t("abos.patternHint")}</p>
<label>
<span>{t("accounts.icon")}</span>
<IconPicker
icons={CATEGORY_ICONS}
value={form.icon}
color={form.color}
onChange={(name) => setForm({ ...form, icon: name })}
/>
</label>
<label>
<span>{t("accounts.color")}</span>
<div className="color-row">
<input
type="color"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
/>
<IconChip name={form.icon} color={form.color} />
</div>
</label>
<label className="checkline">
<input
type="checkbox"
checked={!form.is_active}
onChange={(e) => setForm({ ...form, is_active: !e.target.checked })}
/>
<span>{t("abos.cancelledFlag")}</span>
</label>
<p className="muted small">{t("abos.cancelledFlagHint")}</p>
<label>
<span>{t("abos.notes")}</span>
<textarea
rows={2}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</label>
</form>
</Modal>
)}
</>
);
}
+2 -2
View File
@@ -11,6 +11,7 @@ import Modal from "../components/Modal";
import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker";
import CategorySelect from "../components/CategorySelect";
import { gruppenFuer } from "../kategorien";
import { WAEHRUNGEN } from "../waehrungen";
import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format";
const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"];
@@ -470,8 +471,7 @@ export default function Accounts() {
<label style={{ minWidth: 120 }}>
<span>{t("accounts.currency")}</span>
<select value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })}>
<option value="EUR">EUR</option>
<option value="CHF">CHF</option>
{WAEHRUNGEN.map((w) => <option key={w} value={w}>{w}</option>)}
</select>
</label>
</div>