feat(kontingent): Credits-Stellwerte je Lizenz + Lizenz beim Org-Anlegen

Bedienseite für das Credits-Monatskontingent des Monitors. Das Portal
setzt jetzt alle Stellwerte, der Monitor bleibt Herr der Laufzeitfelder.

- Lizenz-Modal und Budget-Formular um Abrechnungszeitraum (monthly/total),
  Übertrag-Checkbox und Warnschwelle erweitert, Vorbelegungen auf das
  beschlossene Modell (10.000 Credits, monatlich, Übertrag an, 0,20 USD).
- Neue Organisation kann direkt eine Lizenz mit Kontingent anlegen
  (OrgCreate.license, verschachtelt). Org- und Lizenz-Insert laufen in
  EINER Transaktion, schlägt der Lizenz-Teil fehl, entsteht keine
  halbe Organisation. Gemeinsamer Helper insert_license_row statt
  Duplikation der Widerruf- und Audit-Logik.
- PUT /api/token-usage/budget validiert credits_period (monthly|total)
  und budget_warning_percent (1 bis 100), credits_rollover wird wie
  unlimited_budget normalisiert. Laufzeitfelder bleiben bewusst
  außerhalb der Whitelist.
- Overview und Org-Detail rechnen den Rest jetzt gegen verfügbar =
  Kontingent + Übertrag, dieselbe Bezugsgröße wie der Hard-Stop im
  Monitor. Neuer read-only Block Laufzeitwerte im Token-Subtab.
- Startup-Check loggt einen deutlichen Fehler, wenn die licenses-Tabelle
  die Credits-Spalten noch nicht hat (falsche Deploy-Reihenfolge).
- Migration migrations/2026-07-25_credits_period.py (idempotent,
  wortgleich mit der Monitor-Migration) für die Portal-Staging-DB,
  auf Live gilt: Monitor VOR Portal promoten.
- 13 neue Modell-Tests, pytest grün (die zwei bekannten
  shared-Import-Fehlschläge bestehen unverändert seit vorher).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-25 13:11:02 +00:00
Ursprung c95903fb3a
Commit e0c39de21f
10 geänderte Dateien mit 566 neuen und 92 gelöschten Zeilen

Datei anzeigen

@@ -22,6 +22,26 @@ logger = logging.getLogger("verwaltung")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Schutz gegen falsche Deploy-Reihenfolge: Die Credits-Spalten in licenses
# legt die Monitor-Migration an. Fehlen sie, scheitern Lizenz-Anlage und
# Token-Tab mit "no such column". Hier nur ein deutlicher Log-Hinweis,
# kein Hard-Fail.
try:
from database import get_db
db = await get_db()
try:
cursor = await db.execute("PRAGMA table_info(licenses)")
cols = [row[1] for row in await cursor.fetchall()]
if "credits_period" not in cols:
logger.error(
"licenses-Tabelle ohne Credits-Spalten (credits_period fehlt)! "
"Zuerst den Monitor deployen (dessen Migration legt sie an) "
"oder migrations/2026-07-25_credits_period.py gegen diese DB fahren."
)
finally:
await db.close()
except Exception as exc:
logger.warning("Startup-Schema-Check uebersprungen: %s", exc)
logger.info("Verwaltungsportal gestartet auf Port %s", PORT)
yield
logger.info("Verwaltungsportal beendet")

Datei anzeigen

