diff --git a/RELEASES.json b/RELEASES.json index d20cca6..b7b9379 100644 --- a/RELEASES.json +++ b/RELEASES.json @@ -1,4 +1,15 @@ [ + { + "version": "2026-07-25T23:31Z", + "date": "2026-07-25", + "title": "Abrechnung, Budget-Warnung & Telegram-Verbesserungen", + "items": [ + "Nicht genutzte Credits verfallen am Monatsende und werden nicht mehr in den Folgemonat übertragen.", + "Die Budget-Warnung lässt sich durch Setzen der Warnschwelle auf 0 gezielt deaktivieren.", + "Telegram-Statusmeldungen zeigen jetzt die zugehörige Telefonnummer an.", + "Der Verbindungsstatus der Telegram-Sitzung ist jetzt im Systemstatus einsehbar." + ] + }, { "version": "2026-07-23T21:10Z", "date": "2026-07-23", diff --git a/docs/ABRECHNUNG.md b/docs/ABRECHNUNG.md index 5658de5..b8dfd77 100644 --- a/docs/ABRECHNUNG.md +++ b/docs/ABRECHNUNG.md @@ -15,9 +15,12 @@ einheitlich in Monitor und Verwaltungsportal). ## Was eine Aktion kostet -Im Modus `flat` (Voreinstellung) kostet jede Aktion einen festen Satz aus -`CREDIT_TARIFF` in `src/config.py`, unabhängig davon, was sie uns tatsächlich -verursacht hat. 1 Credit entspricht 0,20 USD. +Im Modus `flat` (Voreinstellung) kostet jede Aktion einen festen Satz, +unabhängig davon, was sie uns tatsächlich verursacht hat. 1 Credit entspricht +0,20 USD. Die wirksamen Sätze stehen seit 25.07.2026 in der geteilten Tabelle +`billing_tariff` und sind über den Verbrauchsrechner des Verwaltungsportals +pflegbar. `CREDIT_TARIFF` in `src/config.py` befüllt die Tabelle beim ersten +Start und bleibt Rückfallebene für fehlende Schlüssel. | Aktion | Credits | Herkunft des Werts | |---|---|---| @@ -58,11 +61,9 @@ Der Wechsel passiert träge bei der nächsten Lizenzprüfung, nicht über einen Zeitplan. Ein verpasster Monatswechsel wird dadurch beim nächsten Zugriff nachgeholt, und ohne Nutzung wird ohnehin nichts verbraucht. -`credits_rollover` überträgt ungenutzte Credits in den Folgemonat, gedeckelt -auf ein Monatskontingent. Der Übertrag steht in `credits_carried` und zählt beim -Hard-Stop mit. Das ist empfehlenswert, weil OSINT-Nutzung ereignisgetrieben ist. -Bricht eine Krise aus, braucht der Kunde in einer Woche mehr als sonst im -Quartal, und genau dann darf das System nicht dichtmachen. +Ungenutzte Credits verfallen zum Periodenende. Ein Übertrag in den Folgemonat +war kurz vorgesehen und wurde als Produktentscheidung 07/2026 wieder entfernt, +es gilt der harte Monatsdeckel wie verkauft. Eine Bestandslizenz ohne Periodenmarke bekommt beim ersten Zugriff die aktuelle Periode eingetragen, **ohne** den Verbrauch zurückzusetzen. Sonst würden ihr @@ -90,7 +91,6 @@ nächste Schritt. | `credits_total` | Kontingent je Periode | 10000 | | `cost_per_credit` | nur für `BILLING_MODE=actual` und die interne Rechnung | 0.20 | | `credits_period` | `monthly` oder `total` | monthly | -| `credits_rollover` | Übertrag erlauben | 1 | | `budget_warning_percent` | Warnschwelle in Prozent | 80 | | `unlimited_budget` | Kontingent aushebeln | 0 | @@ -138,8 +138,6 @@ Zur Orientierung bei einem Kontingent von 10.000 Credits und 45 Credits je Lauf. ## Offene Punkte - E-Mail-Versand der Budget-Warnung. -- Eingabefelder für die Stellwerte im Verwaltungsportal (heute erzeugt das - Anlegen einer Organisation dort nicht einmal eine Lizenz). - Takt-Untergrenze und Kostenvorschau aus dem Lokal-Fork portieren (siehe oben). - Nachkaufpakete. Ohne sie ist der Deckel eine Sackgasse, mit ihnen eine Umsatzquelle. Der Verkauf gehört ins Verwaltungsportal, im Monitor müsste nur diff --git a/src/auth.py b/src/auth.py index f60e259..7d5d80b 100644 --- a/src/auth.py +++ b/src/auth.py @@ -59,6 +59,41 @@ def decode_token(token: str) -> dict: ) +# --- Aktivitaets-Erfassung fuer MAU/DAU --------------------------------------- +# Je Nutzer und Kalendertag genau ein Eintrag in user_activity_days. Der +# In-Memory-Merker haelt die DB-Last bei einem INSERT je Nutzer und Tag. +# Ein Fehler hier darf NIEMALS eine Anfrage blockieren. +_activity_seen: set = set() + + +async def _track_activity(user_id: int, tenant_id) -> None: + day = datetime.now(TIMEZONE).strftime("%Y-%m-%d") + key = (user_id, day) + if key in _activity_seen: + return + _activity_seen.add(key) + if len(_activity_seen) > 20000: + # Speicher begrenzen, alte Tage interessieren den Merker nicht mehr + _activity_seen.clear() + _activity_seen.add(key) + try: + import aiosqlite + from config import DB_PATH + db = await aiosqlite.connect(DB_PATH) + try: + await db.execute( + "INSERT OR IGNORE INTO user_activity_days (user_id, day, tenant_id) VALUES (?, ?, ?)", + (user_id, day, tenant_id), + ) + await db.commit() + finally: + await db.close() + except Exception: + # Bewusst schlucken (z.B. Tabelle fehlt noch). Der Merker behaelt den + # Schluessel, damit ein Dauerfehler nicht jede Anfrage erneut trifft. + pass + + async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), ) -> dict: @@ -69,6 +104,7 @@ async def get_current_user( detail="Nicht authentifiziert", ) payload = decode_token(credentials.credentials) + await _track_activity(int(payload["sub"]), payload.get("tenant_id")) return { "id": int(payload["sub"]), "username": payload["username"], diff --git a/src/database.py b/src/database.py index 16878d9..21c5ea8 100644 --- a/src/database.py +++ b/src/database.py @@ -3,7 +3,7 @@ import aiosqlite import json import logging import os -from config import DB_PATH, DATA_DIR +from config import DB_PATH, DATA_DIR, CREDIT_TARIFF logger = logging.getLogger("osint.database") @@ -877,10 +877,9 @@ async def init_db(): # Beginn der laufenden Periode als YYYY-MM. Leer = beim naechsten # Zugriff auf den aktuellen Monat gesetzt, ohne Verbrauch zu loeschen. await db.execute("ALTER TABLE licenses ADD COLUMN credits_period_start TEXT") - # Ungenutzte Credits in die Folgeperiode uebertragen. Aus = harter - # Monatsdeckel wie verkauft, An = faengt Krisenspitzen ab. - await db.execute("ALTER TABLE licenses ADD COLUMN credits_rollover INTEGER DEFAULT 0") - await db.execute("ALTER TABLE licenses ADD COLUMN credits_carried REAL DEFAULT 0") + # Ungenutzte Credits verfallen zum Periodenende. Ein Uebertrag in den + # Folgemonat war kurz vorgesehen und wurde als Produktentscheidung + # 07/2026 wieder entfernt (harter Monatsdeckel wie verkauft). # Verhindert, dass die Warnschwelle bei jeder Buchung erneut meldet. await db.execute("ALTER TABLE licenses ADD COLUMN budget_warning_sent INTEGER DEFAULT 0") await db.commit() @@ -896,6 +895,64 @@ async def init_db(): await db.commit() logger.info("Migration: unlimited_budget zu Lizenzen hinzugefuegt") + # Migration: Preistabelle fuer die Credits-Saetze. Pflegbare Quelle der + # Wahrheit fuer Monitor UND Verwaltungsportal (das Portal schreibt sie + # ueber den Verbrauchsrechner). Beim ersten Start mit den wirksamen + # Konfigurationswerten befuellt, danach gewinnt die Tabelle, + # CREDIT_TARIFF bleibt Rueckfallebene fuer fehlende Schluessel. + cursor = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='billing_tariff'" + ) + if not await cursor.fetchone(): + await db.execute(""" + CREATE TABLE billing_tariff ( + tariff_key TEXT PRIMARY KEY, + credits REAL NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + for tariff_key, credits in CREDIT_TARIFF.items(): + await db.execute( + "INSERT INTO billing_tariff (tariff_key, credits) VALUES (?, ?)", + (tariff_key, float(credits)), + ) + await db.commit() + logger.info("Migration: billing_tariff angelegt und mit Konfigurationswerten befuellt") + + # Migration: Aktivitaets-Tage je Nutzer fuer die MAU/DAU-Statistik im + # Verwaltungsportal. Geschrieben von auth.get_current_user (ein Eintrag + # je Nutzer und Tag), gelesen nur vom Portal. + cursor = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='user_activity_days'" + ) + if not await cursor.fetchone(): + await db.execute(""" + CREATE TABLE user_activity_days ( + user_id INTEGER NOT NULL, + day TEXT NOT NULL, + tenant_id INTEGER, + PRIMARY KEY (user_id, day) + ) + """) + await db.commit() + logger.info("Migration: user_activity_days angelegt (MAU/DAU-Erfassung)") + + # Migration: System-Status (Key-Value). Der Monitor meldet hier z.B. den + # Telegram-Session-Status, das Verwaltungsportal liest ihn nur an. + cursor = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='system_status'" + ) + if not await cursor.fetchone(): + await db.execute(""" + CREATE TABLE system_status ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """) + await db.commit() + logger.info("Migration: system_status angelegt (u.a. Telegram-Session-Status)") + # Migration: Token-Usage-Monatstabelle cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'") if not await cursor.fetchone(): diff --git a/src/main.py b/src/main.py index 58a0f45..c96dfc4 100644 --- a/src/main.py +++ b/src/main.py @@ -222,6 +222,42 @@ async def daily_source_health_check(): finally: await db.close() + +async def update_telegram_status(): + """Prüft die Telegram-Session und meldet den Status in system_status. + + Das Verwaltungsportal zeigt den Eintrag im Reiter Recherche-Zugänge an. + Nutzt den eigenen Telethon-Client des Prozesses, ein Fehler darf den + Betrieb nie stören (nur Status + Log). + """ + db = await get_db() + try: + status = {"ok": False, "account": None, "error": None} + try: + from feeds.telegram_parser import TelegramParser + client = await TelegramParser()._get_client() + if client: + me = await client.get_me() + status["ok"] = True + status["account"] = ((me.first_name or "").strip() or None) if me else None + status["phone"] = ("+" + me.phone) if me and me.phone else None + else: + status["error"] = "Session fehlt oder nicht autorisiert" + except Exception as e: + status["error"] = str(e) + await db.execute( + "INSERT INTO system_status (key, value, updated_at) " + "VALUES ('telegram_session', ?, CURRENT_TIMESTAMP) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP", + (json.dumps(status),), + ) + await db.commit() + logger.info(f"Telegram-Status gemeldet: ok={status['ok']}") + except Exception as e: + logger.error(f"Telegram-Status-Update fehlgeschlagen: {e}", exc_info=True) + finally: + await db.close() + async def cleanup_expired(): """Bereinigt abgelaufene Lagen basierend auf retention_days.""" db = await get_db() @@ -344,8 +380,12 @@ async def lifespan(app: FastAPI): scheduler.add_job(check_auto_refresh, "interval", minutes=1, id="auto_refresh") scheduler.add_job(cleanup_expired, "interval", hours=1, id="cleanup") scheduler.add_job(daily_source_health_check, "cron", hour=4, minute=0, id="source_health") + scheduler.add_job(update_telegram_status, "cron", hour=4, minute=30, id="telegram_status") scheduler.start() + # Telegram-Status einmal beim Start melden (asynchron, blockiert den Start nicht) + asyncio.create_task(update_telegram_status()) + logger.info("OSINT Lagemonitor gestartet") yield diff --git a/src/services/license_service.py b/src/services/license_service.py index 208df9c..4dcc5cc 100644 --- a/src/services/license_service.py +++ b/src/services/license_service.py @@ -41,8 +41,8 @@ async def roll_credit_period(db: aiosqlite.Connection, lic: dict) -> dict: Das ist robuster, weil ein verpasster Monatswechsel beim naechsten Zugriff ohnehin nachgeholt wird und ohne Nutzung auch nichts verbraucht wird. - Bei aktivem Uebertrag wandert ungenutztes Guthaben in die Folgeperiode, - gedeckelt auf ein Monatskontingent, damit es nicht unbegrenzt anwaechst. + Ungenutzte Credits verfallen zum Periodenende (kein Uebertrag, + Produktentscheidung 07/2026). Returns: Das ggf. aktualisierte Lizenz-dict (in-place ergaenzt). @@ -70,33 +70,21 @@ async def roll_credit_period(db: aiosqlite.Connection, lic: dict) -> dict: if started == period: return lic - total = lic.get("credits_total") or 0 - carried_old = lic.get("credits_carried") or 0 - used = lic.get("credits_used") or 0 - - if lic.get("credits_rollover"): - carried_new = max(0.0, (total + carried_old) - used) - carried_new = min(carried_new, float(total)) # hoechstens ein Monat - else: - carried_new = 0.0 - await db.execute( """UPDATE licenses - SET credits_used = 0, credits_carried = ?, credits_period_start = ?, + SET credits_used = 0, credits_period_start = ?, budget_warning_sent = 0 WHERE id = ?""", - (round(carried_new, 2), period, lic["id"]), + (period, lic["id"]), ) await db.commit() lic["credits_used"] = 0 - lic["credits_carried"] = carried_new lic["credits_period_start"] = period lic["budget_warning_sent"] = 0 logger.info( - f"Lizenz {lic['id']}: neue Periode {period}, Verbrauch zurueckgesetzt " - f"(Uebertrag {round(carried_new, 2)} Einheiten)" + f"Lizenz {lic['id']}: neue Periode {period}, Verbrauch zurueckgesetzt" ) return lic @@ -185,10 +173,10 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: unlimited_budget = bool(lic_dict.get("unlimited_budget")) credits_total = lic_dict.get("credits_total") credits_used = lic_dict.get("credits_used") or 0 - credits_carried = lic_dict.get("credits_carried") or 0 credits_period = lic_dict.get("credits_period") or "monthly" - # Verfuegbar ist das Kontingent plus ein etwaiger Uebertrag aus dem Vormonat. - credits_available = (credits_total or 0) + credits_carried + # Verfuegbar ist genau das Kontingent. Ungenutzte Credits verfallen zum + # Periodenende (kein Uebertrag, Produktentscheidung 07/2026). + credits_available = credits_total or 0 # STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited if _staging_mode(): @@ -219,7 +207,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: pass # Budget-Check (Hard-Stop bei aufgebrauchtem Guthaben, ausser unlimited). - # Geprueft wird gegen das verfuegbare Guthaben, also inklusive Uebertrag. budget_exceeded = False if not unlimited_budget and credits_available > 0: if credits_used >= credits_available: @@ -245,7 +232,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: "unlimited_budget": False, "credits_total": credits_total, "credits_used": credits_used, - "credits_carried": credits_carried, "credits_available": credits_available, "credits_period": credits_period, } @@ -262,7 +248,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: "unlimited_budget": unlimited_budget, "credits_total": credits_total, "credits_used": credits_used, - "credits_carried": credits_carried, "credits_available": credits_available, "credits_period": credits_period, } @@ -371,6 +356,24 @@ async def _record_usage_statistics( ) +async def _load_tariff(db: aiosqlite.Connection) -> dict: + """Wirksame Credits-Saetze je Aktion. + + Die Tabelle billing_tariff ist die pflegbare Quelle (das Verwaltungsportal + schreibt sie), CREDIT_TARIFF aus der Konfiguration bleibt Rueckfallebene + fuer fehlende Schluessel oder eine noch fehlende Tabelle. + """ + tariff = dict(CREDIT_TARIFF) + try: + cursor = await db.execute("SELECT tariff_key, credits FROM billing_tariff") + for row in await cursor.fetchall(): + if row["credits"] is not None and row["credits"] > 0: + tariff[row["tariff_key"]] = float(row["credits"]) + except Exception: + pass # Tabelle existiert noch nicht, die Konfiguration gilt + return tariff + + async def _charge_credits( db: aiosqlite.Connection, tenant_id: int, @@ -398,7 +401,8 @@ async def _charge_credits( key = _tariff_key(source, incident_type) if BILLING_MODE == "flat": - credits_consumed = CREDIT_TARIFF.get(key) + tariff = await _load_tariff(db) + credits_consumed = tariff.get(key) if credits_consumed is None: # Unbekannte Quelle. Lieber auf die echte Rechnung zurueckfallen als # stillschweigend gratis abzugeben. @@ -418,7 +422,7 @@ async def _charge_credits( ) used_new = (lic.get("credits_used") or 0) + credits_consumed - available = (lic.get("credits_total") or 0) + (lic.get("credits_carried") or 0) + available = lic.get("credits_total") or 0 logger.info( f"charge_usage_to_tenant[{key}] Tenant {tenant_id}: " @@ -449,7 +453,12 @@ async def _check_budget_warning( if available <= 0 or lic.get("budget_warning_sent"): return - threshold = lic.get("budget_warning_percent") or 80 + # 0 schaltet die Warnung bewusst aus, NULL faellt auf die Voreinstellung 80. + threshold = lic.get("budget_warning_percent") + if threshold is None: + threshold = 80 + if threshold <= 0: + return percent = (used / available) * 100 if percent < threshold: return