# -*- coding: utf-8 -*- """ KI-Zusatzguthaben: Ledger, Stripe-Checkout (Phase 1), Auto-Aufladung (vorbereitet, nicht produktiv). Keine Secrets loggen. Keine Live-Charges ohne AZA_AI_TOPUP_ALLOW_LIVE=1. """ from __future__ import annotations import json import os import sqlite3 import time import uuid from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple DEFAULT_TOPUP_CHF = 30.0 DEFAULT_INTERNAL_USD = 10.0 DEFAULT_TOPUP_SUCCESS_URL = "https://aza-medwork.ch/succes2" DEFAULT_TOPUP_CANCEL_URL = "https://aza-medwork.ch" DEFAULT_SETUP_SUCCESS_URL = "https://aza-medwork.ch/ki-topup-setup-success/" DEFAULT_SETUP_CANCEL_URL = "https://aza-medwork.ch" CHF_TO_INTERNAL_RATIO = 1.0 / 3.0 # CHF 30 → USD 10 (+50 % Monatskontingent) MIN_TOPUP_CHF = 10.0 MAX_TOPUP_CHF = 300.0 DEFAULT_TRIGGER_PERCENT = 5 DEFAULT_MONTHLY_LIMIT_CHF = 300.0 AUTO_TOPUP_MONTHLY_LIMIT_MESSAGE = ( "Das Monatslimit für automatische Aufladungen ist erreicht. " "Sie können weiterhin manuell Zusatzguthaben kaufen." ) AUTO_TOPUP_COOLDOWN_SEC = 24 * 3600 AUTO_TOPUP_PENDING_STALE_SEC = 15 * 60 AUTO_TOPUP_CONSENT_TEXT = ( "Automatische Aufladung: CHF 30 (+10 USD Zusatzguthaben), sobald KI-Guthaben unter 5 % fällt, " "maximal CHF 300 automatische Aufladungen pro Monat. Jederzeit deaktivierbar." ) LEDGER_TOPUP_TYPES = ("topup_purchase", "auto_topup_purchase", "adjustment", "refund") LEDGER_DEBIT_TYPES = ("usage",) USER_HISTORY_EVENT_TYPES = ("topup_purchase", "auto_topup_purchase", "refund") def _auto_topup_enabled_env() -> bool: return os.environ.get("AZA_AI_AUTO_TOPUP_ENABLED", "0").strip() == "1" def _topup_allow_live_env() -> bool: return os.environ.get("AZA_AI_TOPUP_ALLOW_LIVE", "0").strip() == "1" def _topup_dry_run_env() -> bool: return os.environ.get("AZA_AI_TOPUP_DRY_RUN", "1").strip() == "1" AUTO_TOPUP_ENABLED_ENV = _auto_topup_enabled_env() TOPUP_ALLOW_LIVE = _topup_allow_live_env() def _format_date_de(ts: int) -> str: from datetime import datetime, timezone return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime("%d.%m.%Y") def mask_stripe_reference(ref: Optional[str]) -> Optional[str]: rid = (ref or "").strip() if not rid: return None if len(rid) <= 8: return rid[:2] + "…" return rid[:3] + "…" + rid[-4:] def _fetch_stripe_receipt_url(payment_intent_id: Optional[str]) -> Optional[str]: """Read-only: receipt_url vom Charge, falls verfügbar.""" pid = (payment_intent_id or "").strip() if not pid: return None try: import stripe stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "") pi = stripe.PaymentIntent.retrieve(pid, expand=["latest_charge"]) charge = getattr(pi, "latest_charge", None) if charge is None: return None if isinstance(charge, str): charge = stripe.Charge.retrieve(charge) url = getattr(charge, "receipt_url", None) if url is None and isinstance(charge, dict): url = charge.get("receipt_url") cleaned = (str(url).strip() if url else "") or None if cleaned and cleaned.startswith("https://"): return cleaned except Exception: return None return None def _meta_with_receipt( base: Optional[Dict[str, Any]], *, payment_intent_id: Optional[str], ) -> Dict[str, Any]: meta = dict(base or {}) receipt = _fetch_stripe_receipt_url(payment_intent_id) if receipt: meta["receipt_url"] = receipt return meta def chf_to_internal_usd(paid_chf: float) -> float: """Internes Zusatzguthaben: paid_chf / 3 (CHF 30 → 10 USD).""" chf = max(0.0, float(paid_chf)) return round(chf * CHF_TO_INTERNAL_RATIO, 4) def _now() -> int: return int(time.time()) def _month_start_ts(ts: Optional[int] = None) -> int: from datetime import datetime, timezone t = ts or _now() dt = datetime.fromtimestamp(t, tz=timezone.utc) start = dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) return int(start.timestamp()) def _sanitize_meta(meta: Optional[Dict[str, Any]]) -> str: clean: Dict[str, Any] = {} if meta: for k, v in meta.items(): kl = str(k).lower() if any(x in kl for x in ("secret", "token", "password", "key", "payment_method")): continue clean[str(k)[:64]] = v return json.dumps(clean, ensure_ascii=False)[:4000] def ensure_ai_credit_schema(con: sqlite3.Connection) -> None: con.execute( """ CREATE TABLE IF NOT EXISTS ai_credit_ledger ( id TEXT PRIMARY KEY, practice_id TEXT, subscription_id TEXT, customer_email TEXT, event_type TEXT NOT NULL, amount_internal_usd REAL NOT NULL DEFAULT 0, amount_paid_chf REAL, stripe_checkout_session_id TEXT, stripe_payment_intent_id TEXT, stripe_customer_id TEXT, stripe_subscription_id TEXT, status TEXT NOT NULL DEFAULT 'pending', period_start INTEGER, period_end INTEGER, meta_json TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) """ ) con.execute( "CREATE INDEX IF NOT EXISTS idx_ai_credit_ledger_practice " "ON ai_credit_ledger(practice_id, status, event_type)" ) con.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_credit_checkout_session " "ON ai_credit_ledger(stripe_checkout_session_id) " "WHERE stripe_checkout_session_id IS NOT NULL AND stripe_checkout_session_id != ''" ) con.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_credit_payment_intent " "ON ai_credit_ledger(stripe_payment_intent_id) " "WHERE stripe_payment_intent_id IS NOT NULL AND stripe_payment_intent_id != ''" ) con.execute( """ CREATE TABLE IF NOT EXISTS ai_topup_settings ( practice_id TEXT PRIMARY KEY, auto_topup_enabled INTEGER NOT NULL DEFAULT 0, topup_amount_chf REAL NOT NULL DEFAULT 30, internal_credit_usd REAL NOT NULL DEFAULT 10, trigger_below_percent INTEGER NOT NULL DEFAULT 5, monthly_limit_chf REAL NOT NULL DEFAULT 300, stripe_customer_id TEXT, default_payment_method_id TEXT, updated_at INTEGER NOT NULL ) """ ) con.commit() def sum_extra_credit_topups(con: sqlite3.Connection, *, practice_id: str) -> float: pid = (practice_id or "").strip() if not pid: return 0.0 row = con.execute( """ SELECT COALESCE(SUM(amount_internal_usd), 0) FROM ai_credit_ledger WHERE practice_id = ? AND status = 'succeeded' AND event_type IN ('topup_purchase', 'auto_topup_purchase', 'adjustment', 'refund') """, (pid,), ).fetchone() return max(0.0, float(row[0] or 0.0)) def _count_usage_periods(con: sqlite3.Connection, subscription_id: str) -> int: row = con.execute( """ SELECT COUNT(DISTINCT period_start || ':' || period_end) FROM ai_usage_events WHERE subscription_id = ? AND status = 'success' """, (subscription_id,), ).fetchone() n = int(row[0] or 0) return max(1, n) def sum_total_usage_usd(con: sqlite3.Connection, subscription_id: str) -> float: row = con.execute( """ SELECT COALESCE(SUM(estimated_cost_usd), 0) FROM ai_usage_events WHERE subscription_id = ? AND status = 'success' """, (subscription_id,), ).fetchone() return float(row[0] or 0.0) def compute_extra_credit_remaining( con: sqlite3.Connection, *, practice_id: str, subscription_id: str, monthly_budget_usd: float, ) -> float: """Zusatzguthaben = Topups minus Verbrauch über kumuliertes Monatskontingent.""" topups = sum_extra_credit_topups(con, practice_id=practice_id) if topups <= 0: return 0.0 total_used = sum_total_usage_usd(con, subscription_id) periods = _count_usage_periods(con, subscription_id) allocated = max(0.0, float(monthly_budget_usd)) * periods consumed_from_extra = max(0.0, total_used - allocated) return max(0.0, round(topups - consumed_from_extra, 4)) def apply_extra_credit_to_snapshot( snap: Dict[str, Any], *, extra_remaining: float, monthly_budget: float, ) -> Dict[str, Any]: monthly_remaining = max(0.0, float(snap.get("remaining_usd", 0) or 0)) extra = max(0.0, float(extra_remaining)) total_available = monthly_remaining + extra budget = max(0.01, float(monthly_budget)) pct = int((total_available / budget) * 100.0) pct = max(0, pct) label = f"KI-Kontingent: {pct} % verfügbar" out = dict(snap) out["available_percent"] = pct out["remaining_usd"] = round(monthly_remaining, 4) out["extra_credit_remaining_usd"] = round(extra, 4) out["total_available_usd"] = round(total_available, 4) out["show_warning"] = pct <= 20 and pct > 0 out["user_label"] = label out["extra_credit_active"] = extra > 0 and monthly_remaining <= 0 return out def budget_allows_with_extra( snap: Dict[str, Any], *, extra_remaining: float, ) -> bool: monthly_remaining = max(0.0, float(snap.get("remaining_usd", 0) or 0)) extra = max(0.0, float(extra_remaining)) return (monthly_remaining + extra) > 1e-9 @dataclass class TopupSettings: practice_id: str auto_topup_enabled: bool topup_amount_chf: float internal_credit_usd: float trigger_below_percent: int monthly_limit_chf: float stripe_customer_id: Optional[str] default_payment_method_id: Optional[str] def get_topup_settings(con: sqlite3.Connection, practice_id: str) -> TopupSettings: pid = (practice_id or "").strip() row = con.execute( "SELECT * FROM ai_topup_settings WHERE practice_id = ?", (pid,), ).fetchone() if not row: return TopupSettings( practice_id=pid, auto_topup_enabled=False, topup_amount_chf=DEFAULT_TOPUP_CHF, internal_credit_usd=DEFAULT_INTERNAL_USD, trigger_below_percent=DEFAULT_TRIGGER_PERCENT, monthly_limit_chf=DEFAULT_MONTHLY_LIMIT_CHF, stripe_customer_id=None, default_payment_method_id=None, ) cols = [d[1] for d in con.execute("PRAGMA table_info(ai_topup_settings)").fetchall()] d = dict(zip(cols, row)) return TopupSettings( practice_id=pid, auto_topup_enabled=bool(int(d.get("auto_topup_enabled") or 0)), topup_amount_chf=float(d.get("topup_amount_chf") or DEFAULT_TOPUP_CHF), internal_credit_usd=float(d.get("internal_credit_usd") or DEFAULT_INTERNAL_USD), trigger_below_percent=int(d.get("trigger_below_percent") or DEFAULT_TRIGGER_PERCENT), monthly_limit_chf=float(d.get("monthly_limit_chf") or DEFAULT_MONTHLY_LIMIT_CHF), stripe_customer_id=(d.get("stripe_customer_id") or None), default_payment_method_id=(d.get("default_payment_method_id") or None), ) def save_topup_settings(con: sqlite3.Connection, settings: TopupSettings, *, commit: bool = True) -> None: now = _now() con.execute( """ INSERT INTO ai_topup_settings( practice_id, auto_topup_enabled, topup_amount_chf, internal_credit_usd, trigger_below_percent, monthly_limit_chf, stripe_customer_id, default_payment_method_id, updated_at ) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(practice_id) DO UPDATE SET auto_topup_enabled=excluded.auto_topup_enabled, topup_amount_chf=excluded.topup_amount_chf, internal_credit_usd=excluded.internal_credit_usd, trigger_below_percent=excluded.trigger_below_percent, monthly_limit_chf=excluded.monthly_limit_chf, stripe_customer_id=COALESCE(excluded.stripe_customer_id, ai_topup_settings.stripe_customer_id), default_payment_method_id=COALESCE(excluded.default_payment_method_id, ai_topup_settings.default_payment_method_id), updated_at=excluded.updated_at """, ( settings.practice_id, 1 if settings.auto_topup_enabled else 0, settings.topup_amount_chf, settings.internal_credit_usd, settings.trigger_below_percent, settings.monthly_limit_chf, settings.stripe_customer_id, settings.default_payment_method_id, now, ), ) if commit: con.commit() def apply_auto_topup_user_settings( con: sqlite3.Connection, *, practice_id: str, enabled: bool, amount_chf: Optional[float] = None, trigger_below_percent: Optional[int] = None, monthly_limit_chf: Optional[float] = None, ) -> Dict[str, Any]: existing = get_topup_settings(con, practice_id) if enabled and not auto_topup_is_fully_configured(existing): return {"ok": False, "error_code": "AUTO_TOPUP_NO_PAYMENT_METHOD"} amt = amount_chf if amount_chf is not None else existing.topup_amount_chf trig = trigger_below_percent if trigger_below_percent is not None else existing.trigger_below_percent monthly = monthly_limit_chf if monthly_limit_chf is not None else existing.monthly_limit_chf settings = TopupSettings( practice_id=practice_id, auto_topup_enabled=enabled, topup_amount_chf=amt, internal_credit_usd=chf_to_internal_usd(amt), trigger_below_percent=trig, monthly_limit_chf=monthly, stripe_customer_id=existing.stripe_customer_id, default_payment_method_id=existing.default_payment_method_id, ) save_topup_settings(con, settings) return {"ok": True, "auto_topup_enabled": settings.auto_topup_enabled} def auto_topup_settings_status(con: sqlite3.Connection, *, practice_id: str) -> Dict[str, Any]: settings = get_topup_settings(con, practice_id) return { "ok": True, "auto_topup_enabled": settings.auto_topup_enabled, "topup_amount_chf": settings.topup_amount_chf, "trigger_below_percent": settings.trigger_below_percent, "monthly_limit_chf": settings.monthly_limit_chf, "payment_method_configured": auto_topup_is_fully_configured(settings), } def insert_ledger_event( con: sqlite3.Connection, *, practice_id: Optional[str], subscription_id: Optional[str], customer_email: Optional[str], event_type: str, amount_internal_usd: float, amount_paid_chf: Optional[float] = None, stripe_checkout_session_id: Optional[str] = None, stripe_payment_intent_id: Optional[str] = None, stripe_customer_id: Optional[str] = None, stripe_subscription_id: Optional[str] = None, status: str = "pending", period_start: Optional[int] = None, period_end: Optional[int] = None, meta: Optional[Dict[str, Any]] = None, ledger_id: Optional[str] = None, commit: bool = True, ) -> str: eid = ledger_id or str(uuid.uuid4()) now = _now() con.execute( """ INSERT INTO ai_credit_ledger( id, practice_id, subscription_id, customer_email, event_type, amount_internal_usd, amount_paid_chf, stripe_checkout_session_id, stripe_payment_intent_id, stripe_customer_id, stripe_subscription_id, status, period_start, period_end, meta_json, created_at, updated_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """, ( eid, practice_id, subscription_id, customer_email, event_type, float(amount_internal_usd), amount_paid_chf, stripe_checkout_session_id, stripe_payment_intent_id, stripe_customer_id, stripe_subscription_id, status, period_start, period_end, _sanitize_meta(meta), now, now, ), ) if commit: con.commit() return eid def update_ledger_event( con: sqlite3.Connection, ledger_id: str, *, status: Optional[str] = None, stripe_payment_intent_id: Optional[str] = None, meta: Optional[Dict[str, Any]] = None, commit: bool = True, ) -> None: parts: List[str] = ["updated_at = ?"] vals: List[Any] = [_now()] if status is not None: parts.append("status = ?") vals.append(status) if stripe_payment_intent_id is not None: parts.append("stripe_payment_intent_id = ?") vals.append(stripe_payment_intent_id) if meta is not None: parts.append("meta_json = ?") vals.append(_sanitize_meta(meta)) vals.append(ledger_id) con.execute( f"UPDATE ai_credit_ledger SET {', '.join(parts)} WHERE id = ?", tuple(vals), ) if commit: con.commit() def ledger_exists_for_checkout(con: sqlite3.Connection, session_id: str) -> bool: sid = (session_id or "").strip() if not sid: return False row = con.execute( "SELECT 1 FROM ai_credit_ledger WHERE stripe_checkout_session_id = ? LIMIT 1", (sid,), ).fetchone() return bool(row) def ledger_exists_for_payment_intent(con: sqlite3.Connection, payment_intent_id: str) -> bool: pid = (payment_intent_id or "").strip() if not pid: return False row = con.execute( """ SELECT 1 FROM ai_credit_ledger WHERE stripe_payment_intent_id = ? AND status = 'succeeded' LIMIT 1 """, (pid,), ).fetchone() return bool(row) def has_pending_auto_topup(con: sqlite3.Connection, practice_id: str) -> bool: pid = (practice_id or "").strip() if not pid: return False cutoff = _now() - AUTO_TOPUP_PENDING_STALE_SEC row = con.execute( """ SELECT 1 FROM ai_credit_ledger WHERE practice_id = ? AND event_type = 'auto_topup_purchase' AND status = 'pending' AND created_at >= ? LIMIT 1 """, (pid, cutoff), ).fetchone() return bool(row) def last_successful_auto_topup_ts(con: sqlite3.Connection, practice_id: str) -> Optional[int]: pid = (practice_id or "").strip() if not pid: return None row = con.execute( """ SELECT MAX(created_at) FROM ai_credit_ledger WHERE practice_id = ? AND event_type = 'auto_topup_purchase' AND status = 'succeeded' """, (pid,), ).fetchone() if not row or row[0] is None: return None return int(row[0]) def _auto_topup_idempotency_key(practice_id: str) -> str: from datetime import datetime, timezone dt = datetime.fromtimestamp(_now(), tz=timezone.utc) return f"aza_auto_topup_{practice_id}_{dt.strftime('%Y%m')}_{dt.strftime('%d')}" def _pause_auto_topup_on_failure( con: sqlite3.Connection, settings: TopupSettings, *, reason: str, payment_intent_id: Optional[str] = None, commit: bool = True, ) -> None: settings.auto_topup_enabled = False save_topup_settings(con, settings, commit=commit) insert_ledger_event( con, practice_id=settings.practice_id, subscription_id=None, customer_email=None, event_type="failed_auto_topup", amount_internal_usd=0, amount_paid_chf=settings.topup_amount_chf, stripe_payment_intent_id=payment_intent_id, stripe_customer_id=settings.stripe_customer_id, status="failed", meta={"reason": reason}, commit=commit, ) def process_topup_checkout_completed( con: sqlite3.Connection, *, session: Dict[str, Any], ) -> Dict[str, Any]: """Idempotent: checkout.session.completed für aza_purpose=ai_topup.""" md = session.get("metadata") or {} if (md.get("aza_purpose") or "").strip() != "ai_topup": return {"handled": False, "reason": "not_ai_topup"} sid = str(session.get("id") or "") if ledger_exists_for_checkout(con, sid): return {"handled": True, "duplicate": True, "session_id": sid} pi_from_session = str(session.get("payment_intent") or "") or None if pi_from_session and ledger_exists_for_payment_intent(con, pi_from_session): return {"handled": True, "duplicate": True, "payment_intent_id": pi_from_session} practice_id = (md.get("practice_id") or "").strip() or None subscription_id = (md.get("subscription_id") or "").strip() or None try: internal_usd = float(md.get("internal_credit_usd") or 0) paid_chf = float(md.get("paid_chf") or 0) except (TypeError, ValueError): return {"handled": False, "reason": "invalid_metadata_amounts"} if internal_usd <= 0 or paid_chf <= 0: return {"handled": False, "reason": "zero_amounts"} payment_status = (session.get("payment_status") or "").strip() if payment_status != "paid": insert_ledger_event( con, practice_id=practice_id, subscription_id=subscription_id, customer_email=(session.get("customer_email") or None), event_type="topup_purchase", amount_internal_usd=0, amount_paid_chf=paid_chf, stripe_checkout_session_id=sid, stripe_payment_intent_id=str(session.get("payment_intent") or "") or None, stripe_customer_id=str(session.get("customer") or "") or None, status="failed", meta={"payment_status": payment_status}, ) return {"handled": True, "credited": False, "reason": "not_paid"} pi_id = str(session.get("payment_intent") or "") or None insert_ledger_event( con, practice_id=practice_id, subscription_id=subscription_id, customer_email=(session.get("customer_email") or None), event_type="topup_purchase", amount_internal_usd=internal_usd, amount_paid_chf=paid_chf, stripe_checkout_session_id=sid, stripe_payment_intent_id=pi_id, stripe_customer_id=str(session.get("customer") or "") or None, status="succeeded", meta=_meta_with_receipt({"source": "checkout.session.completed"}, payment_intent_id=pi_id), ) return {"handled": True, "credited": True, "session_id": sid} def normalize_stripe_id(value: Any, prefix: str) -> Optional[str]: """Extrahiert eine Stripe-ID (z. B. pm_..., cus_...) aus String, Dict oder StripeObject.""" if value is None: return None pfx = (prefix or "").strip() if isinstance(value, str): raw = value.strip() if not raw: return None if raw.startswith(pfx): return raw if raw.startswith("{"): try: obj = json.loads(raw) except Exception: return None if isinstance(obj, dict): return normalize_stripe_id(obj.get("id"), pfx) return None return None if isinstance(value, dict): if value.get("object") and value.get("id"): return normalize_stripe_id(value.get("id"), pfx) return normalize_stripe_id(value.get("id"), pfx) obj_id = getattr(value, "id", None) if obj_id: return normalize_stripe_id(str(obj_id), pfx) return None def auto_topup_is_fully_configured(settings: TopupSettings) -> bool: cus = normalize_stripe_id(settings.stripe_customer_id, "cus_") pm = normalize_stripe_id(settings.default_payment_method_id, "pm_") return bool(cus and pm) def _retrieve_setup_intent_obj(setup_intent_ref: Any) -> Any: si_id = normalize_stripe_id(setup_intent_ref, "seti_") if not si_id and isinstance(setup_intent_ref, dict): si_id = normalize_stripe_id(setup_intent_ref.get("id"), "seti_") if not si_id: return None try: import stripe return stripe.SetupIntent.retrieve(si_id, expand=["payment_method", "customer"]) except Exception: return None def resolve_auto_topup_setup_ids( *, session: Optional[Dict[str, Any]] = None, setup_intent: Optional[Dict[str, Any]] = None, ) -> Tuple[Optional[str], Optional[str]]: customer_id: Optional[str] = None pm_id: Optional[str] = None def _from_setup_dict(si: Any) -> Tuple[Optional[str], Optional[str]]: if not isinstance(si, dict): return None, None c = normalize_stripe_id(si.get("customer"), "cus_") p = normalize_stripe_id(si.get("payment_method"), "pm_") return c, p if session: customer_id = normalize_stripe_id(session.get("customer"), "cus_") si_ref = session.get("setup_intent") c2, p2 = _from_setup_dict(si_ref) customer_id = customer_id or c2 pm_id = pm_id or p2 si = _retrieve_setup_intent_obj(si_ref) if si is not None: pm_id = pm_id or normalize_stripe_id(getattr(si, "payment_method", None), "pm_") customer_id = customer_id or normalize_stripe_id(getattr(si, "customer", None), "cus_") if pm_id and not customer_id: customer_id = normalize_stripe_id(getattr(getattr(si, "payment_method", None), "customer", None), "cus_") cs_id = normalize_stripe_id(session.get("id"), "cs_") if cs_id and (not customer_id or not pm_id): try: import stripe cs = stripe.checkout.Session.retrieve( cs_id, expand=["setup_intent", "setup_intent.payment_method", "customer"], ) if not customer_id: customer_id = normalize_stripe_id(getattr(cs, "customer", None), "cus_") si2 = getattr(cs, "setup_intent", None) if si2 is not None: if not pm_id: pm_id = normalize_stripe_id(getattr(si2, "payment_method", None), "pm_") if not customer_id: customer_id = normalize_stripe_id(getattr(si2, "customer", None), "cus_") if pm_id and not customer_id: customer_id = normalize_stripe_id( getattr(getattr(si2, "payment_method", None), "customer", None), "cus_", ) except Exception: pass if setup_intent: if not customer_id: customer_id = normalize_stripe_id(setup_intent.get("customer"), "cus_") if not pm_id: pm_id = normalize_stripe_id(setup_intent.get("payment_method"), "pm_") c3, p3 = _from_setup_dict(setup_intent) customer_id = customer_id or c3 pm_id = pm_id or p3 if not customer_id or not pm_id: si = _retrieve_setup_intent_obj(setup_intent) if si is not None: if not pm_id: pm_id = normalize_stripe_id(getattr(si, "payment_method", None), "pm_") if not customer_id: customer_id = normalize_stripe_id(getattr(si, "customer", None), "cus_") if pm_id and not customer_id: customer_id = normalize_stripe_id( getattr(getattr(si, "payment_method", None), "customer", None), "cus_", ) return ( normalize_stripe_id(customer_id, "cus_"), normalize_stripe_id(pm_id, "pm_"), ) def _apply_auto_topup_setup_settings( con: sqlite3.Connection, *, practice_id: str, customer_id: Optional[str], payment_method_id: Optional[str], ) -> Tuple[TopupSettings, bool]: cus = normalize_stripe_id(customer_id, "cus_") pm = normalize_stripe_id(payment_method_id, "pm_") settings = get_topup_settings(con, practice_id) if cus: settings.stripe_customer_id = cus if pm: settings.default_payment_method_id = pm complete = bool(cus and pm) if complete: settings.auto_topup_enabled = True settings.topup_amount_chf = DEFAULT_TOPUP_CHF settings.internal_credit_usd = DEFAULT_INTERNAL_USD settings.trigger_below_percent = DEFAULT_TRIGGER_PERCENT settings.monthly_limit_chf = DEFAULT_MONTHLY_LIMIT_CHF else: settings.auto_topup_enabled = False save_topup_settings(con, settings) return settings, complete def process_setup_checkout_completed( con: sqlite3.Connection, *, session: Dict[str, Any], ) -> Dict[str, Any]: md = session.get("metadata") or {} if (md.get("aza_purpose") or "").strip() != "ai_topup_setup": return {"handled": False} practice_id = (md.get("practice_id") or "").strip() if not practice_id: return {"handled": False, "reason": "no_practice_id"} customer_id, pm_id = resolve_auto_topup_setup_ids(session=session) _, complete = _apply_auto_topup_setup_settings( con, practice_id=practice_id, customer_id=customer_id, payment_method_id=pm_id, ) if not complete: return { "handled": True, "practice_id": practice_id, "auto_topup_enabled": False, "reason": "AUTO_TOPUP_SETUP_INCOMPLETE", "has_customer": bool(customer_id), "has_payment_method": bool(pm_id), } return { "handled": True, "practice_id": practice_id, "auto_topup_enabled": True, "default_payment_method_id": pm_id, "stripe_customer_id": customer_id, } def process_setup_intent_succeeded( con: sqlite3.Connection, *, setup_intent: Dict[str, Any], ) -> Dict[str, Any]: md = setup_intent.get("metadata") or {} if (md.get("aza_purpose") or "").strip() != "ai_topup_setup": return {"handled": False} practice_id = (md.get("practice_id") or "").strip() if not practice_id: return {"handled": False, "reason": "no_practice_id"} customer_id, pm_id = resolve_auto_topup_setup_ids(setup_intent=setup_intent) _, complete = _apply_auto_topup_setup_settings( con, practice_id=practice_id, customer_id=customer_id, payment_method_id=pm_id, ) if not complete: return { "handled": True, "practice_id": practice_id, "auto_topup_enabled": False, "reason": "AUTO_TOPUP_SETUP_INCOMPLETE", "has_customer": bool(customer_id), "has_payment_method": bool(pm_id), } return { "handled": True, "practice_id": practice_id, "auto_topup_enabled": True, "default_payment_method_id": pm_id, "stripe_customer_id": customer_id, } def repair_auto_topup_settings_from_checkout_session( con: sqlite3.Connection, *, practice_id: str, checkout_session_id: str, ) -> Dict[str, Any]: """Read-only Stripe-Abfrage + lokales Settings-Update (keine Zahlung).""" key = (os.environ.get("STRIPE_SECRET_KEY") or "").strip() if not key: return {"ok": False, "reason": "no_stripe_key"} import stripe stripe.api_key = key cs_id = normalize_stripe_id(checkout_session_id, "cs_") if not cs_id: return {"ok": False, "reason": "invalid_checkout_session_id"} try: cs = stripe.checkout.Session.retrieve( cs_id, expand=["setup_intent", "setup_intent.payment_method", "customer"], ) except Exception: return {"ok": False, "reason": "session_retrieve_failed"} session = { "id": getattr(cs, "id", None), "customer": getattr(cs, "customer", None), "setup_intent": getattr(cs, "setup_intent", None), "metadata": getattr(cs, "metadata", None) or {}, } customer_id, pm_id = resolve_auto_topup_setup_ids(session=session) if pm_id and not customer_id: try: pm_obj = stripe.PaymentMethod.retrieve(pm_id) customer_id = normalize_stripe_id(getattr(pm_obj, "customer", None), "cus_") except Exception: pass _, complete = _apply_auto_topup_setup_settings( con, practice_id=practice_id, customer_id=customer_id, payment_method_id=pm_id, ) if not complete: return { "ok": False, "reason": "AUTO_TOPUP_SETUP_INCOMPLETE", "has_customer": bool(customer_id), "has_payment_method": bool(pm_id), } return { "ok": True, "auto_topup_enabled": True, "has_customer": True, "has_payment_method": True, } def process_topup_payment_intent_succeeded( con: sqlite3.Connection, *, payment_intent: Dict[str, Any], ) -> Dict[str, Any]: """Idempotent backup wenn checkout.session.completed noch nicht lief.""" md = payment_intent.get("metadata") or {} if (md.get("aza_purpose") or "").strip() != "ai_topup": return {"handled": False, "reason": "not_ai_topup"} pi_id = str(payment_intent.get("id") or "") if ledger_exists_for_payment_intent(con, pi_id): return {"handled": True, "duplicate": True, "payment_intent_id": pi_id} practice_id = (md.get("practice_id") or "").strip() or None subscription_id = (md.get("subscription_id") or "").strip() or None try: internal_usd = float(md.get("internal_credit_usd") or 0) paid_chf = float(md.get("paid_chf") or 0) except (TypeError, ValueError): return {"handled": False, "reason": "invalid_metadata_amounts"} if internal_usd <= 0 or paid_chf <= 0: return {"handled": False, "reason": "zero_amounts"} insert_ledger_event( con, practice_id=practice_id, subscription_id=subscription_id, customer_email=None, event_type="topup_purchase", amount_internal_usd=internal_usd, amount_paid_chf=paid_chf, stripe_payment_intent_id=pi_id, stripe_customer_id=str(payment_intent.get("customer") or "") or None, status="succeeded", meta=_meta_with_receipt({"source": "payment_intent.succeeded"}, payment_intent_id=pi_id), ) return {"handled": True, "credited": True, "payment_intent_id": pi_id} def process_auto_topup_payment_intent_succeeded( con: sqlite3.Connection, *, payment_intent: Dict[str, Any], ) -> Dict[str, Any]: """Idempotent: payment_intent.succeeded für aza_purpose=ai_auto_topup.""" md = payment_intent.get("metadata") or {} if (md.get("aza_purpose") or "").strip() != "ai_auto_topup": return {"handled": False, "reason": "not_ai_auto_topup"} pi_id = str(payment_intent.get("id") or "") if ledger_exists_for_payment_intent(con, pi_id): return {"handled": True, "duplicate": True, "payment_intent_id": pi_id} practice_id = (md.get("practice_id") or "").strip() or None subscription_id = (md.get("subscription_id") or "").strip() or None try: internal_usd = float(md.get("internal_credit_usd") or 0) paid_chf = float(md.get("paid_chf") or 0) except (TypeError, ValueError): return {"handled": False, "reason": "invalid_metadata_amounts"} if internal_usd <= 0 or paid_chf <= 0: return {"handled": False, "reason": "zero_amounts"} insert_ledger_event( con, practice_id=practice_id, subscription_id=subscription_id, customer_email=None, event_type="auto_topup_purchase", amount_internal_usd=internal_usd, amount_paid_chf=paid_chf, stripe_payment_intent_id=pi_id, stripe_customer_id=str(payment_intent.get("customer") or "") or None, status="succeeded", meta=_meta_with_receipt({"source": "payment_intent.succeeded"}, payment_intent_id=pi_id), ) return {"handled": True, "credited": True, "payment_intent_id": pi_id} def sum_topups_chf_this_month(con: sqlite3.Connection, practice_id: str) -> float: """Summe aller erfolgreichen Topups (manuell + auto) im Kalendermonat.""" start = _month_start_ts() row = con.execute( """ SELECT COALESCE(SUM(amount_paid_chf), 0) FROM ai_credit_ledger WHERE practice_id = ? AND status = 'succeeded' AND event_type IN ('topup_purchase', 'auto_topup_purchase') AND created_at >= ? """, (practice_id, start), ).fetchone() return float(row[0] or 0.0) def sum_auto_topups_chf_this_month(con: sqlite3.Connection, practice_id: str) -> float: """Nur automatische Aufladungen – für Auto-Monatslimit CHF 300.""" start = _month_start_ts() row = con.execute( """ SELECT COALESCE(SUM(amount_paid_chf), 0) FROM ai_credit_ledger WHERE practice_id = ? AND status = 'succeeded' AND event_type = 'auto_topup_purchase' AND created_at >= ? """, (practice_id, start), ).fetchone() return float(row[0] or 0.0) def maybe_run_auto_topup_for_practice( con: sqlite3.Connection, *, practice_id: str, available_percent: int, dry_run: bool = True, subscription_id: Optional[str] = None, customer_email: Optional[str] = None, ) -> Dict[str, Any]: """ Auto-Aufladung nur wenn alle Schutzbedingungen erfüllt sind. Live-Charge nur bei AZA_AI_AUTO_TOPUP_ENABLED=1, AZA_AI_TOPUP_ALLOW_LIVE=1, AZA_AI_TOPUP_DRY_RUN=0. """ pid = (practice_id or "").strip() if not pid: return {"ok": False, "reason": "no_practice_id"} settings = get_topup_settings(con, pid) if not settings.auto_topup_enabled: return {"ok": False, "reason": "auto_topup_not_enabled"} if available_percent >= settings.trigger_below_percent: return {"ok": False, "reason": "above_trigger"} if not normalize_stripe_id(settings.stripe_customer_id, "cus_") or not normalize_stripe_id( settings.default_payment_method_id, "pm_" ): return {"ok": False, "reason": "no_payment_method"} if settings.topup_amount_chf < MIN_TOPUP_CHF or settings.topup_amount_chf > MAX_TOPUP_CHF: return {"ok": False, "reason": "invalid_amount"} spent = sum_auto_topups_chf_this_month(con, pid) if spent + settings.topup_amount_chf > settings.monthly_limit_chf + 1e-9: return { "ok": False, "reason": "auto_topup_monthly_limit_reached", "error_code": "AUTO_TOPUP_MONTHLY_LIMIT_REACHED", "message_user": AUTO_TOPUP_MONTHLY_LIMIT_MESSAGE, } last_ts = last_successful_auto_topup_ts(con, pid) if last_ts and (_now() - last_ts) < AUTO_TOPUP_COOLDOWN_SEC: return {"ok": False, "reason": "cooldown_24h"} if has_pending_auto_topup(con, pid): return {"ok": False, "reason": "pending_auto_topup"} if dry_run or not _topup_allow_live_env() or not _auto_topup_enabled_env(): return { "ok": True, "dry_run": True, "would_charge_chf": settings.topup_amount_chf, "would_credit_usd": settings.internal_credit_usd, } if _topup_dry_run_env(): return { "ok": True, "dry_run": True, "would_charge_chf": settings.topup_amount_chf, "would_credit_usd": settings.internal_credit_usd, } return _execute_live_auto_topup( con, settings=settings, subscription_id=subscription_id, customer_email=customer_email, ) def _execute_live_auto_topup( con: sqlite3.Connection, *, settings: TopupSettings, subscription_id: Optional[str], customer_email: Optional[str], ) -> Dict[str, Any]: pid = settings.practice_id try: con.execute("BEGIN IMMEDIATE") if has_pending_auto_topup(con, pid): con.execute("ROLLBACK") return {"ok": False, "reason": "pending_auto_topup"} last_ts = last_successful_auto_topup_ts(con, pid) if last_ts and (_now() - last_ts) < AUTO_TOPUP_COOLDOWN_SEC: con.execute("ROLLBACK") return {"ok": False, "reason": "cooldown_24h"} spent = sum_auto_topups_chf_this_month(con, pid) if spent + settings.topup_amount_chf > settings.monthly_limit_chf + 1e-9: con.execute("ROLLBACK") return { "ok": False, "reason": "auto_topup_monthly_limit_reached", "error_code": "AUTO_TOPUP_MONTHLY_LIMIT_REACHED", "message_user": AUTO_TOPUP_MONTHLY_LIMIT_MESSAGE, } ledger_id = insert_ledger_event( con, practice_id=pid, subscription_id=subscription_id, customer_email=customer_email, event_type="auto_topup_purchase", amount_internal_usd=settings.internal_credit_usd, amount_paid_chf=settings.topup_amount_chf, stripe_customer_id=settings.stripe_customer_id, status="pending", meta={"source": "auto_topup_off_session"}, commit=False, ) import stripe stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "") amount_rappen = int(round(settings.topup_amount_chf * 100)) idempotency_key = _auto_topup_idempotency_key(pid) try: pi = stripe.PaymentIntent.create( amount=amount_rappen, currency="chf", customer=normalize_stripe_id(settings.stripe_customer_id, "cus_"), payment_method=normalize_stripe_id(settings.default_payment_method_id, "pm_"), off_session=True, confirm=True, metadata={ "aza_purpose": "ai_auto_topup", "practice_id": pid, "subscription_id": subscription_id or "", "internal_credit_usd": str(settings.internal_credit_usd), "paid_chf": str(settings.topup_amount_chf), }, idempotency_key=idempotency_key, ) except stripe.error.CardError as exc: pi_obj = getattr(exc, "error", None) pi_id = None if pi_obj is not None: pi_id = getattr(getattr(pi_obj, "payment_intent", None), "id", None) update_ledger_event(con, ledger_id, status="failed", commit=False) _pause_auto_topup_on_failure( con, settings, reason="card_error", payment_intent_id=None, commit=False, ) con.commit() return {"ok": False, "reason": "payment_failed", "charged": False, "paused": True} except Exception: update_ledger_event(con, ledger_id, status="failed", commit=False) _pause_auto_topup_on_failure( con, settings, reason="stripe_error", commit=False, ) con.commit() return {"ok": False, "reason": "payment_failed", "charged": False, "paused": True} pi_id = str(getattr(pi, "id", "") or "") pi_status = str(getattr(pi, "status", "") or "") if pi_status == "succeeded": update_ledger_event( con, ledger_id, status="succeeded", stripe_payment_intent_id=pi_id or None, meta=_meta_with_receipt({"source": "auto_topup_off_session"}, payment_intent_id=pi_id or None), commit=False, ) con.commit() return { "ok": True, "charged": True, "amount_chf": settings.topup_amount_chf, "internal_credit_usd": settings.internal_credit_usd, "payment_intent_id": pi_id, } update_ledger_event( con, ledger_id, status="failed", stripe_payment_intent_id=pi_id or None, meta={"stripe_status": pi_status}, commit=False, ) _pause_auto_topup_on_failure( con, settings, reason=pi_status or "requires_action", payment_intent_id=None, commit=False, ) con.commit() return {"ok": False, "reason": "payment_failed", "charged": False, "paused": True} except Exception: try: con.execute("ROLLBACK") except Exception: pass return {"ok": False, "reason": "internal_error", "charged": False} def _stripe_live_key() -> bool: key = (os.environ.get("STRIPE_SECRET_KEY") or "").strip() return key.startswith("sk_live") def create_topup_checkout_session( *, practice_id: str, subscription_id: str, customer_email: str, paid_chf: float, internal_usd: float, save_payment_method: bool = False, success_url: str, cancel_url: str, ) -> Dict[str, Any]: if _stripe_live_key() and not _topup_allow_live_env(): return {"ok": False, "error_code": "LIVE_TOPUP_BLOCKED", "dry_run": True} if _topup_dry_run_env(): pending_id = f"dry_chk_{uuid.uuid4().hex[:16]}" return { "ok": True, "dry_run": True, "checkout_url": None, "session_id": pending_id, "paid_chf": paid_chf, "internal_credit_usd": internal_usd, } import stripe stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "") amount_rappen = int(round(paid_chf * 100)) params: Dict[str, Any] = { "mode": "payment", "success_url": success_url, "cancel_url": cancel_url, "customer_email": customer_email, "line_items": [ { "price_data": { "currency": "chf", "product_data": {"name": "AzA KI-Zusatzguthaben"}, "unit_amount": amount_rappen, }, "quantity": 1, } ], "metadata": { "aza_purpose": "ai_topup", "practice_id": practice_id, "subscription_id": subscription_id, "internal_credit_usd": str(internal_usd), "paid_chf": str(paid_chf), }, } if save_payment_method: params["payment_intent_data"] = {"setup_future_usage": "off_session"} session = stripe.checkout.Session.create(**params) return { "ok": True, "dry_run": False, "checkout_url": session.url, "session_id": session.id, "paid_chf": paid_chf, "internal_credit_usd": internal_usd, } def create_setup_checkout_session( *, practice_id: str, customer_email: str, success_url: str, cancel_url: str, ) -> Dict[str, Any]: if _topup_dry_run_env(): return { "ok": True, "dry_run": True, "checkout_url": None, "session_id": f"dry_setup_{uuid.uuid4().hex[:12]}", } if _stripe_live_key() and not _topup_allow_live_env(): return {"ok": False, "error_code": "LIVE_SETUP_BLOCKED", "dry_run": True} import stripe stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "") session = stripe.checkout.Session.create( mode="setup", currency="chf", payment_method_types=["card"], customer_creation="always", success_url=success_url, cancel_url=cancel_url, customer_email=customer_email, custom_text={ "submit": {"message": AUTO_TOPUP_CONSENT_TEXT}, }, metadata={ "aza_purpose": "ai_topup_setup", "practice_id": practice_id, "consent_text": AUTO_TOPUP_CONSENT_TEXT[:500], }, setup_intent_data={ "metadata": { "aza_purpose": "ai_topup_setup", "practice_id": practice_id, }, }, ) return { "ok": True, "dry_run": False, "checkout_url": session.url, "session_id": session.id, } def admin_credit_overview_row( con: sqlite3.Connection, *, practice_id: str, subscription_id: str, monthly_budget: float, snap: Dict[str, Any], ) -> Dict[str, Any]: settings = get_topup_settings(con, practice_id) extra = compute_extra_credit_remaining( con, practice_id=practice_id, subscription_id=subscription_id, monthly_budget_usd=monthly_budget, ) topups_month = sum_topups_chf_this_month(con, practice_id) failed = con.execute( """ SELECT COUNT(*) FROM ai_credit_ledger WHERE practice_id = ? AND status = 'failed' AND event_type IN ('topup_purchase', 'auto_topup_purchase', 'failed_auto_topup') """, (practice_id,), ).fetchone()[0] sub_m = subscription_id[:10] + "…" if len(subscription_id) > 12 else subscription_id return { "practice_id": practice_id, "subscription_id_masked": sub_m, "monthly_budget_usd": round(monthly_budget, 2), "used_current_period_usd": snap.get("used_usd"), "monthly_remaining_usd": snap.get("remaining_usd"), "extra_credit_remaining_usd": extra, "available_percent": snap.get("available_percent"), "auto_topup_enabled": settings.auto_topup_enabled, "topups_chf_this_month": round(topups_month, 2), "failed_topup_events": int(failed or 0), } def list_ledger_for_practice( con: sqlite3.Connection, practice_id: str, *, limit: int = 100, ) -> List[Dict[str, Any]]: rows = con.execute( """ SELECT id, event_type, amount_internal_usd, amount_paid_chf, status, stripe_checkout_session_id, created_at, updated_at FROM ai_credit_ledger WHERE practice_id = ? ORDER BY created_at DESC LIMIT ? """, (practice_id, limit), ).fetchall() out = [] for r in rows: sid = r[5] or "" out.append( { "id": r[0], "event_type": r[1], "amount_internal_usd": r[2], "amount_paid_chf": r[3], "status": r[4], "checkout_session_masked": (sid[:12] + "…") if len(sid) > 14 else sid, "created_at": r[6], } ) return out def list_user_credit_history( con: sqlite3.Connection, *, practice_id: str, limit: int = 100, ) -> List[Dict[str, Any]]: """Benutzer-Historie: nur eigene practice_id, maskierte Stripe-Referenzen.""" pid = (practice_id or "").strip() placeholders = ",".join("?" for _ in USER_HISTORY_EVENT_TYPES) rows = con.execute( f""" SELECT event_type, amount_internal_usd, amount_paid_chf, status, stripe_payment_intent_id, stripe_checkout_session_id, meta_json, created_at FROM ai_credit_ledger WHERE practice_id = ? AND event_type IN ({placeholders}) ORDER BY created_at DESC LIMIT ? """, (pid, *USER_HISTORY_EVENT_TYPES, limit), ).fetchall() out: List[Dict[str, Any]] = [] for r in rows: meta: Dict[str, Any] = {} if r[6]: try: parsed = json.loads(r[6]) if isinstance(parsed, dict): meta = parsed except (TypeError, ValueError, json.JSONDecodeError): meta = {} stripe_ref = mask_stripe_reference(r[4]) or mask_stripe_reference(r[5]) item: Dict[str, Any] = { "created_at": int(r[7]), "date_de": _format_date_de(int(r[7])), "event_type": r[0], "amount_paid_chf": r[2], "amount_internal_usd": r[1], "status": r[3], "stripe_payment_reference": stripe_ref, } receipt = meta.get("receipt_url") if isinstance(receipt, str) and receipt.startswith("https://"): item["receipt_url"] = receipt out.append(item) return out