Dateien
AegisSight-Monitor/src/services/license_service.py
claude-dev f8499c4e40 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 <noreply@anthropic.com>
2026-07-25 21:45:15 +00:00

500 Zeilen
18 KiB
Python

"""Lizenz-Verwaltung und -Pruefung."""
import logging
import os
from datetime import datetime
from config import TIMEZONE, BILLING_MODE, CREDIT_TARIFF
import aiosqlite
logger = logging.getLogger("osint.license")
def _staging_mode() -> bool:
"""Staging-Mode aktiv? Wenn ja, gilt: immer unlimited Budget, kein Hard-Stop.
Wird ueber ENV-Variable STAGING_MODE=1 (oder true) aktiviert.
Nur in Staging-.env gesetzt; Live-.env hat das Flag nicht.
"""
return os.environ.get("STAGING_MODE", "").lower() in ("1", "true", "yes")
def _current_period() -> str:
"""Kennung der laufenden Abrechnungsperiode (Kalendermonat)."""
return datetime.now(TIMEZONE).strftime("%Y-%m")
def _tariff_key(source: str, incident_type: str | None) -> str:
"""Abrechnungsquelle auf einen Tarifschluessel abbilden.
Ein Refresh kostet je nach Lagentyp unterschiedlich viel, deshalb wird
'monitor' anhand des Typs aufgeteilt. Alle anderen Quellen entsprechen
direkt einem Schluessel in CREDIT_TARIFF.
"""
if source == "monitor":
return "monitor_research" if incident_type == "research" else "monitor_adhoc"
return source
async def roll_credit_period(db: aiosqlite.Connection, lic: dict) -> dict:
"""Setzt das Guthaben zurueck, wenn eine neue Abrechnungsperiode begonnen hat.
Wird traege bei jeder Lizenzpruefung aufgerufen statt ueber einen Zeitplan.
Das ist robuster, weil ein verpasster Monatswechsel beim naechsten Zugriff
ohnehin nachgeholt wird und ohne Nutzung auch nichts verbraucht wird.
Ungenutzte Credits verfallen zum Periodenende (kein Uebertrag,
Produktentscheidung 07/2026).
Returns:
Das ggf. aktualisierte Lizenz-dict (in-place ergaenzt).
"""
if (lic.get("credits_period") or "monthly") != "monthly":
return lic
if not lic.get("credits_total"):
return lic
period = _current_period()
started = lic.get("credits_period_start")
if not started:
# Bestandslizenz ohne Periodenmarke. Marke setzen, Verbrauch stehen
# lassen -- ein Reset wuerde dem Kunden hier Guthaben schenken, das er
# in diesem Monat schon verbraucht hat.
await db.execute(
"UPDATE licenses SET credits_period_start = ? WHERE id = ?",
(period, lic["id"]),
)
await db.commit()
lic["credits_period_start"] = period
return lic
if started == period:
return lic
await db.execute(
"""UPDATE licenses
SET credits_used = 0, credits_period_start = ?,
budget_warning_sent = 0
WHERE id = ?""",
(period, lic["id"]),
)
await db.commit()
lic["credits_used"] = 0
lic["credits_period_start"] = period
lic["budget_warning_sent"] = 0
logger.info(
f"Lizenz {lic['id']}: neue Periode {period}, Verbrauch zurueckgesetzt"
)
return lic
async def _notify_budget_warning(
db: aiosqlite.Connection, organization_id: int, percent: float, remaining: float
) -> None:
"""Legt eine Warnung an, sobald die Schwelle des Guthabens erreicht ist.
Die Meldung geht an alle aktiven Nutzer der Organisation, die sich schon
einmal angemeldet haben. Ein E-Mail-Versand haengt hier bewusst nicht dran,
das waere ein eigener Schritt ueber email_utils.
"""
cursor = await db.execute(
"SELECT id FROM users WHERE organization_id = ? AND is_active = 1 AND last_login_at IS NOT NULL",
(organization_id,),
)
user_ids = [row["id"] for row in await cursor.fetchall()]
now = datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M:%S")
title = "Credits zu {p} Prozent verbraucht".format(p=int(percent))
text = (
f"Es sind noch rund {int(remaining)} Credits in dieser Abrechnungsperiode "
f"verfuegbar. Sind die Credits aufgebraucht, bleiben die Lagen lesbar, es "
f"lassen sich aber keine neuen Aktualisierungen mehr starten."
)
for user_id in user_ids:
await db.execute(
"""INSERT INTO notifications (user_id, incident_id, type, title, text, icon, tenant_id, created_at)
VALUES (?, NULL, 'budget_warning', ?, ?, 'warning', ?, ?)""",
(user_id, title, text, organization_id, now),
)
logger.info(
f"Budget-Warnung fuer Org {organization_id} an {len(user_ids)} Nutzer "
f"({int(percent)} Prozent verbraucht)"
)
async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"""Prueft den Lizenzstatus einer Organisation.
Returns:
dict mit: valid, status, license_type, max_users, current_users, read_only,
read_only_reason, message, unlimited_budget, credits_total, credits_used
"""
# Organisation pruefen
cursor = await db.execute(
"SELECT id, name, is_active FROM organizations WHERE id = ?",
(organization_id,),
)
org = await cursor.fetchone()
if not org:
return {"valid": False, "status": "not_found", "read_only": True,
"read_only_reason": "not_found",
"message": "Organisation nicht gefunden"}
if not org["is_active"]:
return {"valid": False, "status": "org_disabled", "read_only": True,
"read_only_reason": "org_disabled",
"message": "Organisation deaktiviert"}
# Aktive Lizenz suchen
cursor = await db.execute(
"""SELECT * FROM licenses
WHERE organization_id = ? AND status = 'active'
ORDER BY created_at DESC LIMIT 1""",
(organization_id,),
)
license_row = await cursor.fetchone()
if not license_row:
return {"valid": False, "status": "no_license", "read_only": True,
"read_only_reason": "no_license",
"message": "Keine aktive Lizenz"}
# Felder zur weiteren Verwendung extrahieren
lic_dict = dict(license_row)
# Periodenwechsel nachholen, bevor irgendetwas geprueft wird. Sonst haengt
# ein Kunde mit monatlichem Kontingent im Nur-Lese-Modus fest, obwohl der
# neue Monat laengst begonnen hat.
lic_dict = await roll_credit_period(db, lic_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_period = lic_dict.get("credits_period") or "monthly"
# 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():
unlimited_budget = True
# Ablauf pruefen
now = datetime.now(TIMEZONE)
valid_until = license_row["valid_until"]
if valid_until is not None:
try:
expiry = datetime.fromisoformat(valid_until)
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=TIMEZONE)
if now > expiry:
return {
"valid": False,
"status": "expired",
"license_type": license_row["license_type"],
"read_only": True,
"read_only_reason": "expired",
"message": "Lizenz abgelaufen",
"unlimited_budget": unlimited_budget,
"credits_total": credits_total,
"credits_used": credits_used,
}
except (ValueError, TypeError):
pass
# Budget-Check (Hard-Stop bei aufgebrauchtem Guthaben, ausser unlimited).
budget_exceeded = False
if not unlimited_budget and credits_available > 0:
if credits_used >= credits_available:
budget_exceeded = True
# Nutzerzahl pruefen
cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM users WHERE organization_id = ? AND is_active = 1",
(organization_id,),
)
current_users = (await cursor.fetchone())["cnt"]
if budget_exceeded:
return {
"valid": True, # Lizenz ist gueltig, aber Budget aufgebraucht -> read-only
"status": "budget_exceeded",
"license_type": license_row["license_type"],
"max_users": license_row["max_users"],
"current_users": current_users,
"read_only": True,
"read_only_reason": "budget_exceeded",
"message": "Credits aufgebraucht",
"unlimited_budget": False,
"credits_total": credits_total,
"credits_used": credits_used,
"credits_available": credits_available,
"credits_period": credits_period,
}
return {
"valid": True,
"status": license_row["status"],
"license_type": license_row["license_type"],
"max_users": license_row["max_users"],
"current_users": current_users,
"read_only": False,
"read_only_reason": None,
"message": "Lizenz aktiv",
"unlimited_budget": unlimited_budget,
"credits_total": credits_total,
"credits_used": credits_used,
"credits_available": credits_available,
"credits_period": credits_period,
}
async def can_add_user(db: aiosqlite.Connection, organization_id: int) -> tuple[bool, str]:
"""Prueft ob ein neuer Nutzer hinzugefuegt werden kann (Nutzer-Limit).
Returns:
(erlaubt, grund)
"""
lic = await check_license(db, organization_id)
if not lic["valid"]:
return False, lic["message"]
if lic["current_users"] >= lic["max_users"]:
return False, f"Nutzer-Limit erreicht ({lic['current_users']}/{lic['max_users']})"
return True, ""
async def charge_usage_to_tenant(
db: aiosqlite.Connection,
tenant_id: int | None,
usage,
source: str,
incident_type: str | None = None,
) -> None:
"""Verbucht eine Aktion auf einen Tenant.
Zwei getrennte Vorgaenge. `token_usage_monthly` bekommt immer die echten
Tokenmengen und Kosten, das ist die interne Kostenkontrolle. Das Guthaben
der Lizenz wird je nach BILLING_MODE belastet.
'flat' zieht den festen Satz aus CREDIT_TARIFF ab. Der Kunde kann seinen
Verbrauch damit vorher ausrechnen, und eine spaetere Verbilligung
des Modell-Backends veraendert sein Kontingent nicht.
'actual' zieht die echten Kosten geteilt durch cost_per_credit ab, also das
bisherige Verhalten.
Args:
db: offene aiosqlite.Connection
tenant_id: Organisations-ID oder None (dann nur geloggt, keine DB-Buchung)
usage: ClaudeUsage oder UsageAccumulator mit input_tokens/output_tokens/
cache_creation_tokens/cache_read_tokens/total_cost_usd/call_count
source: 'monitor' | 'analysis' | 'factcheck' | 'chat' | 'enhance' | 'globe'
incident_type: 'adhoc' | 'research', nur bei source='monitor' relevant
Der Helper ruft KEIN db.commit() auf — die Transaktionsgrenzen bestimmt der Caller.
Ausnahme ist der Periodenwechsel, der eine eigene Transaktion braucht.
"""
total_cost = getattr(usage, "total_cost_usd", None)
if total_cost is None:
total_cost = getattr(usage, "cost_usd", 0.0)
if not tenant_id:
logger.info(
f"charge_usage_to_tenant[{source}]: kein tenant_id, uebersprungen "
f"(cost=${total_cost:.4f})"
)
return
# Ohne echte Kosten gibt es nichts zu statistisch erfassen. Die Guthaben-
# Buchung laeuft im Pauschalmodus trotzdem, weil der Kunde die Aktion
# bezahlt und nicht unseren Einkauf. Auf lokalen Modellen ist total_cost 0.
if total_cost > 0:
await _record_usage_statistics(db, tenant_id, usage, source, total_cost)
await _charge_credits(db, tenant_id, source, incident_type, total_cost)
async def _record_usage_statistics(
db: aiosqlite.Connection, tenant_id: int, usage, source: str, total_cost: float
) -> None:
"""Schreibt die echten Tokenmengen und Kosten nach token_usage_monthly."""
input_tokens = getattr(usage, "input_tokens", 0)
output_tokens = getattr(usage, "output_tokens", 0)
cache_creation = getattr(usage, "cache_creation_tokens", 0)
cache_read = getattr(usage, "cache_read_tokens", 0)
api_calls = getattr(usage, "call_count", 1)
refresh_increment = 1 if source == "monitor" else 0
year_month = datetime.now(TIMEZONE).strftime("%Y-%m")
await db.execute(
"""
INSERT INTO token_usage_monthly
(organization_id, year_month, source, input_tokens, output_tokens,
cache_creation_tokens, cache_read_tokens, total_cost_usd, api_calls, refresh_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(organization_id, year_month, source) DO UPDATE SET
input_tokens = input_tokens + excluded.input_tokens,
output_tokens = output_tokens + excluded.output_tokens,
cache_creation_tokens = cache_creation_tokens + excluded.cache_creation_tokens,
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
total_cost_usd = total_cost_usd + excluded.total_cost_usd,
api_calls = api_calls + excluded.api_calls,
refresh_count = refresh_count + excluded.refresh_count,
updated_at = CURRENT_TIMESTAMP
""",
(
tenant_id, year_month, source,
input_tokens, output_tokens, cache_creation, cache_read,
round(total_cost, 7), api_calls, refresh_increment,
),
)
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,
source: str,
incident_type: str | None,
total_cost: float,
) -> None:
"""Belastet das Guthaben der aktiven Lizenz und prueft die Warnschwelle."""
lic_cursor = await db.execute(
"SELECT * FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
(tenant_id,),
)
lic_row = await lic_cursor.fetchone()
if not lic_row:
return
lic = dict(lic_row)
if not lic.get("credits_total"):
return # Lizenz ohne Kontingent, nichts zu belasten
# Periodenwechsel nachholen, bevor gebucht wird. Sonst landet der erste
# Verbrauch des neuen Monats noch auf dem alten Zaehler.
lic = await roll_credit_period(db, lic)
key = _tariff_key(source, incident_type)
if BILLING_MODE == "flat":
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.
logger.warning(
f"Kein Tarif fuer '{key}', falle auf tatsaechliche Kosten zurueck"
)
credits_consumed = _actual_credits(lic, total_cost)
else:
credits_consumed = _actual_credits(lic, total_cost)
if credits_consumed <= 0:
return
await db.execute(
"UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE id = ?",
(round(credits_consumed, 2), lic["id"]),
)
used_new = (lic.get("credits_used") or 0) + credits_consumed
available = lic.get("credits_total") or 0
logger.info(
f"charge_usage_to_tenant[{key}] Tenant {tenant_id}: "
f"${total_cost:.4f} -> {round(credits_consumed, 2)} Einheiten "
f"({round(used_new, 1)}/{round(available, 1)})"
)
await _check_budget_warning(db, tenant_id, lic, used_new, available)
def _actual_credits(lic: dict, total_cost: float) -> float:
"""Echte Kosten in Einheiten umrechnen (Modus 'actual' und Rueckfallebene)."""
cost_per_credit = lic.get("cost_per_credit")
if not cost_per_credit or cost_per_credit <= 0:
return 0.0
return total_cost / cost_per_credit
async def _check_budget_warning(
db: aiosqlite.Connection, tenant_id: int, lic: dict, used: float, available: float
) -> None:
"""Meldet einmal je Periode, wenn die Warnschwelle erreicht ist.
Die Schwelle steht als budget_warning_percent auf der Lizenz und war bisher
zwar als Spalte vorhanden, wurde aber nirgends ausgewertet. Ohne sie liefen
Kunden ohne Vorwarnung in den Nur-Lese-Modus.
"""
if available <= 0 or lic.get("budget_warning_sent"):
return
# 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
await db.execute(
"UPDATE licenses SET budget_warning_sent = 1 WHERE id = ?", (lic["id"],)
)
try:
await _notify_budget_warning(db, tenant_id, percent, max(0.0, available - used))
except Exception as e:
# Eine fehlgeschlagene Benachrichtigung darf die Buchung nicht kippen.
logger.warning(f"Budget-Warnung konnte nicht zugestellt werden: {e}")
async def expire_licenses(db: aiosqlite.Connection):
"""Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen."""
cursor = await db.execute(
"""SELECT id, organization_id FROM licenses
WHERE status = 'active'
AND valid_until IS NOT NULL
AND valid_until < datetime('now')"""
)
expired = await cursor.fetchall()
count = 0
for lic in expired:
await db.execute(
"UPDATE licenses SET status = 'expired' WHERE id = ?",
(lic["id"],),
)
count += 1
logger.info(f"Lizenz {lic['id']} fuer Org {lic['organization_id']} als abgelaufen markiert")
if count > 0:
await db.commit()
logger.info(f"{count} Lizenz(en) als abgelaufen markiert")
return count