@@ -22,10 +22,36 @@ class TokenResponse(BaseModel):
email: str = ""
class LicenseParams(BaseModel):
"""Stellwerte einer Lizenz ohne Org-Bezug.
Wird zweifach genutzt, direkt beim Lizenz-Anlegen (LicenseCreate) und
verschachtelt beim Org-Anlegen (OrgCreate.license). Die Laufzeitfelder
(credits_used, credits_carried, credits_period_start, budget_warning_sent)
verwaltet der Monitor, das Portal setzt nur Stellwerte.
"""
license_type: str = Field(pattern="^(trial|annual|permanent)$")
max_users: int = Field(default=5, ge=1, le=1000)
duration_days: Optional[int] = Field(default=None, ge=1, le=3650)
token_budget_usd: Optional[float] = None
credits_total: Optional[int] = None
cost_per_credit: Optional[float] = None
budget_warning_percent: Optional[int] = Field(default=80, ge=1, le=100)
unlimited_budget: bool = False
# 'monthly' = Kontingent wird zum Monatswechsel neu gefuellt (der Monitor
# setzt den Verbrauch traege zurueck), 'total' = gilt fuer die Laufzeit.
credits_period: str = Field(default="monthly", pattern="^(monthly|total)$")
# Uebertrag ungenutzter Credits in den Folgemonat, der Monitor deckelt
# auf ein Monatskontingent. Empfohlen an, faengt Krisenspitzen ab.
credits_rollover: bool = True
class OrgCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
slug: str = Field(min_length=1, max_length=100, pattern="^[a-z0-9-]+$")
output_language: str = Field(default="de", pattern="^(de|en)$")
# Optional direkt eine Lizenz mit Credits-Kontingent anlegen
license: Optional[LicenseParams] = None
class OrgUpdate(BaseModel):
@@ -48,16 +74,8 @@ class OrgResponse(BaseModel):
output_language: str = "de"
class LicenseCreate(BaseModel):
class LicenseCreate(LicenseParams):
organization_id: int
license_type: str = Field(pattern="^(trial|annual|permanent)$")
max_users: int = Field(default=5, ge=1, le=1000)
duration_days: Optional[int] = Field(default=None, ge=1, le=3650)
token_budget_usd: Optional[float] = None
credits_total: Optional[int] = None
cost_per_credit: Optional[float] = None
budget_warning_percent: Optional[int] = Field(default=80, ge=1, le=100)
unlimited_budget: bool = False
class LicenseResponse(BaseModel):
@@ -75,6 +93,12 @@ class LicenseResponse(BaseModel):
cost_per_credit: Optional[float] = None
budget_warning_percent: Optional[int] = None
unlimited_budget: bool = False
# Neue Kontingent-Felder, alle optional mit Default, damit Responses auch
# von einer noch nicht migrierten Datenbank validieren.
credits_period: Optional[str] = "monthly"
credits_rollover: Optional[bool] = False
credits_carried: Optional[float] = 0
credits_period_start: Optional[str] = None
created_at: str
globe_access: bool = False
network_access: bool = False

Datei anzeigen

@@ -1,7 +1,7 @@
"""Lizenz-CRUD."""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status, Request
from models import LicenseCreate, LicenseResponse
from models import LicenseCreate, LicenseParams, LicenseResponse
from auth import get_current_admin
from database import db_dependency
from audit import log_action, get_client_ip, row_to_dict
@@ -10,6 +10,90 @@ import aiosqlite
router = APIRouter(prefix="/api/licenses", tags=["licenses"])
async def insert_license_row(
db: aiosqlite.Connection, org_id: int, params: LicenseParams
) -> tuple[int, list[dict]]:
"""Widerruft aktive Lizenzen der Org und legt die neue Zeile an.
Committet bewusst NICHT, die Transaktionsgrenze bestimmt der Aufrufer.
So kann das Org-Anlegen Organisation und Lizenz atomar anlegen.
Gibt (neue Lizenz-ID, Snapshots der widerrufenen Lizenzen) zurueck,
die Audit-Eintraege schreibt audit_license_creation() nach dem Commit.
"""
cursor = await db.execute(
"SELECT * FROM licenses WHERE organization_id = ? AND status = 'active'",
(org_id,),
)
revoked_lics = [dict(r) for r in await cursor.fetchall()]
if revoked_lics:
await db.execute(
"UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'",
(org_id,),
)
now = datetime.now(timezone.utc)
valid_from = now.isoformat()
valid_until = None
if params.license_type == "permanent":
valid_until = None
elif params.duration_days:
valid_until = (now + timedelta(days=params.duration_days)).isoformat()
elif params.license_type == "trial":
valid_until = (now + timedelta(days=14)).isoformat()
elif params.license_type == "annual":
valid_until = (now + timedelta(days=365)).isoformat()
# Bei unlimited_budget: Kontingent-Felder ignorieren, Uebertrag aus
if params.unlimited_budget:
token_budget_usd = None
credits_total = None
cost_per_credit = None
credits_rollover = 0
else:
token_budget_usd = params.token_budget_usd
credits_total = params.credits_total
cost_per_credit = params.cost_per_credit
credits_rollover = 1 if params.credits_rollover else 0
# credits_period_start bleibt bewusst NULL, der Monitor setzt die
# Periodenmarke traege bei der naechsten Lizenzpruefung selbst.
cursor = await db.execute(
"""INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status,
token_budget_usd, credits_total, cost_per_credit, budget_warning_percent, unlimited_budget,
credits_period, credits_rollover)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)""",
(org_id, params.license_type, params.max_users, valid_from, valid_until,
token_budget_usd, credits_total, cost_per_credit, params.budget_warning_percent,
1 if params.unlimited_budget else 0,
params.credits_period, credits_rollover),
)
return cursor.lastrowid, revoked_lics
async def audit_license_creation(
db: aiosqlite.Connection, admin: dict, ip: str, lic_id: int, revoked_lics: list[dict]
) -> dict:
"""Schreibt die Audit-Eintraege fuers Lizenz-Anlegen (nach dem Commit)."""
for old_lic in revoked_lics:
new_lic = dict(old_lic)
new_lic["status"] = "revoked"
await log_action(
db, admin, ip,
action="update", resource_type="license", resource_id=old_lic["id"],
before=old_lic, after=new_lic,
)
cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (lic_id,))
new_lic = dict(await cursor.fetchone())
await log_action(
db, admin, ip,
action="create", resource_type="license", resource_id=lic_id,
after=new_lic,
)
return new_lic
@router.get("", response_model=list[LicenseResponse])
async def list_licenses(
org_id: int = None,
@@ -39,68 +123,12 @@ async def create_license(
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
# Bestehende aktive Lizenz widerrufen + Snapshot fuer Audit
cursor = await db.execute(
"SELECT * FROM licenses WHERE organization_id = ? AND status = 'active'",
(data.organization_id,),
)
revoked_lics = [dict(r) for r in await cursor.fetchall()]
if revoked_lics:
await db.execute(
"UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'",
(data.organization_id,),
)
for old_lic in revoked_lics:
new_lic = dict(old_lic)
new_lic["status"] = "revoked"
await log_action(
db, admin, get_client_ip(request),
action="update", resource_type="license", resource_id=old_lic["id"],
before=old_lic, after=new_lic,
)
now = datetime.now(timezone.utc)
valid_from = now.isoformat()
valid_until = None
if data.license_type == "permanent":
valid_until = None
elif data.duration_days:
valid_until = (now + timedelta(days=data.duration_days)).isoformat()
elif data.license_type == "trial":
valid_until = (now + timedelta(days=14)).isoformat()
elif data.license_type == "annual":
valid_until = (now + timedelta(days=365)).isoformat()
# Bei unlimited_budget: Credits/Cost/Budget ignorieren
if data.unlimited_budget:
token_budget_usd = None
credits_total = None
cost_per_credit = None
else:
token_budget_usd = data.token_budget_usd
credits_total = data.credits_total
cost_per_credit = data.cost_per_credit
cursor = await db.execute(
"""INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status,
token_budget_usd, credits_total, cost_per_credit, budget_warning_percent, unlimited_budget)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)""",
(data.organization_id, data.license_type, data.max_users, valid_from, valid_until,
token_budget_usd, credits_total, cost_per_credit, data.budget_warning_percent,
1 if data.unlimited_budget else 0),
)
lic_id = cursor.lastrowid
lic_id, revoked_lics = await insert_license_row(db, data.organization_id, data)
await db.commit()
cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (lic_id,))
new_lic = dict(await cursor.fetchone())
await log_action(
db, admin, get_client_ip(request),
action="create", resource_type="license", resource_id=lic_id,
after=new_lic,
return await audit_license_creation(
db, admin, get_client_ip(request), lic_id, revoked_lics
)
return new_lic
@router.put("/{license_id}/revoke")

Datei anzeigen

@@ -64,6 +64,16 @@ async def create_organization(
(data.name, data.slug, now, now),
)
org_id = cursor.lastrowid
# Optional direkt eine Lizenz mit Credits-Kontingent anlegen. Beide Inserts
# liegen VOR dem Commit, schlaegt der Lizenz-Teil fehl (z.B. fehlende
# Spalten), entsteht keine halb angelegte Organisation.
lic_id = None
revoked_lics: list = []
if data.license is not None:
from routers.licenses import insert_license_row
lic_id, revoked_lics = await insert_license_row(db, org_id, data.license)
await db.commit()
# output_language als organization_settings-Eintrag persistieren
@@ -77,6 +87,11 @@ async def create_organization(
action="create", resource_type="organization", resource_id=org_id,
after=dict(new_row_obj),
)
if lic_id is not None:
from routers.licenses import audit_license_creation
await audit_license_creation(
db, admin, get_client_ip(request), lic_id, revoked_lics
)
return await _enrich_org(db, new_row_obj)

Datei anzeigen

@@ -20,6 +20,7 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
o.id, o.name, o.slug,
l.credits_total, l.credits_used, l.cost_per_credit,
l.token_budget_usd, l.budget_warning_percent, l.unlimited_budget,
l.credits_period, l.credits_rollover, l.credits_carried, l.credits_period_start,
COALESCE(SUM(r.total_cost_usd), 0) as total_cost,
COALESCE(SUM(r.input_tokens), 0) as total_input_tokens,
COALESCE(SUM(r.output_tokens), 0) as total_output_tokens,
@@ -36,9 +37,14 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
for row in rows:
credits_total = row["credits_total"] or 0
credits_used = row["credits_used"] or 0
credits_carried = row["credits_carried"] or 0
unlimited = bool(row["unlimited_budget"])
credits_remaining = None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total else None)
percent_used = None if unlimited else (round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None)
# Verfuegbar = Kontingent plus Uebertrag. Der Monitor gewaehrt beim
# Hard-Stop inklusive Uebertrag, die Anzeige muss dieselbe
# Bezugsgroesse nutzen, sonst zeigt sie zu wenig Rest an.
credits_available = credits_total + credits_carried
credits_remaining = None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None)
percent_used = None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None)
budget_usd = row["token_budget_usd"]
cost = row["total_cost"]
budget_percent = None if unlimited else (round((cost / budget_usd) * 100, 1) if budget_usd and budget_usd > 0 else None)
@@ -51,6 +57,11 @@ async def get_usage_overview(admin=Depends(get_current_admin)):
"credits_used": round(credits_used, 1),
"credits_remaining": credits_remaining,
"credits_percent_used": percent_used,
"credits_available": credits_available,
"credits_carried": credits_carried,
"credits_period": row["credits_period"] or "monthly",
"credits_rollover": bool(row["credits_rollover"]),
"credits_period_start": row["credits_period_start"],
"token_budget_usd": budget_usd,
"total_cost_usd": round(cost, 2),
"budget_percent_used": budget_percent,
@@ -122,13 +133,19 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
}
cursor = await db.execute(
"SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent, unlimited_budget FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
"SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent, unlimited_budget, "
"credits_period, credits_rollover, credits_carried, credits_period_start "
"FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
(org_id,))
lic = await cursor.fetchone()
unlimited = bool(lic["unlimited_budget"]) if lic else False
credits_total = lic["credits_total"] if lic else None
credits_used = lic["credits_used"] if lic else 0
credits_carried = (lic["credits_carried"] or 0) if lic else 0
# Verfuegbar = Kontingent plus Uebertrag, dieselbe Bezugsgroesse wie
# der Hard-Stop im Monitor.
credits_available = (credits_total or 0) + credits_carried
return {
"year_month": year_month,
@@ -144,8 +161,13 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
"unlimited_budget": unlimited,
"credits_total": credits_total,
"credits_used": round(credits_used, 1) if credits_used else 0,
"credits_remaining": None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total else None),
"credits_percent_used": None if unlimited else (round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None),
"credits_remaining": None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None),
"credits_percent_used": None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None),
"credits_available": credits_available,
"credits_carried": credits_carried,
"credits_period": (lic["credits_period"] or "monthly") if lic else "monthly",
"credits_rollover": bool(lic["credits_rollover"]) if lic else False,
"credits_period_start": lic["credits_period_start"] if lic else None,
"token_budget_usd": lic["token_budget_usd"] if lic else None,
"cost_per_credit": lic["cost_per_credit"] if lic else None,
"budget_warning_percent": lic["budget_warning_percent"] if lic else 80,
@@ -164,9 +186,23 @@ async def update_budget(license_id: int, data: dict, request: Request, admin=Dep
if not before:
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
# Vorab-Validierung, der Endpoint nimmt ein rohes dict entgegen.
# Ein Tippfehler wie 'weekly' wuerde sonst still in der DB landen und
# der Monitor behandelte die Lizenz wie 'total' (kein Monatsreset).
if "credits_period" in data and data["credits_period"] not in ("monthly", "total"):
raise HTTPException(status_code=400, detail="credits_period muss 'monthly' oder 'total' sein")
if "budget_warning_percent" in data:
try:
pct = int(data["budget_warning_percent"])
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="budget_warning_percent muss eine Zahl sein")
if not 1 <= pct <= 100:
raise HTTPException(status_code=400, detail="budget_warning_percent muss zwischen 1 und 100 liegen")
data["budget_warning_percent"] = pct
fields = []
values = []
for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent"):
for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent", "credits_period"):
if key in data:
fields.append(f"{key} = ?")
values.append(data[key])
@@ -179,6 +215,13 @@ async def update_budget(license_id: int, data: dict, request: Request, admin=Dep
fields.append("unlimited_budget = ?")
values.append(1 if data["unlimited_budget"] else 0)
if "credits_rollover" in data:
fields.append("credits_rollover = ?")
values.append(1 if data["credits_rollover"] else 0)
# Bewusst NICHT aenderbar: credits_carried, credits_period_start und
# budget_warning_sent sind Laufzeitfelder, die verwaltet der Monitor.
if not fields:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")

Datei anzeigen

@@ -267,18 +267,35 @@
</div>
<div class="form-group">
<label for="editCreditsTotal">Credits-Kontingent</label>
<input type="number" id="editCreditsTotal" placeholder="z.B. 600000">
<input type="number" id="editCreditsTotal" placeholder="z.B. 10000">
</div>
<div class="form-group">
<label for="editCreditsPeriod">Abrechnungszeitraum</label>
<select id="editCreditsPeriod" onchange="onEditPeriodChange()">
<option value="monthly">Monatlich (füllt sich zum Monatswechsel neu)</option>
<option value="total">Gesamtlaufzeit</option>
</select>
</div>
<div class="form-group" style="display:flex; align-items:flex-end;">
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
<input type="checkbox" id="editCreditsRollover">
<span>Übertrag ungenutzter Credits in den Folgemonat</span>
</label>
</div>
<div class="form-group">
<label for="editWarnPercent">Warnschwelle (%)</label>
<input type="number" id="editWarnPercent" min="1" max="100" placeholder="80">
</div>
<div class="form-group">
<label for="editCostPerCredit">Kosten pro Credit (USD)</label>
<input type="number" id="editCostPerCredit" step="0.0001" placeholder="z.B. 0.0033">
<input type="number" id="editCostPerCredit" step="0.01" placeholder="0.20">
</div>
<div class="form-group">
<label for="editBudgetUsd">Budget-Limit (USD)</label>
<input type="number" id="editBudgetUsd" step="0.01" placeholder="z.B. 2000">
</div>
<div class="form-group">
<label for="editCreditsUsed">Verbrauchte Credits</label>
<label for="editCreditsUsed">Verbrauchte Credits (laufende Periode)</label>
<input type="number" id="editCreditsUsed" step="0.01" placeholder="Aktueller Verbrauch">
</div>
<div style="grid-column:1/-1; display:flex; gap:8px; align-items:center;">
@@ -288,6 +305,31 @@
</form>
</div>
</div>
<h3 style="margin-top:24px;">Laufzeitwerte</h3>
<div class="card" style="margin-top:12px;">
<div class="card-body">
<p class="text-secondary" style="margin:0 0 12px; font-size:13px;">
Diese Werte verwaltet der Monitor selbst, sie sind hier nicht änderbar.
Stand ist die letzte Lizenzprüfung des Monitors, nach einem Monatswechsel
können sie kurzzeitig noch die Vorperiode zeigen.
</p>
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px;">
<div class="form-group">
<label>Übertrag aus dem Vormonat</label>
<div id="viewCreditsCarried" style="font-size:16px; font-weight:600;">-</div>
</div>
<div class="form-group">
<label>Laufende Periode</label>
<div id="viewCreditsPeriodStart" style="font-size:16px; font-weight:600;">-</div>
</div>
<div class="form-group">
<label>Verfügbar gesamt (Kontingent + Übertrag)</label>
<div id="viewCreditsAvailable" style="font-size:16px; font-weight:600;">-</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -574,6 +616,63 @@
</select>
<small class="text-secondary">Steuert die Ausgabesprache der KI-Pipeline (Lagebild, Faktencheck, Recherche) und die sichtbarsten UI-Strings im Monitor.</small>
</div>
<div class="form-group" style="background:var(--bg-tertiary,#0f172a); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="checkbox" id="newOrgWithLicense" checked onchange="onNewOrgLicenseToggle()">
<span><strong>Direkt eine Lizenz mit Credits-Kontingent anlegen</strong></span>
</label>
</div>
<div id="newOrgLicenseFields">
<div class="form-group">
<label for="newOrgLicType">Lizenztyp</label>
<select id="newOrgLicType" onchange="onNewOrgLicTypeChange()">
<option value="trial" selected>Trial</option>
<option value="annual">Jahreslizenz</option>
<option value="permanent">Permanent</option>
</select>
</div>
<div class="form-group">
<label for="newOrgLicMaxUsers">Maximale Nutzer</label>
<input type="number" id="newOrgLicMaxUsers" value="5" min="1" max="1000">
</div>
<div class="form-group" id="newOrgLicDurationGroup">
<label for="newOrgLicDuration">Laufzeit (Tage)</label>
<input type="number" id="newOrgLicDuration" value="14" min="1" max="3650">
<div class="text-muted mt-8" style="font-size: 12px;">Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage</div>
</div>
<div class="form-group" style="background:var(--bg-tertiary,#0f172a); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="checkbox" id="newOrgLicUnlimited" onchange="onNewOrgLicUnlimitedToggle()">
<span><strong>Unbegrenztes Budget</strong> &mdash; getrackt, aber kein Hard-Stop</span>
</label>
</div>
<div class="form-group">
<label for="newOrgLicCreditsTotal">Credits-Kontingent</label>
<input type="number" id="newOrgLicCreditsTotal" value="10000" placeholder="z.B. 10000">
</div>
<div class="form-group">
<label for="newOrgLicCreditsPeriod">Abrechnungszeitraum</label>
<select id="newOrgLicCreditsPeriod" onchange="onNewOrgLicPeriodChange()">
<option value="monthly" selected>Monatlich (Kontingent füllt sich zum Monatswechsel neu)</option>
<option value="total">Gesamtlaufzeit</option>
</select>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="checkbox" id="newOrgLicRollover" checked>
<span>Übertrag ungenutzter Credits in den Folgemonat (gedeckelt auf ein Monatskontingent)</span>
</label>
</div>
<div class="form-group">
<label for="newOrgLicWarnPercent">Warnschwelle (%)</label>
<input type="number" id="newOrgLicWarnPercent" value="80" min="1" max="100">
</div>
<div class="form-group">
<label for="newOrgLicCostPerCredit">Kosten pro Credit (USD)</label>
<input type="number" id="newOrgLicCostPerCredit" step="0.01" value="0.20" placeholder="0.20">
</div>
</div>
<div id="newOrgError" class="error-msg" style="display:none"></div>
</div>
<div class="modal-footer">
@@ -649,11 +748,28 @@
</div>
<div class="form-group">
<label for="newLicCreditsTotal">Credits-Kontingent</label>
<input type="number" id="newLicCreditsTotal" placeholder="z.B. 600000">
<input type="number" id="newLicCreditsTotal" value="10000" placeholder="z.B. 10000">
</div>
<div class="form-group">
<label for="newLicCreditsPeriod">Abrechnungszeitraum</label>
<select id="newLicCreditsPeriod" onchange="onNewLicPeriodChange()">
<option value="monthly" selected>Monatlich (Kontingent füllt sich zum Monatswechsel neu)</option>
<option value="total">Gesamtlaufzeit</option>
</select>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="checkbox" id="newLicRollover" checked>
<span>Übertrag ungenutzter Credits in den Folgemonat (gedeckelt auf ein Monatskontingent)</span>
</label>
</div>
<div class="form-group">
<label for="newLicWarnPercent">Warnschwelle (%)</label>
<input type="number" id="newLicWarnPercent" value="80" min="1" max="100">
</div>
<div class="form-group">
<label for="newLicCostPerCredit">Kosten pro Credit (USD)</label>
<input type="number" id="newLicCostPerCredit" step="0.0001" placeholder="z.B. 0.0033">
<input type="number" id="newLicCostPerCredit" step="0.01" placeholder="0.20">
</div>
<div class="form-group">
<label for="newLicBudgetUsd">Budget-Limit (USD)</label>
@@ -1038,7 +1154,7 @@
</div>
</div>
<script src="/static/js/app.js?v=20260522a"></script>
<script src="/static/js/app.js?v=20260725a"></script>
<script src="/static/js/sources.js?v=20260522x2"></script>
<script src="/static/js/x-scraper.js?v=20260522a"></script>
<script src="/static/js/source-health.js?v=20260509l"></script>

Datei anzeigen

@@ -442,14 +442,42 @@ function setupForms() {
e.preventDefault();
const errEl = document.getElementById("newOrgError");
errEl.style.display = "none";
const body = {
name: document.getElementById("newOrgName").value,
slug: document.getElementById("newOrgSlug").value,
output_language: document.getElementById("newOrgLanguage").value || "de",
};
// Optional direkt eine Lizenz mit Credits-Kontingent mitgeben
const withLic = document.getElementById("newOrgWithLicense");
if (withLic && withLic.checked) {
const licType = document.getElementById("newOrgLicType").value;
const lic = {
license_type: licType,
max_users: parseInt(document.getElementById("newOrgLicMaxUsers").value) || 5,
unlimited_budget: !!document.getElementById("newOrgLicUnlimited").checked,
// Periode und Uebertrag IMMER senden, sonst laesst sich der
// Uebertrag ueber das Formular nie abschalten
credits_period: document.getElementById("newOrgLicCreditsPeriod").value,
credits_rollover: !!document.getElementById("newOrgLicRollover").checked,
};
if (licType !== "permanent") {
lic.duration_days = parseInt(document.getElementById("newOrgLicDuration").value) || undefined;
}
if (!lic.unlimited_budget) {
const ct = document.getElementById("newOrgLicCreditsTotal");
const cpc = document.getElementById("newOrgLicCostPerCredit");
if (ct && ct.value) lic.credits_total = parseInt(ct.value);
if (cpc && cpc.value) lic.cost_per_credit = parseFloat(cpc.value);
}
const warn = document.getElementById("newOrgLicWarnPercent");
if (warn && warn.value) lic.budget_warning_percent = parseInt(warn.value);
body.license = lic;
}
try {
await API.post("/api/orgs", {
name: document.getElementById("newOrgName").value,
slug: document.getElementById("newOrgSlug").value,
output_language: document.getElementById("newOrgLanguage").value || "de",
});
await API.post("/api/orgs", body);
closeModal("modalNewOrg");
document.getElementById("newOrgForm").reset();
onNewOrgLicenseToggle();
loadOrgs();
loadDashboard();
} catch (err) {
@@ -515,6 +543,10 @@ function setupForms() {
}
const unlimitedEl = document.getElementById('newLicUnlimited');
body.unlimited_budget = !!(unlimitedEl && unlimitedEl.checked);
// Periode und Uebertrag IMMER senden (nicht nur bei gesetztem Wert),
// sonst laesst sich der Uebertrag nie abschalten
body.credits_period = document.getElementById('newLicCreditsPeriod').value;
body.credits_rollover = !!document.getElementById('newLicRollover').checked;
if (!body.unlimited_budget) {
const creditsTotal = document.getElementById('newLicCreditsTotal');
const costPerCredit = document.getElementById('newLicCostPerCredit');
@@ -523,6 +555,8 @@ function setupForms() {
if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value);
if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value);
}
const warnEl = document.getElementById('newLicWarnPercent');
if (warnEl && warnEl.value) body.budget_warning_percent = parseInt(warnEl.value);
try {
await API.post("/api/licenses", body);
closeModal("modalNewLicense");
@@ -818,7 +852,11 @@ async function loadOrgTokenUsage(orgId) {
el('tokenCreditsRemaining', '∞ Unbegrenzt');
el('tokenBudgetUsd', '—');
} else {
el('tokenCreditsRemaining', budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-');
var remainingTxt = budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-';
if (budget.credits_carried > 0 && budget.credits_remaining != null) {
remainingTxt += ' (inkl. ' + Math.round(budget.credits_carried).toLocaleString('de-DE') + ' Übertrag)';
}
el('tokenCreditsRemaining', remainingTxt);
el('tokenBudgetUsd', budget.token_budget_usd != null ? '$' + Number(budget.token_budget_usd).toFixed(2) : '-');
}
el('tokenCostUsd', usage.total_cost_usd != null ? '$' + Number(usage.total_cost_usd).toFixed(2) : '-');
@@ -887,37 +925,109 @@ function fillBudgetForm(budget) {
el('editCostPerCredit', budget.cost_per_credit);
el('editBudgetUsd', budget.token_budget_usd);
el('editCreditsUsed', budget.credits_used ? Math.round(budget.credits_used) : 0);
el('editWarnPercent', budget.budget_warning_percent);
const periodSel = document.getElementById('editCreditsPeriod');
if (periodSel) periodSel.value = budget.credits_period || 'monthly';
const rolloverCb = document.getElementById('editCreditsRollover');
if (rolloverCb) rolloverCb.checked = !!budget.credits_rollover;
onEditPeriodChange();
const cb = document.getElementById('editUnlimitedBudget');
if (cb) {
cb.checked = !!budget.unlimited_budget;
onUnlimitedToggle();
}
// Laufzeitwerte (verwaltet der Monitor, hier nur Anzeige)
const txt = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
const carried = budget.credits_carried || 0;
txt('viewCreditsCarried', carried > 0 ? Math.round(carried).toLocaleString('de-DE') + ' Credits' : 'Kein Übertrag');
txt('viewCreditsPeriodStart', budget.credits_period_start || 'Noch nicht initialisiert');
txt('viewCreditsAvailable', budget.unlimited_budget
? '∞ Unbegrenzt'
: (budget.credits_available != null ? Math.round(budget.credits_available).toLocaleString('de-DE') + ' Credits' : '-'));
}
// Unlimited-Toggle: Felder ausgrauen wenn aktiv
function onUnlimitedToggle() {
const cb = document.getElementById('editUnlimitedBudget');
const isUnlimited = cb && cb.checked;
['editCreditsTotal','editCostPerCredit','editBudgetUsd'].forEach(function(id) {
['editCreditsTotal','editCostPerCredit','editBudgetUsd','editCreditsPeriod','editCreditsRollover','editWarnPercent'].forEach(function(id) {
const e = document.getElementById(id);
if (e) {
e.disabled = isUnlimited;
e.style.opacity = isUnlimited ? '0.4' : '1';
}
});
if (!isUnlimited) onEditPeriodChange();
}
// Bei Periode "Gesamtlaufzeit" ist der Monats-Uebertrag bedeutungslos
function onEditPeriodChange() {
const sel = document.getElementById('editCreditsPeriod');
const cb = document.getElementById('editCreditsRollover');
if (!sel || !cb) return;
const unlimitedCb = document.getElementById('editUnlimitedBudget');
cb.disabled = sel.value === 'total' || !!(unlimitedCb && unlimitedCb.checked);
cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1';
}
// Unlimited-Toggle im Lizenz-Modal
function onNewLicUnlimitedToggle() {
const cb = document.getElementById('newLicUnlimited');
const isUnlimited = cb && cb.checked;
['newLicCreditsTotal','newLicCostPerCredit','newLicBudgetUsd'].forEach(function(id) {
['newLicCreditsTotal','newLicCostPerCredit','newLicBudgetUsd','newLicCreditsPeriod','newLicRollover','newLicWarnPercent'].forEach(function(id) {
const e = document.getElementById(id);
if (e) {
e.disabled = isUnlimited;
e.style.opacity = isUnlimited ? '0.4' : '1';
}
});
if (!isUnlimited) onNewLicPeriodChange();
}
function onNewLicPeriodChange() {
const sel = document.getElementById('newLicCreditsPeriod');
const cb = document.getElementById('newLicRollover');
if (!sel || !cb) return;
cb.disabled = sel.value === 'total';
cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1';
}
// === Lizenz-Abschnitt im Org-Modal ===
function onNewOrgLicenseToggle() {
const cb = document.getElementById('newOrgWithLicense');
const fields = document.getElementById('newOrgLicenseFields');
if (fields) fields.style.display = (cb && cb.checked) ? '' : 'none';
}
function onNewOrgLicTypeChange() {
const type = document.getElementById('newOrgLicType').value;
const group = document.getElementById('newOrgLicDurationGroup');
if (group) group.style.display = type === 'permanent' ? 'none' : '';
if (type !== 'permanent') {
document.getElementById('newOrgLicDuration').value = type === 'trial' ? '14' : '365';
}
}
function onNewOrgLicUnlimitedToggle() {
const cb = document.getElementById('newOrgLicUnlimited');
const isUnlimited = cb && cb.checked;
['newOrgLicCreditsTotal','newOrgLicCostPerCredit','newOrgLicCreditsPeriod','newOrgLicRollover','newOrgLicWarnPercent'].forEach(function(id) {
const e = document.getElementById(id);
if (e) {
e.disabled = isUnlimited;
e.style.opacity = isUnlimited ? '0.4' : '1';
}
});
if (!isUnlimited) onNewOrgLicPeriodChange();
}
function onNewOrgLicPeriodChange() {
const sel = document.getElementById('newOrgLicCreditsPeriod');
const cb = document.getElementById('newOrgLicRollover');
if (!sel || !cb) return;
cb.disabled = sel.value === 'total';
cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1';
}
async function loadDashboardTokenStats() {
@@ -977,6 +1087,14 @@ document.addEventListener('DOMContentLoaded', function() {
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value);
if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value);
// Periode und Uebertrag IMMER senden (nicht ueber das if-value-Muster),
// sonst laesst sich der Uebertrag nie abschalten und die Periode nie
// zurueckstellen. Laufzeitfelder (credits_carried, credits_period_start,
// budget_warning_sent) sendet das Portal grundsaetzlich NICHT.
body.credits_period = document.getElementById('editCreditsPeriod').value;
body.credits_rollover = !!document.getElementById('editCreditsRollover').checked;
var warnPercent = document.getElementById('editWarnPercent');
if (warnPercent && warnPercent.value) body.budget_warning_percent = parseInt(warnPercent.value);
}
// credits_used immer mitsenden (auch im Unlimited-Modus, fuer Korrekturen)
if (creditsUsed && creditsUsed.value !== '') body.credits_used = parseFloat(creditsUsed.value);