Block A: License-Check + Credits-Tracking fuer Enhance und Chat

- Neuer Helper charge_usage_to_tenant() in services/license_service.py:
  UPSERT in token_usage_monthly und Credits-Abzug aus licenses.credits_used.
  Wiederverwendbar fuer alle Claude-Call-Verursacher.
- Orchestrator: Inline-Buchungslogik (35 Zeilen) durch Helper-Aufruf ersetzt.
- routers/incidents.py POST /enhance-description: require_writable_license
  statt get_current_user, db_dependency hinzugefuegt, Credits-Buchung mit
  source="enhance" nach jedem Claude-Call.
- routers/chat.py POST /: analog require_writable_license + Credits-Buchung
  mit source="chat". _call_claude_chat() gibt jetzt zusaetzlich ClaudeUsage
  zurueck.

Abgelaufene/gesperrte Lizenzen koennen damit keine Haiku-Calls mehr ausloesen,
und alle Kosten werden konsistent auf Tenant-Ebene verbucht.
Dieser Commit ist enthalten in:
claude-dev
2026-04-23 17:49:32 +00:00
Ursprung c8a8e10020
Commit e8ac0d0c50
4 geänderte Dateien mit 119 neuen und 40 gelöschten Zeilen

Datei anzeigen

@@ -91,6 +91,92 @@ async def can_add_user(db: aiosqlite.Connection, organization_id: int) -> tuple[
return True, ""
async def charge_usage_to_tenant(
db: aiosqlite.Connection,
tenant_id: int | None,
usage,
source: str,
) -> None:
"""Verbucht Token-Verbrauch auf einen Tenant.
Aktualisiert `token_usage_monthly` (UPSERT pro organization_id+year_month+source)
und zieht Credits von der aktiven Lizenz ab (wenn cost_per_credit gesetzt).
Args:
db: offene aiosqlite.Connection
tenant_id: Organisations-ID oder None (dann nur geloggt, keine DB-Buchung)
usage: ClaudeUsage oder UsageAccumulator mit input_tokens/output_tokens/
cache_creation_tokens/cache_read_tokens/total_cost_usd/call_count
source: 'monitor' | 'enhance' | 'chat'
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.
"""
total_cost = getattr(usage, "total_cost_usd", None)
if total_cost is None:
total_cost = getattr(usage, "cost_usd", 0.0)
if not tenant_id:
logger.info(
f"charge_usage_to_tenant[{source}]: kein tenant_id, uebersprungen "
f"(cost=${total_cost:.4f})"
)
return
if total_cost <= 0:
return
input_tokens = getattr(usage, "input_tokens", 0)
output_tokens = getattr(usage, "output_tokens", 0)
cache_creation = getattr(usage, "cache_creation_tokens", 0)
cache_read = getattr(usage, "cache_read_tokens", 0)
api_calls = getattr(usage, "call_count", 1)
refresh_increment = 1 if source == "monitor" else 0
year_month = datetime.now(TIMEZONE).strftime("%Y-%m")
await db.execute(
"""
INSERT INTO token_usage_monthly
(organization_id, year_month, source, input_tokens, output_tokens,
cache_creation_tokens, cache_read_tokens, total_cost_usd, api_calls, refresh_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(organization_id, year_month, source) DO UPDATE SET
input_tokens = input_tokens + excluded.input_tokens,
output_tokens = output_tokens + excluded.output_tokens,
cache_creation_tokens = cache_creation_tokens + excluded.cache_creation_tokens,
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
total_cost_usd = total_cost_usd + excluded.total_cost_usd,
api_calls = api_calls + excluded.api_calls,
refresh_count = refresh_count + excluded.refresh_count,
updated_at = CURRENT_TIMESTAMP
""",
(
tenant_id, year_month, source,
input_tokens, output_tokens, cache_creation, cache_read,
round(total_cost, 7), api_calls, refresh_increment,
),
)
lic_cursor = await db.execute(
"SELECT cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
(tenant_id,),
)
lic = await lic_cursor.fetchone()
credits_consumed = 0.0
if lic and lic["cost_per_credit"] and lic["cost_per_credit"] > 0:
credits_consumed = total_cost / lic["cost_per_credit"]
await db.execute(
"UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE organization_id = ? AND status = 'active'",
(round(credits_consumed, 2), tenant_id),
)
logger.info(
f"charge_usage_to_tenant[{source}] Tenant {tenant_id}: "
f"${total_cost:.4f} -> {round(credits_consumed, 2)} Credits"
)
async def expire_licenses(db: aiosqlite.Connection):
"""Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen."""
cursor = await db.execute(