Files
aza/AzA march 2026/stripe_routes.py

877 lines
32 KiB
Python
Raw Permalink Normal View History

2026-03-30 07:59:11 +02:00
# stripe_routes.py Stripe billing, webhook handling, license persistence.
#
# IMPORTANT (learned the hard way, 2026-03-27):
# - Do NOT call .get() on raw StripeObjects; they are not plain dicts.
# - Do NOT use .to_dict_recursive(); it does not exist in all stripe-python versions.
# - After Webhook.construct_event(), always work with json.loads(body) instead.
# - For Subscription.retrieve() / Customer.retrieve(), convert via _stripe_to_dict(obj).
# - json.loads(str(obj)) breaks when StripeObject contains Decimal values (monetary amounts).
# Always use _stripe_to_dict() instead. Never json.loads(str(...)) on StripeObjects.
# - This router is mounted with prefix="/stripe" in main.py.
# Route decorators here must NOT repeat the prefix (use "/webhook", not "/stripe/webhook").
# - When changing Stripe env vars on Hetzner, rebuild the container:
# docker compose up -d --build --force-recreate aza-api (restart alone is not enough)
2026-03-25 22:03:39 +01:00
from __future__ import annotations
import json
import os
2026-04-16 13:32:32 +02:00
import secrets
import smtplib
2026-03-25 22:03:39 +01:00
import sqlite3
2026-04-16 13:32:32 +02:00
import string
2026-03-25 22:03:39 +01:00
import time
2026-04-21 10:00:36 +02:00
import uuid
2026-03-25 22:03:39 +01:00
from dataclasses import dataclass
2026-03-30 07:59:11 +02:00
from decimal import Decimal
2026-04-16 13:32:32 +02:00
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
2026-03-25 22:03:39 +01:00
from pathlib import Path
from typing import Any, Dict, Optional
import stripe
from fastapi import APIRouter, Header, HTTPException, Request
from fastapi.responses import JSONResponse
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "").strip()
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "").strip()
STRIPE_SUCCESS_URL = os.environ.get("STRIPE_SUCCESS_URL", "").strip()
STRIPE_CANCEL_URL = os.environ.get("STRIPE_CANCEL_URL", "").strip()
STRIPE_PORTAL_RETURN_URL = os.environ.get("STRIPE_PORTAL_RETURN_URL", "").strip()
_BASE_DIR = Path(__file__).resolve().parent
DB_PATH = Path(os.environ.get("STRIPE_DB_PATH", str(_BASE_DIR / "data" / "stripe_webhook.sqlite")))
EVENTS_LOG = Path(os.environ.get("STRIPE_EVENTS_LOG", str(_BASE_DIR / "data" / "stripe_events.log.jsonl")))
router = APIRouter(tags=["stripe"])
2026-03-30 07:59:11 +02:00
def _decimal_default(o: Any) -> Any:
"""json.dumps default handler: Decimal → int/float, StripeObject → dict."""
if isinstance(o, Decimal):
return int(o) if o == int(o) else float(o)
if hasattr(o, "to_dict"):
return o.to_dict()
if hasattr(o, "keys"):
return dict(o)
return str(o)
def _stripe_to_dict(obj: Any) -> dict:
"""Convert a StripeObject to a plain JSON-safe dict.
Handles Decimal values that break json.loads(str(obj))."""
raw = dict(obj) if hasattr(obj, "keys") else obj
return json.loads(json.dumps(raw, default=_decimal_default))
2026-03-25 22:03:39 +01:00
def _require_env() -> None:
missing = []
if not STRIPE_SECRET_KEY:
missing.append("STRIPE_SECRET_KEY")
if not STRIPE_WEBHOOK_SECRET:
missing.append("STRIPE_WEBHOOK_SECRET")
if not STRIPE_SUCCESS_URL:
missing.append("STRIPE_SUCCESS_URL")
if not STRIPE_CANCEL_URL:
missing.append("STRIPE_CANCEL_URL")
if missing:
raise HTTPException(status_code=500, detail=f"Stripe misconfigured. Missing env: {', '.join(missing)}")
def _init_stripe() -> None:
stripe.api_key = STRIPE_SECRET_KEY
def _ensure_storage() -> None:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
EVENTS_LOG.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(DB_PATH) as con:
con.execute(
"""
CREATE TABLE IF NOT EXISTS processed_events (
event_id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL
)
"""
)
con.execute(
"""
CREATE TABLE IF NOT EXISTS licenses (
subscription_id TEXT PRIMARY KEY,
customer_id TEXT,
status TEXT,
lookup_key TEXT,
allowed_users INTEGER,
devices_per_user INTEGER,
customer_email TEXT,
client_reference_id TEXT,
current_period_end INTEGER,
updated_at INTEGER NOT NULL
)
"""
)
cols = [row[1] for row in con.execute("PRAGMA table_info(licenses)").fetchall()]
if "current_period_end" not in cols:
con.execute("ALTER TABLE licenses ADD COLUMN current_period_end INTEGER")
2026-04-16 13:32:32 +02:00
if "license_key" not in cols:
con.execute("ALTER TABLE licenses ADD COLUMN license_key TEXT")
2026-04-21 10:00:36 +02:00
if "practice_id" not in cols:
con.execute("ALTER TABLE licenses ADD COLUMN practice_id TEXT")
2026-03-25 22:03:39 +01:00
con.commit()
2026-04-16 13:32:32 +02:00
_last_mail_error: str = ""
_MAIL_SUBJECT = "Ihr AZA Lizenzschluessel"
def _mail_body_text(license_key: str) -> str:
return (
f"Guten Tag,\n\n"
f"vielen Dank fuer Ihren Kauf von AZA Medical AI Assistant.\n\n"
f"Ihr persoenlicher Lizenzschluessel:\n\n"
f" {license_key}\n\n"
f"So aktivieren Sie AZA:\n"
f"1. Starten Sie die AZA Desktop-App.\n"
f"2. Klicken Sie auf das Schluessel-Symbol (Aktivierung).\n"
f"3. Geben Sie den obigen Schluessel ein und klicken Sie auf \"Aktivieren\".\n\n"
f"Bewahren Sie diese E-Mail sicher auf.\n\n"
f"Bei Fragen: support@aza-medwork.ch\n\n"
f"Freundliche Gruesse\n"
f"Ihr AZA-Team"
)
def _mail_body_html(license_key: str) -> str:
return (
f'<div style="font-family:Segoe UI,system-ui,sans-serif;max-width:560px;margin:0 auto;">'
f'<h2 style="color:#0078D7;">Ihr AZA Lizenzschluessel</h2>'
f'<p>Guten Tag,</p>'
f'<p>vielen Dank f&uuml;r Ihren Kauf von <strong>AZA Medical AI Assistant</strong>.</p>'
f'<p>Ihr pers&ouml;nlicher Lizenzschl&uuml;ssel:</p>'
f'<div style="margin:20px 0;padding:16px 24px;background:#E8F4FD;border-radius:8px;'
f'border:1px solid #B3D8F0;text-align:center;">'
f'<span style="font-size:22px;font-weight:700;letter-spacing:2px;color:#0078D7;'
f'font-family:Consolas,monospace;">{license_key}</span></div>'
f'<p><strong>So aktivieren Sie AZA:</strong></p>'
f'<ol style="line-height:1.8;">'
f'<li>Starten Sie die AZA Desktop-App.</li>'
f'<li>Klicken Sie auf das Schl&uuml;ssel-Symbol (Aktivierung).</li>'
f'<li>Geben Sie den obigen Schl&uuml;ssel ein und klicken Sie auf &laquo;Aktivieren&raquo;.</li>'
f'</ol>'
f'<p style="color:#888;font-size:13px;">Bewahren Sie diese E-Mail sicher auf.</p>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:24px 0;">'
f'<p style="font-size:12px;color:#999;">Bei Fragen: '
f'<a href="mailto:support@aza-medwork.ch">support@aza-medwork.ch</a></p>'
f'<p style="font-size:12px;color:#999;">&copy; AZA Medical AI Assistant aza-medwork.ch</p>'
f'</div>'
)
def _send_via_resend(to_email: str, license_key: str) -> bool:
"""Versand ueber Resend HTTP API (api.resend.com)."""
global _last_mail_error
import urllib.request
import urllib.error
api_key = os.environ.get("RESEND_API_KEY", "").strip()
sender = os.environ.get("MAIL_FROM", "AZA MedWork <noreply@aza-medwork.ch>").strip()
if not api_key:
_last_mail_error = "RESEND_API_KEY nicht gesetzt"
return False
payload = json.dumps({
"from": sender,
"to": [to_email],
"subject": _MAIL_SUBJECT,
"html": _mail_body_html(license_key),
"text": _mail_body_text(license_key),
}).encode("utf-8")
req = urllib.request.Request(
"https://api.resend.com/emails",
data=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"User-Agent": "AZA-MedWork/1.0",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
resp_body = resp.read().decode()
if resp.status in (200, 201):
print(f"[MAIL] Resend OK -> {to_email}")
return True
_last_mail_error = f"Resend HTTP {resp.status}: {resp_body[:200]}"
print(f"[MAIL] {_last_mail_error}")
return False
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")[:300] if exc.fp else ""
_last_mail_error = f"Resend HTTP {exc.code}: {body}"
print(f"[MAIL] FEHLER Resend: {_last_mail_error}")
return False
except Exception as exc:
_last_mail_error = f"Resend {type(exc).__name__}: {exc}"
print(f"[MAIL] FEHLER Resend: {_last_mail_error}")
return False
def _send_via_smtp(to_email: str, license_key: str) -> bool:
"""Fallback-Versand ueber SMTP."""
global _last_mail_error
host = os.environ.get("SMTP_HOST", "").strip()
port_str = os.environ.get("SMTP_PORT", "587").strip()
user = os.environ.get("SMTP_USER", "").strip()
password = os.environ.get("SMTP_PASS", "").strip()
sender = os.environ.get("SMTP_FROM", "").strip() or user
if not all([host, user, password]):
_last_mail_error = "SMTP nicht konfiguriert"
return False
try:
msg = MIMEMultipart("alternative")
msg["From"] = sender
msg["To"] = to_email
msg["Subject"] = _MAIL_SUBJECT
msg.attach(MIMEText(_mail_body_text(license_key), "plain", "utf-8"))
msg.attach(MIMEText(_mail_body_html(license_key), "html", "utf-8"))
port = int(port_str)
if port == 465:
with smtplib.SMTP_SSL(host, port, timeout=15) as srv:
srv.login(user, password)
srv.sendmail(sender, [to_email], msg.as_string())
else:
with smtplib.SMTP(host, port, timeout=15) as srv:
srv.ehlo()
srv.starttls()
srv.ehlo()
srv.login(user, password)
srv.sendmail(sender, [to_email], msg.as_string())
print(f"[MAIL] SMTP OK -> {to_email}")
return True
except Exception as exc:
_last_mail_error = f"SMTP {type(exc).__name__}: {exc}"
print(f"[MAIL] FEHLER SMTP: {_last_mail_error}")
return False
def send_license_email(to_email: str, license_key: str) -> bool:
"""Sendet den Lizenzschluessel per E-Mail. Resend API bevorzugt, SMTP als Fallback."""
global _last_mail_error
_last_mail_error = ""
if not to_email or not license_key:
print(f"[MAIL] skip to_email oder license_key fehlt")
return False
if os.environ.get("RESEND_API_KEY", "").strip():
return _send_via_resend(to_email, license_key)
return _send_via_smtp(to_email, license_key)
def _generate_license_key() -> str:
"""Erzeugt einen kryptographisch sicheren Lizenzschluessel: AZA-XXXX-XXXX-XXXX-XXXX."""
alphabet = string.ascii_uppercase + string.digits
groups = ["".join(secrets.choice(alphabet) for _ in range(4)) for _ in range(4)]
return "AZA-" + "-".join(groups)
2026-03-25 22:03:39 +01:00
@dataclass(frozen=True)
class PlanPolicy:
lookup_key: str
allowed_users: int
devices_per_user: int
def _policy_for_lookup_key(lookup_key: str) -> PlanPolicy:
if lookup_key.startswith("aza_basic_"):
return PlanPolicy(lookup_key=lookup_key, allowed_users=1, devices_per_user=2)
if lookup_key.startswith("aza_team_"):
return PlanPolicy(lookup_key=lookup_key, allowed_users=3, devices_per_user=2)
return PlanPolicy(lookup_key=lookup_key, allowed_users=1, devices_per_user=1)
2026-03-30 07:59:11 +02:00
_PRICE_TO_LOOKUP: Dict[tuple, str] = {
("month", 5900): "aza_basic_monthly",
("year", 59000): "aza_basic_yearly",
("month", 8900): "aza_team_monthly",
("year", 89000): "aza_team_yearly",
}
def _lookup_key_from_price(price: Dict[str, Any]) -> str:
"""Derive lookup_key from price object. Falls back to interval+amount mapping
when Stripe sandbox omits lookup_key."""
lk = (price.get("lookup_key") or "").strip()
if lk:
return lk
recurring = price.get("recurring") or {}
interval = (recurring.get("interval") or "").strip()
amount = price.get("unit_amount")
if interval and amount is not None:
try:
amount = int(amount)
except (TypeError, ValueError):
return ""
return _PRICE_TO_LOOKUP.get((interval, amount), "")
return ""
2026-03-25 22:03:39 +01:00
def _price_id_from_lookup_key(lookup_key: str) -> str:
_init_stripe()
prices = stripe.Price.list(lookup_keys=[lookup_key], active=True, limit=1)
if not prices.data:
raise HTTPException(status_code=400, detail=f"Unknown lookup_key: {lookup_key}")
return prices.data[0].id
@router.get("/health")
def stripe_health() -> Dict[str, Any]:
_require_env()
return {"ok": True}
@router.post("/create_checkout_session")
async def create_checkout_session(payload: Dict[str, Any]) -> Dict[str, Any]:
_require_env()
_ensure_storage()
_init_stripe()
lookup_key = (payload.get("lookup_key") or "").strip()
if not lookup_key:
raise HTTPException(status_code=400, detail="Missing lookup_key")
customer_email = (payload.get("customer_email") or "").strip() or None
client_reference_id = (payload.get("client_reference_id") or "").strip() or None
price_id = _price_id_from_lookup_key(lookup_key)
policy = _policy_for_lookup_key(lookup_key)
subscription_data = {
"metadata": {
"lookup_key": policy.lookup_key,
"allowed_users": str(policy.allowed_users),
"devices_per_user": str(policy.devices_per_user),
}
}
session = stripe.checkout.Session.create(
mode="subscription",
line_items=[{"price": price_id, "quantity": 1}],
success_url=STRIPE_SUCCESS_URL,
cancel_url=STRIPE_CANCEL_URL,
customer_email=customer_email,
client_reference_id=client_reference_id,
subscription_data=subscription_data,
allow_promotion_codes=True,
billing_address_collection="auto",
)
return {"id": session.id, "url": session.url}
@router.post("/create_billing_portal_session")
async def create_billing_portal_session(payload: Dict[str, Any]) -> Dict[str, Any]:
_require_env()
_init_stripe()
customer_id = (payload.get("customer_id") or "").strip()
if not customer_id:
raise HTTPException(status_code=400, detail="Missing customer_id")
if not STRIPE_PORTAL_RETURN_URL:
raise HTTPException(status_code=500, detail="Missing STRIPE_PORTAL_RETURN_URL")
portal = stripe.billing_portal.Session.create(customer=customer_id, return_url=STRIPE_PORTAL_RETURN_URL)
return {"url": portal.url}
def _already_processed(event_id: str) -> bool:
with sqlite3.connect(DB_PATH) as con:
row = con.execute("SELECT 1 FROM processed_events WHERE event_id=? LIMIT 1", (event_id,)).fetchone()
return row is not None
def _mark_processed(event_id: str) -> None:
now = int(time.time())
with sqlite3.connect(DB_PATH) as con:
con.execute("INSERT OR IGNORE INTO processed_events(event_id, created_at) VALUES(?, ?)", (event_id, now))
con.commit()
def _log_event(kind: str, payload: Dict[str, Any]) -> None:
rec = {"ts": int(time.time()), "kind": kind, "payload": payload}
with EVENTS_LOG.open("a", encoding="utf-8") as f:
2026-03-30 07:59:11 +02:00
f.write(json.dumps(rec, ensure_ascii=False, default=_decimal_default) + "\n")
2026-03-25 22:03:39 +01:00
2026-04-21 10:00:36 +02:00
def _new_practice_id() -> str:
"""Gleiches Format wie empfang_routes._generate_practice_id (Mandanten-ID)."""
return f"prac_{uuid.uuid4().hex[:12]}"
def _sync_empfang_practice_from_license(
practice_id: str,
customer_email: Optional[str],
display_name: str,
) -> None:
"""Empfang-Praxisdatei mit SQLite-Lizenz synchronisieren (eine Wahrheit)."""
pid = (practice_id or "").strip()
if not pid:
return
try:
from empfang_routes import _ensure_practice
except Exception as exc:
print(f"[STRIPE] empfang_routes Import: {exc}")
return
try:
em = (customer_email or "").strip()
name = (display_name or "").strip() or (em.split("@")[0] if "@" in em else "Meine Praxis")
_ensure_practice(pid, name=name, admin_email=em)
except Exception as exc:
print(f"[STRIPE] _ensure_practice: {exc}")
def lookup_practice_id_for_license_email(email: str) -> Optional[str]:
"""Liefert die serverseitig gespeicherte practice_id zur Kunden-E-Mail (Lizenz ↔ Praxis)."""
_ensure_storage()
e = (email or "").strip().lower()
if not e:
return None
try:
with sqlite3.connect(DB_PATH) as con:
row = con.execute(
"""
SELECT practice_id FROM licenses
WHERE lower(customer_email) = ?
AND practice_id IS NOT NULL
AND trim(practice_id) != ''
ORDER BY updated_at DESC
LIMIT 1
""",
(e,),
).fetchone()
if not row or not row[0]:
return None
return str(row[0]).strip()
except Exception:
return None
2026-03-25 22:03:39 +01:00
def _upsert_license(
*,
subscription_id: str,
customer_id: str,
status: str,
lookup_key: str,
allowed_users: Optional[int],
devices_per_user: Optional[int],
customer_email: Optional[str],
client_reference_id: Optional[str],
current_period_end: Optional[int],
2026-04-16 13:32:32 +02:00
license_key: Optional[str] = None,
) -> str:
2026-04-21 10:00:36 +02:00
"""Upsert license row. Returns the license_key (generated if not yet set).
Jede Lizenzzeile erhält spätestens hier eine stabile practice_id (Mandant),
damit Stripe-Webhook und Desktop/Empfang dieselbe ID nutzen.
"""
2026-03-25 22:03:39 +01:00
now = int(time.time())
with sqlite3.connect(DB_PATH) as con:
2026-04-16 13:32:32 +02:00
existing_key = None
2026-04-21 10:00:36 +02:00
existing_pid = ""
2026-04-16 13:32:32 +02:00
row = con.execute(
2026-04-21 10:00:36 +02:00
"SELECT license_key, practice_id FROM licenses WHERE subscription_id = ?",
2026-04-16 13:32:32 +02:00
(subscription_id,),
).fetchone()
2026-04-21 10:00:36 +02:00
if row:
if row[0]:
existing_key = row[0]
if row[1]:
existing_pid = str(row[1]).strip()
2026-04-16 13:32:32 +02:00
final_key = existing_key or license_key or _generate_license_key()
2026-04-21 10:00:36 +02:00
final_pid = existing_pid or _new_practice_id()
2026-04-16 13:32:32 +02:00
2026-03-25 22:03:39 +01:00
con.execute(
"""
INSERT INTO licenses(
subscription_id, customer_id, status, lookup_key,
allowed_users, devices_per_user, customer_email, client_reference_id,
2026-04-21 10:00:36 +02:00
current_period_end, updated_at, license_key, practice_id
2026-03-25 22:03:39 +01:00
)
2026-04-21 10:00:36 +02:00
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2026-03-25 22:03:39 +01:00
ON CONFLICT(subscription_id) DO UPDATE SET
customer_id=excluded.customer_id,
status=excluded.status,
2026-04-16 13:32:32 +02:00
lookup_key=COALESCE(NULLIF(excluded.lookup_key, ''), lookup_key),
allowed_users=COALESCE(excluded.allowed_users, allowed_users),
devices_per_user=COALESCE(excluded.devices_per_user, devices_per_user),
customer_email=COALESCE(excluded.customer_email, customer_email),
client_reference_id=COALESCE(excluded.client_reference_id, client_reference_id),
current_period_end=COALESCE(excluded.current_period_end, current_period_end),
updated_at=excluded.updated_at,
2026-04-21 10:00:36 +02:00
license_key=COALESCE(license_key, excluded.license_key),
practice_id=CASE
WHEN licenses.practice_id IS NOT NULL AND trim(licenses.practice_id) != ''
THEN licenses.practice_id
ELSE excluded.practice_id
END
2026-03-25 22:03:39 +01:00
""",
(
subscription_id,
customer_id,
status,
lookup_key,
allowed_users,
devices_per_user,
customer_email,
client_reference_id,
current_period_end,
now,
2026-04-16 13:32:32 +02:00
final_key,
2026-04-21 10:00:36 +02:00
final_pid,
2026-03-25 22:03:39 +01:00
),
)
con.commit()
2026-04-21 10:00:36 +02:00
row2 = con.execute(
"SELECT practice_id, customer_email FROM licenses WHERE subscription_id = ?",
(subscription_id,),
).fetchone()
if row2:
pid_s = str(row2[0]).strip() if row2[0] else ""
em = (str(row2[1]).strip() if row2[1] else "") or (customer_email or "").strip()
if pid_s:
disp = em.split("@")[0] if "@" in em else "Meine Praxis"
_sync_empfang_practice_from_license(pid_s, em or None, disp)
2026-04-16 13:32:32 +02:00
return final_key
def get_license_key_for_email(email: str) -> Optional[str]:
"""Returns the license_key for a customer email, or None."""
_ensure_storage()
if not email:
return None
with sqlite3.connect(DB_PATH) as con:
row = con.execute(
"SELECT license_key FROM licenses WHERE lower(customer_email)=? ORDER BY updated_at DESC LIMIT 1",
(email.strip().lower(),),
).fetchone()
return row[0] if row and row[0] else None
2026-03-25 22:03:39 +01:00
@router.get("/license_debug")
def license_debug(email: str) -> Dict[str, Any]:
"""
Debug helper (local only): returns latest license row for a given email.
"""
_ensure_storage()
email = (email or "").strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Missing email")
with sqlite3.connect(DB_PATH) as con:
con.row_factory = sqlite3.Row
row = con.execute(
"""
SELECT * FROM licenses
WHERE lower(customer_email)=?
ORDER BY updated_at DESC
LIMIT 1
""",
(email,),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="No license for email")
return dict(row)
2026-04-16 13:32:32 +02:00
@router.post("/test_license_email")
def test_license_email(email: str) -> Dict[str, Any]:
"""Admin-Test: sendet die Lizenzschluessel-Mail erneut an eine bestehende E-Mail."""
_ensure_storage()
lk = get_license_key_for_email(email)
if not lk:
raise HTTPException(status_code=404, detail="Kein Lizenzschluessel fuer diese E-Mail")
ok = send_license_email(email, lk)
result: Dict[str, Any] = {"sent": ok, "to": email}
if not ok:
host = os.environ.get("SMTP_HOST", "")
port = os.environ.get("SMTP_PORT", "")
user = os.environ.get("SMTP_USER", "")
result["debug"] = f"host={host}, port={port}, user={user}"
result["error"] = _last_mail_error
return result
2026-03-25 22:03:39 +01:00
@router.post("/sync_subscription")
2026-04-16 13:32:32 +02:00
async def sync_subscription(payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
2026-03-25 22:03:39 +01:00
_require_env()
_ensure_storage()
_init_stripe()
2026-04-16 13:32:32 +02:00
subscription_id = None
if payload:
subscription_id = (payload.get("subscription_id") or "").strip() or None
if not subscription_id:
with sqlite3.connect(DB_PATH) as con:
row = con.execute(
"""
SELECT subscription_id
FROM licenses
ORDER BY updated_at DESC
LIMIT 1
"""
).fetchone()
if not row or not row[0]:
raise HTTPException(status_code=404, detail="No license found")
subscription_id = str(row[0])
2026-03-25 22:03:39 +01:00
2026-03-30 07:59:11 +02:00
sub = _stripe_to_dict(stripe.Subscription.retrieve(subscription_id))
2026-03-25 22:03:39 +01:00
status = (sub.get("status") or "").strip()
2026-04-16 13:32:32 +02:00
if status == "active" and sub.get("cancel_at_period_end"):
status = "canceled"
2026-03-27 21:40:23 +01:00
current_period_end = sub.get("current_period_end")
2026-03-25 22:03:39 +01:00
2026-03-27 21:40:23 +01:00
if not current_period_end:
2026-03-25 22:03:39 +01:00
try:
current_period_end = sub["items"]["data"][0]["current_period_end"]
except Exception:
current_period_end = None
now = int(time.time())
with sqlite3.connect(DB_PATH) as con:
con.execute(
"""
UPDATE licenses
SET status=?, current_period_end=?, updated_at=?
WHERE subscription_id=?
""",
(status, current_period_end, now, subscription_id),
)
con.commit()
return {
"subscription_id": subscription_id,
"status": status,
"current_period_end": current_period_end,
}
2026-03-30 07:59:11 +02:00
# Route is "/webhook" here because main.py mounts this router at prefix="/stripe".
2026-03-25 22:03:39 +01:00
@router.post("/webhook")
async def stripe_webhook(
request: Request,
stripe_signature: Optional[str] = Header(default=None, alias="Stripe-Signature"),
) -> JSONResponse:
_require_env()
_ensure_storage()
_init_stripe()
if not stripe_signature:
raise HTTPException(status_code=400, detail="Missing Stripe-Signature header")
body = await request.body()
try:
stripe.Webhook.construct_event(payload=body, sig_header=stripe_signature, secret=STRIPE_WEBHOOK_SECRET)
2026-03-25 22:03:39 +01:00
except Exception as e:
raise HTTPException(status_code=400, detail=f"Webhook signature verification failed: {e}")
2026-03-30 07:59:11 +02:00
# Parse as plain dict do not use the StripeObject returned by construct_event.
event = json.loads(body)
2026-03-25 22:03:39 +01:00
event_id = event.get("id", "")
2026-03-30 07:59:11 +02:00
etype = event.get("type")
_log_event("webhook_received", {"event_id": event_id, "type": etype})
2026-03-25 22:03:39 +01:00
if event_id and _already_processed(event_id):
2026-03-30 07:59:11 +02:00
_log_event("webhook_duplicate", {"event_id": event_id, "type": etype})
2026-03-25 22:03:39 +01:00
return JSONResponse({"ok": True, "duplicate": True})
obj = (event.get("data") or {}).get("object") or {}
try:
if etype == "checkout.session.completed":
subscription_id = obj.get("subscription")
customer_id = obj.get("customer")
client_reference_id = obj.get("client_reference_id")
2026-03-30 07:59:11 +02:00
_log_event("checkout_entered", {
"event_id": event_id,
"subscription_id": subscription_id,
"customer_id": customer_id,
})
if not subscription_id or not customer_id:
_log_event("license_skip", {
"event_id": event_id,
"reason": "missing_subscription_or_customer",
"subscription_id": subscription_id,
"customer_id": customer_id,
})
else:
# customer_email: cascade through all known locations.
customer_email = (
obj.get("customer_email")
or (obj.get("customer_details") or {}).get("email")
)
if not customer_email and customer_id:
try:
cust = _stripe_to_dict(stripe.Customer.retrieve(customer_id))
customer_email = cust.get("email")
except Exception:
pass
customer_email = (customer_email or "").strip() or None
2026-03-25 22:03:39 +01:00
2026-03-30 07:59:11 +02:00
sub = _stripe_to_dict(stripe.Subscription.retrieve(subscription_id, expand=["items.data.price"]))
2026-03-25 22:03:39 +01:00
status = sub.get("status", "") or ""
current_period_end = sub.get("current_period_end")
2026-04-16 13:32:32 +02:00
if not current_period_end:
try:
current_period_end = sub["items"]["data"][0]["current_period_end"]
except Exception:
current_period_end = None
2026-03-25 22:03:39 +01:00
md = sub.get("metadata") or {}
lookup_key = (md.get("lookup_key") or "").strip()
allowed_users = md.get("allowed_users")
devices_per_user = md.get("devices_per_user")
if not lookup_key:
try:
price = sub["items"]["data"][0]["price"]
2026-03-30 07:59:11 +02:00
lookup_key = _lookup_key_from_price(price)
2026-03-25 22:03:39 +01:00
except Exception:
lookup_key = ""
def _to_int(x: Any) -> Optional[int]:
try:
return int(x)
except Exception:
return None
if lookup_key and not allowed_users:
policy = _policy_for_lookup_key(lookup_key)
allowed_users = allowed_users or str(policy.allowed_users)
devices_per_user = devices_per_user or str(policy.devices_per_user)
2026-03-30 07:59:11 +02:00
if not lookup_key:
_log_event("license_skip", {
"event_id": event_id,
"reason": "missing_lookup_key",
"subscription_id": subscription_id,
})
2026-04-16 13:32:32 +02:00
generated_key = _upsert_license(
2026-03-25 22:03:39 +01:00
subscription_id=subscription_id,
customer_id=customer_id,
status=status,
lookup_key=lookup_key,
allowed_users=_to_int(allowed_users),
devices_per_user=_to_int(devices_per_user),
2026-03-30 07:59:11 +02:00
customer_email=customer_email,
2026-03-25 22:03:39 +01:00
client_reference_id=(client_reference_id or "").strip() or None,
current_period_end=_to_int(current_period_end),
)
2026-03-30 07:59:11 +02:00
_log_event("license_upsert", {
"event_id": event_id,
"etype": etype,
"subscription_id": subscription_id,
"status": status,
"lookup_key": lookup_key,
"email": customer_email,
"client_reference_id": client_reference_id,
2026-04-16 13:32:32 +02:00
"license_key": generated_key,
2026-03-30 07:59:11 +02:00
})
2026-03-25 22:03:39 +01:00
2026-04-16 13:32:32 +02:00
if customer_email and generated_key:
try:
send_license_email(customer_email, generated_key)
except Exception as mail_exc:
_log_event("license_email_failed", {
"event_id": event_id,
"email": customer_email,
"error": str(mail_exc),
})
2026-03-25 22:03:39 +01:00
elif etype in ("customer.subscription.updated", "customer.subscription.deleted"):
subscription_id = obj.get("id")
customer_id = obj.get("customer")
status = obj.get("status", "") or ("canceled" if etype.endswith("deleted") else "")
2026-04-16 13:32:32 +02:00
if status == "active" and obj.get("cancel_at_period_end"):
status = "canceled"
2026-03-25 22:03:39 +01:00
current_period_end = obj.get("current_period_end")
2026-04-16 13:32:32 +02:00
if not current_period_end:
try:
items_data = (obj.get("items") or {}).get("data") or []
if items_data:
current_period_end = items_data[0].get("current_period_end")
except Exception:
current_period_end = None
2026-03-25 22:03:39 +01:00
md = obj.get("metadata") or {}
lookup_key = (md.get("lookup_key") or "").strip()
allowed_users = md.get("allowed_users")
devices_per_user = md.get("devices_per_user")
if not lookup_key:
try:
items = obj.get("items") or {}
2026-03-30 07:59:11 +02:00
data = items.get("data") or []
2026-03-25 22:03:39 +01:00
if data:
price = data[0].get("price") or {}
2026-03-30 07:59:11 +02:00
lookup_key = _lookup_key_from_price(price)
2026-03-25 22:03:39 +01:00
except Exception:
lookup_key = ""
def _to_int(x: Any) -> Optional[int]:
try:
return int(x)
except Exception:
return None
2026-03-30 07:59:11 +02:00
if not subscription_id or not customer_id:
_log_event("license_skip", {
"event_id": event_id,
"reason": "missing_subscription_or_customer",
"etype": etype,
})
else:
2026-03-25 22:03:39 +01:00
_upsert_license(
subscription_id=subscription_id,
customer_id=customer_id,
status=status,
lookup_key=lookup_key,
allowed_users=_to_int(allowed_users),
devices_per_user=_to_int(devices_per_user),
customer_email=None,
client_reference_id=None,
current_period_end=_to_int(current_period_end),
)
2026-03-30 07:59:11 +02:00
_log_event("license_upsert", {
"event_id": event_id,
"etype": etype,
"subscription_id": subscription_id,
"status": status,
"lookup_key": lookup_key,
})
2026-03-25 22:03:39 +01:00
else:
2026-03-30 07:59:11 +02:00
_log_event("stripe_event_ignored", {"event_id": event_id, "type": etype})
# Only mark as processed AFTER successful handling.
# Failed events stay unmarked so Stripe can retry them.
if event_id:
_mark_processed(event_id)
2026-03-25 22:03:39 +01:00
except Exception as e:
2026-03-30 07:59:11 +02:00
_log_event("license_error", {"event_id": event_id, "etype": etype, "error": str(e)})
2026-03-25 22:03:39 +01:00
return JSONResponse({"ok": True})