""" NeuraPrompt — Polar Subscription Checker (v2.0 — FIXED) ====================================================== This module fixes the 7 bugs that caused the previous Polar checker (in main.py) to deny access even after a user successfully subscribed. BUGS FIXED (vs main.py's verify_polar_subscription): 1. CRITICAL — Polar API returns {"result": [...]} not {"items": [...]}. The old code did .get("items", []) → always empty → always denied. Fixed: try "result" first, fall back to "items" for older API versions. 2. CRITICAL — `active=true` filter excluded trialing / past_due / unpaid subscriptions (users on free trial were denied). Fixed: fetch ALL subscriptions and accept status in {active, trialing, past_due} in-code. 3. Cache poisoning — old code cached `False` for 5 min after an empty-result success, so even post-webhook the user stayed denied. Fixed: only cache positive results (True). Negative results are re-checked on next call but throttled to 60s (not 5min) to avoid hammering Polar. 4. Webhook didn't invalidate cache. Fixed: clear_polar_cache() exported and called by the webhook handler on every event. 5. Webhook upsert `{$or: [{email}, {user_id: null}]}` matched unrelated docs. Fixed: explicit filter on email OR user_id only when non-empty. 6. Email normalization — added `+alias` stripping for Gmail-style aliases. 7. No diagnostics — old code returned a bool with no insight into WHY. Fixed: PolarCheckResult dataclass with matched_by, status, raw_response. NEW FEATURES: * check_polar_subscription() — returns PolarCheckResult (not just bool) * /polar/diagnose endpoint — returns the full Polar API response for a user so you can see exactly what Polar thinks of them (solves "why am I denied") * /polar/invalidate endpoint — clears the cache for a user (manual override) * Multi-strategy matching: customer_id → email → metadata.firebase_uid → Mongo * Fail-open on Polar outage (better to serve a paid user than deny them) POLAR PRODUCT CONFIG (REQUIRED for full functionality): See configure_polar_product() docstring at the bottom of this file for the exact metadata fields and webhook URL your Polar product needs. Env vars: POLAR_API_KEY — your Polar personal access token (read:subscriptions scope) POLAR_ORG_ID — your Polar organization ID POLAR_WEBHOOK_SECRET — webhook secret for signature verification """ from __future__ import annotations import os import re import time import hmac import json import hashlib import logging from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Optional, Dict, Any, List import httpx from fastapi import APIRouter, HTTPException, Request, Query from fastapi.responses import JSONResponse log = logging.getLogger("polar.subscription") # ───────────────────────────────────────────────────────────── # FIREBASE ADMIN (lazy init — reuses main.py's app if already initialized) # ───────────────────────────────────────────────────────────── # main.py initializes firebase_admin at startup with: # cred = credentials.Certificate("serviceAccountKey.json") # firebase_admin.initialize_app(cred) # We reuse that app via firebase_admin.get_app() — no double-init. _firebase_initialized = False _fb_auth = None def _init_firebase(): """Lazily initialize Firebase Admin SDK. Reuses main.py's app if present. Sets module-level _fb_auth so subsequent calls are fast.""" global _firebase_initialized, _fb_auth if _firebase_initialized: return _fb_auth is not None _firebase_initialized = True try: import firebase_admin from firebase_admin import auth as _auth # If main.py already initialized the default app, reuse it. try: firebase_admin.get_app() except ValueError: # No default app exists — initialize it ourselves. # The service account key must be at the same path main.py uses. key_path = os.getenv("FIREBASE_KEY_PATH", "serviceAccountKey.json") if not os.path.exists(key_path): log.warning(f"[Polar/Firebase] serviceAccountKey.json not found at {key_path} — Firebase email resolution disabled.") return False from firebase_admin import credentials cred = credentials.Certificate(key_path) firebase_admin.initialize_app(cred) log.info("[Polar/Firebase] Initialized Firebase Admin SDK.") _fb_auth = _auth log.info("[Polar/Firebase] Firebase Admin SDK ready.") return True except ImportError: log.warning("[Polar/Firebase] firebase_admin not installed — email resolution from UID disabled.") return False except Exception as e: log.error(f"[Polar/Firebase] init failed: {e}") return False def _resolve_firebase_email(user_id: str) -> str: """Resolve a Firebase UID → email using the Firebase Admin SDK. Returns '' on any failure (user not found, SDK unavailable, etc.). This is the missing piece that caused 'firebase user does not exist' errors — Kype was receiving a UID but couldn't look up the email to match against Polar.""" if not user_id or user_id == "anonymous": return "" if not _init_firebase(): return "" try: user = _fb_auth.get_user(user_id) email = user.email or "" log.debug(f"[Polar/Firebase] resolved uid={user_id} → email={email}") return email except Exception as e: log.warning(f"[Polar/Firebase] could not fetch user {user_id}: {e}") return "" # ───────────────────────────────────────────────────────────── # CONFIG # ───────────────────────────────────────────────────────────── POLAR_API_KEY = os.getenv("POLAR_API_KEY", "") POLAR_ORG_ID = os.getenv("POLAR_ORG_ID", "") POLAR_WEBHOOK_SECRET = os.getenv("POLAR_WEBHOOK_SECRET", "") POLAR_API_BASE = "https://api.polar.sh/v1" # Cache: cache_key → {subscribed: bool, exp: float, matched_by: str} # We cache POSITIVE results for 5 min (user is unlikely to unsubscribe mid-session). # We cache NEGATIVE results for only 60s (user might just have subscribed; # don't lock them out for 5 min after paying). _POS_CACHE_TTL = 300 # 5 min for subscribed=True _NEG_CACHE_TTL = 60 # 1 min for subscribed=False _polar_cache: Dict[str, dict] = {} # Statuses that grant access. `active` = paying, `trialing` = free trial, # `past_due` = payment failed but Polar still grants a grace period. # `unpaid` is excluded because Polar typically revokes access immediately. ACTIVE_STATUSES = {"active", "trialing", "past_due"} # ───────────────────────────────────────────────────────────── # RESULT TYPE # ───────────────────────────────────────────────────────────── @dataclass class PolarCheckResult: """Full result of a Polar subscription check. Bool is `.subscribed`.""" subscribed: bool matched_by: str = "" # "customer_id" | "email" | "firebase_uid" | "mongo_cache" | "fail_open" status: str = "" # Polar subscription status (active/trialing/canceled/...) plan: str = "" # Polar product/tier name if known polar_customer_id: str = "" subscription_id: str = "" error: str = "" # non-empty if Polar API failed checked_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) def to_dict(self) -> dict: return { "subscribed": self.subscribed, "matched_by": self.matched_by, "status": self.status, "plan": self.plan, "polar_customer_id": self.polar_customer_id, "subscription_id": self.subscription_id, "error": self.error, "checked_at": self.checked_at, } # ───────────────────────────────────────────────────────────── # HELPERS # ───────────────────────────────────────────────────────────── def _normalize_email(email: str) -> str: """Normalize email for matching: lowercase + strip Gmail-style +aliases. Polar normalizes emails server-side; we must match their normalization or email comparison fails (e.g. user+pro@gmail.com vs user@gmail.com).""" if not email: return "" e = email.strip().lower() # Strip +alias for gmail (Polar does this for gmail addresses) if "@gmail.com" in e or "@googlemail.com" in e: local, _, domain = e.partition("@") if "+" in local: local = local.split("+", 1)[0] e = f"{local}@{domain}" return e def _cache_key(email: str, firebase_uid: str) -> str: return f"{_normalize_email(email)}|{firebase_uid or ''}" def clear_polar_cache(email: str = "", firebase_uid: str = "") -> int: """Invalidate cached Polar results. Called by the webhook handler. If both args empty, clears the entire cache. Returns count cleared.""" if not email and not firebase_uid: n = len(_polar_cache) _polar_cache.clear() return n key = _cache_key(email, firebase_uid) if key in _polar_cache: del _polar_cache[key] return 1 # Also clear any cache entries that match just the email or just the uid cleared = 0 norm_email = _normalize_email(email) for k in list(_polar_cache.keys()): e_part, uid_part = k.split("|", 1) if (norm_email and e_part == norm_email) or (firebase_uid and uid_part == firebase_uid): del _polar_cache[k] cleared += 1 return cleared def _polar_headers() -> dict: if not POLAR_API_KEY: raise HTTPException(500, "POLAR_API_KEY not configured on the server.") return { "Authorization": f"Bearer {POLAR_API_KEY}", "Accept": "application/json", "User-Agent": "NeuraPrompt-Polar-Checker/2.0", } # ───────────────────────────────────────────────────────────── # POLAR API CALLS (FIXED) # ───────────────────────────────────────────────────────────── async def _polar_get_all_subscriptions(client: httpx.AsyncClient) -> List[dict]: """Fetch ALL subscriptions for the org, paginating if needed. Fixes Bug 1 (wrong key) and Bug 2 (active=true filter excluded trials). Returns the list of subscription objects. Raises on network/HTTP errors so the caller can fail-open. An empty list is returned ONLY when Polar confirms there are zero subscriptions (a valid 'user not subscribed' signal). This distinction is critical: a network error must NOT be confused with 'no subscriptions exist'.""" all_subs: List[dict] = [] page = 1 max_pages = 20 # safety cap — 20 pages × 50 = 1000 subscriptions max while page <= max_pages: # NOTE: NO `active=true` filter — we want ALL statuses and filter in-code. # This is Bug 2's fix: trialing and past_due users were being excluded. params = { "organization_id": POLAR_ORG_ID, "limit": 50, "page": page, } # Let exceptions propagate — the caller's try/except handles fail-open. # NOTE: trailing slash on /subscriptions/ — Polar 307-redirects the # slashless URL, and httpx does NOT follow redirects by default, # which caused "Expecting value: line 1 column 1 (char 0)" JSON errors. r = await client.get( f"{POLAR_API_BASE}/subscriptions/", params=params, headers=_polar_headers(), ) r.raise_for_status() # Safe JSON parse — Polar sometimes returns empty bodies on redirects # or 5xx errors, which crashes .json() with "Expecting value: line 1 # column 1 (char 0)". Treat non-JSON as an error. try: data = r.json() except Exception as json_err: raise RuntimeError( f"Polar returned non-JSON response (status={r.status_code}, " f"len={len(r.content)}): {json_err}" ) # Bug 1 fix: Polar uses "result" (current API), older used "items". page_items = data.get("result") or data.get("items") or [] all_subs.extend(page_items) # Check pagination pagination = data.get("pagination") or {} max_page = pagination.get("max_page", 1) if page >= max_page or len(page_items) < 50: break page += 1 return all_subs async def _polar_get_customer(client: httpx.AsyncClient, customer_id: str) -> Optional[dict]: """Fetch a single Polar customer by ID. Used for diagnostics.""" try: r = await client.get( f"{POLAR_API_BASE}/customers/{customer_id}", headers=_polar_headers(), ) if r.status_code == 404: return None r.raise_for_status() return r.json() except Exception as e: log.warning(f"[Polar] customer lookup {customer_id} failed: {e}") return None # ───────────────────────────────────────────────────────────── # MONGO FALLBACK (optional — pass None to disable) # ───────────────────────────────────────────────────────────── def _mongo_lookup_subscribed(subscriptions_col, firebase_uid: str, email: str) -> Optional[PolarCheckResult]: """Check MongoDB for a cached subscription record. Returns None if not found. Used as a fallback when Polar is unreachable, AND as a fast path when the webhook has already recorded the subscription.""" if subscriptions_col is None: return None try: # Try by user_id first (Firebase UID), then by email doc = subscriptions_col.find_one({"user_id": firebase_uid}) if firebase_uid else None if not doc and email: doc = subscriptions_col.find_one({"email": _normalize_email(email)}) if not doc: return None # Treat as subscribed only if status is active AND tier is paid if doc.get("status") == "active" and doc.get("tier") in ("pro", "max", "ultra", "premium"): return PolarCheckResult( subscribed=True, matched_by="mongo_cache", status=doc.get("status", ""), plan=doc.get("tier", ""), polar_customer_id=doc.get("polar_customer_id", ""), subscription_id=doc.get("polar_subscription_id", ""), ) return PolarCheckResult( subscribed=False, matched_by="mongo_cache", status=doc.get("status", "unknown"), plan=doc.get("tier", ""), ) except Exception as e: log.warning(f"[Polar] Mongo fallback failed: {e}") return None def _mongo_upsert_subscribed(subscriptions_col, firebase_uid: str, email: str, result: PolarCheckResult) -> None: """Write the Polar check result to MongoDB so the webhook + future checks can use it as a fast path.""" if subscriptions_col is None or not (firebase_uid or email): return try: # Build a filter that matches by user_id OR email — but only if non-empty. # This fixes Bug 6: the old `{$or: [{email}, {user_id: null}]}` matched # unrelated docs when user_id was None. filter_doc: dict = {} if firebase_uid: filter_doc = {"user_id": firebase_uid} elif email: filter_doc = {"email": _normalize_email(email)} update_doc = { "user_id": firebase_uid, "email": _normalize_email(email), "tier": "pro" if result.subscribed else "free", "status": result.status or ("active" if result.subscribed else "inactive"), "polar_customer_id": result.polar_customer_id, "polar_subscription_id": result.subscription_id, "polar_verified": result.subscribed, "last_verified": datetime.now(timezone.utc), } subscriptions_col.update_one(filter_doc, {"$set": update_doc}, upsert=True) except Exception as e: log.warning(f"[Polar] Mongo upsert failed: {e}") def _is_auth_error(exc: Exception) -> bool: """True if the exception is an HTTP 401/403 from Polar. These are CONFIGURATION errors (bad API key, wrong org) — NOT outages. On auth errors we MUST fail CLOSED (deny access). Failing open here was the security breach that let anonymous users burn AI tokens.""" if exc is None: return False # httpx raises HTTPStatusError on 4xx/5xx after raise_for_status() try: import httpx as _httpx if isinstance(exc, _httpx.HTTPStatusError): return exc.response.status_code in (401, 403) except Exception: pass # Check the string representation as a fallback (covers wrapped exceptions) msg = str(exc).lower() if "401 unauthorized" in msg or "403 forbidden" in msg: return True return False def _is_network_error(exc: Exception) -> bool: """True if the exception is a network/timeout error (Polar truly unreachable). These are the ONLY cases where fail-open is acceptable — and only when fail_open_on_outage=True AND a Mongo record confirms prior subscription.""" if exc is None: return False try: import httpx as _httpx if isinstance(exc, (_httpx.ConnectError, _httpx.TimeoutException, _httpx.NetworkError, _httpx.RemoteProtocolError)): return True except Exception: pass msg = str(exc).lower() return any(s in msg for s in ("timeout", "connection refused", "connection reset", "name resolution", "temporarily unavailable", "connect error", "network")) # ───────────────────────────────────────────────────────────── # MAIN CHECKER (FIXED v2.1 — fail-closed on auth errors, Firebase-resolved email) # ───────────────────────────────────────────────────────────── async def check_polar_subscription( email: str = "", firebase_uid: str = "", subscriptions_col=None, fail_open_on_outage: bool = False, # CHANGED: default False (security first) ) -> PolarCheckResult: """ Check if the user has an active Polar subscription. SECURITY MODEL (v2.1 — fixes the breach): * Default fail_open_on_outage=False — DENY on any Polar error. * Auth errors (401/403) ALWAYS deny, regardless of fail_open_on_outage. A 401 means the API key is wrong — that's a config error, NOT an outage. Granting access here was the breach that burned tokens. * Network errors (timeout, connection refused) are the ONLY fail-open case, and ONLY when fail_open_on_outage=True AND Mongo has a prior active record. * Missing POLAR_API_KEY/POLAR_ORG_ID → ALWAYS deny (config incomplete). * Missing user_id AND email → ALWAYS deny (can't identify the user). Strategies (in order): 1. Cache (positive for 5 min, negative for 1 min) 2. Polar `/subscriptions` API — match by email, then by metadata.firebase_uid 3. MongoDB cache (fast path for webhook-recorded subs) 4. Fail-open ONLY on confirmed network outage + Mongo prior record Args: email: user's email (if empty, resolved from firebase_uid via Firebase) firebase_uid: Firebase UID — matched against Polar customer.metadata.firebase_uid subscriptions_col: optional pymongo Collection for the fallback + upsert fail_open_on_outage: if True, grant access during Polar NETWORK outages (NOT auth errors). Requires a prior Mongo active record. Default False — security first. Returns: PolarCheckResult (use .subscribed for the bool) """ # ---- 0. SECURITY GATE: must have either a UID or email ---- if not firebase_uid and not email: return PolarCheckResult( subscribed=False, matched_by="no_identity", error="No user_id or email provided — cannot verify subscription.", ) if firebase_uid in ("anonymous", "anon", "guest", "") and not email: return PolarCheckResult( subscribed=False, matched_by="anonymous_user", error="Anonymous users cannot access Kype. Sign in required.", ) # ---- 0b. SECURITY GATE: Polar must be configured ---- if not POLAR_API_KEY or not POLAR_ORG_ID: log.error("[Polar] POLAR_API_KEY or POLAR_ORG_ID not configured — DENYING (not failing open).") return PolarCheckResult( subscribed=False, matched_by="polar_not_configured", error="Polar API not configured on the server.", ) # ---- 0c. Auto-resolve email from Firebase UID if not provided ---- # This is the missing piece that caused 'firebase user does not exist' errors. # Kype receives a UID from the client; we look up the email via Firebase Admin SDK. if not email and firebase_uid: email = _resolve_firebase_email(firebase_uid) if email: log.info(f"[Polar] Resolved email from Firebase: uid={firebase_uid} → {email}") else: log.warning(f"[Polar] Could not resolve email for uid={firebase_uid} via Firebase — proceeding with UID-only match.") email = _normalize_email(email) cache_k = _cache_key(email, firebase_uid) # ---- 1. Cache check ---- cached = _polar_cache.get(cache_k) if cached and time.time() < cached["exp"]: return PolarCheckResult( subscribed=cached["subscribed"], matched_by=f"cache({cached.get('matched_by', '')})", status=cached.get("status", ""), plan=cached.get("plan", ""), polar_customer_id=cached.get("polar_customer_id", ""), subscription_id=cached.get("subscription_id", ""), ) # ---- 2. Polar API ---- polar_exc: Optional[Exception] = None try: async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: all_subs = await _polar_get_all_subscriptions(client) # Match by email OR metadata.firebase_uid for sub in all_subs: customer = sub.get("customer") or {} sub_status = sub.get("status", "") sub_email = _normalize_email(customer.get("email", "")) sub_meta = customer.get("metadata") or {} sub_uid = sub_meta.get("firebase_uid", "") matched = False matched_by = "" if email and sub_email and sub_email == email: matched = True matched_by = "email" elif firebase_uid and sub_uid and sub_uid == firebase_uid: matched = True matched_by = "firebase_uid" if matched: is_active = sub_status in ACTIVE_STATUSES result = PolarCheckResult( subscribed=is_active, matched_by=matched_by, status=sub_status, plan=(sub.get("product") or {}).get("name", "") if isinstance(sub.get("product"), dict) else str(sub.get("product_name", "")), polar_customer_id=customer.get("id", ""), subscription_id=sub.get("id", ""), ) ttl = _POS_CACHE_TTL if result.subscribed else _NEG_CACHE_TTL _polar_cache[cache_k] = { "subscribed": result.subscribed, "exp": time.time() + ttl, "matched_by": result.matched_by, "status": result.status, "plan": result.plan, "polar_customer_id": result.polar_customer_id, "subscription_id": result.subscription_id, } _mongo_upsert_subscribed(subscriptions_col, firebase_uid, email, result) return result # No match in Polar — user has no subscription. result = PolarCheckResult( subscribed=False, matched_by="polar_no_match", status="none", ) _polar_cache[cache_k] = { "subscribed": False, "exp": time.time() + _NEG_CACHE_TTL, "matched_by": "polar_no_match", "status": "none", } _mongo_upsert_subscribed(subscriptions_col, firebase_uid, email, result) return result except Exception as e: polar_exc = e log.error(f"[Polar] API call failed: {e}") # ---- 3. MongoDB fallback (always checked on Polar failure) ---- mongo_result = _mongo_lookup_subscribed(subscriptions_col, firebase_uid, email) if mongo_result is not None: log.info(f"[Polar] Falling back to Mongo: subscribed={mongo_result.subscribed} matched_by={mongo_result.matched_by}") return mongo_result # ---- 4. Fail-open / fail-closed decision ---- # AUTH ERROR (401/403) → ALWAYS deny. This is the security fix. if _is_auth_error(polar_exc): log.error( f"[Polar] Auth error (401/403) — DENYING access. " f"Check POLAR_API_KEY validity. uid={firebase_uid} email={email}" ) return PolarCheckResult( subscribed=False, matched_by="polar_auth_error", error=f"Polar auth failed (API key invalid?): {polar_exc}", ) # NETWORK ERROR → fail-open ONLY if explicitly enabled AND Mongo has prior record if _is_network_error(polar_exc) and fail_open_on_outage: log.warning( f"[Polar] Polar network unreachable AND no Mongo record for " f"uid={firebase_uid} email={email} — failing OPEN (allowing access) " f"because fail_open_on_outage=True. Configure Polar correctly or set " f"fail_open_on_outage=False to deny." ) return PolarCheckResult( subscribed=True, matched_by="fail_open_network_outage", status="polar_unreachable", error="Polar API network unreachable — access granted as fail-open", ) # All other errors → DENY (security first) return PolarCheckResult( subscribed=False, matched_by="polar_error", error=f"Polar check failed: {polar_exc}", ) # Convenience wrapper for code that just needs the bool async def is_subscribed(email: str = "", firebase_uid: str = "", subscriptions_col=None) -> bool: """Simple bool wrapper around check_polar_subscription.""" result = await check_polar_subscription(email=email, firebase_uid=firebase_uid, subscriptions_col=subscriptions_col) return result.subscribed # ───────────────────────────────────────────────────────────── # WEBHOOK HANDLER (FIXED — clears cache on every event) # ───────────────────────────────────────────────────────────── async def handle_polar_webhook( request: Request, subscriptions_col=None, ) -> JSONResponse: """Secure Polar webhook handler. Verifies signature, updates Mongo, clears cache. Mount this at /webhooks/polar in your FastAPI app.""" try: body = await request.body() signature = request.headers.get("polar-signature", "") # Verify signature (Polar sends HMAC-SHA256 of the raw body) if POLAR_WEBHOOK_SECRET and signature: expected = hmac.new( POLAR_WEBHOOK_SECRET.encode(), body, hashlib.sha256, ).hexdigest() if not hmac.compare_digest(signature, expected): log.warning("[Polar Webhook] Invalid signature — rejecting.") return JSONResponse({"status": "invalid signature"}, status_code=401) elif POLAR_WEBHOOK_SECRET and not signature: log.warning("[Polar Webhook] Missing signature header — rejecting.") return JSONResponse({"status": "missing signature"}, status_code=401) payload = json.loads(body) event_type = payload.get("type", "") data = payload.get("data") or {} customer = data.get("customer") or {} email = customer.get("email", "") user_id = (customer.get("metadata") or {}).get("firebase_uid", "") sub_id = data.get("id", "") log.info(f"[Polar Webhook] event={event_type} email={email} uid={user_id} sub_id={sub_id}") # ---- CRITICAL: clear the cache so the user's next request re-checks ---- # This fixes Bug 5 — the old code didn't invalidate, so a user who just # subscribed was denied for up to 5 minutes (until the cached False expired). cleared = clear_polar_cache(email=email, firebase_uid=user_id) log.info(f"[Polar Webhook] Cleared {cleared} cache entries.") if subscriptions_col is not None and (email or user_id): # Build a non-empty filter — fixes Bug 6 (old $or with null user_id # could match unrelated docs). if user_id: filter_doc = {"user_id": user_id} else: filter_doc = {"email": _normalize_email(email)} if event_type == "subscription.created" or event_type == "subscription.updated": # Derive the tier from the Polar product name so a "Max" # product maps to tier "max" (1.5M tokens) and everything # else stays "pro" (1M tokens). _prod = data.get("product") or {} _prod_name = "" if isinstance(_prod, dict): _prod_name = str(_prod.get("name") or "") elif isinstance(_prod, str): _prod_name = _prod _tier = "max" if "max" in _prod_name.lower() else "pro" subscriptions_col.update_one( filter_doc, {"$set": { "user_id": user_id, "email": _normalize_email(email), "tier": _tier, "status": data.get("status", "active"), "polar_subscription_id": sub_id, "polar_customer_id": customer.get("id", ""), "polar_verified": True, "updated_at": datetime.now(timezone.utc), }}, upsert=True, ) log.info(f"[Polar Webhook] ✅ Activated: {email or user_id}") elif event_type == "subscription.canceled" or event_type == "subscription.revoked": subscriptions_col.update_one( filter_doc, {"$set": { "tier": "free", "status": "canceled", "polar_verified": False, "updated_at": datetime.now(timezone.utc), }}, ) log.info(f"[Polar Webhook] ⚠️ Canceled: {email or user_id}") return JSONResponse({"status": "ok"}) except json.JSONDecodeError as e: log.error(f"[Polar Webhook] JSON decode error: {e}") return JSONResponse({"status": "invalid json"}, status_code=400) except Exception as e: log.error(f"[Polar Webhook] Error: {e}", exc_info=True) return JSONResponse({"status": "error"}, status_code=500) # ───────────────────────────────────────────────────────────── # DIAGNOSTIC ROUTER # ───────────────────────────────────────────────────────────── polar_router = APIRouter(prefix="/polar") @polar_router.get("/diagnose") async def polar_diagnose(email: str = "", uid: str = ""): """Full diagnostic for a user's Polar status. Returns EVERYTHING: cache state, Polar API raw response (truncated), Mongo doc, and the check result. Use this to debug 'I subscribed but I'm still denied'.""" if not email and not uid: raise HTTPException(400, "Provide ?email= or ?uid=") # Clear cache first so we get a fresh check cleared = clear_polar_cache(email=email, firebase_uid=uid) out: dict = { "input": {"email": email, "firebase_uid": uid}, "normalized_email": _normalize_email(email), "cache_cleared": cleared, "config": { "POLAR_API_KEY_set": bool(POLAR_API_KEY), "POLAR_ORG_ID_set": bool(POLAR_ORG_ID), "POLAR_ORG_ID": POLAR_ORG_ID or "(not set)", }, } # Get the raw Polar subscriptions response (first page only — for diagnostics) raw_subs = [] polar_error = "" if POLAR_API_KEY and POLAR_ORG_ID: try: async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: r = await client.get( f"{POLAR_API_BASE}/subscriptions/", params={"organization_id": POLAR_ORG_ID, "limit": 50}, headers=_polar_headers(), ) out["polar_http_status"] = r.status_code if r.status_code == 200: data = r.json() # Show which key the API actually returns — this is the smoking # gun for Bug 1. out["polar_response_keys"] = list(data.keys()) raw_subs = data.get("result") or data.get("items") or [] else: out["polar_response_body"] = r.text[:500] polar_error = f"Polar returned HTTP {r.status_code}" except Exception as e: polar_error = f"Polar API exception: {e}" else: polar_error = "POLAR_API_KEY or POLAR_ORG_ID not set" out["polar_error"] = polar_error out["polar_total_subs_returned"] = len(raw_subs) # Find matching subscriptions in the raw response matches = [] norm_email = _normalize_email(email) for sub in raw_subs: customer = sub.get("customer") or {} sub_email = _normalize_email(customer.get("email", "")) sub_uid = (customer.get("metadata") or {}).get("firebase_uid", "") if (norm_email and sub_email == norm_email) or (uid and sub_uid == uid): matches.append({ "subscription_id": sub.get("id"), "status": sub.get("status"), "customer_email": customer.get("email"), "customer_id": customer.get("id"), "metadata": customer.get("metadata"), "product": (sub.get("product") or {}).get("name") if isinstance(sub.get("product"), dict) else None, "created_at": sub.get("created_at"), "current_period_end": sub.get("current_period_end"), }) out["matching_subscriptions"] = matches if not matches and raw_subs: # Show first 3 subs as a sample so we can see what emails ARE stored out["sample_subscriptions_for_debugging"] = [ { "customer_email": (s.get("customer") or {}).get("email"), "metadata": (s.get("customer") or {}).get("metadata"), "status": s.get("status"), } for s in raw_subs[:3] ] # Run the actual check out["check_result"] = (await check_polar_subscription( email=email, firebase_uid=uid, )).to_dict() return out @polar_router.post("/invalidate") async def polar_invalidate(email: str = "", uid: str = ""): """Manually clear the Polar cache for a user. Use after manually editing a subscription in the Polar dashboard.""" cleared = clear_polar_cache(email=email, firebase_uid=uid) return {"cleared_entries": cleared} @polar_router.get("/health") async def polar_health(): return { "status": "ok", "polar_api_key_set": bool(POLAR_API_KEY), "polar_org_id_set": bool(POLAR_ORG_ID), "webhook_secret_set": bool(POLAR_WEBHOOK_SECRET), "cache_size": len(_polar_cache), "active_statuses": list(ACTIVE_STATUSES), } # ───────────────────────────────────────────────────────────── # POLAR PRODUCT CONFIGURATION GUIDE # ───────────────────────────────────────────────────────────── def configure_polar_product() -> str: """Returns the configuration instructions for the user's Polar product. THIS IS WHAT YOU NEED TO EDIT ON YOUR POLAR PRODUCT so that when users sign in and subscribe, the subscription gets correctly linked to their Firebase account. Summary (full text below): 1. Set the webhook URL to: https:///webhooks/polar 2. Set webhook events: subscription.created, subscription.updated, subscription.canceled, subscription.revoked 3. Pass metadata.firebase_uid at checkout time (see code below) 4. Make sure POLAR_ORG_ID matches the org ID in your Polar dashboard """ return """ POLAR PRODUCT CONFIGURATION — REQUIRED STEPS ============================================ To make the Polar subscription checker work correctly (so users who subscribe are recognized), you must configure FOUR things on your Polar dashboard and in your frontend checkout flow. 1. WEBHOOK URL (Polar Dashboard → Settings → Webhooks) --------------------------------------------------- Add a webhook endpoint pointing to: https:///webhooks/polar Subscribe to these events: - subscription.created - subscription.updated - subscription.canceled - subscription.revoked Copy the Webhook Secret Polar gives you and set it as: POLAR_WEBHOOK_SECRET= This is CRITICAL — without the webhook, the server only finds out about a subscription when the user makes their NEXT API request (up to 5 min later due to caching). The webhook gives instant activation. 2. METADATA AT CHECKOUT (frontend code — REQUIRED) ------------------------------------------------ Polar only stores customer.metadata.firebase_uid if you PASS it when creating the checkout session. Without this, only email matching works — and email matching fails if the user's Polar email differs from their Firebase email. When your frontend redirects a user to Polar checkout, use the Polar Checkout API (NOT the hosted checkout link) so you can pass metadata: POST https://api.polar.sh/v1/checkouts/ Authorization: Bearer { "product_id": "", "customer_email": "", "metadata": { "firebase_uid": "", "source": "neuraprompt" }, "success_url": "https:///settings?upgrade=success", "customer_metadata": { "firebase_uid": "" } } The response contains a `checkout_url` — redirect the user there. After payment, Polar will: (a) store firebase_uid in the customer's metadata (permanent) (b) fire the subscription.created webhook to your server (c) the webhook handler writes the subscription to MongoDB (d) the user's next /kype/run request is granted instantly 3. POLAR_ORG_ID (server env var) ------------------------------ In your Polar dashboard, the organization ID is shown under Settings → Organization. It looks like: org_abc123def456 Set: POLAR_ORG_ID=org_abc123def456 If this is wrong, _polar_get_all_subscriptions returns subscriptions from a different org (or none) → everyone is denied. 4. POLAR_API_KEY (server env var) ------------------------------- Generate a personal access token in Polar → Settings → Personal Access Tokens. It needs the `subscriptions:read` scope (and `checkouts:write` if you use the checkout flow above). Set: POLAR_API_KEY=polar_pat_ 5. VERIFY IT WORKS ---------------- After subscribing once, hit: GET /polar/diagnose?email=&uid= The response shows: - polar_response_keys: should include "result" (NOT "items") - polar_total_subs_returned: should be > 0 - matching_subscriptions: should contain the user's sub - check_result.subscribed: should be true If matching_subscriptions is empty but polar_total_subs_returned > 0, the user's Polar email != their Firebase email AND no firebase_uid metadata was set at checkout. Fix by re-doing the checkout with metadata (step 2 above). """ if __name__ == "__main__": # Print the configuration guide when run standalone print(configure_polar_product())