diff --git a/backend/database.py b/backend/database.py
index 5d992eb..1a70aa5 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -1857,3 +1857,21 @@ def _migrate(conn_factory):
UNIQUE(from_dog_id, to_dog_id)
)
""")
+
+ # Wiederkehrende Ausgaben (Daueraufträge)
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS recurring_expenses (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
+ kategorie TEXT NOT NULL,
+ betrag REAL NOT NULL,
+ haeufigkeit TEXT NOT NULL, -- monatlich|quartalsweise|jaehrlich
+ startdatum TEXT NOT NULL,
+ naechste_faelligkeit TEXT NOT NULL,
+ notiz TEXT,
+ aktiv INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv);
+ """)
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 17db134..c4e830c 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -14,3 +14,4 @@ apscheduler==3.10.4
odfpy==1.4.1
polyline==2.0.2
fpdf2==2.8.3
+python-dateutil>=2.9
diff --git a/backend/routes/expenses.py b/backend/routes/expenses.py
index a3bcf7a..9c93475 100644
--- a/backend/routes/expenses.py
+++ b/backend/routes/expenses.py
@@ -1,7 +1,8 @@
"""BAN YARO — Ausgaben-Tracker Routes"""
import logging
-from datetime import date
+from datetime import date, timedelta
+from dateutil.relativedelta import relativedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
@@ -33,6 +34,43 @@ class ExpenseUpdate(BaseModel):
notiz: Optional[str] = None
+class RecurringCreate(BaseModel):
+ dog_id: Optional[int] = None
+ kategorie: str
+ betrag: float
+ haeufigkeit: str # monatlich | quartalsweise | jaehrlich
+ startdatum: str # ISO date
+ notiz: Optional[str] = None
+
+class RecurringUpdate(BaseModel):
+ dog_id: Optional[int] = None
+ kategorie: Optional[str] = None
+ betrag: Optional[float] = None
+ haeufigkeit: Optional[str] = None
+ startdatum: Optional[str] = None
+ notiz: Optional[str] = None
+ aktiv: Optional[bool] = None
+
+
+HAEUFIGKEITEN = {"monatlich", "quartalsweise", "jaehrlich"}
+
+
+def _next_due(startdatum: str, haeufigkeit: str, after: date) -> date:
+ """Berechnet das nächste Fälligkeitsdatum nach `after`."""
+ d = date.fromisoformat(startdatum)
+ if d > after:
+ return d
+ if haeufigkeit == "monatlich":
+ delta = relativedelta(months=1)
+ elif haeufigkeit == "quartalsweise":
+ delta = relativedelta(months=3)
+ else:
+ delta = relativedelta(years=1)
+ while d <= after:
+ d += delta
+ return d
+
+
def _serialize(row) -> dict:
return dict(row)
@@ -226,3 +264,133 @@ async def delete_expense(expense_id: int, user=Depends(get_current_user)):
raise HTTPException(404, "Eintrag nicht gefunden.")
conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
return None
+
+
+# ------------------------------------------------------------------
+# Wiederkehrende Ausgaben
+# ------------------------------------------------------------------
+@router.get("/recurring")
+async def list_recurring(user=Depends(get_current_user)):
+ with db() as conn:
+ rows = conn.execute(
+ """SELECT r.*, d.name AS dog_name
+ FROM recurring_expenses r
+ LEFT JOIN dogs d ON d.id = r.dog_id
+ WHERE r.user_id=? ORDER BY r.startdatum DESC""",
+ (user["id"],),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
+@router.post("/recurring", status_code=201)
+async def create_recurring(data: RecurringCreate, user=Depends(get_current_user)):
+ if data.kategorie not in KATEGORIEN:
+ raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
+ if data.haeufigkeit not in HAEUFIGKEITEN:
+ raise HTTPException(400, f"Ungültige Häufigkeit: {data.haeufigkeit}")
+ if data.betrag <= 0:
+ raise HTTPException(400, "Betrag muss größer als 0 sein.")
+
+ today = date.today()
+ naechste = _next_due(data.startdatum, data.haeufigkeit, today - timedelta(days=1))
+
+ with db() as conn:
+ if data.dog_id:
+ if not conn.execute("SELECT 1 FROM dogs WHERE id=? AND user_id=?",
+ (data.dog_id, user["id"])).fetchone():
+ raise HTTPException(404, "Hund nicht gefunden.")
+ conn.execute(
+ """INSERT INTO recurring_expenses
+ (user_id, dog_id, kategorie, betrag, haeufigkeit, startdatum, naechste_faelligkeit, notiz)
+ VALUES (?,?,?,?,?,?,?,?)""",
+ (user["id"], data.dog_id, data.kategorie, data.betrag,
+ data.haeufigkeit, data.startdatum, str(naechste), data.notiz),
+ )
+ row = conn.execute(
+ "SELECT * FROM recurring_expenses WHERE user_id=? ORDER BY id DESC LIMIT 1",
+ (user["id"],),
+ ).fetchone()
+ return dict(row)
+
+
+@router.patch("/recurring/{rid}")
+async def update_recurring(rid: int, data: RecurringUpdate, user=Depends(get_current_user)):
+ with db() as conn:
+ row = conn.execute(
+ "SELECT * FROM recurring_expenses WHERE id=? AND user_id=?", (rid, user["id"])
+ ).fetchone()
+ if not row:
+ raise HTTPException(404, "Dauerauftrag nicht gefunden.")
+ updates: dict = {}
+ if data.kategorie is not None:
+ if data.kategorie not in KATEGORIEN:
+ raise HTTPException(400, f"Ungültige Kategorie.")
+ updates["kategorie"] = data.kategorie
+ if data.betrag is not None:
+ updates["betrag"] = data.betrag
+ if data.haeufigkeit is not None:
+ if data.haeufigkeit not in HAEUFIGKEITEN:
+ raise HTTPException(400, "Ungültige Häufigkeit.")
+ updates["haeufigkeit"] = data.haeufigkeit
+ if data.startdatum is not None:
+ updates["startdatum"] = data.startdatum
+ if data.notiz is not None:
+ updates["notiz"] = data.notiz
+ if data.aktiv is not None:
+ updates["aktiv"] = 1 if data.aktiv else 0
+ if updates:
+ # naechste_faelligkeit neu berechnen wenn relevante Felder geändert
+ startdatum = updates.get("startdatum", row["startdatum"])
+ haeufigkeit = updates.get("haeufigkeit", row["haeufigkeit"])
+ today = date.today()
+ updates["naechste_faelligkeit"] = str(
+ _next_due(startdatum, haeufigkeit, today - timedelta(days=1))
+ )
+ set_clause = ", ".join(f"{k}=?" for k in updates)
+ conn.execute(f"UPDATE recurring_expenses SET {set_clause} WHERE id=?",
+ [*updates.values(), rid])
+ row = conn.execute("SELECT * FROM recurring_expenses WHERE id=?", (rid,)).fetchone()
+ return dict(row)
+
+
+@router.delete("/recurring/{rid}", status_code=204)
+async def delete_recurring(rid: int, user=Depends(get_current_user)):
+ with db() as conn:
+ if not conn.execute("SELECT 1 FROM recurring_expenses WHERE id=? AND user_id=?",
+ (rid, user["id"])).fetchone():
+ raise HTTPException(404, "Dauerauftrag nicht gefunden.")
+ conn.execute("DELETE FROM recurring_expenses WHERE id=?", (rid,))
+ return None
+
+
+def process_due_recurring(user_id: int | None = None):
+ """Legt fällige Daueraufträge als Einträge an. Wird vom Scheduler aufgerufen."""
+ today = date.today()
+ today_str = str(today)
+ with db() as conn:
+ where = "aktiv=1 AND naechste_faelligkeit <= ?"
+ params: list = [today_str]
+ if user_id:
+ where += " AND user_id=?"
+ params.append(user_id)
+ rows = conn.execute(
+ f"SELECT * FROM recurring_expenses WHERE {where}", params
+ ).fetchall()
+
+ for r in rows:
+ # Eintrag anlegen
+ conn.execute(
+ """INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz)
+ VALUES (?,?,?,?,?,?)""",
+ (r["user_id"], r["dog_id"], r["kategorie"], r["betrag"],
+ r["naechste_faelligkeit"],
+ f"[Dauerauftrag] {r['notiz'] or r['kategorie']}"),
+ )
+ # Nächste Fälligkeit berechnen
+ naechste = _next_due(r["startdatum"], r["haeufigkeit"],
+ date.fromisoformat(r["naechste_faelligkeit"]))
+ conn.execute(
+ "UPDATE recurring_expenses SET naechste_faelligkeit=? WHERE id=?",
+ (str(naechste), r["id"]),
+ )
+ return len(rows) if rows else 0
diff --git a/backend/scheduler.py b/backend/scheduler.py
index 4dcab4c..4aeb89a 100644
--- a/backend/scheduler.py
+++ b/backend/scheduler.py
@@ -124,6 +124,14 @@ def start():
replace_existing=True,
misfire_grace_time=3600,
)
+ # Täglich 06:30 — Wiederkehrende Ausgaben anlegen
+ _scheduler.add_job(
+ _job_recurring_expenses,
+ CronTrigger(hour=6, minute=30),
+ id="recurring_expenses",
+ replace_existing=True,
+ misfire_grace_time=3600,
+ )
# 1. des Monats 00:05 — Hund des Monats Sieger festlegen
_scheduler.add_job(
_job_hdm_winner,
@@ -1266,3 +1274,17 @@ async def _job_recall_check():
except Exception as e:
logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}")
_log_job("recall_check", "error", str(e))
+
+
+# ------------------------------------------------------------------
+# JOB: Wiederkehrende Ausgaben anlegen
+# ------------------------------------------------------------------
+async def _job_recurring_expenses():
+ try:
+ from routes.expenses import process_due_recurring
+ count = process_due_recurring()
+ logger.info(f"Daueraufträge: {count} Einträge angelegt.")
+ _log_job("recurring_expenses", "ok", f"{count} Einträge")
+ except Exception as e:
+ logger.error(f"Daueraufträge-Job Fehler: {e}")
+ _log_job("recurring_expenses", "error", str(e))
diff --git a/backend/static/css/components.css b/backend/static/css/components.css
index 1930060..60cdfb4 100644
--- a/backend/static/css/components.css
+++ b/backend/static/css/components.css
@@ -7382,3 +7382,56 @@ svg.empty-state-icon {
font-weight: var(--weight-semibold);
color: var(--c-text-secondary);
}
+
+/* Daueraufträge */
+.exp-recurring-card {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ background: var(--c-surface);
+ border: 1.5px solid var(--c-border);
+ border-radius: var(--radius-lg);
+ margin-bottom: var(--space-2);
+ transition: opacity .2s;
+}
+.exp-recurring-card--inaktiv { opacity: .55; }
+.exp-recurring-freq {
+ font-size: var(--text-xs);
+ color: var(--c-primary);
+ font-weight: var(--weight-semibold);
+ background: var(--c-primary-subtle);
+ padding: 1px var(--space-2);
+ border-radius: var(--radius-full);
+}
+.exp-recurring-next {
+ font-size: var(--text-xs);
+ color: var(--c-text-muted);
+ margin-top: var(--space-1);
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+ flex-wrap: wrap;
+}
+.exp-badge-inaktiv {
+ background: var(--c-surface-2);
+ color: var(--c-text-muted);
+ padding: 1px var(--space-2);
+ border-radius: var(--radius-full);
+ font-size: var(--text-xs);
+}
+.exp-icon-btn {
+ width: 28px;
+ height: 28px;
+ border: 1.5px solid var(--c-border);
+ border-radius: var(--radius-sm);
+ background: var(--c-surface);
+ color: var(--c-text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: color .15s, border-color .15s;
+}
+.exp-icon-btn:hover { color: var(--c-text); border-color: var(--c-text-muted); }
+.exp-icon-btn--danger:hover { color: var(--c-danger); border-color: var(--c-danger); }
diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg
index bdbfd78..ac08636 100644
--- a/backend/static/icons/phosphor.svg
+++ b/backend/static/icons/phosphor.svg
@@ -258,4 +258,12 @@