# admin_routes.py – AZA Admin Control Panel v2 (internal JSON endpoints) # # All endpoints require X-Admin-Token header matching AZA_ADMIN_TOKEN env var. # This router is mounted with prefix="/admin" in backend_main.py. # # v1 endpoints: system_status, licenses_overview, backup_status, billing_overview # v2 endpoints: license_customer_map, revenue_overview, alerts, dashboard_summary from __future__ import annotations import calendar import csv import io import json import os import shutil import sqlite3 import time from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, Query from fastapi.responses import JSONResponse, Response from aza_security import require_admin_token router = APIRouter(tags=["admin"], dependencies=[Depends(require_admin_token)]) _BASE_DIR = Path(__file__).resolve().parent _START_TIME = time.time() _BACKUP_PATHS = [ Path("/host_backups"), Path("/root/aza-backups"), Path("/root/aza-backups/daily"), Path("/var/backups/aza"), ] _BACKUP_LOG_PATHS = [ Path("/host_backups/backup.log"), Path("/root/aza-backups/backup.log"), Path("/var/log/aza-backup.log"), ] _LOOKUP_KEY_PRICES_CHF: Dict[str, int] = { "aza_basic_monthly": 59_00, "aza_basic_yearly": 590_00, "aza_team_monthly": 89_00, "aza_team_yearly": 890_00, } def _stripe_db_path() -> Path: return Path(os.environ.get( "STRIPE_DB_PATH", str(_BASE_DIR / "data" / "stripe_webhook.sqlite"), )) def _events_log_path() -> Path: return Path(os.environ.get( "STRIPE_EVENTS_LOG", str(_BASE_DIR / "data" / "stripe_events.log.jsonl"), )) def _disk_usage() -> Dict[str, Any]: try: usage = shutil.disk_usage("/") total_gb = round(usage.total / (1024 ** 3), 2) used_gb = round(usage.used / (1024 ** 3), 2) free_gb = round(usage.free / (1024 ** 3), 2) used_pct = round((usage.used / usage.total) * 100, 1) if usage.total else 0 return { "total_gb": total_gb, "used_gb": used_gb, "free_gb": free_gb, "used_percent": used_pct, } except Exception as e: return {"error": str(e)} def _safe_db_connect(db_path: Path): if not db_path.exists(): return None return sqlite3.connect(str(db_path)) def _stripe_env_ok() -> bool: return ( bool(os.environ.get("STRIPE_SECRET_KEY", "").strip()) and bool(os.environ.get("STRIPE_WEBHOOK_SECRET", "").strip()) ) def _newest_backup_info() -> Dict[str, Any]: """Scan all backup paths and return info about the most recent backup.""" best: Dict[str, Any] = {"found": False} best_mtime = 0.0 for bp in _BACKUP_PATHS: if not bp.exists() or not bp.is_dir(): continue try: for entry in bp.iterdir(): if not entry.is_dir(): continue mt = entry.stat().st_mtime if mt > best_mtime: best_mtime = mt age_h = round((time.time() - mt) / 3600, 1) best = { "found": True, "path": str(bp), "name": entry.name, "time_utc": datetime.fromtimestamp(mt, tz=timezone.utc).isoformat(), "age_hours": age_h, "age_days": round(age_h / 24, 1), } except Exception: continue return best def _license_counts() -> Dict[str, int]: """Return {status: count} from licenses table.""" db_path = _stripe_db_path() if not db_path.exists(): return {} try: con = sqlite3.connect(str(db_path)) rows = con.execute( "SELECT status, COUNT(*) FROM licenses GROUP BY status" ).fetchall() con.close() return {r[0]: r[1] for r in rows} except Exception: return {} # ═══════════════════════════════════════════════════════════════════════════════ # v1 ENDPOINTS (unchanged) # ═══════════════════════════════════════════════════════════════════════════════ # --------------------------------------------------------------------------- # 1. GET /admin/system_status # --------------------------------------------------------------------------- @router.get("/system_status") def system_status() -> Dict[str, Any]: now = datetime.now(timezone.utc) uptime_s = int(time.time() - _START_TIME) stripe_health: Dict[str, Any] = {"ok": False, "detail": "not_checked"} stripe_key_set = bool(os.environ.get("STRIPE_SECRET_KEY", "").strip()) stripe_webhook_set = bool(os.environ.get("STRIPE_WEBHOOK_SECRET", "").strip()) if stripe_key_set and stripe_webhook_set: stripe_health = {"ok": True, "detail": "env_configured"} elif not stripe_key_set: stripe_health = {"ok": False, "detail": "STRIPE_SECRET_KEY missing"} elif not stripe_webhook_set: stripe_health = {"ok": False, "detail": "STRIPE_WEBHOOK_SECRET missing"} db_path = _stripe_db_path() db_exists = db_path.exists() db_size_kb = round(db_path.stat().st_size / 1024, 1) if db_exists else None return { "status": "ok", "timestamp_utc": now.isoformat(), "uptime_seconds": uptime_s, "disk": _disk_usage(), "stripe": stripe_health, "database": { "path": str(db_path), "exists": db_exists, "size_kb": db_size_kb, }, "python_pid": os.getpid(), } # --------------------------------------------------------------------------- # 2. GET /admin/licenses_overview # --------------------------------------------------------------------------- @router.get("/licenses_overview") def licenses_overview(email: Optional[str] = None) -> Dict[str, Any]: db_path = _stripe_db_path() if not db_path.exists(): return { "status": "no_database", "db_path": str(db_path), "counts_by_status": {}, "total": 0, "recent": [], } try: con = sqlite3.connect(str(db_path)) con.row_factory = sqlite3.Row rows = con.execute( "SELECT status, COUNT(*) as cnt FROM licenses GROUP BY status" ).fetchall() counts = {r["status"]: r["cnt"] for r in rows} total = sum(counts.values()) if email: email_clean = email.strip().lower() recent_rows = con.execute( """SELECT * FROM licenses WHERE lower(customer_email) = ? ORDER BY updated_at DESC LIMIT 20""", (email_clean,), ).fetchall() else: recent_rows = con.execute( "SELECT * FROM licenses ORDER BY updated_at DESC LIMIT 20" ).fetchall() recent = [dict(r) for r in recent_rows] con.close() return { "status": "ok", "counts_by_status": counts, "total": total, "recent": recent, "filter_email": email or None, } except Exception as e: return {"status": "error", "error": str(e)} # --------------------------------------------------------------------------- # 3. GET /admin/backup_status # --------------------------------------------------------------------------- @router.get("/backup_status") def backup_status() -> Dict[str, Any]: result: Dict[str, Any] = { "disk": _disk_usage(), "backup_locations": [], "backup_log": None, } for bp in _BACKUP_PATHS: entry: Dict[str, Any] = {"path": str(bp), "exists": bp.exists()} if bp.exists() and bp.is_dir(): try: items = sorted(bp.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True) dirs = [d for d in items if d.is_dir()] all_files = list(bp.rglob("*")) total_size = sum(f.stat().st_size for f in all_files if f.is_file()) entry["folder_count"] = len(dirs) entry["file_count"] = len([f for f in all_files if f.is_file()]) entry["total_size_mb"] = round(total_size / (1024 ** 2), 2) if dirs: newest = dirs[0] mtime = newest.stat().st_mtime entry["newest_backup"] = newest.name entry["newest_backup_time_utc"] = datetime.fromtimestamp( mtime, tz=timezone.utc ).isoformat() entry["newest_backup_age_hours"] = round( (time.time() - mtime) / 3600, 1 ) except Exception as e: entry["error"] = str(e) result["backup_locations"].append(entry) for lp in _BACKUP_LOG_PATHS: if lp.exists() and lp.is_file(): try: text = lp.read_text(encoding="utf-8", errors="replace") lines = text.strip().splitlines() tail = lines[-20:] if len(lines) > 20 else lines result["backup_log"] = { "path": str(lp), "total_lines": len(lines), "last_lines": tail, } except Exception as e: result["backup_log"] = {"path": str(lp), "error": str(e)} break if result["backup_log"] is None: result["backup_log"] = {"status": "not_found", "searched": [str(p) for p in _BACKUP_LOG_PATHS]} return result # --------------------------------------------------------------------------- # 4. GET /admin/billing_overview # --------------------------------------------------------------------------- @router.get("/billing_overview") def billing_overview() -> Dict[str, Any]: stripe_key_set = bool(os.environ.get("STRIPE_SECRET_KEY", "").strip()) stripe_webhook_set = bool(os.environ.get("STRIPE_WEBHOOK_SECRET", "").strip()) stripe_ok = stripe_key_set and stripe_webhook_set db_path = _stripe_db_path() events_path = _events_log_path() db_info: Dict[str, Any] = {"exists": db_path.exists()} licenses_summary: List[Dict[str, Any]] = [] events_info: Dict[str, Any] = {"exists": events_path.exists()} if db_path.exists(): try: con = sqlite3.connect(str(db_path)) con.row_factory = sqlite3.Row db_info["size_kb"] = round(db_path.stat().st_size / 1024, 1) rows = con.execute( """SELECT subscription_id, customer_email, status, lookup_key, current_period_end, updated_at FROM licenses ORDER BY updated_at DESC LIMIT 20""" ).fetchall() licenses_summary = [dict(r) for r in rows] processed_count = con.execute( "SELECT COUNT(*) FROM processed_events" ).fetchone()[0] db_info["processed_events_count"] = processed_count con.close() except Exception as e: db_info["error"] = str(e) if events_path.exists(): try: size_kb = round(events_path.stat().st_size / 1024, 1) events_info["size_kb"] = size_kb with events_path.open("r", encoding="utf-8", errors="replace") as f: lines = f.readlines() events_info["total_lines"] = len(lines) tail_lines = lines[-10:] if len(lines) > 10 else lines recent_events: List[Dict[str, Any]] = [] for line in tail_lines: line = line.strip() if not line: continue try: recent_events.append(json.loads(line)) except Exception: recent_events.append({"raw": line[:200]}) events_info["recent"] = recent_events except Exception as e: events_info["error"] = str(e) return { "stripe_health": { "ok": stripe_ok, "secret_key_set": stripe_key_set, "webhook_secret_set": stripe_webhook_set, }, "database": db_info, "licenses_recent": licenses_summary, "events_log": events_info, } # ═══════════════════════════════════════════════════════════════════════════════ # v2 ENDPOINTS # ═══════════════════════════════════════════════════════════════════════════════ # --------------------------------------------------------------------------- # 5. GET /admin/license_customer_map # --------------------------------------------------------------------------- @router.get("/license_customer_map") def license_customer_map( email: Optional[str] = None, status: Optional[str] = None, ) -> Dict[str, Any]: db_path = _stripe_db_path() if not db_path.exists(): return {"status": "no_database", "total": 0, "licenses": []} try: con = sqlite3.connect(str(db_path)) con.row_factory = sqlite3.Row query = """ SELECT subscription_id, customer_id, customer_email, status, lookup_key, allowed_users, devices_per_user, current_period_end, client_reference_id, updated_at FROM licenses """ conditions: List[str] = [] params: List[Any] = [] if email: conditions.append("lower(customer_email) = ?") params.append(email.strip().lower()) if status: conditions.append("status = ?") params.append(status.strip()) if conditions: query += " WHERE " + " AND ".join(conditions) query += " ORDER BY updated_at DESC LIMIT 200" rows = con.execute(query, params).fetchall() licenses = [] now_ts = int(time.time()) for r in rows: row_dict = dict(r) cpe = row_dict.get("current_period_end") if cpe and isinstance(cpe, int): row_dict["period_end_human"] = datetime.fromtimestamp( cpe, tz=timezone.utc ).strftime("%Y-%m-%d %H:%M UTC") row_dict["period_expired"] = cpe < now_ts ua = row_dict.get("updated_at") if ua and isinstance(ua, int): row_dict["updated_at_human"] = datetime.fromtimestamp( ua, tz=timezone.utc ).strftime("%Y-%m-%d %H:%M UTC") licenses.append(row_dict) counts = _license_counts() con.close() return { "status": "ok", "total": len(licenses), "counts_by_status": counts, "filter_email": email or None, "filter_status": status or None, "licenses": licenses, } except Exception as e: return {"status": "error", "error": str(e)} # --------------------------------------------------------------------------- # 6. GET /admin/revenue_overview # --------------------------------------------------------------------------- @router.get("/revenue_overview") def revenue_overview() -> Dict[str, Any]: now = datetime.now(timezone.utc) month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) month_start_ts = int(month_start.timestamp()) result: Dict[str, Any] = { "month": now.strftime("%Y-%m"), "currency": "chf", "data_source": "local_db", } counts = _license_counts() result["active_subscriptions"] = counts.get("active", 0) result["canceled_subscriptions"] = counts.get("canceled", 0) result["total_subscriptions"] = sum(counts.values()) result["counts_by_status"] = counts db_path = _stripe_db_path() counts_by_key: Dict[str, int] = {} estimated_mrr_cents = 0 if db_path.exists(): try: con = sqlite3.connect(str(db_path)) rows = con.execute( "SELECT lookup_key, COUNT(*) FROM licenses WHERE status='active' GROUP BY lookup_key" ).fetchall() for lk, cnt in rows: lk_str = lk or "unknown" counts_by_key[lk_str] = cnt price = _LOOKUP_KEY_PRICES_CHF.get(lk_str, 0) if "yearly" in lk_str: estimated_mrr_cents += int(price / 12) * cnt else: estimated_mrr_cents += price * cnt con.close() except Exception: pass result["counts_by_lookup_key"] = counts_by_key result["estimated_mrr_chf"] = round(estimated_mrr_cents / 100, 2) stripe_data: Dict[str, Any] = {"available": False} stripe_key = os.environ.get("STRIPE_SECRET_KEY", "").strip() if stripe_key: try: import stripe as _stripe _stripe.api_key = stripe_key charges = _stripe.Charge.list( created={"gte": month_start_ts}, limit=100, ) gross_cents = 0 charge_count = 0 recent_charges: List[Dict[str, Any]] = [] for ch in charges.auto_paging_iter(): if ch.status == "succeeded" and ch.paid: gross_cents += ch.amount charge_count += 1 recent_charges.append({ "amount_chf": round(ch.amount / 100, 2), "email": ch.billing_details.email if ch.billing_details else ch.receipt_email, "date_utc": datetime.fromtimestamp(ch.created, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), "description": ch.description or "", "charge_id": ch.id, }) refunds = _stripe.Refund.list( created={"gte": month_start_ts}, limit=100, ) refund_cents = 0 refund_count = 0 recent_refunds: List[Dict[str, Any]] = [] for rf in refunds.auto_paging_iter(): if rf.status == "succeeded": refund_cents += rf.amount refund_count += 1 recent_refunds.append({ "amount_chf": round(rf.amount / 100, 2), "date_utc": datetime.fromtimestamp(rf.created, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), "refund_id": rf.id, }) stripe_data = { "available": True, "current_month_gross_chf": round(gross_cents / 100, 2), "current_month_charges": charge_count, "current_month_refunds_chf": round(refund_cents / 100, 2), "current_month_refund_count": refund_count, "current_month_net_chf": round((gross_cents - refund_cents) / 100, 2), "recent_charges": recent_charges, "recent_refunds": recent_refunds, } result["data_source"] = "stripe_api+local_db" except Exception as e: stripe_data = {"available": False, "error": str(e)} result["stripe_live"] = stripe_data events_path = _events_log_path() event_summary: Dict[str, int] = {} if events_path.exists(): try: with events_path.open("r", encoding="utf-8", errors="replace") as f: for line in f: line = line.strip() if not line: continue try: evt = json.loads(line) ts = evt.get("ts", 0) if ts >= month_start_ts: kind = evt.get("kind", "unknown") event_summary[kind] = event_summary.get(kind, 0) + 1 except Exception: continue except Exception: pass result["current_month_events"] = event_summary return result # --------------------------------------------------------------------------- # 7. GET /admin/alerts # --------------------------------------------------------------------------- @router.get("/alerts") def alerts() -> Dict[str, Any]: alert_list: List[Dict[str, str]] = [] disk = _disk_usage() used_pct = disk.get("used_percent", 0) free_gb = disk.get("free_gb", 999) if isinstance(used_pct, (int, float)): if used_pct >= 95: alert_list.append({ "id": "disk_critical", "severity": "critical", "message": f"Disk usage {used_pct}% – less than {free_gb} GB free", }) elif used_pct >= 85: alert_list.append({ "id": "disk_high", "severity": "warning", "message": f"Disk usage {used_pct}% – {free_gb} GB free", }) if not _stripe_env_ok(): alert_list.append({ "id": "stripe_not_configured", "severity": "critical", "message": "Stripe env vars (SECRET_KEY / WEBHOOK_SECRET) not set", }) db_path = _stripe_db_path() if not db_path.exists(): alert_list.append({ "id": "db_missing", "severity": "warning", "message": f"License database not found at {db_path}", }) counts = _license_counts() if not counts or counts.get("active", 0) == 0: alert_list.append({ "id": "no_active_licenses", "severity": "info", "message": "No active licenses in database", }) backup = _newest_backup_info() if not backup["found"]: alert_list.append({ "id": "backup_missing", "severity": "warning", "message": "No backup folders found in any known path", }) else: age_h = backup.get("age_hours", 0) if age_h > 48: alert_list.append({ "id": "backup_stale", "severity": "critical", "message": f"Latest backup is {backup.get('age_days', '?')} days old ({backup.get('name', '?')})", }) elif age_h > 26: alert_list.append({ "id": "backup_old", "severity": "warning", "message": f"Latest backup is {round(age_h, 0):.0f}h old ({backup.get('name', '?')})", }) severity_counts: Dict[str, int] = {"info": 0, "warning": 0, "critical": 0} for a in alert_list: sev = a.get("severity", "info") severity_counts[sev] = severity_counts.get(sev, 0) + 1 return { "total": len(alert_list), "counts_by_severity": severity_counts, "alerts": alert_list, } # --------------------------------------------------------------------------- # 8. GET /admin/dashboard_summary # --------------------------------------------------------------------------- @router.get("/dashboard_summary") def dashboard_summary() -> Dict[str, Any]: now = datetime.now(timezone.utc) disk = _disk_usage() counts = _license_counts() backup = _newest_backup_info() alert_data = alerts() stripe_ok = _stripe_env_ok() total_licenses = sum(counts.values()) active = counts.get("active", 0) canceled = counts.get("canceled", 0) rev: Dict[str, Any] = { "gross_chf": None, "refunds_chf": None, "net_chf": None, "data_source": "none", } stripe_key = os.environ.get("STRIPE_SECRET_KEY", "").strip() if stripe_key: try: import stripe as _stripe _stripe.api_key = stripe_key month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) month_start_ts = int(month_start.timestamp()) gross = 0 for ch in _stripe.Charge.list(created={"gte": month_start_ts}, limit=100).auto_paging_iter(): if ch.status == "succeeded" and ch.paid: gross += ch.amount refund_total = 0 for rf in _stripe.Refund.list(created={"gte": month_start_ts}, limit=100).auto_paging_iter(): if rf.status == "succeeded": refund_total += rf.amount rev = { "gross_chf": round(gross / 100, 2), "refunds_chf": round(refund_total / 100, 2), "net_chf": round((gross - refund_total) / 100, 2), "data_source": "stripe_api", } except Exception: rev["data_source"] = "stripe_api_error" return { "timestamp_utc": now.isoformat(), "system_ok": True, "stripe_ok": stripe_ok, "disk_free_gb": disk.get("free_gb"), "disk_used_percent": disk.get("used_percent"), "latest_backup": backup if backup["found"] else None, "licenses": { "total": total_licenses, "active": active, "canceled": canceled, "other": total_licenses - active - canceled, "counts_by_status": counts, }, "current_month_revenue": rev, "alerts_total": alert_data["total"], "alerts_by_severity": alert_data["counts_by_severity"], } # --------------------------------------------------------------------------- # 9. GET /admin/devices?email=... # --------------------------------------------------------------------------- @router.get("/devices") def admin_devices(email: Optional[str] = None) -> Dict[str, Any]: """Device overview for a customer email. If no email given, list all emails with devices.""" from aza_device_enforcement import list_devices_for_email, DB_PATH as _DEV_DB db_path = str(_BASE_DIR / "data" / "stripe_webhook.sqlite") try: from stripe_routes import DB_PATH as _SR_DB # type: ignore db_path = _SR_DB except Exception: pass if email and email.strip(): return list_devices_for_email(email.strip(), db_path=db_path) try: con = sqlite3.connect(db_path) rows = con.execute( """SELECT customer_email, COUNT(*) AS device_count, MAX(last_seen_at) AS last_active FROM device_bindings GROUP BY lower(customer_email) ORDER BY last_active DESC""" ).fetchall() con.close() return { "customers": [ {"email": r[0], "device_count": r[1], "last_active": r[2]} for r in rows ] } except Exception as exc: return {"error": str(exc), "customers": []} # --------------------------------------------------------------------------- # KI-Budget Phase 1 (Admin, USD sichtbar) # --------------------------------------------------------------------------- @router.get("/ai_budget_overview") def ai_budget_overview(status: Optional[str] = Query(None)) -> Dict[str, Any]: """Übersicht aktiver Lizenzen inkl. geschätztem KI-USD-Verbrauch pro Stripe-Periode.""" from aza_ai_budget import LicenseBudgetRow, compute_budget_snapshot, ensure_ai_budget_schema db_path = _stripe_db_path() if not db_path.exists(): return {"status": "no_database", "licenses": []} try: import backend_main as _bm _bm.ensure_license_schema(db_path) except Exception as exc: return {"status": "error", "error": str(exc), "licenses": []} out: List[Dict[str, Any]] = [] try: with sqlite3.connect(str(db_path)) as con: ensure_ai_budget_schema(con) q = ( "SELECT subscription_id, customer_id, customer_email, practice_id, lookup_key, status, " "current_period_start, current_period_end " "FROM licenses" ) params: List[Any] = [] if status and status.strip(): q += " WHERE status = ?" params.append(status.strip()) q += " ORDER BY updated_at DESC LIMIT 200" rows = con.execute(q, params).fetchall() for r in rows: lic = LicenseBudgetRow( subscription_id=str(r[0] or ""), customer_email=str(r[2] or ""), customer_id=str(r[1]) if r[1] else None, practice_id=str(r[3]).strip() if r[3] else None, lookup_key=str(r[4]).strip() if r[4] else None, status=str(r[5] or ""), period_start=int(r[6]) if r[6] is not None else None, period_end=int(r[7]) if r[7] is not None else None, ) snap = compute_budget_snapshot(con, lic) ev = con.execute( """ SELECT COUNT(*), MAX(created_at) FROM ai_usage_events WHERE subscription_id = ? AND period_start = ? AND period_end = ? """, (lic.subscription_id, snap["period_start"], snap["period_end"]), ).fetchone() row_dict = dict(snap) row_dict["customer_email"] = lic.customer_email row_dict["lookup_key"] = lic.lookup_key or "" row_dict["license_status"] = lic.status row_dict["event_count"] = int(ev[0] or 0) row_dict["last_event_ts"] = int(ev[1] or 0) if row_dict["last_event_ts"]: row_dict["last_event_human"] = datetime.fromtimestamp( row_dict["last_event_ts"], tz=timezone.utc ).strftime("%Y-%m-%d %H:%M UTC") out.append(row_dict) return {"status": "ok", "licenses": out} except Exception as exc: return {"status": "error", "error": str(exc), "licenses": []} @router.get("/ai_budget_detail") def ai_budget_detail(email: str = Query(..., min_length=3)) -> Dict[str, Any]: from aza_ai_budget import LicenseBudgetRow, compute_budget_snapshot, ensure_ai_budget_schema db_path = _stripe_db_path() if not db_path.exists(): return {"status": "no_database"} import backend_main as _bm _bm.ensure_license_schema(db_path) em = email.strip().lower() with sqlite3.connect(str(db_path)) as con: ensure_ai_budget_schema(con) row = con.execute( """ SELECT subscription_id, customer_id, customer_email, practice_id, lookup_key, status, current_period_start, current_period_end FROM licenses WHERE lower(customer_email) = ? ORDER BY updated_at DESC LIMIT 1 """, (em,), ).fetchone() if not row: return {"status": "not_found", "email": em} lic = LicenseBudgetRow( subscription_id=str(row[0] or ""), customer_email=str(row[2] or ""), customer_id=str(row[1]) if row[1] else None, practice_id=str(row[3]).strip() if row[3] else None, lookup_key=str(row[4]).strip() if row[4] else None, status=str(row[5] or ""), period_start=int(row[6]) if row[6] is not None else None, period_end=int(row[7]) if row[7] is not None else None, ) snap = compute_budget_snapshot(con, lic) by_op = con.execute( """ SELECT operation_type, COALESCE(SUM(estimated_cost_usd), 0) FROM ai_usage_events WHERE subscription_id = ? AND period_start = ? AND period_end = ? AND status = 'success' GROUP BY operation_type """, (lic.subscription_id, snap["period_start"], snap["period_end"]), ).fetchall() by_model = con.execute( """ SELECT model, COALESCE(SUM(estimated_cost_usd), 0) FROM ai_usage_events WHERE subscription_id = ? AND period_start = ? AND period_end = ? AND status = 'success' GROUP BY model """, (lic.subscription_id, snap["period_start"], snap["period_end"]), ).fetchall() recent = con.execute( """ SELECT created_at, operation_type, model, estimated_cost_usd, status, error_code, request_id FROM ai_usage_events WHERE subscription_id = ? ORDER BY created_at DESC LIMIT 20 """, (lic.subscription_id,), ).fetchall() blocked = con.execute( """ SELECT created_at, operation_type, error_code, request_id FROM ai_usage_events WHERE subscription_id = ? AND status = 'blocked' ORDER BY created_at DESC LIMIT 20 """, (lic.subscription_id,), ).fetchall() return { "status": "ok", "snapshot": snap, "usage_by_operation": [{"operation": a[0], "usd": round(float(a[1] or 0), 6)} for a in by_op], "usage_by_model": [{"model": a[0], "usd": round(float(a[1] or 0), 6)} for a in by_model], "recent_events": [ { "created_at": a[0], "operation_type": a[1], "model": a[2], "usd": float(a[3] or 0), "status": a[4], "error_code": a[5], "request_id": a[6], } for a in recent ], "blocked_events": [ {"created_at": b[0], "operation_type": b[1], "error_code": b[2], "request_id": b[3]} for b in blocked ], } @router.get("/ai_budget_export.csv") def ai_budget_export_csv(status: Optional[str] = Query("active")) -> Response: import backend_main as _bm from aza_ai_budget import LicenseBudgetRow, compute_budget_snapshot, ensure_ai_budget_schema db_path = _stripe_db_path() if not db_path.exists(): return Response("no database\n", media_type="text/plain", status_code=404) _bm.ensure_license_schema(db_path) buf = io.StringIO() w = csv.writer(buf) w.writerow([ "subscription_id", "customer_email", "license_status", "lookup_key", "period_start_utc", "period_end_utc", "budget_usd", "used_usd", "remaining_usd", "available_percent", "event_count", ]) with sqlite3.connect(str(db_path)) as con: ensure_ai_budget_schema(con) q = ( "SELECT subscription_id, customer_id, customer_email, practice_id, lookup_key, status, " "current_period_start, current_period_end FROM licenses" ) params: List[Any] = [] if status and status.strip(): q += " WHERE status = ?" params.append(status.strip()) q += " ORDER BY updated_at DESC LIMIT 500" for r in con.execute(q, params).fetchall(): lic = LicenseBudgetRow( subscription_id=str(r[0] or ""), customer_email=str(r[2] or ""), customer_id=str(r[1]) if r[1] else None, practice_id=str(r[3]).strip() if r[3] else None, lookup_key=str(r[4]).strip() if r[4] else None, status=str(r[5] or ""), period_start=int(r[6]) if r[6] is not None else None, period_end=int(r[7]) if r[7] is not None else None, ) snap = compute_budget_snapshot(con, lic) evc = con.execute( """ SELECT COUNT(*) FROM ai_usage_events WHERE subscription_id = ? AND period_start = ? AND period_end = ? """, (lic.subscription_id, snap["period_start"], snap["period_end"]), ).fetchone() ps_h = datetime.fromtimestamp(int(snap["period_start"]), tz=timezone.utc).strftime("%Y-%m-%d %H:%M") pe_h = datetime.fromtimestamp(int(snap["period_end"]), tz=timezone.utc).strftime("%Y-%m-%d %H:%M") w.writerow([ lic.subscription_id, lic.customer_email, lic.status, lic.lookup_key or "", ps_h, pe_h, snap.get("budget_usd"), snap.get("used_usd"), snap.get("remaining_usd"), snap.get("available_percent"), int(evc[0] or 0), ]) data = buf.getvalue() return Response( content=data.encode("utf-8"), media_type="text/csv; charset=utf-8", headers={"Content-Disposition": 'attachment; filename="ai_budget_export.csv"'}, ) # --------------------------------------------------------------------------- # Stripe: Abrechnungsperioden (read-only Subscription.retrieve, nur Perioden-Spalten) # --------------------------------------------------------------------------- @router.post("/stripe_sync_periods") def stripe_sync_periods() -> Dict[str, Any]: """Aktive Lizenzen: current_period_start/end aus Stripe füllen. Kein Status-/Kunden-Write.""" from stripe_routes import sync_active_license_periods_from_stripe_only return sync_active_license_periods_from_stripe_only() @router.post("/woocommerce_sync_periods") def woocommerce_sync_periods() -> Dict[str, Any]: """Aktive wc_sub_*-Lizenzen: Abrechnungsperioden read-only aus WooCommerce REST.""" from aza_wc_period_sync import sync_active_license_periods_from_woocommerce_only out = sync_active_license_periods_from_woocommerce_only() if isinstance(out, dict): code = out.get("error_code") if code in ("WOO_CREDENTIALS_MISSING", "DB_MISSING", "REQUESTS_MISSING"): return JSONResponse(status_code=503, content=out) # type: ignore[return-value] return out @router.get("/ai_credit_overview") def ai_credit_overview(status: Optional[str] = Query(None)) -> Dict[str, Any]: """KI-Zusatzguthaben-Übersicht (Admin, keine Secrets).""" from aza_ai_budget import LicenseBudgetRow, compute_budget_snapshot, ensure_ai_budget_schema, monthly_budget_usd_for_license from aza_ai_credit import admin_credit_overview_row, ensure_ai_credit_schema db_path = _stripe_db_path() if not db_path.exists(): return {"status": "no_database", "licenses": []} import backend_main as _bm _bm.ensure_license_schema(db_path) out: List[Dict[str, Any]] = [] with sqlite3.connect(str(db_path)) as con: ensure_ai_budget_schema(con) ensure_ai_credit_schema(con) q = ( "SELECT subscription_id, customer_id, customer_email, practice_id, lookup_key, status, " "current_period_start, current_period_end FROM licenses" ) params: List[Any] = [] if status and status.strip(): q += " WHERE status = ?" params.append(status.strip()) q += " ORDER BY updated_at DESC LIMIT 200" rows = con.execute(q, params).fetchall() for r in rows: lic = LicenseBudgetRow( subscription_id=str(r[0] or ""), customer_email=str(r[2] or ""), customer_id=str(r[1]) if r[1] else None, practice_id=str(r[3]).strip() if r[3] else None, lookup_key=str(r[4]).strip() if r[4] else None, status=str(r[5] or ""), period_start=int(r[6]) if r[6] is not None else None, period_end=int(r[7]) if r[7] is not None else None, ) if not (lic.practice_id or "").strip(): continue snap = compute_budget_snapshot(con, lic) budget = monthly_budget_usd_for_license(lic) out.append( admin_credit_overview_row( con, practice_id=lic.practice_id or "", subscription_id=lic.subscription_id, monthly_budget=budget, snap=snap, ) ) return {"status": "ok", "licenses": out} @router.get("/ai_credit_ledger") def ai_credit_ledger( practice_id: str = Query(..., min_length=3), limit: int = Query(100, ge=1, le=500), ) -> Dict[str, Any]: from aza_ai_credit import ensure_ai_credit_schema, list_ledger_for_practice db_path = _stripe_db_path() if not db_path.exists(): return {"status": "no_database", "entries": []} import backend_main as _bm _bm.ensure_license_schema(db_path) pid = practice_id.strip() with sqlite3.connect(str(db_path)) as con: ensure_ai_credit_schema(con) entries = list_ledger_for_practice(con, pid, limit=limit) return {"status": "ok", "practice_id": pid, "entries": entries} @router.get("/ai_credit_overview") def ai_credit_overview(status: Optional[str] = Query(None)) -> Dict[str, Any]: """KI-Zusatzguthaben-Übersicht (Admin, keine Secrets).""" from aza_ai_budget import LicenseBudgetRow, compute_budget_snapshot, ensure_ai_budget_schema, monthly_budget_usd_for_license from aza_ai_credit import admin_credit_overview_row, ensure_ai_credit_schema db_path = _stripe_db_path() if not db_path.exists(): return {"status": "no_database", "licenses": []} import backend_main as _bm _bm.ensure_license_schema(db_path) out: List[Dict[str, Any]] = [] with sqlite3.connect(str(db_path)) as con: ensure_ai_budget_schema(con) ensure_ai_credit_schema(con) q = ( "SELECT subscription_id, customer_id, customer_email, practice_id, lookup_key, status, " "current_period_start, current_period_end FROM licenses" ) params: List[Any] = [] if status and status.strip(): q += " WHERE status = ?" params.append(status.strip()) q += " ORDER BY updated_at DESC LIMIT 200" rows = con.execute(q, params).fetchall() for r in rows: lic = LicenseBudgetRow( subscription_id=str(r[0] or ""), customer_email=str(r[2] or ""), customer_id=str(r[1]) if r[1] else None, practice_id=str(r[3]).strip() if r[3] else None, lookup_key=str(r[4]).strip() if r[4] else None, status=str(r[5] or ""), period_start=int(r[6]) if r[6] is not None else None, period_end=int(r[7]) if r[7] is not None else None, ) if not (lic.practice_id or "").strip(): continue snap = compute_budget_snapshot(con, lic) budget = monthly_budget_usd_for_license(lic) out.append( admin_credit_overview_row( con, practice_id=lic.practice_id or "", subscription_id=lic.subscription_id, monthly_budget=budget, snap=snap, ) ) return {"status": "ok", "licenses": out} @router.get("/ai_credit_ledger") def ai_credit_ledger( practice_id: str = Query(..., min_length=3), limit: int = Query(100, ge=1, le=500), ) -> Dict[str, Any]: from aza_ai_credit import ensure_ai_credit_schema, list_ledger_for_practice db_path = _stripe_db_path() if not db_path.exists(): return {"status": "no_database", "entries": []} import backend_main as _bm _bm.ensure_license_schema(db_path) pid = practice_id.strip() with sqlite3.connect(str(db_path)) as con: ensure_ai_credit_schema(con) entries = list_ledger_for_practice(con, pid, limit=limit) return {"status": "ok", "practice_id": pid, "entries": entries}