feat(abrechnung): pflegbare Preistabelle billing_tariff als Quelle der Sätze

Die Credits-Sätze je Aktion stehen jetzt in der geteilten Tabelle
billing_tariff statt nur in der Konfiguration. Die Migration legt die
Tabelle an und befüllt sie einmalig mit den wirksamen CREDIT_TARIFF-
Werten, danach liest _charge_credits() bei jeder Buchung die Tabelle,
fehlende Schlüssel oder eine fehlende Tabelle fallen auf die
Konfiguration zurück. Damit können Monitor und Verwaltungsportal
dieselben Sätze anzeigen und das Portal kann sie künftig pflegen,
eine Preisänderung braucht keinen Server-Eingriff mehr.

Getestet gegen eine Kopie der Staging-DB. Seed mit 7 Sätzen (45/40/
12/12/1/1/1), Buchung nach Tabelle, Tabellenänderung greift sofort,
Rückfall auf die Konfiguration ohne Tabelle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-25 14:28:09 +00:00
Ursprung ec3843bbe3
Commit 027244ada5
3 geänderte Dateien mit 51 neuen und 7 gelöschten Zeilen

Datei anzeigen

@@ -15,9 +15,12 @@ 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.
Im Modus `flat` (Voreinstellung) kostet jede Aktion einen festen Satz,
unabhängig davon, was sie uns tatsächlich verursacht hat. 1 Credit entspricht
0,20 USD. Die wirksamen Sätze stehen seit 25.07.2026 in der geteilten Tabelle
`billing_tariff` und sind über den Verbrauchsrechner des Verwaltungsportals
pflegbar. `CREDIT_TARIFF` in `src/config.py` befüllt die Tabelle beim ersten
Start und bleibt Rückfallebene für fehlende Schlüssel.
| Aktion | Credits | Herkunft des Werts |
|---|---|---|
@@ -138,8 +141,6 @@ Zur Orientierung bei einem Kontingent von 10.000 Credits und 45 Credits je Lauf.
## 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

Datei anzeigen

@@ -3,7 +3,7 @@ import aiosqlite
import json
import logging
import os
from config import DB_PATH, DATA_DIR
from config import DB_PATH, DATA_DIR, CREDIT_TARIFF
logger = logging.getLogger("osint.database")
@@ -896,6 +896,30 @@ async def init_db():
await db.commit()
logger.info("Migration: unlimited_budget zu Lizenzen hinzugefuegt")
# Migration: Preistabelle fuer die Credits-Saetze. Pflegbare Quelle der
# Wahrheit fuer Monitor UND Verwaltungsportal (das Portal schreibt sie
# ueber den Verbrauchsrechner). Beim ersten Start mit den wirksamen
# Konfigurationswerten befuellt, danach gewinnt die Tabelle,
# CREDIT_TARIFF bleibt Rueckfallebene fuer fehlende Schluessel.
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='billing_tariff'"
)
if not await cursor.fetchone():
await db.execute("""
CREATE TABLE billing_tariff (
tariff_key TEXT PRIMARY KEY,
credits REAL NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
for tariff_key, credits in CREDIT_TARIFF.items():
await db.execute(
"INSERT INTO billing_tariff (tariff_key, credits) VALUES (?, ?)",
(tariff_key, float(credits)),
)
await db.commit()
logger.info("Migration: billing_tariff angelegt und mit Konfigurationswerten befuellt")
# Migration: Token-Usage-Monatstabelle
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'")
if not await cursor.fetchone():

Datei anzeigen

@@ -371,6 +371,24 @@ async def _record_usage_statistics(
)
async def _load_tariff(db: aiosqlite.Connection) -> dict:
"""Wirksame Credits-Saetze je Aktion.
Die Tabelle billing_tariff ist die pflegbare Quelle (das Verwaltungsportal
schreibt sie), CREDIT_TARIFF aus der Konfiguration bleibt Rueckfallebene
fuer fehlende Schluessel oder eine noch fehlende Tabelle.
"""
tariff = dict(CREDIT_TARIFF)
try:
cursor = await db.execute("SELECT tariff_key, credits FROM billing_tariff")
for row in await cursor.fetchall():
if row["credits"] is not None and row["credits"] > 0:
tariff[row["tariff_key"]] = float(row["credits"])
except Exception:
pass # Tabelle existiert noch nicht, die Konfiguration gilt
return tariff
async def _charge_credits(
db: aiosqlite.Connection,
tenant_id: int,
@@ -398,7 +416,8 @@ async def _charge_credits(
key = _tariff_key(source, incident_type)
if BILLING_MODE == "flat":
credits_consumed = CREDIT_TARIFF.get(key)
tariff = await _load_tariff(db)
credits_consumed = tariff.get(key)
if credits_consumed is None:
# Unbekannte Quelle. Lieber auf die echte Rechnung zurueckfallen als
# stillschweigend gratis abzugeben.