wright 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. api/__init__.py +0 -0
  2. api/auth.py +165 -0
  3. api/chroma_cache.py +98 -0
  4. api/embedder.py +39 -0
  5. api/main.py +264 -0
  6. api/observability.py +74 -0
  7. api/quota.py +451 -0
  8. api/rate_limit.py +20 -0
  9. api/repo_store.py +67 -0
  10. api/routes/__init__.py +0 -0
  11. api/routes/auth.py +341 -0
  12. api/routes/billing.py +303 -0
  13. api/routes/chat.py +120 -0
  14. api/routes/coverage.py +120 -0
  15. api/routes/drift.py +593 -0
  16. api/routes/fix_pr.py +420 -0
  17. api/routes/generate.py +200 -0
  18. api/routes/internal.py +38 -0
  19. api/routes/llms_txt.py +79 -0
  20. api/routes/repos.py +854 -0
  21. api/routes/usage.py +19 -0
  22. api/routes/webhooks.py +156 -0
  23. api/tasks/__init__.py +0 -0
  24. api/tasks/email_tasks.py +413 -0
  25. api/tasks/ops_alerts.py +198 -0
  26. api/token_store.py +61 -0
  27. api/usage_store.py +215 -0
  28. api/user_store.py +182 -0
  29. cli/__init__.py +0 -0
  30. cli/main.py +1125 -0
  31. core/__init__.py +0 -0
  32. core/config.py +142 -0
  33. core/drift/__init__.py +0 -0
  34. core/drift/drift_detector.py +564 -0
  35. core/embeddings/__init__.py +0 -0
  36. core/embeddings/chroma_store.py +142 -0
  37. core/embeddings/pgvector_store.py +191 -0
  38. core/embeddings/voyage_embeddings.py +74 -0
  39. core/llm/__init__.py +0 -0
  40. core/llm/gateway.py +605 -0
  41. core/llm/graph.py +179 -0
  42. core/llm/prompts.py +450 -0
  43. core/llm/schema.py +31 -0
  44. core/output/__init__.py +0 -0
  45. core/output/injector.py +524 -0
  46. core/output/llms_txt.py +37 -0
  47. core/output/markdown_writer.py +96 -0
  48. core/output/openapi_gen.py +77 -0
  49. core/parser/__init__.py +0 -0
  50. core/parser/ast_chunker.py +131 -0
  51. core/parser/cache.py +480 -0
  52. core/parser/dep_graph.py +89 -0
  53. core/parser/tree_sitter_parser.py +1111 -0
  54. core/retrieval/__init__.py +0 -0
  55. core/retrieval/hybrid_retriever.py +368 -0
  56. mcp_server/__init__.py +0 -0
  57. mcp_server/server.py +374 -0
  58. wright-0.1.0.dist-info/METADATA +557 -0
  59. wright-0.1.0.dist-info/RECORD +64 -0
  60. wright-0.1.0.dist-info/WHEEL +5 -0
  61. wright-0.1.0.dist-info/entry_points.txt +4 -0
  62. wright-0.1.0.dist-info/licenses/LICENSE +661 -0
  63. wright-0.1.0.dist-info/licenses/LICENSE-COMMERCIAL.md +43 -0
  64. wright-0.1.0.dist-info/top_level.txt +4 -0
