"""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, 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, current_period, flows
from app.services.settings import month_start_day
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 Abrechnungsmonat und Kategorie."""
monatsbeginn = await month_start_day(session)
zeitraum = current_period(monatsbeginn, as_of)
return [
Event(
ref_type=REF_BUDGET,
ref_id=str(eintrag.category_id),
dedupe_day=zeitraum.key,
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=zeitraum.end,
)
for eintrag in await budget_status(session, zeitraum.key, monatsbeginn)
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''
if anhang
else ""
)
zeilen_html.append(
'
{len(events)} {"Eintrag" if len(events) == 1 else "Einträge"}
Wenn du das liest, ist der Kanal richtig eingerichtet.
Gesendet am {zeitpunkt}.