feat(pricing): Sätze kommen aus der Preistabelle, Rechner kann sie speichern

Die Credits-Sätze sind im Portal nicht mehr hart codiert. Neuer Endpoint
GET /api/pricing/tariff liest die geteilte Tabelle billing_tariff (mit
den beschlossenen Werten als Rückfallebene, falls die Tabelle fehlt),
PUT /api/pricing/tariff schreibt sie mit Validierung und Audit-Eintrag.

- Die Infozeile im Verbrauch-Bereich rendert ihre Zahlen aus der Tabelle,
  der statische Text bleibt nur als Rückfallebene stehen.
- Die Rechner-Regler starten auf den aktiven Preisen, der Hinweis nennt
  die tatsächlich abgerechneten Sätze, und der neue Knopf "Diese Sätze
  als aktive Preise speichern" schreibt sie nach Rückfrage in die
  Tabelle. Der Monitor bucht ab der nächsten Aktion mit den neuen Werten.
- Migration migrations/2026-07-25_billing_tariff.py für die eigene
  Staging-DB (auf Live legt die Tabelle der Monitor an und befüllt sie
  mit seinen wirksamen Konfigurationswerten).

Gegenstück im Monitor: Commit 027244a (billing_tariff als Quelle der
Buchung). Promote-Reihenfolge bleibt, erst Monitor, dann Verwaltung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-25 14:32:25 +00:00
Ursprung a639579366
Commit c4ea949f91
5 geänderte Dateien mit 257 neuen und 5 gelöschten Zeilen

Datei anzeigen

@@ -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))

Datei anzeigen