api/routes/auth.py ADDED
@@ -0,0 +1,341 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import os
5
+
6
+ import httpx
7
+ from workos import WorkOSClient
8
+ from fastapi import APIRouter, HTTPException, Request
9
+ from fastapi.responses import RedirectResponse
10
+ from pydantic import BaseModel
11
+
12
+ from api.token_store import load_token, save_token, user_id_from_api_key
13
+
14
+ router = APIRouter(prefix="/auth", tags=["auth"])
15
+
16
+ _workos_client: WorkOSClient | None = None
17
+
18
+
19
+ def _get_workos() -> WorkOSClient:
20
+ """
21
+ Retrieves or initializes a module-level singleton WorkOSClient instance using API credentials from environment variables.
22
+
23
+ Implements a lazy initialization pattern for a module-level WorkOSClient singleton. On the first call, reads WORKOS_API_KEY and WORKOS_CLIENT_ID from environment variables, validates that both are non-empty, and constructs the client. All subsequent calls return the already-initialized cached instance without re-reading environment variables. Called internally by the login() and callback() route handlers.
24
+
25
+ Returns:
26
+ WorkOSClient: The singleton WorkOSClient instance configured with the API key and client ID sourced from the WORKOS_API_KEY and WORKOS_CLIENT_ID environment variables.
27
+
28
+ Raises:
29
+ HTTPException: Raised with status_code=503 when either WORKOS_API_KEY or WORKOS_CLIENT_ID environment variables are not set or are empty strings.
30
+
31
+ Example:
32
+ ```
33
+ workos_client = _get_workos()
34
+ authorization_url = workos_client.sso.get_authorization_url(domain='example.com')
35
+ ```
36
+ """
37
+ global _workos_client
38
+ if _workos_client is None:
39
+ api_key = os.getenv("WORKOS_API_KEY", "")
40
+ client_id = os.getenv("WORKOS_CLIENT_ID", "")
41
+ if not api_key or not client_id:
42
+ raise HTTPException(status_code=503, detail="WorkOS not configured")
43
+ _workos_client = WorkOSClient(api_key=api_key, client_id=client_id)
44
+ return _workos_client
45
+
46
+
47
+ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
48
+
49
+
50
+ @router.get("/login")
51
+ async def login(
52
+ provider: str = "GoogleOAuth",
53
+ redirect_uri: str | None = None,
54
+ state: str | None = None,
55
+ ) -> RedirectResponse:
56
+ """
57
+ Initiates the OAuth login flow by redirecting the user to the authentication provider's authorization URL.
58
+
59
+ This async endpoint starts the OAuth authentication process using WorkOS user management. It constructs an authorization URL with the specified provider, redirect URI, and optional state, then returns a RedirectResponse to send the user to the provider's login page. If no redirect URI is provided, it defaults to '{FRONTEND_URL}/auth/callback'.
60
+
61
+ Args:
62
+ provider (str): The OAuth provider to use for authentication. Defaults to 'GoogleOAuth'.
63
+ redirect_uri (str | None): The URI to redirect to after successful authentication. If not provided, defaults to '{FRONTEND_URL}/auth/callback'.
64
+ state (str | None): An opaque value passed through the OAuth flow and echoed back in the callback, used to restore the user's intended destination after sign-in.
65
+
66
+ Returns:
67
+ RedirectResponse: A redirect response that sends the user's browser to the OAuth provider's authorization URL to complete the login process.
68
+
69
+ Example:
70
+ ```
71
+ response = await login(provider='GoogleOAuth', redirect_uri='https://example.com/auth/callback', state='/dashboard')
72
+ ```
73
+ """
74
+ uri = redirect_uri or f"{FRONTEND_URL}/auth/callback"
75
+ url = _get_workos().user_management.get_authorization_url(
76
+ provider=provider,
77
+ redirect_uri=uri,
78
+ state=state,
79
+ )
80
+ return RedirectResponse(url)
81
+
82
+
83
+ class CallbackRequest(BaseModel):
84
+ code: str
85
+
86
+
87
+ @router.post("/callback")
88
+ async def callback(body: CallbackRequest, redirect_uri: str | None = None) -> dict:
89
+ """
90
+ Handles OAuth callback by exchanging a WorkOS authorization code for user credentials and returning an API key with profile information.
91
+
92
+ Processes the OAuth callback request by exchanging the authorization code for user authentication data via WorkOS, creates or retrieves the user from the local database, and returns the user's API key along with basic profile information. If any step in the authentication or user lookup/creation process fails, a 401 HTTP exception is raised.
93
+
94
+ Args:
95
+ body (CallbackRequest): Request body containing the authorization code received from the OAuth flow to be exchanged for user authentication data.
96
+ redirect_uri (str | None): Optional redirect URI parameter passed as a query argument; currently unused in the function logic.
97
+
98
+ Returns:
99
+ dict: A dictionary containing 'api_key' (the user's API key string) and 'user' (a nested dict with 'id', 'email', and 'first_name' fields from the authenticated WorkOS user).
100
+
101
+ Raises:
102
+ HTTPException: Raised with a 401 status code when WorkOS authentication fails, the authorization code is invalid or expired, or an error occurs during user creation or retrieval.
103
+
104
+ Example:
105
+ ```
106
+ response = await callback(body=CallbackRequest(code='auth_code_abc123'), redirect_uri=None)
107
+ # Returns: {'api_key': 'usr_key_xyz', 'user': {'id': 'user_01ABC', 'email': 'jane@example.com', 'first_name': 'Jane'}}
108
+ ```
109
+ """
110
+ try:
111
+ from api.user_store import get_or_create_user
112
+
113
+ auth = _get_workos().user_management.authenticate_with_code(
114
+ code=body.code,
115
+ )
116
+ user = get_or_create_user(
117
+ workos_user_id=auth.user.id,
118
+ email=auth.user.email,
119
+ )
120
+ return {
121
+ "api_key": user.api_key,
122
+ "user": {
123
+ "id": auth.user.id,
124
+ "email": auth.user.email,
125
+ "first_name": auth.user.first_name,
126
+ },
127
+ }
128
+ except Exception as e:
129
+ raise HTTPException(status_code=401, detail=str(e))
130
+
131
+
132
+ @router.get("/callback")
133
+ async def callback_get(code: str, redirect_uri: str | None = None) -> dict:
134
+ """
135
+ Handles the OAuth callback GET request by delegating to the callback handler with the authorization code.
136
+
137
+ This endpoint receives the OAuth authorization code from the OAuth provider's redirect and processes it by creating a CallbackRequest object and invoking the main callback handler function. It serves as a GET endpoint wrapper around the POST callback logic, allowing OAuth providers that use GET redirects to be handled seamlessly.
138
+
139
+ Args:
140
+ code (str): The authorization code received from the OAuth provider after successful user authentication.
141
+ redirect_uri (str | None): Optional redirect URI parameter passed through to callback(); currently unused there too. Defaults to None.
142
+
143
+ Returns:
144
+ dict: A dictionary containing 'api_key' (the user's API key string) and 'user' (a nested dict with 'id', 'email', and 'first_name' fields from the authenticated WorkOS user) — the same shape returned by callback().
145
+
146
+ Example:
147
+ ```
148
+ result = await callback_get(code="AUTH_CODE_123", redirect_uri="https://example.com/callback")
149
+ ```
150
+ """
151
+ return await callback(CallbackRequest(code=code), redirect_uri=redirect_uri)
152
+
153
+
154
+ # ── GitHub OAuth for private repo access ─────────────────────────────────────
155
+
156
+ _GH_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID", "")
157
+ _GH_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET", "")
158
+
159
+
160
+ def _save_github_token(api_key: str, token: str) -> None:
161
+ """Persist the user's GitHub OAuth token, keyed by '_github_oauth'."""
162
+ save_token(user_id_from_api_key(api_key), "_github_oauth", token)
163
+
164
+
165
+ @router.get("/github")
166
+ async def github_login(request: Request) -> RedirectResponse:
167
+ """
168
+ Initiates the GitHub OAuth login flow by redirecting the user to GitHub's authorization page with the appropriate parameters.
169
+
170
+ Constructs a GitHub OAuth authorization URL with the client ID, repository scope, API key encoded as state, and callback redirect URI. The API key is extracted from the X-Wright-API-Key request header and base64-encoded to preserve it through the OAuth flow. Raises an HTTP 503 error if GitHub OAuth is not configured due to a missing client ID.
171
+
172
+ Args:
173
+ request (Request): The incoming HTTP request object containing headers with an optional X-Wright-API-Key value used as state in the OAuth flow.
174
+
175
+ Returns:
176
+ RedirectResponse: A redirect response pointing to GitHub's OAuth authorization URL, including client_id, scope (repo), base64-encoded state, and redirect_uri query parameters.
177
+
178
+ Raises:
179
+ HTTPException: Raised with status_code=503 when GitHub OAuth is not configured due to a missing _GH_CLIENT_ID environment variable.
180
+
181
+ Example:
182
+ ```
183
+ response = await github_login(request) # Redirects to https://github.com/login/oauth/authorize?client_id=...&scope=repo&state=...&redirect_uri=...
184
+ ```
185
+ """
186
+ if not _GH_CLIENT_ID:
187
+ raise HTTPException(status_code=503, detail="GitHub OAuth not configured")
188
+ api_key = request.headers.get("X-Wright-API-Key", "")
189
+ state = base64.urlsafe_b64encode(api_key.encode()).decode()
190
+ backend_url = os.getenv("BACKEND_URL", "https://api.wrightai.live")
191
+ redirect_uri = f"{backend_url}/auth/github/callback"
192
+ url = (
193
+ f"https://github.com/login/oauth/authorize"
194
+ f"?client_id={_GH_CLIENT_ID}"
195
+ f"&scope=repo"
196
+ f"&state={state}"
197
+ f"&redirect_uri={redirect_uri}"
198
+ )
199
+ return RedirectResponse(url)
200
+
201
+
202
+ @router.get("/github/callback")
203
+ async def github_callback(code: str, state: str = "") -> RedirectResponse:
204
+ """
205
+ Handles the GitHub OAuth callback by exchanging an authorization code for an access token and redirecting the user to the frontend dashboard.
206
+
207
+ This async endpoint is invoked by GitHub after the user completes OAuth authorization. It posts the received authorization code to GitHub's token endpoint to obtain an access token, optionally decodes the base64-encoded state parameter to extract an API key for associating the token with a user account, persists the token if an API key is present via _save_github_token, and finally issues a redirect to the frontend dashboard with a 'github=connected' query parameter.
208
+
209
+ Args:
210
+ code (str): The short-lived authorization code returned by GitHub after the user grants OAuth permission.
211
+ state (str): Optional base64-encoded API key passed through the OAuth flow; used to associate the resulting GitHub access token with the correct user account. Defaults to an empty string.
212
+
213
+ Returns:
214
+ RedirectResponse: An HTTP redirect response pointing to the frontend dashboard URL with the query parameter 'github=connected' appended.
215
+
216
+ Raises:
217
+ HTTPException: Raised with status 503 when GitHub OAuth is not configured (i.e., _GH_CLIENT_ID or _GH_CLIENT_SECRET are unset).
218
+ HTTPException: Raised with status 400 when GitHub's token endpoint does not return a valid access token, including the error description from GitHub in the detail message.
219
+
220
+ Example:
221
+ ```
222
+ response = await github_callback(code='gho_abc123xyz', state='YXBpX2tleV8xMjM=')
223
+ ```
224
+ """
225
+ if not _GH_CLIENT_ID or not _GH_CLIENT_SECRET:
226
+ raise HTTPException(status_code=503, detail="GitHub OAuth not configured")
227
+
228
+ async with httpx.AsyncClient() as client:
229
+ resp = await client.post(
230
+ "https://github.com/login/oauth/access_token",
231
+ headers={"Accept": "application/json"},
232
+ json={
233
+ "client_id": _GH_CLIENT_ID,
234
+ "client_secret": _GH_CLIENT_SECRET,
235
+ "code": code,
236
+ },
237
+ timeout=15,
238
+ )
239
+
240
+ data = resp.json()
241
+ token = data.get("access_token", "")
242
+ if not token:
243
+ raise HTTPException(
244
+ status_code=400, detail=f"GitHub OAuth failed: {data.get('error_description', data)}"
245
+ )
246
+
247
+ try:
248
+ api_key = base64.urlsafe_b64decode(state.encode()).decode()
249
+ except Exception:
250
+ api_key = ""
251
+
252
+ if api_key:
253
+ _save_github_token(api_key, token)
254
+
255
+ return RedirectResponse(f"{FRONTEND_URL}/dashboard?github=connected")
256
+
257
+
258
+ @router.get("/github/repos")
259
+ async def github_repos(request: Request) -> dict:
260
+ """
261
+ Fetches all GitHub repositories for the authenticated user by paginating through the GitHub API using the stored OAuth token.
262
+
263
+ Retrieves the GitHub OAuth token from the Supabase tokens table (looked up via the user id derived from the X-Wright-API-Key header), then iterates through paginated GitHub API responses to collect all repositories where the user is an owner or collaborator. Each repository entry includes its full name, privacy status, and clone URL. Returns an error indicator if no valid GitHub token is found.
264
+
265
+ Args:
266
+ request (Request): FastAPI Request object used to extract the X-Wright-API-Key header for identifying and authenticating the current user.
267
+
268
+ Returns:
269
+ dict: A dictionary with a 'repos' key mapping to a list of repository objects, each containing 'full_name' (str), 'private' (bool), and 'clone_url' (str). If GitHub is not connected or no token is found, also includes an 'error' key with the message 'GitHub not connected' and an empty 'repos' list.
270
+
271
+ Example:
272
+ ```
273
+ result = await github_repos(request)
274
+ # Returns: {'repos': [{'full_name': 'octocat/Hello-World', 'private': False, 'clone_url': 'https://github.com/octocat/Hello-World.git'}]}
275
+ # If GitHub not connected: {'repos': [], 'error': 'GitHub not connected'}
276
+ ```
277
+
278
+ Complexity: O(n) time where n is the total number of repositories across all pages, O(n) space to store the aggregated repository list
279
+ """
280
+ api_key = request.headers.get("X-Wright-API-Key", "")
281
+ token = load_token(user_id_from_api_key(api_key), "_github_oauth") or ""
282
+ if not token:
283
+ return {"repos": [], "error": "GitHub not connected"}
284
+
285
+ all_repos = []
286
+ page = 1
287
+ async with httpx.AsyncClient() as client:
288
+ while True:
289
+ resp = await client.get(
290
+ "https://api.github.com/user/repos",
291
+ headers={
292
+ "Authorization": f"token {token}",
293
+ "Accept": "application/vnd.github.v3+json",
294
+ },
295
+ params={
296
+ "per_page": 100,
297
+ "page": page,
298
+ "sort": "updated",
299
+ "affiliation": "owner,collaborator",
300
+ },
301
+ timeout=15,
302
+ )
303
+ if resp.status_code != 200:
304
+ break
305
+ batch = resp.json()
306
+ if not batch:
307
+ break
308
+ all_repos.extend(
309
+ {"full_name": r["full_name"], "private": r["private"], "clone_url": r["clone_url"]}
310
+ for r in batch
311
+ )
312
+ if len(batch) < 100:
313
+ break
314
+ page += 1
315
+
316
+ return {"repos": all_repos}
317
+
318
+
319
+ @router.get("/github/status")
320
+ async def github_status(request: Request) -> dict:
321
+ """
322
+ Checks whether a GitHub OAuth connection exists for the authenticated user by looking up their stored token in the Supabase tokens table.
323
+
324
+ Retrieves the user's API key from the X-Wright-API-Key request header and looks up the user's GitHub OAuth token via the tokens table. Returns a dictionary indicating the connection status.
325
+
326
+ Args:
327
+ request (Request): The incoming HTTP request object containing the X-Wright-API-Key header used to identify and authenticate the user.
328
+
329
+ Returns:
330
+ dict: A dictionary with a single key 'connected' whose value is True if a GitHub OAuth token row exists for the user, or False if no row exists or the lookup fails.
331
+
332
+ Example:
333
+ ```
334
+ status = await github_status(request)
335
+ # Returns: {'connected': True} if GitHub is linked, {'connected': False} otherwise
336
+ ```
337
+ """
338
+ api_key = request.headers.get("X-Wright-API-Key", "")
339
+ if load_token(user_id_from_api_key(api_key), "_github_oauth"):
340
+ return {"connected": True}
341
+ return {"connected": False}
api/routes/billing.py ADDED
@@ -0,0 +1,303 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import os
7
+
8
+ import httpx
9
+ from fastapi import APIRouter, Depends, HTTPException, Request
10
+ from fastapi.responses import JSONResponse
11
+ from pydantic import BaseModel
12
+
13
+ from api.auth import verify_api_key
14
+
15
+ router = APIRouter(prefix="/billing", tags=["billing"])
16
+
17
+ _PADDLE_API_URL = os.getenv("PADDLE_API_URL", "https://api.paddle.com")
18
+ _FRONTEND_URL = os.getenv("FRONTEND_URL", "https://www.wrightai.live")
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Paddle helpers
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def _headers() -> dict[str, str]:
27
+ key = os.getenv("PADDLE_API_KEY", "")
28
+ if not key:
29
+ raise HTTPException(status_code=503, detail="Paddle not configured")
30
+ return {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
31
+
32
+
33
+ def _db():
34
+ from api.user_store import _db as _get_db
35
+
36
+ return _get_db()
37
+
38
+
39
+ async def _get_or_create_paddle_customer(api_key: str) -> str:
40
+ result = (
41
+ _db().table("users").select("paddle_customer_id, email").eq("api_key", api_key).execute()
42
+ )
43
+ if not result.data:
44
+ raise HTTPException(status_code=401, detail="User not found")
45
+
46
+ row = result.data[0]
47
+ customer_id = row.get("paddle_customer_id")
48
+ if customer_id:
49
+ return customer_id
50
+
51
+ async with httpx.AsyncClient() as client:
52
+ resp = await client.post(
53
+ f"{_PADDLE_API_URL}/customers",
54
+ headers=_headers(),
55
+ json={"email": row["email"]},
56
+ timeout=10,
57
+ )
58
+ if not resp.is_success:
59
+ raise HTTPException(
60
+ status_code=502, detail=f"Failed to create Paddle customer: {resp.text}"
61
+ )
62
+
63
+ customer_id = resp.json()["data"]["id"]
64
+ _db().table("users").update({"paddle_customer_id": customer_id}).eq(
65
+ "api_key", api_key
66
+ ).execute()
67
+ return customer_id
68
+
69
+
70
+ def _get_paddle_price_id(plan_id: str, interval: str) -> str:
71
+ col = "paddle_price_id_annual" if interval == "annual" else "paddle_price_id_monthly"
72
+ result = _db().table("plans").select(col).eq("id", plan_id).execute()
73
+ if not result.data or not result.data[0].get(col):
74
+ raise HTTPException(
75
+ status_code=400,
76
+ detail=(
77
+ f"Paddle price ID not configured for plan '{plan_id}' ({interval}). "
78
+ "Add paddle_price_id_monthly / paddle_price_id_annual in the Supabase plans table."
79
+ ),
80
+ )
81
+ return result.data[0][col]
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Request schemas
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ class CheckoutRequest(BaseModel):
90
+ plan: str = "pro"
91
+ interval: str = "monthly"
92
+
93
+
94
+ class PortalRequest(BaseModel):
95
+ return_url: str = "https://www.wrightai.live/dashboard"
96
+
97
+
98
+ class SyncTransactionRequest(BaseModel):
99
+ transaction_id: str
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Routes
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ @router.post("/checkout", dependencies=[Depends(verify_api_key)])
108
+ async def create_checkout_session(body: CheckoutRequest, request: Request) -> dict:
109
+ """Create a Paddle transaction and return its ID for Paddle.js overlay checkout."""
110
+ api_key = request.headers.get("X-Wright-API-Key", "")
111
+ price_id = _get_paddle_price_id(body.plan, body.interval)
112
+
113
+ # Fetch user email to pre-fill the checkout form (no customer_id — avoids
114
+ # Paddle's customer verification flow which breaks the Paddle.js overlay)
115
+ user_result = _db().table("users").select("email").eq("api_key", api_key).execute()
116
+ if not user_result.data:
117
+ raise HTTPException(status_code=401, detail="User not found")
118
+ user_email = user_result.data[0].get("email", "")
119
+
120
+ async with httpx.AsyncClient() as client:
121
+ resp = await client.post(
122
+ f"{_PADDLE_API_URL}/transactions",
123
+ headers=_headers(),
124
+ json={
125
+ "items": [{"price_id": price_id, "quantity": 1}],
126
+ "customer": {"email": user_email},
127
+ "custom_data": {"api_key": api_key, "plan": body.plan},
128
+ },
129
+ timeout=15,
130
+ )
131
+ if not resp.is_success:
132
+ raise HTTPException(status_code=502, detail=f"Paddle error: {resp.text}")
133
+
134
+ data = resp.json()["data"]
135
+ checkout_url = (data.get("checkout") or {}).get("url", "")
136
+
137
+ return {"checkout_url": checkout_url, "transaction_id": data["id"]}
138
+
139
+
140
+ @router.post("/sync-transaction", dependencies=[Depends(verify_api_key)])
141
+ async def sync_transaction(body: SyncTransactionRequest, request: Request) -> dict:
142
+ """
143
+ Fallback for the Paddle webhook: fetch the transaction directly from Paddle
144
+ and apply the same plan upgrade. Called by the frontend right after
145
+ checkout.completed so the account upgrades even if no webhook is configured
146
+ or it hasn't arrived yet.
147
+ """
148
+ api_key = request.headers.get("X-Wright-API-Key", "")
149
+
150
+ async with httpx.AsyncClient() as client:
151
+ resp = await client.get(
152
+ f"{_PADDLE_API_URL}/transactions/{body.transaction_id}",
153
+ headers=_headers(),
154
+ timeout=10,
155
+ )
156
+ if not resp.is_success:
157
+ raise HTTPException(status_code=502, detail=f"Paddle error: {resp.text}")
158
+
159
+ data = resp.json()["data"]
160
+ custom_data = data.get("custom_data") or {}
161
+ if custom_data.get("api_key") != api_key:
162
+ raise HTTPException(status_code=403, detail="Transaction does not belong to this account")
163
+
164
+ if data.get("status") == "completed":
165
+ _handle_transaction_completed(data)
166
+
167
+ result = (
168
+ _db().table("users").select("plan, subscription_status").eq("api_key", api_key).execute()
169
+ )
170
+ return result.data[0] if result.data else {"plan": "free", "subscription_status": None}
171
+
172
+
173
+ @router.post("/portal", dependencies=[Depends(verify_api_key)])
174
+ async def create_billing_portal(body: PortalRequest, request: Request) -> dict:
175
+ """Generate a Paddle customer portal URL for subscription management."""
176
+ api_key = request.headers.get("X-Wright-API-Key", "")
177
+ result = _db().table("users").select("paddle_customer_id").eq("api_key", api_key).execute()
178
+
179
+ if not result.data or not result.data[0].get("paddle_customer_id"):
180
+ raise HTTPException(status_code=400, detail="No active subscription found")
181
+
182
+ customer_id = result.data[0]["paddle_customer_id"]
183
+ async with httpx.AsyncClient() as client:
184
+ resp = await client.post(
185
+ f"{_PADDLE_API_URL}/customers/{customer_id}/auth-token",
186
+ headers=_headers(),
187
+ timeout=10,
188
+ )
189
+ if not resp.is_success:
190
+ raise HTTPException(status_code=502, detail="Failed to create portal session")
191
+
192
+ auth_code = resp.json()["data"]["customer_auth_token"]
193
+ portal_url = (
194
+ f"https://customer.paddle.com/?customer_auth_code={auth_code}&return={body.return_url}"
195
+ )
196
+ return {"portal_url": portal_url}
197
+
198
+
199
+ @router.post("/webhook")
200
+ async def paddle_webhook(request: Request) -> JSONResponse:
201
+ """
202
+ Paddle webhook handler. Verifies signature then updates user plan in Supabase.
203
+
204
+ Events handled:
205
+ transaction.completed → upgrade user to paid plan
206
+ subscription.updated → sync status / plan changes
207
+ subscription.canceled → downgrade to free
208
+ """
209
+ payload = await request.body()
210
+ signature = request.headers.get("Paddle-Signature", "")
211
+ secret = os.getenv("PADDLE_WEBHOOK_SECRET", "")
212
+
213
+ if secret:
214
+ if not signature:
215
+ raise HTTPException(status_code=400, detail="Missing Paddle-Signature header")
216
+ parts = {}
217
+ for part in signature.split(";"):
218
+ if "=" in part:
219
+ k, v = part.split("=", 1)
220
+ parts[k] = v
221
+ ts = parts.get("ts", "")
222
+ h1 = parts.get("h1", "")
223
+ signed_payload = f"{ts}:{payload.decode()}"
224
+ expected = hmac.new(secret.encode(), signed_payload.encode(), hashlib.sha256).hexdigest()
225
+ if not hmac.compare_digest(expected, h1):
226
+ raise HTTPException(status_code=400, detail="Invalid Paddle signature")
227
+
228
+ try:
229
+ event = json.loads(payload)
230
+ except Exception:
231
+ raise HTTPException(status_code=400, detail="Invalid JSON payload")
232
+
233
+ event_type = event.get("event_type", "")
234
+ data = event.get("data", {})
235
+
236
+ if event_type == "transaction.completed":
237
+ _handle_transaction_completed(data)
238
+ elif event_type == "subscription.updated":
239
+ _handle_subscription_updated(data)
240
+ elif event_type == "subscription.canceled":
241
+ _handle_subscription_canceled(data)
242
+
243
+ return JSONResponse({"received": True})
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Webhook event handlers
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ def _handle_transaction_completed(data: dict) -> None:
252
+ custom_data = data.get("custom_data") or {}
253
+ api_key = custom_data.get("api_key", "")
254
+ plan_id = custom_data.get("plan", "pro")
255
+ subscription_id = data.get("subscription_id", "")
256
+ customer_id = data.get("customer_id", "")
257
+
258
+ if not api_key or not api_key.startswith("wai_"):
259
+ return
260
+
261
+ _db().table("users").update(
262
+ {
263
+ "plan": plan_id,
264
+ "paddle_customer_id": customer_id,
265
+ "paddle_subscription_id": subscription_id,
266
+ "subscription_status": "active",
267
+ }
268
+ ).eq("api_key", api_key).execute()
269
+
270
+
271
+ def _handle_subscription_updated(data: dict) -> None:
272
+ customer_id = data.get("customer_id", "")
273
+ if not customer_id:
274
+ return
275
+
276
+ status = data.get("status", "inactive")
277
+ custom_data = data.get("custom_data") or {}
278
+ plan_id = custom_data.get("plan", "pro")
279
+ next_billed = data.get("next_billed_at")
280
+
281
+ _db().table("users").update(
282
+ {
283
+ "plan": plan_id if status == "active" else "free",
284
+ "subscription_status": status,
285
+ "paddle_subscription_id": data.get("id", ""),
286
+ **({"current_period_end": next_billed} if next_billed else {}),
287
+ }
288
+ ).eq("paddle_customer_id", customer_id).execute()
289
+
290
+
291
+ def _handle_subscription_canceled(data: dict) -> None:
292
+ customer_id = data.get("customer_id", "")
293
+ if not customer_id:
294
+ return
295
+
296
+ _db().table("users").update(
297
+ {
298
+ "plan": "free",
299
+ "subscription_status": "cancelled",
300
+ "paddle_subscription_id": None,
301
+ "current_period_end": None,
302
+ }
303
+ ).eq("paddle_customer_id", customer_id).execute()