feat(notifications): täglicher Lauf über SMTP und Apprise

- Kanäle SMTP (HTML-Mail mit Logos als CID-Anhang) und Apprise, beide
  blockierenden Bibliotheken laufen in einem Thread
- Vier Anlässe: Fälligkeiten im Vorlauf, Kündigungsfristen in drei Stufen,
  überschrittene Budgets je Monat, Vertragsverlängerungen im Folgemonat
- APScheduler im Anwendungsprozess, täglich 07:00 Europe/Berlin, räumt zugleich
  abgelaufene Sitzungen auf
- Duplikatsschutz über den Zieltag statt den Versandtag; fehlgeschlagener
  Versand wird beim nächsten Lauf erneut versucht
- Endpunkte für Regeln, Protokoll, Testversand und sofortigen Lauf
- Einstellungsseite mit Einrichtungsstand, Regelpflege und Protokoll
- 25 neue Backend-Tests (260 gesamt), 9 neue Frontend-Tests (74 gesamt)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 17:04:30 +02:00
co-authored by Claude Opus 5
parent 0adf154049
commit 54c59c9f71
17 changed files with 2500 additions and 1 deletions
+15
View File
@@ -96,6 +96,21 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
Serienpaar aus (bei Deuteranopie nicht unterscheidbar) und bleibt den
Vorzeichen im Text vorbehalten.
- Benachrichtigungen über SMTP (HTML-Mail mit eingebetteten Firmenlogos) und
Apprise, gesteuert über Regeln je Anlass und Kanal.
- Vier Anlässe: bald fällige Posten, Kündigungsfristen (30, 14 und 7 Tage vor
dem Termin), überschrittene Budgets und anstehende Vertragsverlängerungen.
- APScheduler-Job täglich um 07:00 `Europe/Berlin`, der zugleich abgelaufene
Sitzungen aufräumt; verpasste Läufe werden einmal nachgeholt.
- Duplikatsschutz über `(rule_id, ref_type, ref_id, Zieltag)`. Maßgeblich ist der
Zieltag des Ereignisses, nicht der Versandtag ein zweiter Lauf am selben oder
am nächsten Tag erzeugt keine zweite Nachricht. Ein fehlgeschlagener Versand
wird beim nächsten Lauf erneut versucht.
- `POST /api/notifications/test` verschickt eine Testnachricht über beide Kanäle,
`POST /api/notifications/run` führt einen Lauf sofort aus.
- Benachrichtigungen sind in den Einstellungen pflegbar, samt Einrichtungsstand
der Kanäle und Versandprotokoll.
### Geändert
- `SECRET_KEY` muss mindestens 32 Zeichen lang sein (Vorgabe von HS256); in
+16
View File
@@ -83,6 +83,22 @@ vollständige, kommentierte Liste. Die wichtigsten:
| `COOKIE_SECURE` | `true` | Hinter HTTPS `true`, für lokales HTTP `false` |
| `SCHEDULER_ENABLED` | `true` | Täglicher Benachrichtigungslauf um 07:00 |
## Benachrichtigungen
Ein Job läuft täglich um 07:00 Uhr (`Europe/Berlin`) im Anwendungsprozess ohne
Redis, ohne Celery. Er prüft vier Anlässe: bald fällige Posten, Kündigungsfristen
30, 14 und 7 Tage vor dem Termin, überschrittene Budgets und Vertragsverlängerungen
im kommenden Monat.
Der Duplikatsschutz hängt am **Zieltag des Ereignisses**, nicht am Versandtag:
Eine Fälligkeit am 15.03. wird genau einmal gemeldet, unabhängig davon, an
welchem Tag des Vorlaufs der Job läuft. Ein fehlgeschlagener Versand gilt als
offen und wird beim nächsten Lauf erneut versucht.
Als Kanäle stehen SMTP (HTML-Mail mit eingebetteten Firmenlogos) und Apprise zur
Verfügung. Über *Einstellungen → Benachrichtigungen* lassen sich Regeln pflegen,
eine Testnachricht verschicken und das Versandprotokoll einsehen.
## Diagramme
Die Serienfarben stammen aus einer Palette, die gegen die hellen und dunklen
+2
View File
@@ -16,6 +16,7 @@ from app.api.routes import (
logos,
me,
merchants,
notifications,
occurrences,
recurrences,
reports,
@@ -45,5 +46,6 @@ protected.include_router(budgets.templates)
protected.include_router(savings_goals.router)
protected.include_router(reports.router)
protected.include_router(export.router)
protected.include_router(notifications.router)
api_router.include_router(protected)
+176
View File
@@ -0,0 +1,176 @@
"""Benachrichtigungsregeln, Versandprotokoll und Testversand."""
from datetime import date
from fastapi import APIRouter, Query, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.api.deps import DbSession
from app.core.config import settings
from app.core.errors import ConflictError
from app.models import NotificationLog, NotificationRule
from app.models.enums import NotificationChannel, NotificationStatus
from app.scheduler import next_run_time
from app.schemas.common import ErrorResponse, MessageResponse
from app.schemas.notification import (
ChannelStatusOut,
NotificationLogOut,
NotificationRuleCreate,
NotificationRuleOut,
NotificationRuleUpdate,
NotificationSettingsOut,
RunResponse,
TestResultOut,
TestSendRequest,
TestSendResponse,
)
from app.services.channels import get_channel
from app.services.crud import apply_updates, get_or_404
from app.services.notifications import run_all, send_test
router = APIRouter(prefix="/notifications", tags=["notifications"])
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
CHANNEL_HINTS = {
NotificationChannel.SMTP: "SMTP_HOST und SMTP_FROM setzen.",
NotificationChannel.APPRISE: "APPRISE_URLS setzen, komma-separiert.",
}
@router.get(
"/settings",
response_model=NotificationSettingsOut,
summary="Einrichtungsstand",
description="Zeigt, welche Kanäle einsatzbereit sind und wann der nächste Lauf ansteht.",
)
async def read_settings() -> NotificationSettingsOut:
kanaele = []
for art, hinweis in CHANNEL_HINTS.items():
eingerichtet = get_channel(art).is_configured()
kanaele.append(
ChannelStatusOut(
channel=art,
configured=eingerichtet,
detail="Einsatzbereit." if eingerichtet else hinweis,
)
)
return NotificationSettingsOut(
enabled=settings.notifications_enabled,
scheduler_enabled=settings.scheduler_enabled,
run_at=f"{settings.notification_hour:02d}:{settings.notification_minute:02d}",
timezone=settings.timezone,
next_run_at=next_run_time(),
channels=kanaele,
)
@router.get("/rules", response_model=list[NotificationRuleOut], summary="Regeln auflisten")
async def list_rules(session: DbSession) -> list[NotificationRule]:
stmt = select(NotificationRule).order_by(NotificationRule.type, NotificationRule.channel)
return list((await session.execute(stmt)).scalars().all())
@router.post(
"/rules",
response_model=NotificationRuleOut,
status_code=status.HTTP_201_CREATED,
summary="Regel anlegen",
)
async def create_rule(payload: NotificationRuleCreate, session: DbSession) -> NotificationRule:
regel = NotificationRule(**payload.model_dump())
session.add(regel)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError("Diese Regel existiert bereits.") from exc
await session.refresh(regel)
return regel
@router.patch(
"/rules/{rule_id}",
response_model=NotificationRuleOut,
responses=NOT_FOUND,
summary="Regel ändern",
)
async def update_rule(
rule_id: int, payload: NotificationRuleUpdate, session: DbSession
) -> NotificationRule:
regel = await get_or_404(session, NotificationRule, rule_id)
apply_updates(regel, payload)
await session.commit()
await session.refresh(regel)
return regel
@router.delete(
"/rules/{rule_id}",
response_model=MessageResponse,
responses=NOT_FOUND,
summary="Regel löschen",
description="Entfernt die Regel samt ihrem Versandprotokoll.",
)
async def delete_rule(rule_id: int, session: DbSession) -> MessageResponse:
regel = await get_or_404(session, NotificationRule, rule_id)
await session.delete(regel)
await session.commit()
return MessageResponse(detail="Regel gelöscht.")
@router.get(
"/log",
response_model=list[NotificationLogOut],
summary="Versandprotokoll",
description="Neueste Einträge zuerst.",
)
async def read_log(
session: DbSession,
rule_id: int | None = Query(default=None),
status_filter: NotificationStatus | None = Query(default=None, alias="status"),
limit: int = Query(default=100, ge=1, le=500),
) -> list[NotificationLog]:
stmt = select(NotificationLog).order_by(NotificationLog.sent_at.desc()).limit(limit)
if rule_id is not None:
stmt = stmt.where(NotificationLog.rule_id == rule_id)
if status_filter is not None:
stmt = stmt.where(NotificationLog.status == status_filter)
return list((await session.execute(stmt)).scalars().all())
@router.post(
"/test",
response_model=TestSendResponse,
summary="Testnachricht senden",
description="Verschickt eine Testnachricht über beide Kanäle. Nicht eingerichtete "
"Kanäle werden gemeldet, gelten aber nicht als Fehler.",
)
async def send_test_notification(payload: TestSendRequest | None = None) -> TestSendResponse:
ergebnisse = await send_test(payload.target if payload else None)
return TestSendResponse(
results=[TestResultOut.model_validate(eintrag) for eintrag in ergebnisse],
any_sent=any(eintrag.sent for eintrag in ergebnisse),
)
@router.post(
"/run",
response_model=RunResponse,
summary="Lauf sofort ausführen",
description="Führt alle aktiven Regeln aus, ohne auf den Zeitplan zu warten. "
"Bereits gemeldete Ereignisse werden dabei übersprungen.",
)
async def run_now(
session: DbSession,
as_of: date | None = Query(default=None, description="Stichtag; Vorgabe ist heute."),
) -> RunResponse:
ergebnis = await run_all(session, as_of)
return RunResponse(
checked=ergebnis.checked,
sent=ergebnis.sent,
skipped=ergebnis.skipped,
failed=ergebnis.failed,
)
+3
View File
@@ -11,6 +11,7 @@ from app.api.router import api_router
from app.core.config import settings
from app.core.errors import register_exception_handlers
from app.db.session import SessionLocal, engine
from app.scheduler import shutdown_scheduler, start_scheduler
from app.services.auth import ensure_admin_user, purge_expired_refresh_tokens
logging.basicConfig(
@@ -25,8 +26,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
"""Start- und Stopplogik der Anwendung."""
settings.logo_storage_dir.mkdir(parents=True, exist_ok=True)
await _bootstrap()
start_scheduler()
logger.info("moneyfy %s gestartet (%s)", settings.app_version, settings.environment)
yield
shutdown_scheduler()
await engine.dispose()
logger.info("moneyfy wird beendet")
+93
View File
@@ -0,0 +1,93 @@
"""Zeitgesteuerte Aufgaben.
APScheduler läuft im selben Prozess wie die Anwendung kein Redis, kein Celery.
Der tägliche Lauf prüft die Benachrichtigungsregeln und räumt abgelaufene
Sitzungen auf.
"""
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from app.core.clock import tz
from app.core.config import settings
from app.db.session import SessionLocal
from app.services.auth import purge_expired_refresh_tokens
from app.services.notifications import run_all
logger = logging.getLogger(__name__)
DAILY_JOB_ID = "moneyfy-daily"
_scheduler: AsyncIOScheduler | None = None
async def daily_job() -> None:
"""Täglicher Lauf. Fehler werden protokolliert, beenden den Scheduler aber nicht."""
try:
async with SessionLocal() as session:
await run_all(session)
entfernt = await purge_expired_refresh_tokens(session)
await session.commit()
if entfernt:
logger.info("%d abgelaufene Sitzungen entfernt.", entfernt)
except Exception:
logger.exception("Der tägliche Lauf ist fehlgeschlagen.")
def start_scheduler() -> AsyncIOScheduler | None:
"""Startet den Scheduler, sofern er eingeschaltet ist."""
global _scheduler
if not settings.scheduler_enabled:
logger.info("Scheduler ist abgeschaltet (SCHEDULER_ENABLED=false).")
return None
if _scheduler is not None:
return _scheduler
_scheduler = AsyncIOScheduler(timezone=tz())
_scheduler.add_job(
daily_job,
CronTrigger(
hour=settings.notification_hour,
minute=settings.notification_minute,
timezone=tz(),
),
id=DAILY_JOB_ID,
name="Benachrichtigungen und Aufräumen",
# Verpasste Läufe (etwa nach einem Neustart) einmal nachholen.
misfire_grace_time=3600,
coalesce=True,
max_instances=1,
replace_existing=True,
)
_scheduler.start()
logger.info(
"Scheduler gestartet täglicher Lauf um %02d:%02d %s.",
settings.notification_hour,
settings.notification_minute,
settings.timezone,
)
return _scheduler
def shutdown_scheduler() -> None:
"""Hält den Scheduler beim Beenden der Anwendung an."""
global _scheduler
if _scheduler is None:
return
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Scheduler beendet.")
def next_run_time():
"""Nächster geplanter Lauf, für die Anzeige in den Einstellungen."""
if _scheduler is None:
return None
job = _scheduler.get_job(DAILY_JOB_ID)
return job.next_run_time if job else None
+113
View File
@@ -0,0 +1,113 @@
"""Schemata für Benachrichtigungsregeln, Protokoll und Testversand."""
from datetime import date, datetime
from pydantic import Field
from app.models.enums import (
NotificationChannel,
NotificationStatus,
NotificationType,
)
from app.schemas.common import ApiModel, InputModel
TYPE_HINTS = {
"due_soon": "Fälligkeiten innerhalb der Vorlaufzeit.",
"notice_deadline": "Kündigungsfristen, die in 30, 14 oder 7 Tagen ablaufen.",
"budget_exceeded": "Überschrittene Budgets, einmal je Monat und Kategorie.",
"contract_renewal": "Verträge, die sich im kommenden Monat verlängern.",
}
class NotificationRuleCreate(InputModel):
type: NotificationType = Field(
description="; ".join(f"{k}: {v}" for k, v in TYPE_HINTS.items())
)
channel: NotificationChannel
lead_days: int = Field(
default=7, ge=0, le=365, description="Vorlauf in Tagen; nur für `due_soon` maßgeblich."
)
target: str | None = Field(
default=None,
description="Mailadresse bzw. Apprise-URL. Ohne Angabe gelten SMTP_FROM "
"beziehungsweise APPRISE_URLS.",
)
is_active: bool = True
class NotificationRuleUpdate(InputModel):
lead_days: int | None = Field(default=None, ge=0, le=365)
channel: NotificationChannel | None = None
target: str | None = None
is_active: bool | None = None
class NotificationRuleOut(ApiModel):
id: int
type: NotificationType
channel: NotificationChannel
lead_days: int
target: str | None
is_active: bool
created_at: datetime
class NotificationLogOut(ApiModel):
"""Ein Eintrag des Versandprotokolls."""
id: int
rule_id: int
ref_type: str
ref_id: str
dedupe_day: date = Field(
description="Zieltag des Ereignisses Grundlage des Duplikatsschutzes."
)
sent_at: datetime
status: NotificationStatus
error: str | None
class ChannelStatusOut(ApiModel):
"""Einrichtungsstand eines Kanals."""
channel: NotificationChannel
configured: bool
detail: str
class NotificationSettingsOut(ApiModel):
"""Überblick über Kanäle und Zeitplan."""
enabled: bool
scheduler_enabled: bool
run_at: str = Field(description="Uhrzeit des täglichen Laufs, etwa '07:00'.")
timezone: str
next_run_at: datetime | None
channels: list[ChannelStatusOut]
class TestSendRequest(InputModel):
target: str | None = Field(
default=None, description="Abweichendes Ziel; ohne Angabe gelten die Einstellungen."
)
class TestResultOut(ApiModel):
channel: NotificationChannel
configured: bool
sent: bool
error: str | None = None
class TestSendResponse(ApiModel):
results: list[TestResultOut]
any_sent: bool = Field(description="True, wenn mindestens ein Kanal zugestellt hat.")
class RunResponse(ApiModel):
"""Ergebnis eines manuell ausgelösten Laufs."""
checked: int
sent: int
skipped: int = Field(description="Bereits gemeldete Ereignisse.")
failed: int
+196
View File
@@ -0,0 +1,196 @@
"""Versandkanäle für Benachrichtigungen: SMTP und Apprise.
Beide Bibliotheken arbeiten blockierend; die Aufrufe laufen deshalb in einem
Thread, damit der Scheduler-Job den Event-Loop nicht anhält. Ein Kanal wirft
`ChannelError`, wenn der Versand fehlschlägt der Aufrufer protokolliert das.
"""
import asyncio
import logging
import smtplib
from dataclasses import dataclass, field
from email.message import EmailMessage
from email.utils import make_msgid
from pathlib import Path
from typing import Protocol, runtime_checkable
from app.core.config import settings
from app.models.enums import NotificationChannel
logger = logging.getLogger(__name__)
SMTP_TIMEOUT_SECONDS = 20
class ChannelError(RuntimeError):
"""Der Versand über einen Kanal ist fehlgeschlagen."""
@dataclass(frozen=True, slots=True)
class Attachment:
"""Ein eingebettetes Bild, das per Content-ID in der HTML-Mail steckt."""
cid: str
"""Ohne spitze Klammern im HTML als `cid:<wert>` referenziert."""
path: Path
mime: str
@dataclass(slots=True)
class Notification:
"""Eine fertig formulierte Nachricht."""
subject: str
text: str
html: str | None = None
attachments: list[Attachment] = field(default_factory=list)
@runtime_checkable
class Channel(Protocol):
"""Ein Weg, auf dem eine Nachricht den Nutzer erreicht."""
kind: NotificationChannel
def is_configured(self) -> bool:
"""False, wenn die nötigen Einstellungen fehlen der Kanal wird übersprungen."""
...
async def send(self, notification: Notification, target: str | None = None) -> None: ...
# --- SMTP ----------------------------------------------------------------------
class SmtpChannel:
"""Mailversand über einen SMTP-Server."""
kind = NotificationChannel.SMTP
def is_configured(self) -> bool:
return bool(settings.smtp_host and settings.smtp_from)
async def send(self, notification: Notification, target: str | None = None) -> None:
if not self.is_configured():
raise ChannelError("SMTP ist nicht konfiguriert (SMTP_HOST und SMTP_FROM fehlen).")
empfaenger = target or settings.smtp_from
if not empfaenger:
raise ChannelError("Kein Empfänger angegeben.")
nachricht = self._build(notification, empfaenger)
try:
await asyncio.to_thread(self._deliver, nachricht)
except ChannelError:
raise
except Exception as exc:
raise ChannelError(f"Mailversand fehlgeschlagen: {exc}") from exc
def _build(self, notification: Notification, empfaenger: str) -> EmailMessage:
"""Baut eine Mail mit Text- und HTML-Teil sowie eingebetteten Logos."""
nachricht = EmailMessage()
nachricht["Subject"] = notification.subject
nachricht["From"] = settings.smtp_from or ""
nachricht["To"] = empfaenger
nachricht.set_content(notification.text)
if notification.html is None:
return nachricht
html = notification.html
anhaenge: list[tuple[Attachment, str]] = []
for anhang in notification.attachments:
# Für jedes Bild eine echte Message-ID erzeugen und im HTML einsetzen.
message_id = make_msgid()
html = html.replace(f"cid:{anhang.cid}", f"cid:{message_id[1:-1]}")
anhaenge.append((anhang, message_id))
nachricht.add_alternative(html, subtype="html")
html_teil = nachricht.get_payload()[-1]
for anhang, message_id in anhaenge:
try:
inhalt = anhang.path.read_bytes()
except OSError:
logger.debug("Anhang %s konnte nicht gelesen werden.", anhang.path)
continue
haupttyp, _, untertyp = anhang.mime.partition("/")
html_teil.add_related(inhalt, maintype=haupttyp, subtype=untertyp, cid=message_id)
return nachricht
def _deliver(self, nachricht: EmailMessage) -> None:
"""Blockierender Teil des Versands."""
host = settings.smtp_host or ""
port = settings.smtp_port
if settings.smtp_use_ssl:
verbindung = smtplib.SMTP_SSL(host, port, timeout=SMTP_TIMEOUT_SECONDS)
else:
verbindung = smtplib.SMTP(host, port, timeout=SMTP_TIMEOUT_SECONDS)
with verbindung as server:
if settings.smtp_use_tls and not settings.smtp_use_ssl:
server.starttls()
if settings.smtp_user and settings.smtp_password:
server.login(settings.smtp_user, settings.smtp_password)
server.send_message(nachricht)
# --- Apprise -------------------------------------------------------------------
class AppriseChannel:
"""Versand über Apprise deckt ntfy, Gotify, Matrix, Discord und viele mehr ab."""
kind = NotificationChannel.APPRISE
def is_configured(self) -> bool:
return bool(settings.apprise_url_list)
async def send(self, notification: Notification, target: str | None = None) -> None:
# Eine Regel kann ein eigenes Ziel vorgeben, sonst gelten die globalen URLs.
ziele = [target] if target else settings.apprise_url_list
if not ziele:
raise ChannelError("Apprise ist nicht konfiguriert (APPRISE_URLS fehlt).")
try:
erfolg = await asyncio.to_thread(self._deliver, notification, ziele)
except Exception as exc:
raise ChannelError(f"Apprise-Versand fehlgeschlagen: {exc}") from exc
if not erfolg:
raise ChannelError("Apprise konnte die Nachricht an kein Ziel zustellen.")
def _deliver(self, notification: Notification, ziele: list[str]) -> bool:
import apprise
sammlung = apprise.Apprise()
for ziel in ziele:
if not sammlung.add(ziel):
logger.warning("Apprise-Ziel konnte nicht gelesen werden: %s", ziel)
if len(sammlung) == 0:
raise ChannelError("Kein gültiges Apprise-Ziel.")
return bool(sammlung.notify(title=notification.subject, body=notification.text))
# --- Registrierung -------------------------------------------------------------
_CHANNELS: dict[NotificationChannel, Channel] = {
NotificationChannel.SMTP: SmtpChannel(),
NotificationChannel.APPRISE: AppriseChannel(),
}
def get_channel(kind: NotificationChannel) -> Channel:
kanal = _CHANNELS.get(kind)
if kanal is None: # pragma: no cover - alle Enum-Werte sind registriert
raise ChannelError(f"Unbekannter Kanal: {kind}")
return kanal
def configured_channels() -> list[Channel]:
"""Alle Kanäle, die tatsächlich einsatzbereit sind."""
return [kanal for kanal in _CHANNELS.values() if kanal.is_configured()]
+533
View File
@@ -0,0 +1,533 @@
"""Benachrichtigungen: Ereignisse sammeln, Duplikate ausschließen, versenden.
Der Duplikatsschutz hängt nicht am Versandtag, sondern am Zieltag des Ereignisses:
Eine Fälligkeit am 15.03. wird genau einmal gemeldet, egal an welchem Tag des
Vorlaufs der Job läuft. Ein zweiter Lauf am selben Tag erzeugt daher ebenso wenig
eine zweite Nachricht wie ein Lauf am Folgetag.
"""
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import date, timedelta
from decimal import Decimal
from html import escape
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.clock import add_months, month_end, month_start, today, utcnow
from app.core.config import settings
from app.models import LogoAsset, Merchant, NotificationLog, NotificationRule
from app.models.enums import (
EntryKind,
NotificationChannel,
NotificationStatus,
NotificationType,
)
from app.services.channels import Attachment, ChannelError, Notification, get_channel
from app.services.occurrences import load_recurrences
from app.services.recurrence import contract_term
from app.services.reports import budget_status, flows
logger = logging.getLogger(__name__)
ZERO = Decimal("0.00")
# Vorlaufstufen für Kündigungsfristen gemäß Fachspezifikation.
NOTICE_STAGES = (30, 14, 7)
REF_OCCURRENCE = "occurrence"
REF_RECURRENCE = "recurrence"
REF_BUDGET = "budget"
TYPE_LABELS: dict[NotificationType, str] = {
NotificationType.DUE_SOON: "Bald fällig",
NotificationType.NOTICE_DEADLINE: "Kündigungsfrist läuft ab",
NotificationType.BUDGET_EXCEEDED: "Budget überschritten",
NotificationType.CONTRACT_RENEWAL: "Vertrag verlängert sich",
}
@dataclass(frozen=True, slots=True)
class Event:
"""Ein meldenswertes Ereignis."""
ref_type: str
ref_id: str
"""Stabiler Schlüssel innerhalb des ref_type."""
dedupe_day: date
"""Zieltag des Ereignisses nicht der Versandtag."""
headline: str
detail: str
amount: Decimal | None = None
merchant_id: int | None = None
on: date | None = None
@dataclass(slots=True)
class RunResult:
"""Ergebnis eines Laufs."""
checked: int = 0
sent: int = 0
skipped: int = 0
failed: int = 0
by_rule: dict[int, int] = field(default_factory=dict)
# --- Ereignisse sammeln --------------------------------------------------------
def _money(amount: Decimal | None) -> str:
if amount is None:
return ""
return f"{amount:,.2f}".replace(",", "#").replace(".", ",").replace("#", ".")
def _day(value: date) -> str:
return value.strftime("%d.%m.%Y")
async def collect_due_soon(
session: AsyncSession, rule: NotificationRule, as_of: date
) -> list[Event]:
"""Fälligkeiten innerhalb des Vorlaufs."""
ende = as_of + timedelta(days=max(rule.lead_days, 0))
ereignisse: list[Event] = []
for eintrag in await flows(session, as_of, ende):
# Einmalige Buchungen sind bereits erfasst und brauchen keine Erinnerung.
if eintrag.source != "recurrence" or eintrag.recurrence_id is None:
continue
if eintrag.is_confirmed:
continue
richtung = "Einkunft" if eintrag.kind is EntryKind.INCOME else "Zahlung"
ereignisse.append(
Event(
ref_type=REF_OCCURRENCE,
ref_id=f"{eintrag.recurrence_id}:{eintrag.occurrence_date}",
dedupe_day=eintrag.on,
headline=eintrag.title,
detail=f"{richtung} am {_day(eintrag.on)} über {_money(eintrag.amount)}",
amount=eintrag.amount,
merchant_id=eintrag.merchant_id,
on=eintrag.on,
)
)
return ereignisse
async def collect_notice_deadlines(
session: AsyncSession, rule: NotificationRule, as_of: date
) -> list[Event]:
"""Kündigungsfristen, die in 30, 14 oder 7 Tagen ablaufen.
Jede Stufe wird einmal gemeldet; der Schlüssel enthält deshalb die Stufe.
"""
# Aufsteigend, damit die *engste* zutreffende Stufe gewinnt: bei 14 Resttagen
# ist es die 14er-Stufe, nicht erneut die 30er.
stufen = sorted({*NOTICE_STAGES, rule.lead_days} - {0})
ereignisse: list[Event] = []
for recurrence in await load_recurrences(session):
if recurrence.contract_cancelled_at is not None:
continue
laufzeit = contract_term(recurrence, as_of)
if laufzeit is None or laufzeit.notice_deadline is None:
continue
verbleibend = (laufzeit.notice_deadline - as_of).days
if verbleibend < 0:
continue
# Die erste Stufe, die der Termin gerade erreicht oder unterschritten hat.
stufe = next((wert for wert in stufen if verbleibend <= wert), None)
if stufe is None:
continue
ereignisse.append(
Event(
ref_type=REF_RECURRENCE,
ref_id=f"{recurrence.id}:{stufe}",
dedupe_day=laufzeit.notice_deadline,
headline=recurrence.title,
detail=(
f"Kündigung bis {_day(laufzeit.notice_deadline)} möglich "
f"(noch {verbleibend} Tage), Laufzeit endet am {_day(laufzeit.term_end)}"
),
amount=recurrence.amount,
merchant_id=recurrence.merchant_id,
on=laufzeit.notice_deadline,
)
)
return ereignisse
async def collect_budget_exceeded(
session: AsyncSession, rule: NotificationRule, as_of: date
) -> list[Event]:
"""Überschrittene Budgets höchstens einmal je Monat und Kategorie."""
monat = month_start(as_of)
return [
Event(
ref_type=REF_BUDGET,
ref_id=str(eintrag.category_id),
dedupe_day=monat,
headline=eintrag.category_name,
detail=(
f"{_money(eintrag.spent)} von {_money(eintrag.available)} verbraucht "
f"({round(float(eintrag.ratio) * 100)} %), "
f"{_money(abs(eintrag.remaining))} zu viel"
),
amount=eintrag.spent,
on=month_end(monat),
)
for eintrag in await budget_status(session, monat)
if eintrag.state == "exceeded"
]
async def collect_contract_renewals(
session: AsyncSession, rule: NotificationRule, as_of: date
) -> list[Event]:
"""Verträge, die sich im kommenden Monat automatisch verlängern."""
fenster_ende = month_end(add_months(as_of, 1))
ereignisse: list[Event] = []
for recurrence in await load_recurrences(session):
if recurrence.contract_cancelled_at is not None:
continue
laufzeit = contract_term(recurrence, as_of)
if laufzeit is None or laufzeit.renews_on is None:
continue
if not as_of <= laufzeit.renews_on <= fenster_ende:
continue
verlaengerung = recurrence.contract_auto_renew_months
ereignisse.append(
Event(
ref_type=REF_RECURRENCE,
ref_id=str(recurrence.id),
dedupe_day=laufzeit.renews_on,
headline=recurrence.title,
detail=(
f"Verlängert sich am {_day(laufzeit.renews_on)}"
+ (f" um {verlaengerung} Monate" if verlaengerung else "")
),
amount=recurrence.amount,
merchant_id=recurrence.merchant_id,
on=laufzeit.renews_on,
)
)
return ereignisse
COLLECTORS = {
NotificationType.DUE_SOON: collect_due_soon,
NotificationType.NOTICE_DEADLINE: collect_notice_deadlines,
NotificationType.BUDGET_EXCEEDED: collect_budget_exceeded,
NotificationType.CONTRACT_RENEWAL: collect_contract_renewals,
}
# --- Nachricht bauen -----------------------------------------------------------
async def _logo_attachments(session: AsyncSession, events: list[Event]) -> dict[int, Attachment]:
"""Lädt die Logodateien der beteiligten Firmen für den Mailanhang."""
firmen_ids = {ereignis.merchant_id for ereignis in events if ereignis.merchant_id}
if not firmen_ids:
return {}
stmt = (
select(Merchant, LogoAsset)
.join(LogoAsset, LogoAsset.id == Merchant.logo_asset_id)
.where(Merchant.id.in_(firmen_ids))
)
anhaenge: dict[int, Attachment] = {}
for merchant, asset in (await session.execute(stmt)).all():
pfad = Path(settings.logo_storage_dir) / asset.file_path
if not pfad.exists():
continue
anhaenge[merchant.id] = Attachment(cid=f"logo-{merchant.id}", path=pfad, mime=asset.mime)
return anhaenge
def build_notification(
rule_type: NotificationType,
events: list[Event],
attachments: dict[int, Attachment],
) -> Notification:
"""Formuliert aus den Ereignissen eine Nachricht in Text und HTML."""
ueberschrift = TYPE_LABELS[rule_type]
betreff = (
f"moneyfy: {ueberschrift}"
if len(events) == 1
else f"moneyfy: {ueberschrift} ({len(events)})"
)
text_zeilen = [ueberschrift, ""]
for ereignis in events:
text_zeilen.append(f"{ereignis.headline}: {ereignis.detail}")
text_zeilen.extend(["", settings.public_base_url])
zeilen_html: list[str] = []
for ereignis in events:
anhang = attachments.get(ereignis.merchant_id or -1)
bild = (
f'<img src="cid:{anhang.cid}" alt="" width="28" height="28" '
'style="border-radius:6px;vertical-align:middle;margin-right:10px">'
if anhang
else ""
)
zeilen_html.append(
'<tr><td style="padding:10px 0;border-bottom:1px solid #e2e8f0">'
f"{bild}"
f'<strong style="color:#0f172a">{escape(ereignis.headline)}</strong><br>'
f'<span style="color:#475569;font-size:14px">{escape(ereignis.detail)}</span>'
"</td></tr>"
)
html = f"""<!doctype html>
<html lang="de"><body style="margin:0;padding:24px;background:#f8fafc;
font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif">
<div style="max-width:560px;margin:0 auto;background:#ffffff;border-radius:14px;
border:1px solid #e2e8f0;padding:24px">
<h1 style="margin:0 0 4px;font-size:18px;color:#0f172a">{escape(ueberschrift)}</h1>
<p style="margin:0 0 16px;font-size:13px;color:#64748b">
{len(events)} {"Eintrag" if len(events) == 1 else "Einträge"}
</p>
<table style="width:100%;border-collapse:collapse">{"".join(zeilen_html)}</table>
<p style="margin:20px 0 0;font-size:13px">
<a href="{escape(settings.public_base_url)}" style="color:#16a34a">In moneyfy öffnen</a>
</p>
</div>
</body></html>"""
beteiligte = {
ereignis.merchant_id for ereignis in events if ereignis.merchant_id in attachments
}
return Notification(
subject=betreff,
text="\n".join(text_zeilen),
html=html,
attachments=[attachments[firma_id] for firma_id in beteiligte if firma_id],
)
# --- Versand und Protokoll -----------------------------------------------------
async def _existing_log(
session: AsyncSession, rule_id: int, event: Event
) -> NotificationLog | None:
stmt = select(NotificationLog).where(
NotificationLog.rule_id == rule_id,
NotificationLog.ref_type == event.ref_type,
NotificationLog.ref_id == event.ref_id,
NotificationLog.dedupe_day == event.dedupe_day,
)
return (await session.execute(stmt)).scalar_one_or_none()
async def pending_events(
session: AsyncSession, rule: NotificationRule, as_of: date
) -> tuple[list[Event], list[Event], dict[str, NotificationLog]]:
"""Trennt neue Ereignisse von bereits gemeldeten.
Ein zuvor fehlgeschlagener Versand gilt als offen und wird erneut versucht;
der vorhandene Protokolleintrag wird dabei aktualisiert statt neu angelegt.
"""
sammler = COLLECTORS[rule.type]
alle = await sammler(session, rule, as_of)
offen: list[Event] = []
erledigt: list[Event] = []
vorhandene: dict[str, NotificationLog] = {}
for ereignis in alle:
eintrag = await _existing_log(session, rule.id, ereignis)
if eintrag is None:
offen.append(ereignis)
continue
if eintrag.status is NotificationStatus.FAILED:
offen.append(ereignis)
vorhandene[f"{ereignis.ref_type}:{ereignis.ref_id}"] = eintrag
continue
erledigt.append(ereignis)
return offen, erledigt, vorhandene
async def _record(
session: AsyncSession,
rule: NotificationRule,
events: list[Event],
existing: dict[str, NotificationLog],
status: NotificationStatus,
error: str | None,
) -> None:
"""Schreibt das Versandprotokoll für alle Ereignisse einer Nachricht."""
zeitpunkt = utcnow()
for ereignis in events:
schluessel = f"{ereignis.ref_type}:{ereignis.ref_id}"
eintrag = existing.get(schluessel)
if eintrag is not None:
eintrag.status = status
eintrag.error = error
eintrag.sent_at = zeitpunkt
continue
session.add(
NotificationLog(
rule_id=rule.id,
ref_type=ereignis.ref_type,
ref_id=ereignis.ref_id,
dedupe_day=ereignis.dedupe_day,
sent_at=zeitpunkt,
status=status,
error=error,
)
)
await session.flush()
async def run_rule(
session: AsyncSession, rule: NotificationRule, as_of: date | None = None
) -> tuple[int, int, int]:
"""Führt eine Regel aus. Liefert (geprüft, versendet, übersprungen)."""
stichtag = as_of or today()
offen, erledigt, vorhandene = await pending_events(session, rule, stichtag)
if not offen:
return len(offen) + len(erledigt), 0, len(erledigt)
anhaenge = await _logo_attachments(session, offen)
nachricht = build_notification(rule.type, offen, anhaenge)
try:
await get_channel(rule.channel).send(nachricht, rule.target)
except ChannelError as fehler:
logger.warning("Regel %s konnte nicht zugestellt werden: %s", rule.id, fehler)
await _record(session, rule, offen, vorhandene, NotificationStatus.FAILED, str(fehler))
return len(offen) + len(erledigt), 0, len(erledigt)
await _record(session, rule, offen, vorhandene, NotificationStatus.SENT, None)
return len(offen) + len(erledigt), len(offen), len(erledigt)
async def run_all(session: AsyncSession, as_of: date | None = None) -> RunResult:
"""Führt alle aktiven Regeln aus der tägliche Lauf des Schedulers."""
if not settings.notifications_enabled:
logger.info("Benachrichtigungen sind abgeschaltet.")
return RunResult()
stichtag = as_of or today()
stmt = select(NotificationRule).where(NotificationRule.is_active.is_(True))
regeln = list((await session.execute(stmt)).scalars())
ergebnis = RunResult()
for regel in regeln:
try:
geprueft, versendet, uebersprungen = await run_rule(session, regel, stichtag)
except Exception:
# Eine fehlerhafte Regel darf die übrigen nicht verhindern.
logger.exception("Regel %s ist fehlgeschlagen.", regel.id)
ergebnis.failed += 1
continue
ergebnis.checked += geprueft
ergebnis.sent += versendet
ergebnis.skipped += uebersprungen
if versendet:
ergebnis.by_rule[regel.id] = versendet
await session.commit()
logger.info(
"Benachrichtigungslauf: %d geprüft, %d versendet, %d übersprungen, %d fehlerhaft.",
ergebnis.checked,
ergebnis.sent,
ergebnis.skipped,
ergebnis.failed,
)
return ergebnis
# --- Testversand ---------------------------------------------------------------
def test_notification() -> Notification:
"""Eine kurze Nachricht, mit der sich die Einrichtung prüfen lässt."""
zeitpunkt = utcnow().astimezone().strftime("%d.%m.%Y um %H:%M Uhr")
return Notification(
subject="moneyfy: Testnachricht",
text=(
"Das ist eine Testnachricht von moneyfy.\n"
f"Gesendet am {zeitpunkt}.\n\n"
"Wenn du sie liest, ist der Kanal richtig eingerichtet.\n\n"
f"{settings.public_base_url}"
),
html=f"""<!doctype html>
<html lang="de"><body style="margin:0;padding:24px;background:#f8fafc;
font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif">
<div style="max-width:560px;margin:0 auto;background:#ffffff;border-radius:14px;
border:1px solid #e2e8f0;padding:24px">
<h1 style="margin:0 0 8px;font-size:18px;color:#0f172a">Testnachricht</h1>
<p style="margin:0;font-size:14px;color:#475569">
Wenn du das liest, ist der Kanal richtig eingerichtet.<br>
Gesendet am {zeitpunkt}.
</p>
<p style="margin:20px 0 0;font-size:13px">
<a href="{escape(settings.public_base_url)}" style="color:#16a34a">moneyfy öffnen</a>
</p>
</div>
</body></html>""",
)
@dataclass(frozen=True, slots=True)
class TestResult:
"""Ergebnis eines Testversands je Kanal."""
channel: NotificationChannel
configured: bool
sent: bool
error: str | None = None
async def send_test(target: str | None = None) -> list[TestResult]:
"""Verschickt eine Testnachricht über beide Kanäle.
Nicht eingerichtete Kanäle werden gemeldet, aber nicht als Fehler gewertet.
"""
nachricht = test_notification()
ergebnisse: list[TestResult] = []
for art in (NotificationChannel.SMTP, NotificationChannel.APPRISE):
kanal = get_channel(art)
if not kanal.is_configured():
ergebnisse.append(TestResult(channel=art, configured=False, sent=False))
continue
try:
await kanal.send(nachricht, target)
except ChannelError as fehler:
ergebnisse.append(
TestResult(channel=art, configured=True, sent=False, error=str(fehler))
)
continue
ergebnisse.append(TestResult(channel=art, configured=True, sent=True))
return ergebnisse
def group_by_type(events: list[Event]) -> dict[str, list[Event]]:
"""Hilfsfunktion für Vorschauen in der Oberfläche."""
gruppen: dict[str, list[Event]] = defaultdict(list)
for ereignis in events:
gruppen[ereignis.ref_type].append(ereignis)
return dict(gruppen)
+544
View File
@@ -0,0 +1,544 @@
"""Tests der Benachrichtigungen: Ereignisse, Duplikatsschutz und Versand."""
import smtplib
from datetime import date, timedelta
from email.message import EmailMessage
from typing import ClassVar
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models import NotificationLog, NotificationRule
from app.models.enums import NotificationChannel, NotificationStatus, NotificationType
from app.services import notifications as service
from app.services.channels import AppriseChannel, ChannelError, SmtpChannel
def text_of(nachricht: EmailMessage) -> str:
"""Der reine Textteil einer mehrteiligen Mail."""
teil = nachricht.get_body(preferencelist=("plain",))
assert teil is not None, "Die Mail hat keinen Textteil."
return teil.get_content()
# --- Doppel für die Kanäle ------------------------------------------------------
class FakeSmtp:
"""Ersetzt `smtplib.SMTP` und merkt sich die versendeten Nachrichten."""
versendet: ClassVar[list[EmailMessage]] = []
fehler: ClassVar[Exception | None] = None
def __init__(self, host: str, port: int, timeout: int = 0) -> None:
self.host = host
self.port = port
def __enter__(self) -> "FakeSmtp":
return self
def __exit__(self, *_args: object) -> None:
return None
def starttls(self) -> None:
return None
def login(self, user: str, password: str) -> None:
return None
def send_message(self, nachricht: EmailMessage) -> None:
if FakeSmtp.fehler is not None:
raise FakeSmtp.fehler
FakeSmtp.versendet.append(nachricht)
class FakeApprise:
"""Ersetzt `apprise.Apprise`."""
versendet: ClassVar[list[tuple[str, str]]] = []
erfolg: ClassVar[bool] = True
def __init__(self) -> None:
self.ziele: list[str] = []
def add(self, ziel: str) -> bool:
self.ziele.append(ziel)
return True
def __len__(self) -> int:
return len(self.ziele)
def notify(self, title: str, body: str) -> bool:
if FakeApprise.erfolg:
FakeApprise.versendet.append((title, body))
return FakeApprise.erfolg
@pytest.fixture
def smtp(monkeypatch: pytest.MonkeyPatch) -> type[FakeSmtp]:
"""Richtet SMTP ein und fängt den Versand ab."""
FakeSmtp.versendet = []
FakeSmtp.fehler = None
monkeypatch.setattr(smtplib, "SMTP", FakeSmtp)
monkeypatch.setattr(settings, "smtp_host", "mail.example.org")
monkeypatch.setattr(settings, "smtp_from", "moneyfy@example.org")
monkeypatch.setattr(settings, "smtp_use_tls", False)
monkeypatch.setattr(settings, "smtp_use_ssl", False)
return FakeSmtp
@pytest.fixture
def apprise(monkeypatch: pytest.MonkeyPatch) -> type[FakeApprise]:
"""Richtet Apprise ein und fängt den Versand ab."""
import apprise as apprise_modul
FakeApprise.versendet = []
FakeApprise.erfolg = True
monkeypatch.setattr(apprise_modul, "Apprise", FakeApprise)
monkeypatch.setattr(settings, "apprise_urls", "ntfy://example.org/moneyfy")
return FakeApprise
async def regel(
session: AsyncSession,
typ: NotificationType,
*,
kanal: NotificationChannel = NotificationChannel.SMTP,
lead_days: int = 7,
target: str | None = None,
) -> NotificationRule:
eintrag = NotificationRule(
type=typ, channel=kanal, lead_days=lead_days, target=target, is_active=True
)
session.add(eintrag)
await session.flush()
return eintrag
async def abo(client: AsyncClient, seeded: dict, **overrides) -> dict:
payload = {
"kind": "expense",
"title": "Netflix",
"category_id": seeded["streaming"],
"account_id": seeded["account_id"],
"amount": "13.99",
"rrule": "FREQ=MONTHLY;BYMONTHDAY=15",
"dtstart": "2026-01-15",
"business_day_shift": "none",
}
payload.update(overrides)
antwort = await client.post("/api/recurrences", json=payload)
assert antwort.status_code == 201, antwort.text
return antwort.json()
# --- Kanäle ---------------------------------------------------------------------
async def test_smtp_baut_eine_mail_mit_text_und_html(smtp: type[FakeSmtp]) -> None:
await SmtpChannel().send(service.test_notification(), "jemand@example.org")
assert len(smtp.versendet) == 1
nachricht = smtp.versendet[0]
assert nachricht["To"] == "jemand@example.org"
assert nachricht["From"] == "moneyfy@example.org"
assert nachricht["Subject"] == "moneyfy: Testnachricht"
# Text und HTML liegen als Alternativen nebeneinander.
typen = {teil.get_content_type() for teil in nachricht.walk()}
assert "text/plain" in typen
assert "text/html" in typen
async def test_smtp_ohne_konfiguration_meldet_das(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "smtp_host", None)
assert SmtpChannel().is_configured() is False
with pytest.raises(ChannelError, match="nicht konfiguriert"):
await SmtpChannel().send(service.test_notification())
async def test_smtp_reicht_serverfehler_als_channelerror_durch(
smtp: type[FakeSmtp],
) -> None:
smtp.fehler = smtplib.SMTPRecipientsRefused({})
with pytest.raises(ChannelError, match="Mailversand fehlgeschlagen"):
await SmtpChannel().send(service.test_notification(), "jemand@example.org")
async def test_apprise_versendet_titel_und_text(apprise: type[FakeApprise]) -> None:
await AppriseChannel().send(service.test_notification())
assert len(apprise.versendet) == 1
titel, text = apprise.versendet[0]
assert titel == "moneyfy: Testnachricht"
assert "Testnachricht von moneyfy" in text
async def test_apprise_ohne_ziel_meldet_das(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "apprise_urls", None)
assert AppriseChannel().is_configured() is False
with pytest.raises(ChannelError, match="nicht konfiguriert"):
await AppriseChannel().send(service.test_notification())
async def test_apprise_meldet_fehlgeschlagene_zustellung(
apprise: type[FakeApprise],
) -> None:
apprise.erfolg = False
with pytest.raises(ChannelError, match="kein Ziel"):
await AppriseChannel().send(service.test_notification())
# --- Testversand ----------------------------------------------------------------
async def test_testversand_ueber_beide_kanaele(
auth_client: AsyncClient, smtp: type[FakeSmtp], apprise: type[FakeApprise]
) -> None:
"""Akzeptanzkriterium: der Testversand erreicht beide Kanäle."""
antwort = await auth_client.post("/api/notifications/test", json={})
assert antwort.status_code == 200
body = antwort.json()
assert body["any_sent"] is True
nach_kanal = {eintrag["channel"]: eintrag for eintrag in body["results"]}
assert nach_kanal["smtp"] == {
"channel": "smtp",
"configured": True,
"sent": True,
"error": None,
}
assert nach_kanal["apprise"]["sent"] is True
assert len(smtp.versendet) == 1
assert len(apprise.versendet) == 1
async def test_testversand_meldet_nicht_eingerichtete_kanaele(
auth_client: AsyncClient, smtp: type[FakeSmtp], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "apprise_urls", None)
body = (await auth_client.post("/api/notifications/test", json={})).json()
nach_kanal = {eintrag["channel"]: eintrag for eintrag in body["results"]}
assert nach_kanal["smtp"]["sent"] is True
assert nach_kanal["apprise"]["configured"] is False
assert nach_kanal["apprise"]["sent"] is False
# Ein fehlender Kanal ist kein Fehler.
assert body["any_sent"] is True
async def test_einrichtungsstand_wird_gemeldet(
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "smtp_host", None)
monkeypatch.setattr(settings, "apprise_urls", None)
body = (await auth_client.get("/api/notifications/settings")).json()
assert body["run_at"] == "07:00"
assert body["timezone"] == "Europe/Berlin"
nach_kanal = {eintrag["channel"]: eintrag for eintrag in body["channels"]}
assert nach_kanal["smtp"]["configured"] is False
assert "SMTP_HOST" in nach_kanal["smtp"]["detail"]
# --- Duplikatsschutz ------------------------------------------------------------
async def test_zweiter_lauf_am_selben_tag_sendet_nicht_erneut(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
"""Akzeptanzkriterium: kein Doppelversand beim zweiten Lauf."""
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=7)
erster = await service.run_all(session, date(2026, 3, 10))
zweiter = await service.run_all(session, date(2026, 3, 10))
assert erster.sent == 1
assert zweiter.sent == 0
assert zweiter.skipped == 1
assert len(smtp.versendet) == 1
async def test_auch_ein_spaeterer_lauf_im_vorlauf_sendet_nicht_erneut(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
"""Der Schlüssel ist der Zieltag, nicht der Versandtag."""
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=7)
await service.run_all(session, date(2026, 3, 10))
await service.run_all(session, date(2026, 3, 11))
await service.run_all(session, date(2026, 3, 12))
assert len(smtp.versendet) == 1
async def test_naechster_monat_wird_wieder_gemeldet(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=7)
await service.run_all(session, date(2026, 3, 10))
await service.run_all(session, date(2026, 4, 10))
assert len(smtp.versendet) == 2
async def test_protokoll_haelt_den_zieltag_fest(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=7)
await service.run_all(session, date(2026, 3, 10))
eintraege = (await session.execute(select(NotificationLog))).scalars().all()
assert len(eintraege) == 1
assert eintraege[0].status is NotificationStatus.SENT
assert eintraege[0].dedupe_day == date(2026, 3, 15)
assert eintraege[0].ref_type == "occurrence"
async def test_fehlgeschlagener_versand_wird_erneut_versucht(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
"""Ein Fehlschlag darf nicht dazu führen, dass die Meldung verloren geht."""
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=7)
smtp.fehler = smtplib.SMTPServerDisconnected("Server weg")
erster = await service.run_all(session, date(2026, 3, 10))
assert erster.sent == 0
eintrag = (await session.execute(select(NotificationLog))).scalar_one()
assert eintrag.status is NotificationStatus.FAILED
assert eintrag.error
smtp.fehler = None
zweiter = await service.run_all(session, date(2026, 3, 11))
assert zweiter.sent == 1
assert len(smtp.versendet) == 1
# Der vorhandene Eintrag wird aktualisiert, kein zweiter angelegt.
eintraege = (await session.execute(select(NotificationLog))).scalars().all()
assert len(eintraege) == 1
assert eintraege[0].status is NotificationStatus.SENT
assert eintraege[0].error is None
# --- Ereignisarten --------------------------------------------------------------
async def test_faelligkeiten_ausserhalb_des_vorlaufs_bleiben_still(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=3)
ergebnis = await service.run_all(session, date(2026, 3, 1))
assert ergebnis.sent == 0
assert smtp.versendet == []
async def test_bestaetigte_faelligkeit_wird_nicht_gemeldet(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
posten = await abo(
auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15"
)
await auth_client.post(
"/api/occurrences/confirm",
json={"recurrence_id": posten["id"], "occurrence_date": "2026-03-15"},
)
await regel(session, NotificationType.DUE_SOON, lead_days=7)
ergebnis = await service.run_all(session, date(2026, 3, 10))
assert ergebnis.sent == 0
async def test_kuendigungsfrist_meldet_sich_in_drei_stufen(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
"""30, 14 und 7 Tage vor dem Termin je einmal."""
await abo(
auth_client,
seeded,
title="Handyvertrag",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-01-01",
contract_start="2026-01-01",
contract_min_term_months=24,
contract_notice_period_days=90,
)
await regel(session, NotificationType.NOTICE_DEADLINE, lead_days=30)
# Laufzeitende 31.12.2027, Frist also am 02.10.2027.
frist = date(2027, 10, 2)
for versatz in (30, 14, 7):
await service.run_all(session, frist - timedelta(days=versatz))
# Ein weiterer Lauf innerhalb derselben Stufe meldet nichts Neues.
await service.run_all(session, frist - timedelta(days=6))
assert len(smtp.versendet) == 3
assert all("Handyvertrag" in text_of(nachricht) for nachricht in smtp.versendet)
async def test_gekuendigter_vertrag_meldet_keine_frist(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
posten = await abo(
auth_client,
seeded,
title="Handyvertrag",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-01-01",
contract_start="2026-01-01",
contract_min_term_months=24,
contract_notice_period_days=90,
)
await auth_client.post(f"/api/recurrences/{posten['id']}/cancel")
await regel(session, NotificationType.NOTICE_DEADLINE)
ergebnis = await service.run_all(session, date(2027, 9, 15))
assert ergebnis.sent == 0
async def test_ueberschrittenes_budget_meldet_sich_einmal_im_monat(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await auth_client.post(
"/api/budgets",
json={
"category_id": seeded["lebensmittel"],
"period_month": "2026-03-01",
"limit_amount": "100.00",
},
)
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Großeinkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "150.00",
"booking_date": "2026-03-05",
},
)
await regel(session, NotificationType.BUDGET_EXCEEDED)
erster = await service.run_all(session, date(2026, 3, 10))
zweiter = await service.run_all(session, date(2026, 3, 20))
assert erster.sent == 1
assert zweiter.sent == 0
assert "Lebensmittel" in text_of(smtp.versendet[0])
async def test_vertragsverlaengerung_im_kommenden_monat(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await abo(
auth_client,
seeded,
title="Zeitschrift",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-01-01",
contract_start="2026-01-01",
contract_min_term_months=12,
contract_auto_renew_months=12,
)
await regel(session, NotificationType.CONTRACT_RENEWAL)
# Verlängerung am 01.01.2027; ein Lauf im Dezember trifft das Fenster.
ergebnis = await service.run_all(session, date(2026, 12, 5))
assert ergebnis.sent == 1
assert "Zeitschrift" in text_of(smtp.versendet[0])
async def test_abgeschaltete_regel_laeuft_nicht(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
eintrag = await regel(session, NotificationType.DUE_SOON, lead_days=7)
eintrag.is_active = False
await session.flush()
ergebnis = await service.run_all(session, date(2026, 3, 10))
assert ergebnis.sent == 0
assert smtp.versendet == []
async def test_abgeschaltete_benachrichtigungen(
session: AsyncSession, monkeypatch: pytest.MonkeyPatch, smtp: type[FakeSmtp]
) -> None:
monkeypatch.setattr(settings, "notifications_enabled", False)
ergebnis = await service.run_all(session, date(2026, 3, 10))
assert ergebnis.checked == 0
assert smtp.versendet == []
# --- Regeln über die API --------------------------------------------------------
async def test_regeln_pflegen(auth_client: AsyncClient) -> None:
angelegt = await auth_client.post(
"/api/notifications/rules",
json={"type": "due_soon", "channel": "apprise", "lead_days": 5},
)
assert angelegt.status_code == 201
eintrag = angelegt.json()
assert eintrag["lead_days"] == 5
geaendert = await auth_client.patch(
f"/api/notifications/rules/{eintrag['id']}", json={"lead_days": 10, "is_active": False}
)
assert geaendert.json()["lead_days"] == 10
assert geaendert.json()["is_active"] is False
liste = (await auth_client.get("/api/notifications/rules")).json()
assert any(regel["id"] == eintrag["id"] for regel in liste)
assert (
await auth_client.delete(f"/api/notifications/rules/{eintrag['id']}")
).status_code == 200
async def test_lauf_ueber_die_api(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=7)
antwort = await auth_client.post("/api/notifications/run", params={"as_of": "2026-03-10"})
assert antwort.status_code == 200
assert antwort.json()["sent"] == 1
protokoll = (await auth_client.get("/api/notifications/log")).json()
assert len(protokoll) == 1
assert protokoll[0]["status"] == "sent"
async def test_benachrichtigungen_verlangen_anmeldung(client: AsyncClient) -> None:
assert (await client.get("/api/notifications/rules")).status_code == 401
assert (await client.post("/api/notifications/test", json={})).status_code == 401
@@ -0,0 +1,214 @@
/** Tests der Benachrichtigungs-Einstellungen. */
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NotificationSettings } from "@/components/NotificationSettings";
import { renderWithProviders } from "@/test/utils";
import type { NotificationRule, NotificationSettings as Einstellungen } from "@/types/api";
const EINSTELLUNGEN: Einstellungen = {
enabled: true,
scheduler_enabled: true,
run_at: "07:00",
timezone: "Europe/Berlin",
next_run_at: "2026-03-11T07:00:00+01:00",
channels: [
{ channel: "smtp", configured: true, detail: "Einsatzbereit." },
{ channel: "apprise", configured: false, detail: "APPRISE_URLS setzen, komma-separiert." },
],
};
const REGEL: NotificationRule = {
id: 1,
type: "due_soon",
channel: "smtp",
lead_days: 3,
target: null,
is_active: true,
created_at: "2026-03-01T10:00:00+01:00",
};
function mockApi(
overrides: {
rules?: NotificationRule[];
/** "sent" liefert Erfolg, "failed" einen Fehler, "unconfigured" gar keinen Kanal. */
test?: "sent" | "failed" | "unconfigured";
} = {},
) {
const anfragen: { url: string; method: string; body: unknown }[] = [];
const json = (daten: unknown, status = 200) =>
new Response(JSON.stringify(daten), {
status,
headers: { "content-type": "application/json" },
});
vi.stubGlobal(
"fetch",
vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
const methode = init?.method ?? "GET";
anfragen.push({
url,
method: methode,
body: typeof init?.body === "string" ? JSON.parse(init.body) : null,
});
if (url.includes("/notifications/settings")) return json(EINSTELLUNGEN);
if (url.includes("/notifications/log")) return json([]);
if (url.includes("/notifications/test")) {
const modus = overrides.test ?? "sent";
const smtp =
modus === "sent"
? { channel: "smtp", configured: true, sent: true, error: null }
: modus === "failed"
? { channel: "smtp", configured: true, sent: false, error: "Server weg" }
: { channel: "smtp", configured: false, sent: false, error: null };
return json({
results: [smtp, { channel: "apprise", configured: false, sent: false, error: null }],
any_sent: modus === "sent",
});
}
if (url.includes("/notifications/run")) {
return json({ checked: 4, sent: 2, skipped: 2, failed: 0 });
}
if (url.includes("/notifications/rules")) {
if (methode === "POST") return json({ ...REGEL, id: 9 }, 201);
return json(overrides.rules ?? [REGEL]);
}
return json({});
}),
);
return anfragen;
}
describe("Benachrichtigungs-Einstellungen", () => {
beforeEach(() => {
mockApi();
});
it("zeigt Zeitplan und Einrichtungsstand der Kanäle", async () => {
renderWithProviders(<NotificationSettings />);
await waitFor(() => {
expect(screen.getByText(/Täglicher Lauf um 07:00 Uhr/)).toBeInTheDocument();
});
// "E-Mail" steht sowohl auf der Kanalkarte als auch als Abzeichen an der Regel.
expect(screen.getAllByText("E-Mail").length).toBeGreaterThan(0);
expect(screen.getByText("Einsatzbereit.")).toBeInTheDocument();
// Der nicht eingerichtete Kanal nennt die fehlende Variable.
expect(screen.getByText(/APPRISE_URLS setzen/)).toBeInTheDocument();
});
it("listet Regeln mit Anlass, Kanal und Vorlauf", async () => {
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Bald fällig")).toBeInTheDocument());
expect(screen.getByText("3 Tage Vorlauf")).toBeInTheDocument();
expect(screen.getByText(/Meldet Fälligkeiten innerhalb der Vorlaufzeit/)).toBeInTheDocument();
});
it("meldet einen leeren Regelsatz verständlich", async () => {
mockApi({ rules: [] });
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Keine Regeln")).toBeInTheDocument());
expect(screen.getByText(/verschickt moneyfy nichts/)).toBeInTheDocument();
});
it("löst den Testversand aus", async () => {
const anfragen = mockApi();
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Testnachricht/ }));
await waitFor(() => {
expect(anfragen.some((eintrag) => eintrag.url.includes("/notifications/test"))).toBe(true);
});
// Der Erfolg wird sichtbar zurückgemeldet.
expect(await screen.findByText(/Testnachricht versendet über E-Mail/)).toBeInTheDocument();
});
it("meldet einen fehlgeschlagenen Testversand mit Ursache", async () => {
mockApi({ test: "failed" });
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Testnachricht/ }));
expect(await screen.findByText(/Testversand ist fehlgeschlagen/)).toBeInTheDocument();
expect(screen.getByText("Server weg")).toBeInTheDocument();
});
it("weist auf fehlende Kanäle hin, statt einen Fehler zu melden", async () => {
mockApi({ test: "unconfigured" });
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Testnachricht/ }));
expect(await screen.findByText(/Kein Kanal eingerichtet/)).toBeInTheDocument();
});
it("startet einen Lauf und meldet das Ergebnis", async () => {
const anfragen = mockApi();
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Jetzt prüfen/ }));
await waitFor(() => {
expect(anfragen.some((eintrag) => eintrag.url.includes("/notifications/run"))).toBe(true);
});
expect(await screen.findByText(/2 Benachrichtigungen versendet/)).toBeInTheDocument();
expect(screen.getByText(/4 geprüft, 2 bereits gemeldet/)).toBeInTheDocument();
});
it("legt eine Regel über das Formular an", async () => {
const anfragen = mockApi();
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Regeln")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /^Regel$/ }));
const dialog = await screen.findByRole("dialog", { name: "Neue Regel" });
await nutzer.selectOptions(within(dialog).getByLabelText(/Anlass/), "notice_deadline");
await nutzer.selectOptions(within(dialog).getByLabelText(/Kanal/), "apprise");
await nutzer.click(within(dialog).getByRole("button", { name: "Speichern" }));
await waitFor(() => {
const angelegt = anfragen.find(
(eintrag) => eintrag.method === "POST" && eintrag.url.includes("/notifications/rules"),
);
expect(angelegt?.body).toMatchObject({
type: "notice_deadline",
channel: "apprise",
is_active: true,
});
});
});
it("blendet den Vorlauf nur bei Fälligkeiten ein", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Regeln")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /^Regel$/ }));
const dialog = await screen.findByRole("dialog", { name: "Neue Regel" });
expect(within(dialog).getByLabelText(/Vorlauf in Tagen/)).toBeInTheDocument();
// Für ein Budget gibt es keinen Vorlauf.
await nutzer.selectOptions(within(dialog).getByLabelText(/Anlass/), "budget_exceeded");
expect(within(dialog).queryByLabelText(/Vorlauf in Tagen/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,390 @@
/** Einstellungen der Benachrichtigungen: Kanäle, Regeln, Testversand, Protokoll. */
import { type FormEvent, useState } from "react";
import {
BellRing,
CheckCircle2,
Play,
Plus,
Send,
Trash2,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Checkbox, Field, Input, Select } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import {
useDeleteNotificationRule,
useNotificationLog,
useNotificationRules,
useNotificationSettings,
useRunNotifications,
useSaveNotificationRule,
useSendTestNotification,
} from "@/hooks/useNotifications";
import { formatDate } from "@/lib/format";
import type { NotificationChannel, NotificationRule, NotificationType } from "@/types/api";
const TYP_NAMEN: Record<NotificationType, string> = {
due_soon: "Bald fällig",
notice_deadline: "Kündigungsfrist",
budget_exceeded: "Budget überschritten",
contract_renewal: "Vertragsverlängerung",
};
const TYP_HINWEISE: Record<NotificationType, string> = {
due_soon: "Meldet Fälligkeiten innerhalb der Vorlaufzeit.",
notice_deadline: "Meldet 30, 14 und 7 Tage vor dem letzten Kündigungstermin.",
budget_exceeded: "Meldet überschrittene Budgets, einmal je Monat und Kategorie.",
contract_renewal: "Meldet Verträge, die sich im kommenden Monat verlängern.",
};
const KANAL_NAMEN: Record<NotificationChannel, string> = {
smtp: "E-Mail",
apprise: "Apprise",
};
export function NotificationSettings() {
const [formularOffen, setFormularOffen] = useState(false);
const [bearbeiten, setBearbeiten] = useState<NotificationRule | null>(null);
const [loeschen, setLoeschen] = useState<NotificationRule | null>(null);
const { data: einstellungen, isLoading } = useNotificationSettings();
const { data: regeln = [] } = useNotificationRules();
const { data: protokoll = [] } = useNotificationLog(25);
const entfernen = useDeleteNotificationRule();
const testen = useSendTestNotification();
const laufStarten = useRunNotifications();
if (isLoading || !einstellungen) return <Skeleton className="h-64 w-full" />;
return (
<div className="space-y-4">
<section className="card p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-ink">
<BellRing aria-hidden className="h-4 w-4" />
Zeitplan
</h2>
<p className="mt-0.5 text-xs text-muted">
{einstellungen.scheduler_enabled
? `Täglicher Lauf um ${einstellungen.run_at} Uhr (${einstellungen.timezone}).`
: "Der Scheduler ist abgeschaltet (SCHEDULER_ENABLED=false)."}
{einstellungen.next_run_at &&
` Nächster Lauf am ${formatDate(einstellungen.next_run_at.slice(0, 10))}.`}
</p>
</div>
<div className="flex gap-2">
<Button onClick={() => testen.mutate(undefined)} loading={testen.isPending}>
<Send aria-hidden className="h-4 w-4" />
Testnachricht
</Button>
<Button onClick={() => laufStarten.mutate()} loading={laufStarten.isPending}>
<Play aria-hidden className="h-4 w-4" />
Jetzt prüfen
</Button>
</div>
</div>
<ul className="mt-3 grid gap-2 sm:grid-cols-2">
{einstellungen.channels.map((kanal) => (
<li
key={kanal.channel}
className="flex items-start gap-2 rounded-lg border border-line bg-raised p-3"
>
{kanal.configured ? (
<CheckCircle2 aria-hidden className="mt-0.5 h-4 w-4 shrink-0 text-positive" />
) : (
<XCircle aria-hidden className="mt-0.5 h-4 w-4 shrink-0 text-faint" />
)}
<div className="min-w-0">
<p className="text-sm font-medium text-ink">{KANAL_NAMEN[kanal.channel]}</p>
<p className="text-xs text-muted">{kanal.detail}</p>
</div>
</li>
))}
</ul>
</section>
<section>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">Regeln</h2>
<Button
size="sm"
onClick={() => {
setBearbeiten(null);
setFormularOffen(true);
}}
>
<Plus aria-hidden className="h-3.5 w-3.5" />
Regel
</Button>
</div>
{regeln.length === 0 ? (
<EmptyState
icon={BellRing}
title="Keine Regeln"
description="Ohne Regel verschickt moneyfy nichts. Lege fest, worüber du informiert werden willst."
/>
) : (
<ul className="divide-y divide-line rounded-card border border-line">
{regeln.map((regel) => (
<li key={regel.id} className="group flex items-center gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-medium text-ink">{TYP_NAMEN[regel.type]}</span>
<Badge>{KANAL_NAMEN[regel.channel]}</Badge>
{regel.type === "due_soon" && (
<Badge tone="accent">{regel.lead_days} Tage Vorlauf</Badge>
)}
{!regel.is_active && <Badge tone="warning">Inaktiv</Badge>}
</div>
<p className="truncate text-xs text-faint">
{TYP_HINWEISE[regel.type]}
{regel.target && ` An: ${regel.target}`}
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => {
setBearbeiten(regel);
setFormularOffen(true);
}}
>
Ändern
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setLoeschen(regel)}
aria-label={`Regel ${TYP_NAMEN[regel.type]} löschen`}
>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
</section>
{protokoll.length > 0 && (
<section>
<h2 className="mb-2 text-sm font-semibold text-ink">Versandprotokoll</h2>
<div className="overflow-x-auto rounded-card border border-line">
<table className="w-full text-xs">
<caption className="sr-only">Zuletzt versendete Benachrichtigungen</caption>
<thead className="border-b border-line text-left text-muted">
<tr>
<th scope="col" className="px-3 py-2 font-medium">
Gesendet
</th>
<th scope="col" className="px-3 py-2 font-medium">
Bezug
</th>
<th scope="col" className="px-3 py-2 font-medium">
Zieltag
</th>
<th scope="col" className="px-3 py-2 font-medium">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{protokoll.map((eintrag) => (
<tr key={eintrag.id}>
<td className="whitespace-nowrap px-3 py-1.5 tabular text-muted">
{formatDate(eintrag.sent_at.slice(0, 10))}
</td>
<td className="px-3 py-1.5 text-muted">
{eintrag.ref_type} {eintrag.ref_id}
</td>
<td className="whitespace-nowrap px-3 py-1.5 tabular text-muted">
{formatDate(eintrag.dedupe_day)}
</td>
<td className="px-3 py-1.5">
{eintrag.status === "sent" ? (
<Badge tone="positive">zugestellt</Badge>
) : (
<Badge tone="negative" className="max-w-xs truncate">
{eintrag.error ?? "fehlgeschlagen"}
</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
<RuleDialog
rule={bearbeiten}
open={formularOffen}
onClose={() => {
setFormularOffen(false);
setBearbeiten(null);
}}
/>
<ConfirmDialog
open={loeschen !== null}
title="Regel löschen"
description="Die Regel wird samt ihrem Versandprotokoll entfernt."
loading={entfernen.isPending}
onCancel={() => setLoeschen(null)}
onConfirm={() => {
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
}}
/>
</div>
);
}
function RuleDialog({
rule,
open,
onClose,
}: {
rule: NotificationRule | null;
open: boolean;
onClose: () => void;
}) {
const speichern = useSaveNotificationRule();
const [typ, setTyp] = useState<NotificationType>("due_soon");
const [kanal, setKanal] = useState<NotificationChannel>("smtp");
const [vorlauf, setVorlauf] = useState(7);
const [ziel, setZiel] = useState("");
const [aktiv, setAktiv] = useState(true);
const [initialisiert, setInitialisiert] = useState<number | null | undefined>(undefined);
if (open && initialisiert !== (rule?.id ?? null)) {
setTyp(rule?.type ?? "due_soon");
setKanal(rule?.channel ?? "smtp");
setVorlauf(rule?.lead_days ?? 7);
setZiel(rule?.target ?? "");
setAktiv(rule?.is_active ?? true);
setInitialisiert(rule?.id ?? null);
}
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
speichern.mutate(
{
id: rule?.id,
daten: rule
? { channel: kanal, lead_days: vorlauf, target: ziel.trim() || null, is_active: aktiv }
: {
type: typ,
channel: kanal,
lead_days: vorlauf,
target: ziel.trim() || null,
is_active: aktiv,
},
},
{
onSuccess: () => {
setInitialisiert(undefined);
onClose();
},
},
);
}
return (
<Modal
open={open}
onClose={onClose}
size="sm"
title={rule ? "Regel ändern" : "Neue Regel"}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button variant="primary" onClick={absenden} loading={speichern.isPending}>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="space-y-3">
<Field label="Anlass" required hint={TYP_HINWEISE[typ]}>
{(id) => (
<Select
id={id}
value={typ}
disabled={rule !== null}
onChange={(ereignis) => setTyp(ereignis.target.value as NotificationType)}
>
{(Object.keys(TYP_NAMEN) as NotificationType[]).map((wert) => (
<option key={wert} value={wert}>
{TYP_NAMEN[wert]}
</option>
))}
</Select>
)}
</Field>
<Field label="Kanal" required>
{(id) => (
<Select
id={id}
value={kanal}
onChange={(ereignis) => setKanal(ereignis.target.value as NotificationChannel)}
>
<option value="smtp">E-Mail</option>
<option value="apprise">Apprise</option>
</Select>
)}
</Field>
{typ === "due_soon" && (
<Field label="Vorlauf in Tagen" hint="Wie früh vor der Fälligkeit gemeldet wird.">
{(id) => (
<Input
id={id}
type="number"
min={0}
max={365}
value={vorlauf}
onChange={(ereignis) => setVorlauf(Number(ereignis.target.value))}
/>
)}
</Field>
)}
<Field
label="Ziel"
hint={
kanal === "smtp"
? "Mailadresse; leer lassen für SMTP_FROM."
: "Apprise-URL; leer lassen für APPRISE_URLS."
}
>
{(id) => (
<Input
id={id}
value={ziel}
placeholder={kanal === "smtp" ? "ich@example.org" : "ntfy://host/topic"}
onChange={(ereignis) => setZiel(ereignis.target.value)}
/>
)}
</Field>
<Checkbox
label="Aktiv"
checked={aktiv}
onChange={(ereignis) => setAktiv(ereignis.target.checked)}
/>
</form>
</Modal>
);
}
+121
View File
@@ -0,0 +1,121 @@
/** Benachrichtigungsregeln, Protokoll und Testversand. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { toast } from "@/store/toast";
import type {
MessageResponse,
NotificationLogEntry,
NotificationRule,
NotificationRuleInput,
NotificationRunResult,
NotificationSettings,
TestSendResponse,
} from "@/types/api";
const KANAL_NAMEN = { smtp: "E-Mail", apprise: "Apprise" } as const;
export function useNotificationSettings() {
return useQuery({
queryKey: ["notifications", "settings"],
queryFn: () => api.get<NotificationSettings>("/notifications/settings"),
});
}
export function useNotificationRules() {
return useQuery({
queryKey: ["notifications", "rules"],
queryFn: () => api.get<NotificationRule[]>("/notifications/rules"),
});
}
export function useNotificationLog(limit = 50) {
return useQuery({
queryKey: ["notifications", "log", limit],
queryFn: () => api.get<NotificationLogEntry[]>("/notifications/log", { limit }),
});
}
export function useSaveNotificationRule() {
const client = useQueryClient();
return useMutation({
mutationFn: ({
id,
daten,
}: {
id?: number;
daten: NotificationRuleInput | Partial<NotificationRuleInput>;
}) =>
id
? api.patch<NotificationRule>(`/notifications/rules/${id}`, daten)
: api.post<NotificationRule>("/notifications/rules", daten),
onSuccess: (_regel, variablen) => {
void client.invalidateQueries({ queryKey: ["notifications"] });
toast.success(variablen.id ? "Regel gespeichert." : "Regel angelegt.");
},
});
}
export function useDeleteNotificationRule() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/notifications/rules/${id}`),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ["notifications"] });
toast.success("Regel gelöscht.");
},
});
}
export function useSendTestNotification() {
return useMutation({
mutationFn: (target?: string) =>
api.post<TestSendResponse>("/notifications/test", { target: target || null }),
onSuccess: (ergebnis) => {
const zugestellt = ergebnis.results
.filter((eintrag) => eintrag.sent)
.map((eintrag) => KANAL_NAMEN[eintrag.channel]);
const gescheitert = ergebnis.results.filter(
(eintrag) => eintrag.configured && !eintrag.sent,
);
if (zugestellt.length > 0) {
toast.success(
`Testnachricht versendet über ${zugestellt.join(" und ")}.`,
gescheitert.length > 0
? `Fehlgeschlagen: ${gescheitert
.map((eintrag) => `${KANAL_NAMEN[eintrag.channel]} (${eintrag.error})`)
.join(", ")}`
: undefined,
);
return;
}
if (gescheitert.length > 0) {
toast.error(
"Der Testversand ist fehlgeschlagen.",
gescheitert.map((eintrag) => eintrag.error).join(" · "),
);
return;
}
toast.info("Kein Kanal eingerichtet.", "Trage SMTP- oder Apprise-Daten in die Umgebung ein.");
},
});
}
export function useRunNotifications() {
const client = useQueryClient();
return useMutation({
mutationFn: () => api.post<NotificationRunResult>("/notifications/run"),
onSuccess: (ergebnis) => {
void client.invalidateQueries({ queryKey: ["notifications"] });
toast.success(
ergebnis.sent > 0
? `${ergebnis.sent} Benachrichtigung${ergebnis.sent === 1 ? "" : "en"} versendet.`
: "Nichts zu melden.",
`${ergebnis.checked} geprüft, ${ergebnis.skipped} bereits gemeldet.`,
);
},
});
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { type FormEvent, useState } from "react";
import { KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
import { NotificationSettings } from "@/components/NotificationSettings";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
@@ -31,11 +32,12 @@ const KONTOARTEN: Record<AccountType, string> = {
cash: "Bargeld",
};
type Reiter = "accounts" | "categories" | "account";
type Reiter = "accounts" | "categories" | "notifications" | "account";
const REITER: { id: Reiter; label: string }[] = [
{ id: "accounts", label: "Konten" },
{ id: "categories", label: "Kategorien" },
{ id: "notifications", label: "Benachrichtigungen" },
{ id: "account", label: "Konto & Darstellung" },
];
@@ -67,6 +69,7 @@ export function SettingsPage() {
{reiter === "accounts" && <AccountsSection />}
{reiter === "categories" && <CategoriesSection />}
{reiter === "notifications" && <NotificationSettings />}
{reiter === "account" && <UserSection />}
</>
);
+4
View File
@@ -4,9 +4,13 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import { useToastStore } from "@/store/toast";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
// Der Toast-Store lebt global ohne Zurücksetzen tropfen Meldungen in den nächsten Test.
useToastStore.getState().clear();
});
// jsdom kennt matchMedia nicht; einzelne Komponenten fragen es ab.
+4
View File
@@ -6,6 +6,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { Toaster } from "@/components/ui/Toaster";
/** Frischer Client je Test, ohne Wiederholungen und ohne Konsolenausgabe. */
export function createTestQueryClient(): QueryClient {
return new QueryClient({
@@ -27,6 +29,8 @@ export function renderWithProviders(ui: ReactElement, route = "/") {
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
>
{children}
{/* Wie in der echten Anwendung so lassen sich Rückmeldungen prüfen. */}
<Toaster />
</MemoryRouter>
</QueryClientProvider>
);
+72
View File
@@ -529,3 +529,75 @@ export interface Dashboard {
upcoming: CalendarEntry[];
upcoming_deadlines: Subscription[];
}
export type NotificationType =
| "due_soon"
| "notice_deadline"
| "budget_exceeded"
| "contract_renewal";
export type NotificationChannel = "smtp" | "apprise";
export type NotificationStatus = "sent" | "failed";
export interface NotificationRule {
id: number;
type: NotificationType;
channel: NotificationChannel;
lead_days: number;
target: string | null;
is_active: boolean;
created_at: IsoDateTime;
}
export interface NotificationRuleInput {
type: NotificationType;
channel: NotificationChannel;
lead_days?: number;
target?: string | null;
is_active?: boolean;
}
export interface NotificationLogEntry {
id: number;
rule_id: number;
ref_type: string;
ref_id: string;
dedupe_day: IsoDate;
sent_at: IsoDateTime;
status: NotificationStatus;
error: string | null;
}
export interface ChannelStatus {
channel: NotificationChannel;
configured: boolean;
detail: string;
}
export interface NotificationSettings {
enabled: boolean;
scheduler_enabled: boolean;
run_at: string;
timezone: string;
next_run_at: IsoDateTime | null;
channels: ChannelStatus[];
}
export interface TestResult {
channel: NotificationChannel;
configured: boolean;
sent: boolean;
error: string | null;
}
export interface TestSendResponse {
results: TestResult[];
any_sent: boolean;
}
export interface NotificationRunResult {
checked: number;
sent: number;
skipped: number;
failed: number;
}