@@ -12,8 +12,10 @@ from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
import aiosqlite import aiosqlite
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, Request
from auth import get_current_admin from auth import get_current_admin
from database import get_db
from audit import log_action, get_client_ip
logger = logging.getLogger("verwaltung.pricing") logger = logging.getLogger("verwaltung.pricing")
router = APIRouter(prefix="/api/pricing", tags=["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") COST_BASIS_DB = os.environ.get("COST_BASIS_DB_PATH", "/home/claude-dev/osint-data/osint.db")
USD_PER_CREDIT = 0.20 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") @router.get("/cost-basis")
async def cost_basis(days: int = 30, admin=Depends(get_current_admin)): async def cost_basis(days: int = 30, admin=Depends(get_current_admin)):

Datei anzeigen

@@ -271,7 +271,7 @@
</div> </div>
<p style="margin-top:10px; font-size:12px; color:var(--text-muted); display:flex; align-items:center; gap:6px;"> <p style="margin-top:10px; font-size:12px; color:var(--text-muted); display:flex; align-items:center; gap:6px;">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
<span>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. <span><span id="tariffInfoText">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.</span>
<a href="#" onclick="openRechnerTab(); return false;" style="color:var(--accent);">Verbrauchsrechner öffnen</a></span> <a href="#" onclick="openRechnerTab(); return false;" style="color:var(--accent);">Verbrauchsrechner öffnen</a></span>
</p> </p>
@@ -1217,7 +1217,7 @@
</div> </div>
<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=20260725d"></script> <script src="/static/js/app.js?v=20260725e"></script>
<script src="/static/js/sources.js?v=20260522x2"></script> <script src="/static/js/sources.js?v=20260522x2"></script>
<script src="/static/js/x-scraper.js?v=20260522a"></script> <script src="/static/js/x-scraper.js?v=20260522a"></script>
<script src="/static/js/source-health.js?v=20260509l"></script> <script src="/static/js/source-health.js?v=20260509l"></script>

Datei anzeigen

@@ -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 // Sprung zum (nachgeladenen) Verbrauchsrechner-Tab
function openRechnerTab() { function openRechnerTab() {
const tab = document.querySelector('.nav-tab[data-section="rechner"]'); const tab = document.querySelector('.nav-tab[data-section="rechner"]');
@@ -115,6 +132,7 @@ document.addEventListener("DOMContentLoaded", () => {
setupForms(); setupForms();
setupTranslation(); setupTranslation();
loadDashboard(); loadDashboard();
loadTariffInfo();
loadDashboardTokenStats(); loadDashboardTokenStats();
loadTranslationStatus(); loadTranslationStatus();
loadOrgs(); loadOrgs();
@@ -1289,6 +1307,6 @@ document.addEventListener("DOMContentLoaded", () => {
// --- Verbrauchsrechner nachladen (eigenstaendiges Modul, siehe rechner.js) --- // --- Verbrauchsrechner nachladen (eigenstaendiges Modul, siehe rechner.js) ---
(function () { (function () {
var s = document.createElement("script"); 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); document.head.appendChild(s);
})(); })();

Datei anzeigen

@@ -157,6 +157,79 @@
var empfDaten = null; 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 = "<strong>Simulation.</strong> Abgerechnet wird nach der Preistabelle, aktuell <b>" +
aktivTarif.live + "</b> je Live-Lauf, <b>" + aktivTarif.research + "</b> je Recherche-Durchlauf, <b>" +
aktivTarif.neben + "</b> 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() { function renderEmpfehlung() {
if (!empfDaten) return; if (!empfDaten) return;
["live", "research"].forEach(function (satz) { ["live", "research"].forEach(function (satz) {
@@ -344,13 +417,17 @@
return '<div class="vr-kopf">' + return '<div class="vr-kopf">' +
"<h2>Verbrauchsrechner</h2>" + "<h2>Verbrauchsrechner</h2>" +
"<p>Sätze frei einstellbar. Die Empfehlung stammt aus den echten API-Kosten der letzten 30 Tage (Median je Aktion geteilt durch 0,20 USD).</p>" + "<p>Sätze frei einstellbar. Die Empfehlung stammt aus den echten API-Kosten der letzten 30 Tage (Median je Aktion geteilt durch 0,20 USD).</p>" +
'<p class="vr-basis"><strong>Reine Simulation.</strong> 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).</p>' + '<p class="vr-basis" id="vr-aktiv-hint"><strong>Simulation.</strong> Die Regler ändern erst dann etwas, wenn du sie unten als aktive Preise speicherst.</p>' +
"</div>" + "</div>" +
'<div class="vr-regler-grid">' + '<div class="vr-regler-grid">' +
reglerHtml("live", "Live-Monitoring", "Anlegen und Aktualisierung", 1, 100) + reglerHtml("live", "Live-Monitoring", "Anlegen und Aktualisierung", 1, 100) +
reglerHtml("research", "Recherche", "Je Aktualisierung (Durchlauf)", 1, 100) + reglerHtml("research", "Recherche", "Je Aktualisierung (Durchlauf)", 1, 100) +
reglerHtml("neben", "Nebenkosten", "Je Aufruf", 1, 10) + reglerHtml("neben", "Nebenkosten", "Je Aufruf", 1, 10) +
"</div>" + "</div>" +
'<div style="margin:14px 0 4px; display:flex; align-items:center; gap:10px;">' +
'<button type="button" class="btn btn-primary" id="vr-tarif-speichern">Diese Sätze als aktive Preise speichern</button>' +
'<span id="vr-tarif-msg" style="font-size:13px; color:var(--text-secondary);"></span>' +
"</div>" +
'<p class="vr-basis">1 Credit = 0,20 USD Selbstkosten.</p>' + '<p class="vr-basis">1 Credit = 0,20 USD Selbstkosten.</p>' +
'<div class="vr-calc">' + '<div class="vr-calc">' +
"<div>" + "<div>" +
@@ -473,12 +550,16 @@
} }
}); });
var speichernBtn = document.getElementById("vr-tarif-speichern");
if (speichernBtn) speichernBtn.addEventListener("click", speichereTarif);
updateWerte(); updateWerte();
renderBlock("live"); renderBlock("live");
renderBlock("recherche"); renderBlock("recherche");
renderBlock("neben"); renderBlock("neben");
calc(); calc();
ladeEmpfehlung(); ladeEmpfehlung();
ladeTarif();
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {