DeepImagix commited on
Commit
f301738
Β·
verified Β·
1 Parent(s): ec25797

Upload polar_subscription.py

Browse files
Files changed (1) hide show
  1. polar_subscription.py +942 -0
polar_subscription.py ADDED
@@ -0,0 +1,942 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NeuraPrompt β€” Polar Subscription Checker (v2.0 β€” FIXED)
3
+ ======================================================
4
+
5
+ This module fixes the 7 bugs that caused the previous Polar checker (in main.py)
6
+ to deny access even after a user successfully subscribed.
7
+
8
+ BUGS FIXED (vs main.py's verify_polar_subscription):
9
+ 1. CRITICAL β€” Polar API returns {"result": [...]} not {"items": [...]}.
10
+ The old code did .get("items", []) β†’ always empty β†’ always denied.
11
+ Fixed: try "result" first, fall back to "items" for older API versions.
12
+ 2. CRITICAL β€” `active=true` filter excluded trialing / past_due / unpaid
13
+ subscriptions (users on free trial were denied). Fixed: fetch ALL
14
+ subscriptions and accept status in {active, trialing, past_due} in-code.
15
+ 3. Cache poisoning β€” old code cached `False` for 5 min after an empty-result
16
+ success, so even post-webhook the user stayed denied. Fixed: only cache
17
+ positive results (True). Negative results are re-checked on next call
18
+ but throttled to 60s (not 5min) to avoid hammering Polar.
19
+ 4. Webhook didn't invalidate cache. Fixed: clear_polar_cache() exported
20
+ and called by the webhook handler on every event.
21
+ 5. Webhook upsert `{$or: [{email}, {user_id: null}]}` matched unrelated
22
+ docs. Fixed: explicit filter on email OR user_id only when non-empty.
23
+ 6. Email normalization β€” added `+alias` stripping for Gmail-style aliases.
24
+ 7. No diagnostics β€” old code returned a bool with no insight into WHY.
25
+ Fixed: PolarCheckResult dataclass with matched_by, status, raw_response.
26
+
27
+ NEW FEATURES:
28
+ * check_polar_subscription() β€” returns PolarCheckResult (not just bool)
29
+ * /polar/diagnose endpoint β€” returns the full Polar API response for a user
30
+ so you can see exactly what Polar thinks of them (solves "why am I denied")
31
+ * /polar/invalidate endpoint β€” clears the cache for a user (manual override)
32
+ * Multi-strategy matching: customer_id β†’ email β†’ metadata.firebase_uid β†’ Mongo
33
+ * Fail-open on Polar outage (better to serve a paid user than deny them)
34
+
35
+ POLAR PRODUCT CONFIG (REQUIRED for full functionality):
36
+ See configure_polar_product() docstring at the bottom of this file for the
37
+ exact metadata fields and webhook URL your Polar product needs.
38
+
39
+ Env vars:
40
+ POLAR_API_KEY β€” your Polar personal access token (read:subscriptions scope)
41
+ POLAR_ORG_ID β€” your Polar organization ID
42
+ POLAR_WEBHOOK_SECRET β€” webhook secret for signature verification
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import os
48
+ import re
49
+ import time
50
+ import hmac
51
+ import json
52
+ import hashlib
53
+ import logging
54
+ from dataclasses import dataclass, field
55
+ from datetime import datetime, timezone
56
+ from typing import Optional, Dict, Any, List
57
+
58
+ import httpx
59
+ from fastapi import APIRouter, HTTPException, Request, Query
60
+ from fastapi.responses import JSONResponse
61
+
62
+ log = logging.getLogger("polar.subscription")
63
+
64
+ # ─────────────────────────────────────────────────────────────
65
+ # FIREBASE ADMIN (lazy init β€” reuses main.py's app if already initialized)
66
+ # ─────────────────────────────────────────────────────────────
67
+ # main.py initializes firebase_admin at startup with:
68
+ # cred = credentials.Certificate("serviceAccountKey.json")
69
+ # firebase_admin.initialize_app(cred)
70
+ # We reuse that app via firebase_admin.get_app() β€” no double-init.
71
+ _firebase_initialized = False
72
+ _fb_auth = None
73
+
74
+ def _init_firebase():
75
+ """Lazily initialize Firebase Admin SDK. Reuses main.py's app if present.
76
+ Sets module-level _fb_auth so subsequent calls are fast."""
77
+ global _firebase_initialized, _fb_auth
78
+ if _firebase_initialized:
79
+ return _fb_auth is not None
80
+ _firebase_initialized = True
81
+ try:
82
+ import firebase_admin
83
+ from firebase_admin import auth as _auth
84
+ # If main.py already initialized the default app, reuse it.
85
+ try:
86
+ firebase_admin.get_app()
87
+ except ValueError:
88
+ # No default app exists β€” initialize it ourselves.
89
+ # The service account key must be at the same path main.py uses.
90
+ key_path = os.getenv("FIREBASE_KEY_PATH", "serviceAccountKey.json")
91
+ if not os.path.exists(key_path):
92
+ log.warning(f"[Polar/Firebase] serviceAccountKey.json not found at {key_path} β€” Firebase email resolution disabled.")
93
+ return False
94
+ from firebase_admin import credentials
95
+ cred = credentials.Certificate(key_path)
96
+ firebase_admin.initialize_app(cred)
97
+ log.info("[Polar/Firebase] Initialized Firebase Admin SDK.")
98
+ _fb_auth = _auth
99
+ log.info("[Polar/Firebase] Firebase Admin SDK ready.")
100
+ return True
101
+ except ImportError:
102
+ log.warning("[Polar/Firebase] firebase_admin not installed β€” email resolution from UID disabled.")
103
+ return False
104
+ except Exception as e:
105
+ log.error(f"[Polar/Firebase] init failed: {e}")
106
+ return False
107
+
108
+
109
+ def _resolve_firebase_email(user_id: str) -> str:
110
+ """Resolve a Firebase UID β†’ email using the Firebase Admin SDK.
111
+ Returns '' on any failure (user not found, SDK unavailable, etc.).
112
+ This is the missing piece that caused 'firebase user does not exist' errors β€”
113
+ Kype was receiving a UID but couldn't look up the email to match against Polar."""
114
+ if not user_id or user_id == "anonymous":
115
+ return ""
116
+ if not _init_firebase():
117
+ return ""
118
+ try:
119
+ user = _fb_auth.get_user(user_id)
120
+ email = user.email or ""
121
+ log.debug(f"[Polar/Firebase] resolved uid={user_id} β†’ email={email}")
122
+ return email
123
+ except Exception as e:
124
+ log.warning(f"[Polar/Firebase] could not fetch user {user_id}: {e}")
125
+ return ""
126
+
127
+ # ─────────────────────────────────────────────────────────────
128
+ # CONFIG
129
+ # ─────────────────────────────────────────────────────────────
130
+ POLAR_API_KEY = os.getenv("POLAR_API_KEY", "")
131
+ POLAR_ORG_ID = os.getenv("POLAR_ORG_ID", "")
132
+ POLAR_WEBHOOK_SECRET = os.getenv("POLAR_WEBHOOK_SECRET", "")
133
+
134
+ POLAR_API_BASE = "https://api.polar.sh/v1"
135
+
136
+ # Cache: cache_key β†’ {subscribed: bool, exp: float, matched_by: str}
137
+ # We cache POSITIVE results for 5 min (user is unlikely to unsubscribe mid-session).
138
+ # We cache NEGATIVE results for only 60s (user might just have subscribed;
139
+ # don't lock them out for 5 min after paying).
140
+ _POS_CACHE_TTL = 300 # 5 min for subscribed=True
141
+ _NEG_CACHE_TTL = 60 # 1 min for subscribed=False
142
+ _polar_cache: Dict[str, dict] = {}
143
+
144
+ # Statuses that grant access. `active` = paying, `trialing` = free trial,
145
+ # `past_due` = payment failed but Polar still grants a grace period.
146
+ # `unpaid` is excluded because Polar typically revokes access immediately.
147
+ ACTIVE_STATUSES = {"active", "trialing", "past_due"}
148
+
149
+
150
+ # ─────────────────────────────────────────────────────────────
151
+ # RESULT TYPE
152
+ # ─────────────────────────────────────────────────────────────
153
+ @dataclass
154
+ class PolarCheckResult:
155
+ """Full result of a Polar subscription check. Bool is `.subscribed`."""
156
+ subscribed: bool
157
+ matched_by: str = "" # "customer_id" | "email" | "firebase_uid" | "mongo_cache" | "fail_open"
158
+ status: str = "" # Polar subscription status (active/trialing/canceled/...)
159
+ plan: str = "" # Polar product/tier name if known
160
+ polar_customer_id: str = ""
161
+ subscription_id: str = ""
162
+ error: str = "" # non-empty if Polar API failed
163
+ checked_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
164
+
165
+ def to_dict(self) -> dict:
166
+ return {
167
+ "subscribed": self.subscribed,
168
+ "matched_by": self.matched_by,
169
+ "status": self.status,
170
+ "plan": self.plan,
171
+ "polar_customer_id": self.polar_customer_id,
172
+ "subscription_id": self.subscription_id,
173
+ "error": self.error,
174
+ "checked_at": self.checked_at,
175
+ }
176
+
177
+
178
+ # ─────────────────────────────────────────────────────────────
179
+ # HELPERS
180
+ # ─────────────────────────────────────────────────────────────
181
+
182
+ def _normalize_email(email: str) -> str:
183
+ """Normalize email for matching: lowercase + strip Gmail-style +aliases.
184
+ Polar normalizes emails server-side; we must match their normalization
185
+ or email comparison fails (e.g. user+pro@gmail.com vs user@gmail.com)."""
186
+ if not email:
187
+ return ""
188
+ e = email.strip().lower()
189
+ # Strip +alias for gmail (Polar does this for gmail addresses)
190
+ if "@gmail.com" in e or "@googlemail.com" in e:
191
+ local, _, domain = e.partition("@")
192
+ if "+" in local:
193
+ local = local.split("+", 1)[0]
194
+ e = f"{local}@{domain}"
195
+ return e
196
+
197
+
198
+ def _cache_key(email: str, firebase_uid: str) -> str:
199
+ return f"{_normalize_email(email)}|{firebase_uid or ''}"
200
+
201
+
202
+ def clear_polar_cache(email: str = "", firebase_uid: str = "") -> int:
203
+ """Invalidate cached Polar results. Called by the webhook handler.
204
+ If both args empty, clears the entire cache. Returns count cleared."""
205
+ if not email and not firebase_uid:
206
+ n = len(_polar_cache)
207
+ _polar_cache.clear()
208
+ return n
209
+ key = _cache_key(email, firebase_uid)
210
+ if key in _polar_cache:
211
+ del _polar_cache[key]
212
+ return 1
213
+ # Also clear any cache entries that match just the email or just the uid
214
+ cleared = 0
215
+ norm_email = _normalize_email(email)
216
+ for k in list(_polar_cache.keys()):
217
+ e_part, uid_part = k.split("|", 1)
218
+ if (norm_email and e_part == norm_email) or (firebase_uid and uid_part == firebase_uid):
219
+ del _polar_cache[k]
220
+ cleared += 1
221
+ return cleared
222
+
223
+
224
+ def _polar_headers() -> dict:
225
+ if not POLAR_API_KEY:
226
+ raise HTTPException(500, "POLAR_API_KEY not configured on the server.")
227
+ return {
228
+ "Authorization": f"Bearer {POLAR_API_KEY}",
229
+ "Accept": "application/json",
230
+ "User-Agent": "NeuraPrompt-Polar-Checker/2.0",
231
+ }
232
+
233
+
234
+ # ─────────────────────────────────────────────────────────────
235
+ # POLAR API CALLS (FIXED)
236
+ # ─────────────────────────────────────────────────────────────
237
+
238
+ async def _polar_get_all_subscriptions(client: httpx.AsyncClient) -> List[dict]:
239
+ """Fetch ALL subscriptions for the org, paginating if needed.
240
+ Fixes Bug 1 (wrong key) and Bug 2 (active=true filter excluded trials).
241
+ Returns the list of subscription objects.
242
+
243
+ Raises on network/HTTP errors so the caller can fail-open. An empty list
244
+ is returned ONLY when Polar confirms there are zero subscriptions (a valid
245
+ 'user not subscribed' signal). This distinction is critical: a network
246
+ error must NOT be confused with 'no subscriptions exist'."""
247
+ all_subs: List[dict] = []
248
+ page = 1
249
+ max_pages = 20 # safety cap β€” 20 pages Γ— 50 = 1000 subscriptions max
250
+
251
+ while page <= max_pages:
252
+ # NOTE: NO `active=true` filter β€” we want ALL statuses and filter in-code.
253
+ # This is Bug 2's fix: trialing and past_due users were being excluded.
254
+ params = {
255
+ "organization_id": POLAR_ORG_ID,
256
+ "limit": 50,
257
+ "page": page,
258
+ }
259
+ # Let exceptions propagate β€” the caller's try/except handles fail-open.
260
+ # NOTE: trailing slash on /subscriptions/ β€” Polar 307-redirects the
261
+ # slashless URL, and httpx does NOT follow redirects by default,
262
+ # which caused "Expecting value: line 1 column 1 (char 0)" JSON errors.
263
+ r = await client.get(
264
+ f"{POLAR_API_BASE}/subscriptions/",
265
+ params=params,
266
+ headers=_polar_headers(),
267
+ )
268
+ r.raise_for_status()
269
+ # Safe JSON parse β€” Polar sometimes returns empty bodies on redirects
270
+ # or 5xx errors, which crashes .json() with "Expecting value: line 1
271
+ # column 1 (char 0)". Treat non-JSON as an error.
272
+ try:
273
+ data = r.json()
274
+ except Exception as json_err:
275
+ raise RuntimeError(
276
+ f"Polar returned non-JSON response (status={r.status_code}, "
277
+ f"len={len(r.content)}): {json_err}"
278
+ )
279
+
280
+ # Bug 1 fix: Polar uses "result" (current API), older used "items".
281
+ page_items = data.get("result") or data.get("items") or []
282
+ all_subs.extend(page_items)
283
+
284
+ # Check pagination
285
+ pagination = data.get("pagination") or {}
286
+ max_page = pagination.get("max_page", 1)
287
+ if page >= max_page or len(page_items) < 50:
288
+ break
289
+ page += 1
290
+
291
+ return all_subs
292
+
293
+
294
+ async def _polar_get_customer(client: httpx.AsyncClient, customer_id: str) -> Optional[dict]:
295
+ """Fetch a single Polar customer by ID. Used for diagnostics."""
296
+ try:
297
+ r = await client.get(
298
+ f"{POLAR_API_BASE}/customers/{customer_id}",
299
+ headers=_polar_headers(),
300
+ )
301
+ if r.status_code == 404:
302
+ return None
303
+ r.raise_for_status()
304
+ return r.json()
305
+ except Exception as e:
306
+ log.warning(f"[Polar] customer lookup {customer_id} failed: {e}")
307
+ return None
308
+
309
+
310
+ # ─────────────────────────────────────────────────────────────
311
+ # MONGO FALLBACK (optional β€” pass None to disable)
312
+ # ─────────────────────────────────────────────────────────────
313
+
314
+ def _mongo_lookup_subscribed(subscriptions_col, firebase_uid: str, email: str) -> Optional[PolarCheckResult]:
315
+ """Check MongoDB for a cached subscription record. Returns None if not found.
316
+ Used as a fallback when Polar is unreachable, AND as a fast path when the
317
+ webhook has already recorded the subscription."""
318
+ if subscriptions_col is None:
319
+ return None
320
+ try:
321
+ # Try by user_id first (Firebase UID), then by email
322
+ doc = subscriptions_col.find_one({"user_id": firebase_uid}) if firebase_uid else None
323
+ if not doc and email:
324
+ doc = subscriptions_col.find_one({"email": _normalize_email(email)})
325
+ if not doc:
326
+ return None
327
+ # Treat as subscribed only if status is active AND tier is paid
328
+ if doc.get("status") == "active" and doc.get("tier") in ("pro", "ultra", "premium"):
329
+ return PolarCheckResult(
330
+ subscribed=True,
331
+ matched_by="mongo_cache",
332
+ status=doc.get("status", ""),
333
+ plan=doc.get("tier", ""),
334
+ polar_customer_id=doc.get("polar_customer_id", ""),
335
+ subscription_id=doc.get("polar_subscription_id", ""),
336
+ )
337
+ return PolarCheckResult(
338
+ subscribed=False,
339
+ matched_by="mongo_cache",
340
+ status=doc.get("status", "unknown"),
341
+ plan=doc.get("tier", ""),
342
+ )
343
+ except Exception as e:
344
+ log.warning(f"[Polar] Mongo fallback failed: {e}")
345
+ return None
346
+
347
+
348
+ def _mongo_upsert_subscribed(subscriptions_col, firebase_uid: str, email: str,
349
+ result: PolarCheckResult) -> None:
350
+ """Write the Polar check result to MongoDB so the webhook + future checks
351
+ can use it as a fast path."""
352
+ if subscriptions_col is None or not (firebase_uid or email):
353
+ return
354
+ try:
355
+ # Build a filter that matches by user_id OR email β€” but only if non-empty.
356
+ # This fixes Bug 6: the old `{$or: [{email}, {user_id: null}]}` matched
357
+ # unrelated docs when user_id was None.
358
+ filter_doc: dict = {}
359
+ if firebase_uid:
360
+ filter_doc = {"user_id": firebase_uid}
361
+ elif email:
362
+ filter_doc = {"email": _normalize_email(email)}
363
+
364
+ update_doc = {
365
+ "user_id": firebase_uid,
366
+ "email": _normalize_email(email),
367
+ "tier": "pro" if result.subscribed else "free",
368
+ "status": result.status or ("active" if result.subscribed else "inactive"),
369
+ "polar_customer_id": result.polar_customer_id,
370
+ "polar_subscription_id": result.subscription_id,
371
+ "polar_verified": result.subscribed,
372
+ "last_verified": datetime.now(timezone.utc),
373
+ }
374
+ subscriptions_col.update_one(filter_doc, {"$set": update_doc}, upsert=True)
375
+ except Exception as e:
376
+ log.warning(f"[Polar] Mongo upsert failed: {e}")
377
+
378
+
379
+ def _is_auth_error(exc: Exception) -> bool:
380
+ """True if the exception is an HTTP 401/403 from Polar.
381
+ These are CONFIGURATION errors (bad API key, wrong org) β€” NOT outages.
382
+ On auth errors we MUST fail CLOSED (deny access). Failing open here was
383
+ the security breach that let anonymous users burn AI tokens."""
384
+ if exc is None:
385
+ return False
386
+ # httpx raises HTTPStatusError on 4xx/5xx after raise_for_status()
387
+ try:
388
+ import httpx as _httpx
389
+ if isinstance(exc, _httpx.HTTPStatusError):
390
+ return exc.response.status_code in (401, 403)
391
+ except Exception:
392
+ pass
393
+ # Check the string representation as a fallback (covers wrapped exceptions)
394
+ msg = str(exc).lower()
395
+ if "401 unauthorized" in msg or "403 forbidden" in msg:
396
+ return True
397
+ return False
398
+
399
+
400
+ def _is_network_error(exc: Exception) -> bool:
401
+ """True if the exception is a network/timeout error (Polar truly unreachable).
402
+ These are the ONLY cases where fail-open is acceptable β€” and only when
403
+ fail_open_on_outage=True AND a Mongo record confirms prior subscription."""
404
+ if exc is None:
405
+ return False
406
+ try:
407
+ import httpx as _httpx
408
+ if isinstance(exc, (_httpx.ConnectError, _httpx.TimeoutException,
409
+ _httpx.NetworkError, _httpx.RemoteProtocolError)):
410
+ return True
411
+ except Exception:
412
+ pass
413
+ msg = str(exc).lower()
414
+ return any(s in msg for s in ("timeout", "connection refused", "connection reset",
415
+ "name resolution", "temporarily unavailable",
416
+ "connect error", "network"))
417
+
418
+
419
+ # ─────────────────────────────────────────────────────────────
420
+ # MAIN CHECKER (FIXED v2.1 β€” fail-closed on auth errors, Firebase-resolved email)
421
+ # ─────────────────────────────────────────────────────────────
422
+
423
+ async def check_polar_subscription(
424
+ email: str = "",
425
+ firebase_uid: str = "",
426
+ subscriptions_col=None,
427
+ fail_open_on_outage: bool = False, # CHANGED: default False (security first)
428
+ ) -> PolarCheckResult:
429
+ """
430
+ Check if the user has an active Polar subscription.
431
+
432
+ SECURITY MODEL (v2.1 β€” fixes the breach):
433
+ * Default fail_open_on_outage=False β€” DENY on any Polar error.
434
+ * Auth errors (401/403) ALWAYS deny, regardless of fail_open_on_outage.
435
+ A 401 means the API key is wrong β€” that's a config error, NOT an outage.
436
+ Granting access here was the breach that burned tokens.
437
+ * Network errors (timeout, connection refused) are the ONLY fail-open case,
438
+ and ONLY when fail_open_on_outage=True AND Mongo has a prior active record.
439
+ * Missing POLAR_API_KEY/POLAR_ORG_ID β†’ ALWAYS deny (config incomplete).
440
+ * Missing user_id AND email β†’ ALWAYS deny (can't identify the user).
441
+
442
+ Strategies (in order):
443
+ 1. Cache (positive for 5 min, negative for 1 min)
444
+ 2. Polar `/subscriptions` API β€” match by email, then by metadata.firebase_uid
445
+ 3. MongoDB cache (fast path for webhook-recorded subs)
446
+ 4. Fail-open ONLY on confirmed network outage + Mongo prior record
447
+
448
+ Args:
449
+ email: user's email (if empty, resolved from firebase_uid via Firebase)
450
+ firebase_uid: Firebase UID β€” matched against Polar customer.metadata.firebase_uid
451
+ subscriptions_col: optional pymongo Collection for the fallback + upsert
452
+ fail_open_on_outage: if True, grant access during Polar NETWORK outages
453
+ (NOT auth errors). Requires a prior Mongo active record.
454
+ Default False β€” security first.
455
+
456
+ Returns: PolarCheckResult (use .subscribed for the bool)
457
+ """
458
+ # ---- 0. SECURITY GATE: must have either a UID or email ----
459
+ if not firebase_uid and not email:
460
+ return PolarCheckResult(
461
+ subscribed=False,
462
+ matched_by="no_identity",
463
+ error="No user_id or email provided β€” cannot verify subscription.",
464
+ )
465
+ if firebase_uid in ("anonymous", "anon", "guest", "") and not email:
466
+ return PolarCheckResult(
467
+ subscribed=False,
468
+ matched_by="anonymous_user",
469
+ error="Anonymous users cannot access Kype. Sign in required.",
470
+ )
471
+
472
+ # ---- 0b. SECURITY GATE: Polar must be configured ----
473
+ if not POLAR_API_KEY or not POLAR_ORG_ID:
474
+ log.error("[Polar] POLAR_API_KEY or POLAR_ORG_ID not configured β€” DENYING (not failing open).")
475
+ return PolarCheckResult(
476
+ subscribed=False,
477
+ matched_by="polar_not_configured",
478
+ error="Polar API not configured on the server.",
479
+ )
480
+
481
+ # ---- 0c. Auto-resolve email from Firebase UID if not provided ----
482
+ # This is the missing piece that caused 'firebase user does not exist' errors.
483
+ # Kype receives a UID from the client; we look up the email via Firebase Admin SDK.
484
+ if not email and firebase_uid:
485
+ email = _resolve_firebase_email(firebase_uid)
486
+ if email:
487
+ log.info(f"[Polar] Resolved email from Firebase: uid={firebase_uid} β†’ {email}")
488
+ else:
489
+ log.warning(f"[Polar] Could not resolve email for uid={firebase_uid} via Firebase β€” proceeding with UID-only match.")
490
+
491
+ email = _normalize_email(email)
492
+ cache_k = _cache_key(email, firebase_uid)
493
+
494
+ # ---- 1. Cache check ----
495
+ cached = _polar_cache.get(cache_k)
496
+ if cached and time.time() < cached["exp"]:
497
+ return PolarCheckResult(
498
+ subscribed=cached["subscribed"],
499
+ matched_by=f"cache({cached.get('matched_by', '')})",
500
+ status=cached.get("status", ""),
501
+ plan=cached.get("plan", ""),
502
+ polar_customer_id=cached.get("polar_customer_id", ""),
503
+ subscription_id=cached.get("subscription_id", ""),
504
+ )
505
+
506
+ # ---- 2. Polar API ----
507
+ polar_exc: Optional[Exception] = None
508
+ try:
509
+ async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
510
+ all_subs = await _polar_get_all_subscriptions(client)
511
+
512
+ # Match by email OR metadata.firebase_uid
513
+ for sub in all_subs:
514
+ customer = sub.get("customer") or {}
515
+ sub_status = sub.get("status", "")
516
+ sub_email = _normalize_email(customer.get("email", ""))
517
+ sub_meta = customer.get("metadata") or {}
518
+ sub_uid = sub_meta.get("firebase_uid", "")
519
+
520
+ matched = False
521
+ matched_by = ""
522
+ if email and sub_email and sub_email == email:
523
+ matched = True
524
+ matched_by = "email"
525
+ elif firebase_uid and sub_uid and sub_uid == firebase_uid:
526
+ matched = True
527
+ matched_by = "firebase_uid"
528
+
529
+ if matched:
530
+ is_active = sub_status in ACTIVE_STATUSES
531
+ result = PolarCheckResult(
532
+ subscribed=is_active,
533
+ matched_by=matched_by,
534
+ status=sub_status,
535
+ plan=(sub.get("product") or {}).get("name", "") if isinstance(sub.get("product"), dict) else str(sub.get("product_name", "")),
536
+ polar_customer_id=customer.get("id", ""),
537
+ subscription_id=sub.get("id", ""),
538
+ )
539
+ ttl = _POS_CACHE_TTL if result.subscribed else _NEG_CACHE_TTL
540
+ _polar_cache[cache_k] = {
541
+ "subscribed": result.subscribed,
542
+ "exp": time.time() + ttl,
543
+ "matched_by": result.matched_by,
544
+ "status": result.status,
545
+ "plan": result.plan,
546
+ "polar_customer_id": result.polar_customer_id,
547
+ "subscription_id": result.subscription_id,
548
+ }
549
+ _mongo_upsert_subscribed(subscriptions_col, firebase_uid, email, result)
550
+ return result
551
+
552
+ # No match in Polar β€” user has no subscription.
553
+ result = PolarCheckResult(
554
+ subscribed=False,
555
+ matched_by="polar_no_match",
556
+ status="none",
557
+ )
558
+ _polar_cache[cache_k] = {
559
+ "subscribed": False,
560
+ "exp": time.time() + _NEG_CACHE_TTL,
561
+ "matched_by": "polar_no_match",
562
+ "status": "none",
563
+ }
564
+ _mongo_upsert_subscribed(subscriptions_col, firebase_uid, email, result)
565
+ return result
566
+
567
+ except Exception as e:
568
+ polar_exc = e
569
+ log.error(f"[Polar] API call failed: {e}")
570
+
571
+ # ---- 3. MongoDB fallback (always checked on Polar failure) ----
572
+ mongo_result = _mongo_lookup_subscribed(subscriptions_col, firebase_uid, email)
573
+ if mongo_result is not None:
574
+ log.info(f"[Polar] Falling back to Mongo: subscribed={mongo_result.subscribed} matched_by={mongo_result.matched_by}")
575
+ return mongo_result
576
+
577
+ # ---- 4. Fail-open / fail-closed decision ----
578
+ # AUTH ERROR (401/403) β†’ ALWAYS deny. This is the security fix.
579
+ if _is_auth_error(polar_exc):
580
+ log.error(
581
+ f"[Polar] Auth error (401/403) β€” DENYING access. "
582
+ f"Check POLAR_API_KEY validity. uid={firebase_uid} email={email}"
583
+ )
584
+ return PolarCheckResult(
585
+ subscribed=False,
586
+ matched_by="polar_auth_error",
587
+ error=f"Polar auth failed (API key invalid?): {polar_exc}",
588
+ )
589
+
590
+ # NETWORK ERROR β†’ fail-open ONLY if explicitly enabled AND Mongo has prior record
591
+ if _is_network_error(polar_exc) and fail_open_on_outage:
592
+ log.warning(
593
+ f"[Polar] Polar network unreachable AND no Mongo record for "
594
+ f"uid={firebase_uid} email={email} β€” failing OPEN (allowing access) "
595
+ f"because fail_open_on_outage=True. Configure Polar correctly or set "
596
+ f"fail_open_on_outage=False to deny."
597
+ )
598
+ return PolarCheckResult(
599
+ subscribed=True,
600
+ matched_by="fail_open_network_outage",
601
+ status="polar_unreachable",
602
+ error="Polar API network unreachable β€” access granted as fail-open",
603
+ )
604
+
605
+ # All other errors β†’ DENY (security first)
606
+ return PolarCheckResult(
607
+ subscribed=False,
608
+ matched_by="polar_error",
609
+ error=f"Polar check failed: {polar_exc}",
610
+ )
611
+
612
+
613
+ # Convenience wrapper for code that just needs the bool
614
+ async def is_subscribed(email: str = "", firebase_uid: str = "", subscriptions_col=None) -> bool:
615
+ """Simple bool wrapper around check_polar_subscription."""
616
+ result = await check_polar_subscription(email=email, firebase_uid=firebase_uid, subscriptions_col=subscriptions_col)
617
+ return result.subscribed
618
+
619
+
620
+ # ─────────────────────────────────────────────────────────────
621
+ # WEBHOOK HANDLER (FIXED β€” clears cache on every event)
622
+ # ─────────────────────────────────────────────────────────────
623
+
624
+ async def handle_polar_webhook(
625
+ request: Request,
626
+ subscriptions_col=None,
627
+ ) -> JSONResponse:
628
+ """Secure Polar webhook handler. Verifies signature, updates Mongo, clears cache.
629
+ Mount this at /webhooks/polar in your FastAPI app."""
630
+ try:
631
+ body = await request.body()
632
+ signature = request.headers.get("polar-signature", "")
633
+
634
+ # Verify signature (Polar sends HMAC-SHA256 of the raw body)
635
+ if POLAR_WEBHOOK_SECRET and signature:
636
+ expected = hmac.new(
637
+ POLAR_WEBHOOK_SECRET.encode(),
638
+ body,
639
+ hashlib.sha256,
640
+ ).hexdigest()
641
+ if not hmac.compare_digest(signature, expected):
642
+ log.warning("[Polar Webhook] Invalid signature β€” rejecting.")
643
+ return JSONResponse({"status": "invalid signature"}, status_code=401)
644
+ elif POLAR_WEBHOOK_SECRET and not signature:
645
+ log.warning("[Polar Webhook] Missing signature header β€” rejecting.")
646
+ return JSONResponse({"status": "missing signature"}, status_code=401)
647
+
648
+ payload = json.loads(body)
649
+ event_type = payload.get("type", "")
650
+ data = payload.get("data") or {}
651
+ customer = data.get("customer") or {}
652
+ email = customer.get("email", "")
653
+ user_id = (customer.get("metadata") or {}).get("firebase_uid", "")
654
+ sub_id = data.get("id", "")
655
+
656
+ log.info(f"[Polar Webhook] event={event_type} email={email} uid={user_id} sub_id={sub_id}")
657
+
658
+ # ---- CRITICAL: clear the cache so the user's next request re-checks ----
659
+ # This fixes Bug 5 β€” the old code didn't invalidate, so a user who just
660
+ # subscribed was denied for up to 5 minutes (until the cached False expired).
661
+ cleared = clear_polar_cache(email=email, firebase_uid=user_id)
662
+ log.info(f"[Polar Webhook] Cleared {cleared} cache entries.")
663
+
664
+ if subscriptions_col is not None and (email or user_id):
665
+ # Build a non-empty filter β€” fixes Bug 6 (old $or with null user_id
666
+ # could match unrelated docs).
667
+ if user_id:
668
+ filter_doc = {"user_id": user_id}
669
+ else:
670
+ filter_doc = {"email": _normalize_email(email)}
671
+
672
+ if event_type == "subscription.created" or event_type == "subscription.updated":
673
+ subscriptions_col.update_one(
674
+ filter_doc,
675
+ {"$set": {
676
+ "user_id": user_id,
677
+ "email": _normalize_email(email),
678
+ "tier": "pro",
679
+ "status": data.get("status", "active"),
680
+ "polar_subscription_id": sub_id,
681
+ "polar_customer_id": customer.get("id", ""),
682
+ "polar_verified": True,
683
+ "updated_at": datetime.now(timezone.utc),
684
+ }},
685
+ upsert=True,
686
+ )
687
+ log.info(f"[Polar Webhook] βœ… Activated: {email or user_id}")
688
+
689
+ elif event_type == "subscription.canceled" or event_type == "subscription.revoked":
690
+ subscriptions_col.update_one(
691
+ filter_doc,
692
+ {"$set": {
693
+ "tier": "free",
694
+ "status": "canceled",
695
+ "polar_verified": False,
696
+ "updated_at": datetime.now(timezone.utc),
697
+ }},
698
+ )
699
+ log.info(f"[Polar Webhook] ⚠️ Canceled: {email or user_id}")
700
+
701
+ return JSONResponse({"status": "ok"})
702
+
703
+ except json.JSONDecodeError as e:
704
+ log.error(f"[Polar Webhook] JSON decode error: {e}")
705
+ return JSONResponse({"status": "invalid json"}, status_code=400)
706
+ except Exception as e:
707
+ log.error(f"[Polar Webhook] Error: {e}", exc_info=True)
708
+ return JSONResponse({"status": "error"}, status_code=500)
709
+
710
+
711
+ # ─────────────────────────────────────────────────────────────
712
+ # DIAGNOSTIC ROUTER
713
+ # ─────────────────────────────────────────────────────────────
714
+
715
+ polar_router = APIRouter(prefix="/polar")
716
+
717
+
718
+ @polar_router.get("/diagnose")
719
+ async def polar_diagnose(email: str = "", uid: str = ""):
720
+ """Full diagnostic for a user's Polar status. Returns EVERYTHING:
721
+ cache state, Polar API raw response (truncated), Mongo doc, and the
722
+ check result. Use this to debug 'I subscribed but I'm still denied'."""
723
+ if not email and not uid:
724
+ raise HTTPException(400, "Provide ?email= or ?uid=")
725
+
726
+ # Clear cache first so we get a fresh check
727
+ cleared = clear_polar_cache(email=email, firebase_uid=uid)
728
+
729
+ out: dict = {
730
+ "input": {"email": email, "firebase_uid": uid},
731
+ "normalized_email": _normalize_email(email),
732
+ "cache_cleared": cleared,
733
+ "config": {
734
+ "POLAR_API_KEY_set": bool(POLAR_API_KEY),
735
+ "POLAR_ORG_ID_set": bool(POLAR_ORG_ID),
736
+ "POLAR_ORG_ID": POLAR_ORG_ID or "(not set)",
737
+ },
738
+ }
739
+
740
+ # Get the raw Polar subscriptions response (first page only β€” for diagnostics)
741
+ raw_subs = []
742
+ polar_error = ""
743
+ if POLAR_API_KEY and POLAR_ORG_ID:
744
+ try:
745
+ async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
746
+ r = await client.get(
747
+ f"{POLAR_API_BASE}/subscriptions/",
748
+ params={"organization_id": POLAR_ORG_ID, "limit": 50},
749
+ headers=_polar_headers(),
750
+ )
751
+ out["polar_http_status"] = r.status_code
752
+ if r.status_code == 200:
753
+ data = r.json()
754
+ # Show which key the API actually returns β€” this is the smoking
755
+ # gun for Bug 1.
756
+ out["polar_response_keys"] = list(data.keys())
757
+ raw_subs = data.get("result") or data.get("items") or []
758
+ else:
759
+ out["polar_response_body"] = r.text[:500]
760
+ polar_error = f"Polar returned HTTP {r.status_code}"
761
+ except Exception as e:
762
+ polar_error = f"Polar API exception: {e}"
763
+ else:
764
+ polar_error = "POLAR_API_KEY or POLAR_ORG_ID not set"
765
+
766
+ out["polar_error"] = polar_error
767
+ out["polar_total_subs_returned"] = len(raw_subs)
768
+
769
+ # Find matching subscriptions in the raw response
770
+ matches = []
771
+ norm_email = _normalize_email(email)
772
+ for sub in raw_subs:
773
+ customer = sub.get("customer") or {}
774
+ sub_email = _normalize_email(customer.get("email", ""))
775
+ sub_uid = (customer.get("metadata") or {}).get("firebase_uid", "")
776
+ if (norm_email and sub_email == norm_email) or (uid and sub_uid == uid):
777
+ matches.append({
778
+ "subscription_id": sub.get("id"),
779
+ "status": sub.get("status"),
780
+ "customer_email": customer.get("email"),
781
+ "customer_id": customer.get("id"),
782
+ "metadata": customer.get("metadata"),
783
+ "product": (sub.get("product") or {}).get("name") if isinstance(sub.get("product"), dict) else None,
784
+ "created_at": sub.get("created_at"),
785
+ "current_period_end": sub.get("current_period_end"),
786
+ })
787
+ out["matching_subscriptions"] = matches
788
+
789
+ if not matches and raw_subs:
790
+ # Show first 3 subs as a sample so we can see what emails ARE stored
791
+ out["sample_subscriptions_for_debugging"] = [
792
+ {
793
+ "customer_email": (s.get("customer") or {}).get("email"),
794
+ "metadata": (s.get("customer") or {}).get("metadata"),
795
+ "status": s.get("status"),
796
+ }
797
+ for s in raw_subs[:3]
798
+ ]
799
+
800
+ # Run the actual check
801
+ out["check_result"] = (await check_polar_subscription(
802
+ email=email, firebase_uid=uid,
803
+ )).to_dict()
804
+
805
+ return out
806
+
807
+
808
+ @polar_router.post("/invalidate")
809
+ async def polar_invalidate(email: str = "", uid: str = ""):
810
+ """Manually clear the Polar cache for a user. Use after manually editing
811
+ a subscription in the Polar dashboard."""
812
+ cleared = clear_polar_cache(email=email, firebase_uid=uid)
813
+ return {"cleared_entries": cleared}
814
+
815
+
816
+ @polar_router.get("/health")
817
+ async def polar_health():
818
+ return {
819
+ "status": "ok",
820
+ "polar_api_key_set": bool(POLAR_API_KEY),
821
+ "polar_org_id_set": bool(POLAR_ORG_ID),
822
+ "webhook_secret_set": bool(POLAR_WEBHOOK_SECRET),
823
+ "cache_size": len(_polar_cache),
824
+ "active_statuses": list(ACTIVE_STATUSES),
825
+ }
826
+
827
+
828
+ # ─────────────────────────────────────────────────────────────
829
+ # POLAR PRODUCT CONFIGURATION GUIDE
830
+ # ─────────────────────────────────────────────────────────────
831
+
832
+ def configure_polar_product() -> str:
833
+ """Returns the configuration instructions for the user's Polar product.
834
+
835
+ THIS IS WHAT YOU NEED TO EDIT ON YOUR POLAR PRODUCT so that when users
836
+ sign in and subscribe, the subscription gets correctly linked to their
837
+ Firebase account.
838
+
839
+ Summary (full text below):
840
+ 1. Set the webhook URL to: https://<your-domain>/webhooks/polar
841
+ 2. Set webhook events: subscription.created, subscription.updated,
842
+ subscription.canceled, subscription.revoked
843
+ 3. Pass metadata.firebase_uid at checkout time (see code below)
844
+ 4. Make sure POLAR_ORG_ID matches the org ID in your Polar dashboard
845
+ """
846
+ return """
847
+ POLAR PRODUCT CONFIGURATION β€” REQUIRED STEPS
848
+ ============================================
849
+
850
+ To make the Polar subscription checker work correctly (so users who
851
+ subscribe are recognized), you must configure FOUR things on your Polar
852
+ dashboard and in your frontend checkout flow.
853
+
854
+ 1. WEBHOOK URL (Polar Dashboard β†’ Settings β†’ Webhooks)
855
+ ---------------------------------------------------
856
+ Add a webhook endpoint pointing to:
857
+ https://<your-domain>/webhooks/polar
858
+
859
+ Subscribe to these events:
860
+ - subscription.created
861
+ - subscription.updated
862
+ - subscription.canceled
863
+ - subscription.revoked
864
+
865
+ Copy the Webhook Secret Polar gives you and set it as:
866
+ POLAR_WEBHOOK_SECRET=<the secret>
867
+
868
+ This is CRITICAL β€” without the webhook, the server only finds out about
869
+ a subscription when the user makes their NEXT API request (up to 5 min
870
+ later due to caching). The webhook gives instant activation.
871
+
872
+ 2. METADATA AT CHECKOUT (frontend code β€” REQUIRED)
873
+ ------------------------------------------------
874
+ Polar only stores customer.metadata.firebase_uid if you PASS it when
875
+ creating the checkout session. Without this, only email matching works
876
+ β€” and email matching fails if the user's Polar email differs from
877
+ their Firebase email.
878
+
879
+ When your frontend redirects a user to Polar checkout, use the Polar
880
+ Checkout API (NOT the hosted checkout link) so you can pass metadata:
881
+
882
+ POST https://api.polar.sh/v1/checkouts/
883
+ Authorization: Bearer <POLAR_API_KEY>
884
+ {
885
+ "product_id": "<your-polar-product-id>",
886
+ "customer_email": "<user's-firebase-email>",
887
+ "metadata": {
888
+ "firebase_uid": "<user's-firebase-uid>",
889
+ "source": "neuraprompt"
890
+ },
891
+ "success_url": "https://<your-domain>/settings?upgrade=success",
892
+ "customer_metadata": {
893
+ "firebase_uid": "<user's-firebase-uid>"
894
+ }
895
+ }
896
+
897
+ The response contains a `checkout_url` β€” redirect the user there.
898
+ After payment, Polar will:
899
+ (a) store firebase_uid in the customer's metadata (permanent)
900
+ (b) fire the subscription.created webhook to your server
901
+ (c) the webhook handler writes the subscription to MongoDB
902
+ (d) the user's next /kype/run request is granted instantly
903
+
904
+ 3. POLAR_ORG_ID (server env var)
905
+ ------------------------------
906
+ In your Polar dashboard, the organization ID is shown under
907
+ Settings β†’ Organization. It looks like: org_abc123def456
908
+
909
+ Set: POLAR_ORG_ID=org_abc123def456
910
+
911
+ If this is wrong, _polar_get_all_subscriptions returns subscriptions
912
+ from a different org (or none) β†’ everyone is denied.
913
+
914
+ 4. POLAR_API_KEY (server env var)
915
+ -------------------------------
916
+ Generate a personal access token in Polar β†’ Settings β†’ Personal Access
917
+ Tokens. It needs the `subscriptions:read` scope (and `checkouts:write`
918
+ if you use the checkout flow above).
919
+
920
+ Set: POLAR_API_KEY=polar_pat_<the-token>
921
+
922
+ 5. VERIFY IT WORKS
923
+ ----------------
924
+ After subscribing once, hit:
925
+ GET /polar/diagnose?email=<user-email>&uid=<firebase-uid>
926
+
927
+ The response shows:
928
+ - polar_response_keys: should include "result" (NOT "items")
929
+ - polar_total_subs_returned: should be > 0
930
+ - matching_subscriptions: should contain the user's sub
931
+ - check_result.subscribed: should be true
932
+
933
+ If matching_subscriptions is empty but polar_total_subs_returned > 0,
934
+ the user's Polar email != their Firebase email AND no firebase_uid
935
+ metadata was set at checkout. Fix by re-doing the checkout with
936
+ metadata (step 2 above).
937
+ """
938
+
939
+
940
+ if __name__ == "__main__":
941
+ # Print the configuration guide when run standalone
942
+ print(configure_polar_product())