feat(abrechnung): Monatskontingent in Credits mit festen Sätzen, Hard-Stop und Warnschwelle
Portierung aus dem Lokal-Fork (AegisSight-Monitor-Local, Commit d2b9168, per Cherry-Pick übernommen und an den Server-Stand angepasst). - Feste Sätze je Aktion (BILLING_MODE=flat) statt echter Kosten geteilt durch cost_per_credit. Sätze auf die am 23.07.2026 beschlossenen Verkaufswerte gesetzt. Live-Lauf 45, Recherche je Durchlauf 40, Studio-Bausteine 12, Chat/Beschreibung/Globe 1. Per ENV überschreibbar, der alte Modus bleibt als BILLING_MODE=actual erhalten. - Abrechnungsperiode. licenses.credits_period trennt monthly von total, träger Monatsreset bei der nächsten Lizenzprüfung, optionaler Übertrag (credits_rollover, gedeckelt auf ein Monatskontingent). Bestandslizenzen ohne Periodenmarke behalten ihren Verbrauch. - Hard-Stop gegen das verfügbare Monatskontingent (Kontingent plus Übertrag) statt gegen das Lebenszeit-Total. - Warnschwelle budget_warning_percent (Default 80 Prozent) wird erstmals ausgewertet, einmalige Meldung an alle aktiven Nutzer der Organisation. - DB-Migration additiv und idempotent (credits_period, credits_period_start, credits_rollover, credits_carried, budget_warning_sent, unlimited_budget). - /api/auth/me liefert credits_available als Bezugsgröße plus credits_period, das Credits-Widget zeigt "Credits diesen Monat". - Wortlaut überall Credits (nicht Guthaben/Einheiten), docs/ABRECHNUNG.md auf den Online-Stand gebracht. Abweichungen zur Fork-Vorlage. Die Takt-Änderungen (Untergrenze 30 Min, Kostenvorschau im Anlege-Dialog, Fork-Commit 5b0b578) sind bewusst NICHT enthalten, die Sätze stehen auf 45/40 statt der Fork-Defaults 24/33. Getestet gegen eine Kopie der Staging-DB, 20 Prüfungen bestanden. Migration, Flat-Buchung adhoc/research/chat, Warnschwelle einmalig, Hard-Stop, Monatsreset, Übertrag gedeckelt, Bestandslizenz ohne Marke. Die vier Live-Lizenzen stehen auf unlimited_budget und sind unbeeinflusst. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
149
docs/ABRECHNUNG.md
Normale Datei
149
docs/ABRECHNUNG.md
Normale Datei
@@ -0,0 +1,149 @@
|
|||||||
|
# Credits und Abrechnung
|
||||||
|
|
||||||
|
Wie das Kontingent eines Kunden funktioniert, welche Stellschrauben es gibt und
|
||||||
|
was die Verwaltung setzen muss. Stand 2026-07-25 (Portierung aus dem Lokal-Fork,
|
||||||
|
Sätze auf die am 23.07.2026 beschlossenen Verkaufswerte gesetzt).
|
||||||
|
|
||||||
|
## Begriffe
|
||||||
|
|
||||||
|
Der Kunde sieht **Credits**, nicht Token und nicht Dollar. Das ist Absicht.
|
||||||
|
"Token" ist ein KI-Fachbegriff mit einer festen Bedeutung, und 10.000 echte
|
||||||
|
Token wären etwa 7.500 Wörter, also ein einziger längerer Artikel. Wer den
|
||||||
|
Begriff kennt, hält so eine Angabe für einen Fehler. Intern heißen die Felder
|
||||||
|
`credits_*`, nach außen steht überall "Credits" (Entscheidung vom 25.07.2026,
|
||||||
|
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.
|
||||||
|
|
||||||
|
| Aktion | Credits | Herkunft des Werts |
|
||||||
|
|---|---|---|
|
||||||
|
| Live-Refresh (adhoc) | 45 | beschlossen 23.07.2026 (gemessener Median 24 Credits = 4,76 USD) |
|
||||||
|
| Recherche je Durchlauf | 40 | beschlossen 23.07.2026 (gemessener Median 33 Credits = 6,54 USD) |
|
||||||
|
| Analyse-Baustein (Studio) | 12 | geschätzt, rund ein Viertel eines Live-Laufs |
|
||||||
|
| Faktencheck-Baustein (Studio) | 12 | geschätzt |
|
||||||
|
| Chat, Beschreibungs-Assistent, Globe | 1 | real 0,02 bis 0,03 USD je Aufruf |
|
||||||
|
|
||||||
|
Die erste Aktualisierung einer Recherche-Lage fährt drei Durchläufe, kostet also
|
||||||
|
120 Credits. Die Verkaufssätze liegen bewusst über den gemessenen Medianen.
|
||||||
|
Messgrundlage sind 1.341 abgeschlossene Refreshes des Live-Systems aus dem
|
||||||
|
Zeitraum 28.02.2026 bis 21.06.2026 (`refresh_log`). Der Verbrauchsrechner im
|
||||||
|
Verwaltungsportal rechnet mit denselben Sätzen.
|
||||||
|
|
||||||
|
### Warum feste Sätze und nicht die echten Kosten
|
||||||
|
|
||||||
|
Vorher wurde `echte_kosten / cost_per_credit` abgebucht. Das hatte zwei Nachteile.
|
||||||
|
Der Kunde konnte nicht planen, weil ein Refresh einer großen Lage ein Vielfaches
|
||||||
|
eines Refreshs einer frischen kostet, ohne dass er den Unterschied sieht. Und
|
||||||
|
sobald das Modell-Backend billiger wird, etwa beim Wechsel von Anthropic auf eine
|
||||||
|
EU-Cloud, hätte derselbe Kunde plötzlich ein Vielfaches an Aktionen bekommen,
|
||||||
|
oder wir hätten `cost_per_credit` nachziehen müssen, was nach einer heimlichen
|
||||||
|
Preiserhöhung aussieht.
|
||||||
|
|
||||||
|
Der alte Modus lebt weiter unter `BILLING_MODE=actual` als Rückfallebene. Die
|
||||||
|
echten Kosten wandern in beiden Modi unverändert nach `token_usage_monthly`, die
|
||||||
|
interne Kostenkontrolle bleibt also vollständig erhalten.
|
||||||
|
|
||||||
|
## Abrechnungsperiode
|
||||||
|
|
||||||
|
`licenses.credits_period` steuert den Bezugszeitraum.
|
||||||
|
|
||||||
|
- `monthly` (Voreinstellung) füllt die Credits zum Monatswechsel neu auf.
|
||||||
|
- `total` lässt das Kontingent für die gesamte Lizenzlaufzeit gelten.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Eine Bestandslizenz ohne Periodenmarke bekommt beim ersten Zugriff die aktuelle
|
||||||
|
Periode eingetragen, **ohne** den Verbrauch zurückzusetzen. Sonst würden ihr
|
||||||
|
Credits geschenkt, die im laufenden Monat bereits verbraucht wurden.
|
||||||
|
|
||||||
|
## Warnung und Sperre
|
||||||
|
|
||||||
|
Bei Erreichen von `budget_warning_percent` (Voreinstellung 80) bekommen alle
|
||||||
|
aktiven Nutzer der Organisation eine Meldung im Benachrichtigungsbereich. Das
|
||||||
|
Flag `budget_warning_sent` verhindert, dass sich die Warnung bei jeder weiteren
|
||||||
|
Buchung wiederholt, und wird beim Periodenwechsel zurückgesetzt.
|
||||||
|
|
||||||
|
Sind die Credits aufgebraucht, wechselt die Organisation in den Nur-Lese-Modus.
|
||||||
|
Bestehende Lagen bleiben vollständig lesbar, es lassen sich nur keine neuen
|
||||||
|
Aktualisierungen mehr starten. Abgeschaltet wird nichts.
|
||||||
|
|
||||||
|
**Offen.** Die Warnung geht bisher nur in die Oberfläche, nicht per E-Mail. Wer
|
||||||
|
sich nicht anmeldet, sieht sie nicht. Ein Versand über `email_utils` wäre der
|
||||||
|
nächste Schritt.
|
||||||
|
|
||||||
|
## Was die Verwaltung je Lizenz setzen muss
|
||||||
|
|
||||||
|
| Spalte | Bedeutung | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
Ohne `credits_total` läuft die Organisation ohne Kontingent, dann wird nichts
|
||||||
|
belastet. So stehen aktuell alle vier Live-Lizenzen (`unlimited_budget=1`), die
|
||||||
|
Umstellung ändert für sie nichts, bis ihnen ein Kontingent gesetzt wird.
|
||||||
|
Das Verwaltungsportal hat für diese Stellwerte noch keine Eingabefelder, das ist
|
||||||
|
ein eigenes, offenes Arbeitspaket auf der Portal-Seite.
|
||||||
|
|
||||||
|
## Einordnung der Größenordnung
|
||||||
|
|
||||||
|
Bei einem Satz von 45 Credits je Live-Lauf entspricht ein Monatskontingent von
|
||||||
|
10.000 Credits rund 222 Live-Refreshes oder rund 83 neu angelegten Recherchen.
|
||||||
|
Zum Vergleich, das gesamte Live-System mit 46 aktiven Lagen verbrauchte im Mai
|
||||||
|
2026 nach den gemessenen Medianen rund 11.700 Credits, mit den Verkaufssätzen
|
||||||
|
bewertet wären es grob 20.000 bis 22.000.
|
||||||
|
|
||||||
|
Ein Hinweis zur Fortschreibung. Die Ist-Kosten je Refresh sind zwischen März und
|
||||||
|
Mai 2026 von 3,54 auf 7,80 Dollar gestiegen, vermutlich weil ein Lagebild mit
|
||||||
|
wachsendem Materialbestand auf mehr Kontext aufsetzt. Falls sich das bestätigt,
|
||||||
|
verbraucht derselbe Kunde im zweiten Jahr mehr als im ersten. Die Sätze in
|
||||||
|
`CREDIT_TARIFF` sollten deshalb regelmäßig gegen `token_usage_monthly` geprüft
|
||||||
|
werden.
|
||||||
|
|
||||||
|
## Auto-Refresh-Takt
|
||||||
|
|
||||||
|
Der Takt ist die wichtigste Stellschraube am Verbrauch. Achtung, im Online-Monitor
|
||||||
|
liegt die Untergrenze derzeit noch bei 10 Minuten und die Voreinstellung bei
|
||||||
|
15 Minuten. Ein Live-Refresh dauert gemessen im Median aber 12,7 Minuten, jeder
|
||||||
|
zehnte länger als 22,9 Minuten, und im 15-Minuten-Takt verbraucht eine einzige
|
||||||
|
Lage bei 45 Credits je Lauf rund 130.000 Credits im Monat. Der Lokal-Fork hat
|
||||||
|
dafür bereits eine Lösung (Untergrenze 30 Minuten, Voreinstellung 12 Stunden,
|
||||||
|
Kostenvorschau im Anlege-Dialog, Commit 5b0b578), deren Portierung noch offen ist.
|
||||||
|
|
||||||
|
Zur Orientierung bei einem Kontingent von 10.000 Credits und 45 Credits je Lauf.
|
||||||
|
|
||||||
|
| Takt | Refreshes je Monat | Credits | Anteil |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 30 Min | 1.440 | 64.800 | 648 Prozent |
|
||||||
|
| 1 Stunde | 720 | 32.400 | 324 Prozent |
|
||||||
|
| 6 Stunden | 120 | 5.400 | 54 Prozent |
|
||||||
|
| 12 Stunden | 60 | 2.700 | 27 Prozent |
|
||||||
|
| 24 Stunden | 30 | 1.350 | 14 Prozent |
|
||||||
|
|
||||||
|
## 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
|
||||||
|
der Hinweistext bei aufgebrauchten Credits darauf zeigen.
|
||||||
|
- Sätze für die Studio-Bausteine sind geschätzt und sollten nachgemessen werden,
|
||||||
|
sobald `token_usage_monthly` Zeilen mit `source='analysis'` und `'factcheck'`
|
||||||
|
enthält.
|
||||||
@@ -2170,10 +2170,15 @@ class AgentOrchestrator:
|
|||||||
f"${usage_acc.total_cost_usd:.4f} ({usage_acc.call_count} Calls)"
|
f"${usage_acc.total_cost_usd:.4f} ({usage_acc.call_count} Calls)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Credits-Tracking: Monatliche Aggregation + Credits abziehen
|
# Guthaben belasten + monatliche Statistik fortschreiben. Der Lagentyp
|
||||||
if tenant_id and usage_acc.total_cost_usd > 0:
|
# entscheidet ueber den Tarif, ein Research-Durchlauf kostet mehr als
|
||||||
|
# ein Live-Refresh. Nicht mehr an total_cost_usd > 0 gebunden, weil im
|
||||||
|
# Pauschalmodus die Aktion zaehlt und lokale Modelle 0 kosten.
|
||||||
|
if tenant_id:
|
||||||
from services.license_service import charge_usage_to_tenant
|
from services.license_service import charge_usage_to_tenant
|
||||||
await charge_usage_to_tenant(db, tenant_id, usage_acc, source="monitor")
|
await charge_usage_to_tenant(
|
||||||
|
db, tenant_id, usage_acc, source="monitor", incident_type=incident_type
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Quellen-Discovery im Background starten
|
# Quellen-Discovery im Background starten
|
||||||
|
|||||||
@@ -34,6 +34,51 @@ CLAUDE_MODEL_FAST = "claude-haiku-4-5-20251001" # Für einfache Aufgaben (Feed-
|
|||||||
CLAUDE_MODEL_MEDIUM = "claude-sonnet-4-6" # Für qualitätskritische Aufgaben (Netzwerkanalyse)
|
CLAUDE_MODEL_MEDIUM = "claude-sonnet-4-6" # Für qualitätskritische Aufgaben (Netzwerkanalyse)
|
||||||
CLAUDE_MODEL_STANDARD = "claude-opus-4-7" # Standard-Opus für Recherche, Analyse, Faktencheck
|
CLAUDE_MODEL_STANDARD = "claude-opus-4-7" # Standard-Opus für Recherche, Analyse, Faktencheck
|
||||||
|
|
||||||
|
# --- Verbrauchssaetze (Credits je Aktion) -------------------------------------
|
||||||
|
# Beschlossen 2026-07-23, der Verbrauchsrechner im Verwaltungsportal nutzt
|
||||||
|
# dieselben Werte: Live-Lauf 45, Recherche je Durchlauf 40 (das Anlegen faehrt
|
||||||
|
# drei Durchlaeufe, also 120), 1 Credit = 0,20 USD. Per ENV ueberschreibbar.
|
||||||
|
# (Die gemessenen Mediane aus refresh_log lagen bei 24 bzw. 33 Credits, die
|
||||||
|
# Verkaufssaetze sind bewusst hoeher angesetzt.)
|
||||||
|
CREDITS_PER_ADHOC_REFRESH = float(os.environ.get("CREDITS_PER_ADHOC_REFRESH", "45"))
|
||||||
|
CREDITS_PER_RESEARCH_PASS = float(os.environ.get("CREDITS_PER_RESEARCH_PASS", "40"))
|
||||||
|
|
||||||
|
# --- Abrechnungsmodus ---------------------------------------------------------
|
||||||
|
# 'flat' = feste Credits je Aktion nach CREDIT_TARIFF. Der Kunde kann seinen
|
||||||
|
# Verbrauch vorher ausrechnen, und eine spaetere Verbilligung des
|
||||||
|
# Modell-Backends veraendert sein Kontingent nicht.
|
||||||
|
# 'actual' = bisheriges Verhalten, echte Kosten geteilt durch cost_per_credit.
|
||||||
|
# Damit haengt das Kundenerlebnis am Einkaufspreis, ein Refresh einer
|
||||||
|
# grossen Lage zieht ein Vielfaches eines Refreshs einer frischen.
|
||||||
|
# Die echten Kosten wandern in beiden Modi unveraendert nach token_usage_monthly,
|
||||||
|
# die interne Kostenkontrolle bleibt also erhalten.
|
||||||
|
BILLING_MODE = os.environ.get("BILLING_MODE", "flat").lower()
|
||||||
|
|
||||||
|
# Credits je Aktion im Modus 'flat'. Schluessel ist die Abrechnungsquelle,
|
||||||
|
# beim Refresh zusaetzlich nach Lagentyp getrennt.
|
||||||
|
#
|
||||||
|
# monitor_* sind die beschlossenen Verkaufssaetze (siehe oben).
|
||||||
|
# analysis/factcheck sind Einzelbausteine aus dem Studio und liegen mangels
|
||||||
|
# eigener Messreihe bei rund einem Viertel eines Live-Laufs. Sobald
|
||||||
|
# token_usage_monthly dafuer Zahlen hat, hier nachziehen.
|
||||||
|
# chat/enhance/globe kosten real 0,02 bis 0,03 USD je Aufruf, also unter einem
|
||||||
|
# halben Credit. Der Satz 1 verhindert Missbrauch, ohne echte Nutzung spuerbar
|
||||||
|
# zu belasten.
|
||||||
|
CREDIT_TARIFF = {
|
||||||
|
"monitor_adhoc": CREDITS_PER_ADHOC_REFRESH,
|
||||||
|
"monitor_research": CREDITS_PER_RESEARCH_PASS,
|
||||||
|
"analysis": float(os.environ.get("CREDITS_PER_ANALYSIS", "12")),
|
||||||
|
"factcheck": float(os.environ.get("CREDITS_PER_FACTCHECK", "12")),
|
||||||
|
"chat": 1.0,
|
||||||
|
"enhance": 1.0,
|
||||||
|
"globe": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Voreinstellung fuer neue Lizenzen, wenn die Verwaltung nichts anderes setzt.
|
||||||
|
# 'monthly' = Kontingent gilt je Kalendermonat und wird zum Monatswechsel neu
|
||||||
|
# gefuellt. 'total' = Kontingent gilt fuer die gesamte Lizenzlaufzeit.
|
||||||
|
CREDITS_PERIOD_DEFAULT = os.environ.get("CREDITS_PERIOD_DEFAULT", "monthly")
|
||||||
|
|
||||||
# Ausgabesprache wird pro Organisation gesteuert -- siehe services/org_settings.py
|
# Ausgabesprache wird pro Organisation gesteuert -- siehe services/org_settings.py
|
||||||
# (organization_settings-Tabelle, Key 'output_language', Werte 'de' | 'en').
|
# (organization_settings-Tabelle, Key 'output_language', Werte 'de' | 'en').
|
||||||
# Default-Fallback in den Agent-Methoden ist 'Deutsch', sodass Calls ohne
|
# Default-Fallback in den Agent-Methoden ist 'Deutsch', sodass Calls ohne
|
||||||
|
|||||||
@@ -863,6 +863,39 @@ async def init_db():
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Migration: Credits-System zu Lizenzen hinzugefuegt")
|
logger.info("Migration: Credits-System zu Lizenzen hinzugefuegt")
|
||||||
|
|
||||||
|
# Migration: Credits-Periode. Bis hierher war credits_total ein
|
||||||
|
# Gesamtwert ueber die ganze Lizenzlaufzeit, credits_used wurde nur
|
||||||
|
# hochgezaehlt und nie zurueckgesetzt. Ein als "monatlich" verkauftes
|
||||||
|
# Kontingent haette den Kunden nach dem ersten starken Monat dauerhaft
|
||||||
|
# in den Nur-Lese-Modus gestellt.
|
||||||
|
cursor = await db.execute("PRAGMA table_info(licenses)")
|
||||||
|
lic_columns = [row[1] for row in await cursor.fetchall()]
|
||||||
|
if "credits_period" not in lic_columns:
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE licenses ADD COLUMN credits_period TEXT DEFAULT 'monthly'"
|
||||||
|
)
|
||||||
|
# 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")
|
||||||
|
# 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()
|
||||||
|
logger.info("Migration: Credits-Periode zu Lizenzen hinzugefuegt")
|
||||||
|
|
||||||
|
# Migration: unlimited_budget nachziehen. Auf dem Live-Stand existiert die
|
||||||
|
# Spalte, im Schema fehlte sie -- check_license() las sie defensiv per
|
||||||
|
# .get() aus und bekam auf frischen Datenbanken immer None.
|
||||||
|
if "unlimited_budget" not in lic_columns:
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE licenses ADD COLUMN unlimited_budget INTEGER DEFAULT 0"
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: unlimited_budget zu Lizenzen hinzugefuegt")
|
||||||
|
|
||||||
# Migration: Token-Usage-Monatstabelle
|
# Migration: Token-Usage-Monatstabelle
|
||||||
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'")
|
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'")
|
||||||
if not await cursor.fetchone():
|
if not await cursor.fetchone():
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ class UserMeResponse(BaseModel):
|
|||||||
credits_total: Optional[int] = None
|
credits_total: Optional[int] = None
|
||||||
credits_remaining: Optional[int] = None
|
credits_remaining: Optional[int] = None
|
||||||
credits_percent_used: Optional[float] = None
|
credits_percent_used: Optional[float] = None
|
||||||
|
# 'monthly' = Kontingent wird zum Monatswechsel neu gefuellt, 'total' = gilt
|
||||||
|
# fuer die ganze Lizenzlaufzeit. Steuert nur die Beschriftung im Frontend.
|
||||||
|
credits_period: Optional[str] = None
|
||||||
is_global_admin: bool = False
|
is_global_admin: bool = False
|
||||||
output_language: str = "de"
|
output_language: str = "de"
|
||||||
|
|
||||||
|
|||||||
@@ -195,19 +195,20 @@ async def get_me(
|
|||||||
from services.license_service import check_license
|
from services.license_service import check_license
|
||||||
license_info = await check_license(db, current_user["tenant_id"])
|
license_info = await check_license(db, current_user["tenant_id"])
|
||||||
|
|
||||||
# Credits-Daten laden (echte Prozente, nicht gekappt)
|
# Guthaben-Daten aus der Lizenzpruefung uebernehmen. check_license() hat den
|
||||||
|
# Periodenwechsel bereits nachgeholt, ein zweiter Griff in die Tabelle wuerde
|
||||||
|
# nur dieselben Werte noch einmal lesen.
|
||||||
credits_total = None
|
credits_total = None
|
||||||
credits_remaining = None
|
credits_remaining = None
|
||||||
credits_percent_used = None
|
credits_percent_used = None
|
||||||
|
credits_period = None
|
||||||
unlimited_budget = bool(license_info.get("unlimited_budget", False))
|
unlimited_budget = bool(license_info.get("unlimited_budget", False))
|
||||||
if current_user.get("tenant_id"):
|
if current_user.get("tenant_id") and license_info.get("credits_available"):
|
||||||
lic_cursor = await db.execute(
|
# Verfuegbar ist Kontingent plus Uebertrag, danach richtet sich auch der
|
||||||
"SELECT credits_total, credits_used, cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
# Hard-Stop. Die Anzeige muss dieselbe Bezugsgroesse nutzen.
|
||||||
(current_user["tenant_id"],))
|
credits_total = int(license_info["credits_available"])
|
||||||
lic_row = await lic_cursor.fetchone()
|
credits_used = license_info.get("credits_used") or 0
|
||||||
if lic_row and lic_row["credits_total"]:
|
credits_period = license_info.get("credits_period")
|
||||||
credits_total = lic_row["credits_total"]
|
|
||||||
credits_used = lic_row["credits_used"] or 0
|
|
||||||
credits_remaining = max(0, int(credits_total - credits_used))
|
credits_remaining = max(0, int(credits_total - credits_used))
|
||||||
credits_percent_used = round((credits_used / credits_total) * 100, 1) if credits_total > 0 else 0
|
credits_percent_used = round((credits_used / credits_total) * 100, 1) if credits_total > 0 else 0
|
||||||
|
|
||||||
@@ -240,6 +241,7 @@ async def get_me(
|
|||||||
unlimited_budget=unlimited_budget,
|
unlimited_budget=unlimited_budget,
|
||||||
is_global_admin=is_global_admin_response,
|
is_global_admin=is_global_admin_response,
|
||||||
output_language=output_language_iso,
|
output_language=output_language_iso,
|
||||||
|
credits_period=credits_period,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from config import TIMEZONE
|
from config import TIMEZONE, BILLING_MODE, CREDIT_TARIFF
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
logger = logging.getLogger("osint.license")
|
logger = logging.getLogger("osint.license")
|
||||||
@@ -17,6 +17,126 @@ def _staging_mode() -> bool:
|
|||||||
return os.environ.get("STAGING_MODE", "").lower() in ("1", "true", "yes")
|
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.
|
||||||
|
|
||||||
|
Bei aktivem Uebertrag wandert ungenutztes Guthaben in die Folgeperiode,
|
||||||
|
gedeckelt auf ein Monatskontingent, damit es nicht unbegrenzt anwaechst.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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 = ?,
|
||||||
|
budget_warning_sent = 0
|
||||||
|
WHERE id = ?""",
|
||||||
|
(round(carried_new, 2), 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)"
|
||||||
|
)
|
||||||
|
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:
|
async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
||||||
"""Prueft den Lizenzstatus einer Organisation.
|
"""Prueft den Lizenzstatus einer Organisation.
|
||||||
|
|
||||||
@@ -56,9 +176,19 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
|
|
||||||
# Felder zur weiteren Verwendung extrahieren
|
# Felder zur weiteren Verwendung extrahieren
|
||||||
lic_dict = dict(license_row)
|
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"))
|
unlimited_budget = bool(lic_dict.get("unlimited_budget"))
|
||||||
credits_total = lic_dict.get("credits_total")
|
credits_total = lic_dict.get("credits_total")
|
||||||
credits_used = lic_dict.get("credits_used") or 0
|
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
|
||||||
|
|
||||||
# STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited
|
# STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited
|
||||||
if _staging_mode():
|
if _staging_mode():
|
||||||
@@ -88,10 +218,11 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Budget-Check (Hard-Stop bei aufgebrauchten Credits, ausser unlimited)
|
# Budget-Check (Hard-Stop bei aufgebrauchtem Guthaben, ausser unlimited).
|
||||||
|
# Geprueft wird gegen das verfuegbare Guthaben, also inklusive Uebertrag.
|
||||||
budget_exceeded = False
|
budget_exceeded = False
|
||||||
if not unlimited_budget and credits_total and credits_total > 0:
|
if not unlimited_budget and credits_available > 0:
|
||||||
if credits_used >= credits_total:
|
if credits_used >= credits_available:
|
||||||
budget_exceeded = True
|
budget_exceeded = True
|
||||||
|
|
||||||
# Nutzerzahl pruefen
|
# Nutzerzahl pruefen
|
||||||
@@ -110,10 +241,13 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
"current_users": current_users,
|
"current_users": current_users,
|
||||||
"read_only": True,
|
"read_only": True,
|
||||||
"read_only_reason": "budget_exceeded",
|
"read_only_reason": "budget_exceeded",
|
||||||
"message": "Token-Budget aufgebraucht",
|
"message": "Credits aufgebraucht",
|
||||||
"unlimited_budget": False,
|
"unlimited_budget": False,
|
||||||
"credits_total": credits_total,
|
"credits_total": credits_total,
|
||||||
"credits_used": credits_used,
|
"credits_used": credits_used,
|
||||||
|
"credits_carried": credits_carried,
|
||||||
|
"credits_available": credits_available,
|
||||||
|
"credits_period": credits_period,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -128,6 +262,9 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
"unlimited_budget": unlimited_budget,
|
"unlimited_budget": unlimited_budget,
|
||||||
"credits_total": credits_total,
|
"credits_total": credits_total,
|
||||||
"credits_used": credits_used,
|
"credits_used": credits_used,
|
||||||
|
"credits_carried": credits_carried,
|
||||||
|
"credits_available": credits_available,
|
||||||
|
"credits_period": credits_period,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -152,21 +289,30 @@ async def charge_usage_to_tenant(
|
|||||||
tenant_id: int | None,
|
tenant_id: int | None,
|
||||||
usage,
|
usage,
|
||||||
source: str,
|
source: str,
|
||||||
|
incident_type: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Verbucht Token-Verbrauch auf einen Tenant.
|
"""Verbucht eine Aktion auf einen Tenant.
|
||||||
|
|
||||||
Aktualisiert `token_usage_monthly` (UPSERT pro organization_id+year_month+source)
|
Zwei getrennte Vorgaenge. `token_usage_monthly` bekommt immer die echten
|
||||||
und zieht Credits von der aktiven Lizenz ab (wenn cost_per_credit gesetzt).
|
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:
|
Args:
|
||||||
db: offene aiosqlite.Connection
|
db: offene aiosqlite.Connection
|
||||||
tenant_id: Organisations-ID oder None (dann nur geloggt, keine DB-Buchung)
|
tenant_id: Organisations-ID oder None (dann nur geloggt, keine DB-Buchung)
|
||||||
usage: ClaudeUsage oder UsageAccumulator mit input_tokens/output_tokens/
|
usage: ClaudeUsage oder UsageAccumulator mit input_tokens/output_tokens/
|
||||||
cache_creation_tokens/cache_read_tokens/total_cost_usd/call_count
|
cache_creation_tokens/cache_read_tokens/total_cost_usd/call_count
|
||||||
source: 'monitor' | 'enhance' | 'chat'
|
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.
|
Der Helper ruft KEIN db.commit() auf — die Transaktionsgrenzen bestimmt der Caller.
|
||||||
Ohne Verbrauch (total_cost_usd == 0) oder ohne tenant_id wird nichts gebucht.
|
Ausnahme ist der Periodenwechsel, der eine eigene Transaktion braucht.
|
||||||
"""
|
"""
|
||||||
total_cost = getattr(usage, "total_cost_usd", None)
|
total_cost = getattr(usage, "total_cost_usd", None)
|
||||||
if total_cost is None:
|
if total_cost is None:
|
||||||
@@ -179,9 +325,19 @@ async def charge_usage_to_tenant(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if total_cost <= 0:
|
# Ohne echte Kosten gibt es nichts zu statistisch erfassen. Die Guthaben-
|
||||||
return
|
# 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)
|
input_tokens = getattr(usage, "input_tokens", 0)
|
||||||
output_tokens = getattr(usage, "output_tokens", 0)
|
output_tokens = getattr(usage, "output_tokens", 0)
|
||||||
cache_creation = getattr(usage, "cache_creation_tokens", 0)
|
cache_creation = getattr(usage, "cache_creation_tokens", 0)
|
||||||
@@ -214,24 +370,99 @@ async def charge_usage_to_tenant(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
lic_cursor = await db.execute(
|
||||||
"SELECT cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
"SELECT * FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
||||||
(tenant_id,),
|
(tenant_id,),
|
||||||
)
|
)
|
||||||
lic = await lic_cursor.fetchone()
|
lic_row = await lic_cursor.fetchone()
|
||||||
credits_consumed = 0.0
|
if not lic_row:
|
||||||
if lic and lic["cost_per_credit"] and lic["cost_per_credit"] > 0:
|
return
|
||||||
credits_consumed = total_cost / lic["cost_per_credit"]
|
|
||||||
|
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":
|
||||||
|
credits_consumed = CREDIT_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(
|
await db.execute(
|
||||||
"UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE organization_id = ? AND status = 'active'",
|
"UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE id = ?",
|
||||||
(round(credits_consumed, 2), tenant_id),
|
(round(credits_consumed, 2), lic["id"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
used_new = (lic.get("credits_used") or 0) + credits_consumed
|
||||||
|
available = (lic.get("credits_total") or 0) + (lic.get("credits_carried") or 0)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"charge_usage_to_tenant[{source}] Tenant {tenant_id}: "
|
f"charge_usage_to_tenant[{key}] Tenant {tenant_id}: "
|
||||||
f"${total_cost:.4f} -> {round(credits_consumed, 2)} Credits"
|
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
|
||||||
|
|
||||||
|
threshold = lic.get("budget_warning_percent") or 80
|
||||||
|
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):
|
async def expire_licenses(db: aiosqlite.Connection):
|
||||||
"""Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen."""
|
"""Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen."""
|
||||||
|
|||||||
@@ -63,12 +63,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="credits-section" class="credits-section" style="display: none;">
|
<div id="credits-section" class="credits-section" style="display: none;">
|
||||||
<div class="credits-divider"></div>
|
<div class="credits-divider"></div>
|
||||||
<div class="credits-label">Credits</div>
|
<div class="credits-label" id="credits-label" data-i18n="credits.label">Credits</div>
|
||||||
<div class="credits-bar-container">
|
<div class="credits-bar-container">
|
||||||
<div id="credits-bar" class="credits-bar"></div>
|
<div id="credits-bar" class="credits-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="credits-info">
|
<div class="credits-info">
|
||||||
<span><span id="credits-remaining">0</span> von <span id="credits-total">0</span></span>
|
<span><span id="credits-remaining">0</span> <span data-i18n="credits.of">von</span> <span id="credits-total">0</span></span>
|
||||||
<span class="credits-percent" id="credits-percent"></span>
|
<span class="credits-percent" id="credits-percent"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -807,13 +807,13 @@
|
|||||||
<script src="/static/vendor/leaflet.js"></script>
|
<script src="/static/vendor/leaflet.js"></script>
|
||||||
<script src="/static/vendor/leaflet.markercluster.js"></script>
|
<script src="/static/vendor/leaflet.markercluster.js"></script>
|
||||||
<script src="/static/js/i18n.js?v=20260513a"></script>
|
<script src="/static/js/i18n.js?v=20260513a"></script>
|
||||||
<script src="/static/js/api.js?v=20260522f"></script>
|
<script src="/static/js/api.js?v=20260725b"></script>
|
||||||
<script src="/static/js/ws.js?v=20260316b"></script>
|
<script src="/static/js/ws.js?v=20260316b"></script>
|
||||||
<script src="/static/js/components.js?v=20260723a"></script>
|
<script src="/static/js/components.js?v=20260723a"></script>
|
||||||
<script src="/static/js/layout.js?v=20260513f"></script>
|
<script src="/static/js/layout.js?v=20260513f"></script>
|
||||||
<script src="/static/js/pipeline.js?v=20260513d"></script>
|
<script src="/static/js/pipeline.js?v=20260513d"></script>
|
||||||
<script src="/static/js/a11y.js?v=20260725a"></script>
|
<script src="/static/js/a11y.js?v=20260725a"></script>
|
||||||
<script src="/static/js/app.js?v=20260725a"></script>
|
<script src="/static/js/app.js?v=20260725b"></script>
|
||||||
<script src="/static/js/cluster-data.js?v=20260322f"></script>
|
<script src="/static/js/cluster-data.js?v=20260322f"></script>
|
||||||
<script src="/static/js/tutorial.js?v=20260316z"></script>
|
<script src="/static/js/tutorial.js?v=20260316z"></script>
|
||||||
<script src="/static/js/chat.js?v=20260514e"></script>
|
<script src="/static/js/chat.js?v=20260514e"></script>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"action.restore": "Wiederherstellen",
|
"action.restore": "Wiederherstellen",
|
||||||
"action.budget_exceeded": "Budget aufgebraucht",
|
"action.budget_exceeded": "Budget aufgebraucht",
|
||||||
"action.read_only": "Nur Lesezugriff",
|
"action.read_only": "Nur Lesezugriff",
|
||||||
"action.budget_exceeded_title": "Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren.",
|
"action.budget_exceeded_title": "Credits aufgebraucht. Für weitere Aktualisierungen bitte die Verwaltung kontaktieren.",
|
||||||
"action.read_only_title": "Lizenz erlaubt keinen Schreibzugriff",
|
"action.read_only_title": "Lizenz erlaubt keinen Schreibzugriff",
|
||||||
"sidebar.empty": "Keine Lagen vorhanden",
|
"sidebar.empty": "Keine Lagen vorhanden",
|
||||||
"header.logout": "Abmelden",
|
"header.logout": "Abmelden",
|
||||||
@@ -262,5 +262,8 @@
|
|||||||
"chat.send_title": "Senden",
|
"chat.send_title": "Senden",
|
||||||
"chat.send_aria": "Nachricht senden",
|
"chat.send_aria": "Nachricht senden",
|
||||||
"chat.greeting": "Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.",
|
"chat.greeting": "Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.",
|
||||||
"stats.articles_total": "Artikel gesamt"
|
"stats.articles_total": "Artikel gesamt",
|
||||||
|
"credits.label": "Credits",
|
||||||
|
"credits.label_monthly": "Credits diesen Monat",
|
||||||
|
"credits.of": "von"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"action.restore": "Restore",
|
"action.restore": "Restore",
|
||||||
"action.budget_exceeded": "Budget exhausted",
|
"action.budget_exceeded": "Budget exhausted",
|
||||||
"action.read_only": "Read-only",
|
"action.read_only": "Read-only",
|
||||||
"action.budget_exceeded_title": "Token budget exhausted. Please contact administration.",
|
"action.budget_exceeded_title": "Credits used up. Please contact your administrator to continue updating.",
|
||||||
"action.read_only_title": "License does not permit write access",
|
"action.read_only_title": "License does not permit write access",
|
||||||
"sidebar.empty": "No situations yet",
|
"sidebar.empty": "No situations yet",
|
||||||
"header.logout": "Sign out",
|
"header.logout": "Sign out",
|
||||||
@@ -262,5 +262,8 @@
|
|||||||
"chat.send_title": "Send",
|
"chat.send_title": "Send",
|
||||||
"chat.send_aria": "Send message",
|
"chat.send_aria": "Send message",
|
||||||
"chat.greeting": "Hi! I'm the AegisSight Assistant. Ask me anything about how to use the monitor and I'll guide you through.",
|
"chat.greeting": "Hi! I'm the AegisSight Assistant. Ask me anything about how to use the monitor and I'll guide you through.",
|
||||||
"stats.articles_total": "Articles total"
|
"stats.articles_total": "Articles total",
|
||||||
|
"credits.label": "Credits",
|
||||||
|
"credits.label_monthly": "Credits this month",
|
||||||
|
"credits.of": "of"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,10 +102,10 @@ const API = {
|
|||||||
const warningEl = document.getElementById('header-license-warning');
|
const warningEl = document.getElementById('header-license-warning');
|
||||||
if (warningEl) {
|
if (warningEl) {
|
||||||
let text = 'Nur Lesezugriff';
|
let text = 'Nur Lesezugriff';
|
||||||
if (licStatus === 'budget_exceeded') text = 'Token-Budget aufgebraucht – nur Lesezugriff. Bitte Verwaltung kontaktieren.';
|
if (licStatus === 'budget_exceeded') text = 'Credits aufgebraucht, nur Lesezugriff. Für weitere Aktualisierungen bitte die Verwaltung kontaktieren.';
|
||||||
else if (licStatus === 'expired') text = 'Lizenz abgelaufen – nur Lesezugriff';
|
else if (licStatus === 'expired') text = 'Lizenz abgelaufen, nur Lesezugriff';
|
||||||
else if (licStatus === 'no_license') text = 'Keine aktive Lizenz – nur Lesezugriff';
|
else if (licStatus === 'no_license') text = 'Keine aktive Lizenz, nur Lesezugriff';
|
||||||
else if (licStatus === 'org_disabled') text = 'Organisation deaktiviert – nur Lesezugriff';
|
else if (licStatus === 'org_disabled') text = 'Organisation deaktiviert, nur Lesezugriff';
|
||||||
warningEl.textContent = text;
|
warningEl.textContent = text;
|
||||||
warningEl.classList.add('visible');
|
warningEl.classList.add('visible');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,6 +358,16 @@ const App = {
|
|||||||
}
|
}
|
||||||
const percentEl = document.getElementById("credits-percent");
|
const percentEl = document.getElementById("credits-percent");
|
||||||
if (percentEl) percentEl.textContent = percentRemaining.toFixed(0) + "% verbleibend";
|
if (percentEl) percentEl.textContent = percentRemaining.toFixed(0) + "% verbleibend";
|
||||||
|
|
||||||
|
// Bezugszeitraum benennen. Ohne den Zusatz liest sich ein
|
||||||
|
// monatliches Kontingent wie ein Gesamtvorrat, der nie wiederkommt.
|
||||||
|
const labelEl = document.getElementById('credits-label');
|
||||||
|
if (labelEl) {
|
||||||
|
const _tt = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
|
||||||
|
labelEl.textContent = user.credits_period === 'monthly'
|
||||||
|
? _tt('credits.label_monthly', 'Credits diesen Monat')
|
||||||
|
: _tt('credits.label', 'Credits');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dropdown Toggle
|
// Dropdown Toggle
|
||||||
|
|||||||
@@ -561,7 +561,7 @@
|
|||||||
<script src="/static/vendor/leaflet.js"></script>
|
<script src="/static/vendor/leaflet.js"></script>
|
||||||
<script src="/static/vendor/leaflet.markercluster.js"></script>
|
<script src="/static/vendor/leaflet.markercluster.js"></script>
|
||||||
<script src="/static/js/i18n.js?v=20260513a"></script>
|
<script src="/static/js/i18n.js?v=20260513a"></script>
|
||||||
<script src="/static/js/api.js?v=20260714a"></script>
|
<script src="/static/js/api.js?v=20260725b"></script>
|
||||||
<script src="/static/js/ws.js?v=20260316b"></script>
|
<script src="/static/js/ws.js?v=20260316b"></script>
|
||||||
<script src="/static/js/components.js?v=20260514e"></script>
|
<script src="/static/js/components.js?v=20260514e"></script>
|
||||||
<script src="/static/js/a11y.js?v=20260725a"></script>
|
<script src="/static/js/a11y.js?v=20260725a"></script>
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren