diff --git a/migrations/2026-07-25_billing_tariff.py b/migrations/2026-07-25_billing_tariff.py new file mode 100644 index 0000000..633000c --- /dev/null +++ b/migrations/2026-07-25_billing_tariff.py @@ -0,0 +1,67 @@ +"""Migration 2026-07-25: Preistabelle billing_tariff anlegen und befüllen. + +Die Tabelle ist die pflegbare Quelle der Credits-Sätze je Aktion. Der Monitor +legt sie beim Start selbst an (init_db, mit seinen Konfigurationswerten als +Erstbefüllung). Die Portal-Staging-Umgebung nutzt aber eine EIGENE Datenbank, +auf der kein Monitor läuft, deshalb braucht sie dieses Skript. Idempotent, +vorhandene Sätze werden NICHT überschrieben (INSERT OR IGNORE). + +Ausführung: + DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-07-25_billing_tariff.py + DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_billing_tariff.py +""" +import os +import sqlite3 +import sys + +# Beschlossene Verkaufssätze (23.07.2026), identisch zu den Monitor-Defaults +SEED = { + "monitor_adhoc": 45.0, + "monitor_research": 40.0, + "analysis": 12.0, + "factcheck": 12.0, + "chat": 1.0, + "enhance": 1.0, + "globe": 1.0, +} + + +def main(db_path: str) -> int: + if not os.path.exists(db_path): + print(f"FEHLER: DB nicht gefunden: {db_path}", file=sys.stderr) + return 1 + + conn = sqlite3.connect(db_path, timeout=60) + conn.execute("PRAGMA busy_timeout = 60000") + conn.execute("PRAGMA journal_mode = WAL") + + print(f"Migration auf {db_path}") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS billing_tariff ( + tariff_key TEXT PRIMARY KEY, + credits REAL NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + print(" + billing_tariff angelegt (oder vorhanden)") + + for key, credits in SEED.items(): + cur = conn.execute( + "INSERT OR IGNORE INTO billing_tariff (tariff_key, credits) VALUES (?, ?)", + (key, credits), + ) + if cur.rowcount: + print(f" + Satz {key} = {credits}") + else: + print(f" = Satz {key} war bereits gesetzt") + + conn.commit() + conn.close() + print("Migration abgeschlossen.") + return 0 + + +if __name__ == "__main__": + db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db") + sys.exit(main(db_path)) diff --git a/src/routers/pricing.py b/src/routers/pricing.py index 5b10a96..ae0ba60 100644 --- a/src/routers/pricing.py +++ b/src/routers/pricing.py @@ -12,8 +12,10 @@ from collections import defaultdict from datetime import datetime, timedelta import aiosqlite -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Request from auth import get_current_admin +from database import get_db +from audit import log_action, get_client_ip logger = logging.getLogger("verwaltung.pricing") router = APIRouter(prefix="/api/pricing", tags=["Pricing"]) @@ -21,6 +23,90 @@ router = APIRouter(prefix="/api/pricing", tags=["Pricing"]) COST_BASIS_DB = os.environ.get("COST_BASIS_DB_PATH", "/home/claude-dev/osint-data/osint.db") USD_PER_CREDIT = 0.20 +# Rückfallwerte, falls die Preistabelle (noch) fehlt. Entsprechen den +# Monitor-Defaults (CREDIT_TARIFF in AegisSight-Monitor, src/config.py). +TARIFF_DEFAULTS = { + "monitor_adhoc": 45.0, + "monitor_research": 40.0, + "analysis": 12.0, + "factcheck": 12.0, + "chat": 1.0, + "enhance": 1.0, + "globe": 1.0, +} + + +@router.get("/tariff") +async def get_tariff(admin=Depends(get_current_admin)): + """Wirksame Credits-Sätze aus der geteilten Preistabelle billing_tariff. + + Die Tabelle ist die Quelle der Buchung im Monitor. Fehlt sie noch + (Monitor-Migration nicht gelaufen), gelten die Rückfallwerte. + """ + tariff = dict(TARIFF_DEFAULTS) + source = "defaults" + db = await get_db() + try: + try: + cursor = await db.execute("SELECT tariff_key, credits FROM billing_tariff") + rows = await cursor.fetchall() + if rows: + source = "db" + for row in rows: + tariff[row["tariff_key"]] = row["credits"] + except Exception: + logger.warning("billing_tariff fehlt noch, liefere Rückfallwerte") + finally: + await db.close() + return {"tariff": tariff, "source": source, "usd_per_credit": USD_PER_CREDIT} + + +@router.put("/tariff") +async def update_tariff(data: dict, request: Request, admin=Depends(get_current_admin)): + """Setzt Credits-Sätze in billing_tariff. Ab der nächsten Buchung wirksam.""" + allowed = tuple(TARIFF_DEFAULTS.keys()) + updates = {} + for key, value in (data or {}).items(): + if key not in allowed: + raise HTTPException(status_code=400, detail=f"Unbekannter Tarif-Schlüssel: {key}") + try: + v = float(value) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=f"{key} muss eine Zahl sein") + if not 0.5 <= v <= 10000: + raise HTTPException(status_code=400, detail=f"{key} muss zwischen 0,5 und 10000 liegen") + updates[key] = v + if not updates: + raise HTTPException(status_code=400, detail="Keine Sätze übergeben") + + db = await get_db() + try: + cursor = await db.execute("SELECT tariff_key, credits FROM billing_tariff") + before = {r["tariff_key"]: r["credits"] for r in await cursor.fetchall()} + for key, v in updates.items(): + await db.execute( + "INSERT INTO billing_tariff (tariff_key, credits, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) " + "ON CONFLICT(tariff_key) DO UPDATE SET credits = excluded.credits, updated_at = CURRENT_TIMESTAMP", + (key, v), + ) + await db.commit() + await log_action( + db, admin, get_client_ip(request), + action="update", resource_type="tariff", resource_id=0, + before=before, after={**before, **updates}, + ) + logger.info(f"Preistabelle aktualisiert: {updates}") + return {"ok": True, "updated": updates} + except HTTPException: + raise + except Exception as exc: + raise HTTPException( + status_code=500, + detail=f"Preistabelle nicht beschreibbar ({exc}). Falls sie fehlt, zuerst den Monitor deployen oder migrations/2026-07-25_billing_tariff.py fahren.", + ) + finally: + await db.close() + @router.get("/cost-basis") async def cost_basis(days: int = 30, admin=Depends(get_current_admin)): diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 05b78f2..e79cc2c 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -271,7 +271,7 @@
- Zur Einordnung. Ein Live-Lauf kostet 45 Credits, das Anlegen einer Recherche 120 (drei Durchläufe), jede Vertiefung 40, Chat und Beschreibungs-Assistent je 1 Credit. + Zur Einordnung. Ein Live-Lauf kostet 45 Credits, das Anlegen einer Recherche 120 (drei Durchläufe), jede Vertiefung 40, Chat und Beschreibungs-Assistent je 1 Credit. Verbrauchsrechner öffnen
@@ -1217,7 +1217,7 @@ - + diff --git a/src/static/js/app.js b/src/static/js/app.js index 515c2fb..6f50ccd 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -69,6 +69,23 @@ const ThemeManager = { }, }; +// Wirksame Credits-Sätze aus der Preistabelle, für alle Anzeigetexte +let PORTAL_TARIFF = null; +async function loadTariffInfo() { + try { + const data = await API.get("/api/pricing/tariff"); + PORTAL_TARIFF = data.tariff || null; + const el = document.getElementById("tariffInfoText"); + if (el && PORTAL_TARIFF) { + const t = PORTAL_TARIFF; + const n = (v) => Number(v).toLocaleString("de-DE"); + el.textContent = `Zur Einordnung. Ein Live-Lauf kostet ${n(t.monitor_adhoc)} Credits, ` + + `das Anlegen einer Recherche ${n(t.monitor_research * 3)} (drei Durchläufe), ` + + `jede Vertiefung ${n(t.monitor_research)}, Chat und Beschreibungs-Assistent je ${n(t.chat)}.`; + } + } catch (e) { /* statischer Text bleibt als Rückfallebene stehen */ } +} + // Sprung zum (nachgeladenen) Verbrauchsrechner-Tab function openRechnerTab() { const tab = document.querySelector('.nav-tab[data-section="rechner"]'); @@ -115,6 +132,7 @@ document.addEventListener("DOMContentLoaded", () => { setupForms(); setupTranslation(); loadDashboard(); + loadTariffInfo(); loadDashboardTokenStats(); loadTranslationStatus(); loadOrgs(); @@ -1289,6 +1307,6 @@ document.addEventListener("DOMContentLoaded", () => { // --- Verbrauchsrechner nachladen (eigenstaendiges Modul, siehe rechner.js) --- (function () { var s = document.createElement("script"); - s.src = "/static/js/rechner.js?v=20260725a"; + s.src = "/static/js/rechner.js?v=20260725b"; document.head.appendChild(s); })(); diff --git a/src/static/js/rechner.js b/src/static/js/rechner.js index f6c13ae..52b7b16 100644 --- a/src/static/js/rechner.js +++ b/src/static/js/rechner.js @@ -157,6 +157,79 @@ var empfDaten = null; + // ---------- Aktive Preise (Tabelle billing_tariff) ---------- + + var aktivTarif = null; // zuletzt geladene bzw. gespeicherte aktive Saetze + + function renderAktivHint() { + var el = document.getElementById("vr-aktiv-hint"); + if (!el) return; + if (aktivTarif) { + el.innerHTML = "Simulation. Abgerechnet wird nach der Preistabelle, aktuell " + + aktivTarif.live + " je Live-Lauf, " + aktivTarif.research + " je Recherche-Durchlauf, " + + aktivTarif.neben + " je Nebenkosten-Aufruf. Die Regler ändern erst dann etwas, wenn du sie unten als aktive Preise speicherst."; + } + } + + function setzeSlider(satz, wert) { + var slider = document.getElementById("vr-slider-" + satz); + if (slider) { + if (wert > parseInt(slider.max, 10)) slider.max = wert; + slider.value = wert; + } + } + + function ladeTarif() { + if (typeof API === "undefined") return; + API.get("/api/pricing/tariff").then(function (data) { + var t = (data && data.tariff) || {}; + aktivTarif = { + live: Math.round(t.monitor_adhoc || saetze.live), + research: Math.round(t.monitor_research || saetze.research), + neben: Math.round(t.chat || saetze.neben), + }; + // Regler starten auf den aktiven Preisen + saetze.live = aktivTarif.live; + saetze.research = aktivTarif.research; + saetze.neben = aktivTarif.neben; + setzeSlider("live", saetze.live); + setzeSlider("research", saetze.research); + setzeSlider("neben", saetze.neben); + satzGesetzt(); + renderAktivHint(); + }).catch(function () { /* Hinweis bleibt generisch */ }); + } + + function speichereTarif() { + var msg = document.getElementById("vr-tarif-msg"); + var tuEs = function () { + if (msg) msg.textContent = "Speichern …"; + API.put("/api/pricing/tariff", { + monitor_adhoc: saetze.live, + monitor_research: saetze.research, + chat: saetze.neben, + enhance: saetze.neben, + globe: saetze.neben, + }).then(function () { + aktivTarif = { live: saetze.live, research: saetze.research, neben: saetze.neben }; + renderAktivHint(); + if (typeof loadTariffInfo === "function") loadTariffInfo(); + if (msg) msg.textContent = "Gespeichert, gilt ab sofort für alle neuen Buchungen."; + setTimeout(function () { if (msg) msg.textContent = ""; }, 6000); + }).catch(function (err) { + if (msg) msg.textContent = "Fehler: " + err.message; + }); + }; + if (typeof showConfirm === "function") { + showConfirm( + "Aktive Preise ändern?", + "Live-Lauf " + saetze.live + ", Recherche-Durchlauf " + saetze.research + + ", Nebenkosten " + saetze.neben + " Credits gelten dann ab sofort für alle neuen Buchungen.", + tuEs + ); + } else tuEs(); + } + function renderEmpfehlung() { if (!empfDaten) return; ["live", "research"].forEach(function (satz) { @@ -344,13 +417,17 @@ return 'Sätze frei einstellbar. Die Empfehlung stammt aus den echten API-Kosten der letzten 30 Tage (Median je Aktion geteilt durch 0,20 USD).
" + - 'Reine Simulation. Die Regler ändern keine Sätze im Monitor, abgerechnet wird nach der zentralen Konfiguration (aktuell 45 je Live-Lauf, 40 je Recherche-Durchlauf, Nebenkosten 1).
' + + 'Simulation. Die Regler ändern erst dann etwas, wenn du sie unten als aktive Preise speicherst.
' + "1 Credit = 0,20 USD Selbstkosten.
' + '