From 027244ada59de0080a0154eca08affe701f31493 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 14:28:09 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(abrechnung):=20pflegbare=20Preistabell?= =?UTF-8?q?e=20billing=5Ftariff=20als=20Quelle=20der=20S=C3=A4tze?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Credits-Sätze je Aktion stehen jetzt in der geteilten Tabelle billing_tariff statt nur in der Konfiguration. Die Migration legt die Tabelle an und befüllt sie einmalig mit den wirksamen CREDIT_TARIFF- Werten, danach liest _charge_credits() bei jeder Buchung die Tabelle, fehlende Schlüssel oder eine fehlende Tabelle fallen auf die Konfiguration zurück. Damit können Monitor und Verwaltungsportal dieselben Sätze anzeigen und das Portal kann sie künftig pflegen, eine Preisänderung braucht keinen Server-Eingriff mehr. Getestet gegen eine Kopie der Staging-DB. Seed mit 7 Sätzen (45/40/ 12/12/1/1/1), Buchung nach Tabelle, Tabellenänderung greift sofort, Rückfall auf die Konfiguration ohne Tabelle. Co-Authored-By: Claude Fable 5 --- docs/ABRECHNUNG.md | 11 ++++++----- src/database.py | 26 +++++++++++++++++++++++++- src/services/license_service.py | 21 ++++++++++++++++++++- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/docs/ABRECHNUNG.md b/docs/ABRECHNUNG.md index 5658de5..3ef161d 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 | |---|---|---| @@ -138,8 +141,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/database.py b/src/database.py index 16878d9..1cc0592 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") @@ -896,6 +896,30 @@ 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: 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/services/license_service.py b/src/services/license_service.py index 208df9c..2e8070e 100644 --- a/src/services/license_service.py +++ b/src/services/license_service.py @@ -371,6 +371,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 +416,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. -- 2.49.1 From d367c60b26259919ce2f0206069596be188d6347 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 15:01:00 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat(statistik):=20Aktivit=C3=A4ts-Tage=20j?= =?UTF-8?q?e=20Nutzer=20erfassen=20(MAU/DAU-Grundlage)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Tabelle user_activity_days (user_id, day, tenant_id, ein Eintrag je Nutzer und Kalendertag), angelegt per idempotenter Startup-Migration. Geschrieben wird sie in get_current_user bei jeder authentifizierten Nutzung, ein In-Memory-Tagesmerker begrenzt das auf einen INSERT OR IGNORE je Nutzer und Tag, Fehler werden geschluckt und blockieren nie eine Anfrage. Gelesen wird die Tabelle nur vom Verwaltungsportal für die neue MAU/DAU-Statistik. Ein reines Login-Protokoll wäre zu grob, weil das JWT 24 Stunden hält. Getestet gegen eine DB-Kopie, Tabelle entsteht, je Nutzer und Tag genau ein Eintrag trotz Mehrfachaufruf. Co-Authored-By: Claude Fable 5 --- src/auth.py | 36 ++++++++++++++++++++++++++++++++++++ src/database.py | 18 ++++++++++++++++++ 2 files changed, 54 insertions(+) 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 1cc0592..b54e145 100644 --- a/src/database.py +++ b/src/database.py @@ -920,6 +920,24 @@ async def init_db(): 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: 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(): -- 2.49.1 From 17b1886f25ae569b3a86621c868184f0a151b289 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 15:51:45 +0000 Subject: [PATCH 3/7] fix(abrechnung): Warnschwelle 0 schaltet die Budget-Warnung aus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher fiel eine 0 durch das or-80-Muster still auf die Voreinstellung zurück, ein Abschalten der Warnung war unmöglich. Jetzt gilt, 0 = aus, NULL = Voreinstellung 80, alles andere wie gehabt. Das Verwaltungsportal bietet die 0 in seinen Formularen an. Co-Authored-By: Claude Fable 5 --- src/services/license_service.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/services/license_service.py b/src/services/license_service.py index 2e8070e..5c25238 100644 --- a/src/services/license_service.py +++ b/src/services/license_service.py @@ -468,7 +468,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 -- 2.49.1 From 7dae63ebf941d7e06e9509b12b3261b673b2d26a Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 19:04:46 +0000 Subject: [PATCH 4/7] feat(system): Telegram-Session-Status in system_status melden Neue Key-Value-Tabelle system_status (idempotente Migration). Der Monitor prueft die Telethon-Session beim Start und taeglich um 04:30 (eigener Scheduler-Job) und schreibt ok/account/error unter dem Key telegram_session. Das Verwaltungsportal zeigt den Eintrag im Reiter Recherche-Zugaenge an, damit ein Session-Ausfall sichtbar wird statt nur im Server-Log zu stehen. Fehler beim Pruefen stoeren den Betrieb nie (try/except, eigene DB-Connection wie beim Health-Nachtjob). Promote-Reihenfolge wie gehabt: Monitor VOR Verwaltung (Migration). Co-Authored-By: Claude Fable 5 --- src/database.py | 16 ++++++++++++++++ src/main.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/database.py b/src/database.py index b54e145..e6ff7bf 100644 --- a/src/database.py +++ b/src/database.py @@ -938,6 +938,22 @@ async def init_db(): 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..a1592ee 100644 --- a/src/main.py +++ b/src/main.py @@ -222,6 +222,41 @@ 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 + 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 +379,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 -- 2.49.1 From 5450fd25aeda624a66031cf40b89758fe7062c69 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 19:15:56 +0000 Subject: [PATCH 5/7] feat(system): Telefonnummer in der Telegram-Status-Meldung Ergaenzt phone im telegram_session-Status, damit das Portal die Telegram-Zeile im Reiter Recherche-Zugaenge vollstaendig fuellen kann. Co-Authored-By: Claude Fable 5 --- src/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.py b/src/main.py index a1592ee..c96dfc4 100644 --- a/src/main.py +++ b/src/main.py @@ -240,6 +240,7 @@ async def update_telegram_status(): 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: -- 2.49.1 From f8499c4e40c8311a23870b6c9fe93f07e846e6b2 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 21:45:15 +0000 Subject: [PATCH 6/7] feat(abrechnung): Credits-Uebertrag in den Folgemonat entfernt Produktentscheidung 07/2026: ungenutzte Credits verfallen zum Periodenende, es gilt der harte Monatsdeckel wie verkauft. Der Uebertrag (credits_rollover/credits_carried) fliegt komplett raus: roll_credit_period setzt nur noch Verbrauch + Warnflag zurueck, check_license rechnet verfuegbar = credits_total, die Migration legt die beiden Spalten gar nicht erst an (Live hat sie nie bekommen, die Promote-Warteschlange enthielt sie noch). ABRECHNUNG.md angepasst. Co-Authored-By: Claude Fable 5 --- docs/ABRECHNUNG.md | 9 +++------ src/database.py | 7 +++---- src/services/license_service.py | 33 +++++++++------------------------ 3 files changed, 15 insertions(+), 34 deletions(-) diff --git a/docs/ABRECHNUNG.md b/docs/ABRECHNUNG.md index 3ef161d..b8dfd77 100644 --- a/docs/ABRECHNUNG.md +++ b/docs/ABRECHNUNG.md @@ -61,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 @@ -93,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 | diff --git a/src/database.py b/src/database.py index e6ff7bf..21c5ea8 100644 --- a/src/database.py +++ b/src/database.py @@ -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() diff --git a/src/services/license_service.py b/src/services/license_service.py index 5c25238..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, } @@ -437,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}: " -- 2.49.1 From 444241c7d313a2886e7b26925913be8767b2aa75 Mon Sep 17 00:00:00 2001 From: IntelSight_Admin Date: Sun, 26 Jul 2026 01:31:17 +0200 Subject: [PATCH 7/7] Release-Notes: Abrechnung, Budget-Warnung & Telegram-Verbesserungen --- RELEASES.json | 11 +++++++++++ 1 file changed, 11 insertions(+) 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", -- 2.49.1