1478 lines
51 KiB
Python
1478 lines
51 KiB
Python
|
|
# -*- 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
|