Files
aza/APP/backup 24.2.26/stripe_routes.py

450 lines
16 KiB
Python
Raw Normal View History

2026-03-25 14:14:07 +01:00
from __future__ import annotations
import json
import os
import sqlite3
import time
from dataclasses import dataclass
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"])
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")
con.commit()
@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)
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:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
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],
) -> None:
now = int(time.time())
with sqlite3.connect(DB_PATH) as con:
con.execute(
"""
INSERT INTO licenses(
subscription_id, customer_id, status, lookup_key,
allowed_users, devices_per_user, customer_email, client_reference_id,
current_period_end, updated_at
)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(subscription_id) DO UPDATE SET
customer_id=excluded.customer_id,
status=excluded.status,
lookup_key=excluded.lookup_key,
allowed_users=excluded.allowed_users,
devices_per_user=excluded.devices_per_user,
customer_email=excluded.customer_email,
client_reference_id=excluded.client_reference_id,
current_period_end=excluded.current_period_end,
updated_at=excluded.updated_at
""",
(
subscription_id,
customer_id,
status,
lookup_key,
allowed_users,
devices_per_user,
customer_email,
client_reference_id,
current_period_end,
now,
),
)
con.commit()
@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)
@router.post("/sync_subscription")
async def sync_subscription() -> Dict[str, Any]:
_require_env()
_ensure_storage()
_init_stripe()
with sqlite3.connect(DB_PATH) as con:
row = con.execute(
"""
SELECT subscription_id
FROM licenses
WHERE status='active'
ORDER BY updated_at DESC
LIMIT 1
"""
).fetchone()
if not row or not row[0]:
raise HTTPException(status_code=404, detail="No active license found")
subscription_id = str(row[0])
sub = stripe.Subscription.retrieve(subscription_id)
status = (sub.get("status") or "").strip()
current_period_end = None
if "current_period_end" in sub and sub["current_period_end"]:
current_period_end = sub["current_period_end"]
else:
# Fallback für neuere API-Versionen
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,
}
@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:
event = stripe.Webhook.construct_event(payload=body, sig_header=stripe_signature, secret=STRIPE_WEBHOOK_SECRET)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Webhook signature verification failed: {e}")
event_id = event.get("id", "")
if event_id and _already_processed(event_id):
return JSONResponse({"ok": True, "duplicate": True})
etype = event.get("type")
obj = (event.get("data") or {}).get("object") or {}
# Persist license state from key lifecycle events
try:
if etype == "checkout.session.completed":
# We get subscription + customer from session
subscription_id = obj.get("subscription")
customer_id = obj.get("customer")
client_reference_id = obj.get("client_reference_id")
# Email can be in customer_details.email or customer_email
customer_email = None
cd = obj.get("customer_details") or {}
if isinstance(cd, dict):
customer_email = cd.get("email") or None
if not customer_email:
customer_email = obj.get("customer_email") or None
if subscription_id and customer_id:
sub = stripe.Subscription.retrieve(subscription_id, expand=["items.data.price"])
status = sub.get("status", "") or ""
current_period_end = sub.get("current_period_end")
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")
# Fallback: derive lookup_key from price
if not lookup_key:
try:
price = sub["items"]["data"][0]["price"]
lookup_key = (price.get("lookup_key") or "").strip()
except Exception:
lookup_key = ""
def _to_int(x: Any) -> Optional[int]:
try:
return int(x)
except Exception:
return None
_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=(customer_email or "").strip() or None,
client_reference_id=(client_reference_id or "").strip() or None,
current_period_end=_to_int(current_period_end),
)
_log_event(
"license_upsert",
{
"etype": etype,
"subscription_id": subscription_id,
"status": status,
"lookup_key": lookup_key,
"email": customer_email,
"client_reference_id": client_reference_id,
},
)
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 "")
current_period_end = obj.get("current_period_end")
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")
# Fallback: derive lookup_key from price on the subscription object if present
if not lookup_key:
try:
items = obj.get("items") or {}
data = (items.get("data") or [])
if data:
price = data[0].get("price") or {}
lookup_key = (price.get("lookup_key") or "").strip()
except Exception:
lookup_key = ""
# Email is not reliably present on subscription events; keep null (we store it on checkout completion).
def _to_int(x: Any) -> Optional[int]:
try:
return int(x)
except Exception:
return None
if subscription_id and customer_id:
_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),
)
_log_event(
"license_upsert",
{"etype": etype, "subscription_id": subscription_id, "status": status, "lookup_key": lookup_key},
)
else:
_log_event("stripe_event_ignored", {"type": etype})
except Exception as e:
# Keep webhook returning 200 for Stripe retries logic; log error for debugging
_log_event("license_error", {"etype": etype, "error": str(e)})
if event_id:
_mark_processed(event_id)
return JSONResponse({"ok": True})