Coverage for src/alprina_cli/api/services/polar_service.py: 20%
163 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
1"""
2Polar.sh Integration Service
4Handles Polar API interactions and webhook processing.
5"""
7import os
8import httpx
9import hmac
10import hashlib
11from typing import Dict, Any, Optional
12from datetime import datetime
13from loguru import logger
15# Polar API Configuration
16POLAR_API_URL = "https://api.polar.sh/v1"
17POLAR_API_TOKEN = os.getenv("POLAR_ACCESS_TOKEN") or os.getenv("POLAR_API_TOKEN")
18POLAR_WEBHOOK_SECRET = os.getenv("POLAR_WEBHOOK_SECRET")
21class PolarService:
22 """Service for Polar.sh payment platform integration."""
24 def __init__(self, api_token: str = POLAR_API_TOKEN):
25 self.api_token = api_token
26 self.api_url = POLAR_API_URL
27 self.headers = {
28 "Authorization": f"Bearer {self.api_token}",
29 "Content-Type": "application/json"
30 }
32 async def create_checkout_session(
33 self,
34 product_price_id: str,
35 success_url: str,
36 customer_email: Optional[str] = None,
37 customer_metadata: Optional[Dict[str, Any]] = None,
38 max_retries: int = 3
39 ) -> Dict[str, Any]:
40 """
41 Create a Polar checkout session using the custom checkouts API.
43 Args:
44 product_price_id: Polar product price ID
45 success_url: URL to redirect after success (use {CHECKOUT_ID} placeholder)
46 customer_email: Customer email (optional)
47 customer_metadata: Additional metadata
48 max_retries: Maximum number of retries for rate limiting
50 Returns:
51 Checkout session data with URL
52 """
53 import asyncio
55 # Build payload according to Polar API docs
56 payload = {
57 "product_price_id": product_price_id,
58 "success_url": success_url,
59 }
61 if customer_email:
62 payload["customer_email"] = customer_email
64 if customer_metadata:
65 payload["metadata"] = customer_metadata
67 last_error = None
68 for attempt in range(max_retries):
69 try:
70 async with httpx.AsyncClient() as client:
71 response = await client.post(
72 f"{self.api_url}/checkouts/", # Correct endpoint
73 headers=self.headers,
74 json=payload,
75 timeout=30.0
76 )
78 # Handle rate limiting (429)
79 if response.status_code == 429:
80 retry_after = int(response.headers.get('retry-after', '2'))
81 if attempt < max_retries - 1:
82 logger.warning(f"Rate limited by Polar, retrying in {retry_after}s (attempt {attempt + 1}/{max_retries})")
83 await asyncio.sleep(retry_after)
84 continue
85 else:
86 logger.error("Max retries reached for rate limiting")
87 raise Exception(f"Polar API rate limit exceeded. Please try again in {retry_after} seconds.")
89 # Log request and response for debugging
90 logger.info(f"Polar API request: POST {self.api_url}/checkouts/")
91 logger.info(f"Payload: {payload}")
92 logger.info(f"Response status: {response.status_code}")
94 response.raise_for_status()
95 result = response.json()
96 logger.info(f"Checkout session created: {result.get('id')}")
97 return result
99 except httpx.HTTPStatusError as e:
100 last_error = e
101 if e.response.status_code != 429:
102 error_body = e.response.text
103 logger.error(f"Polar API error: {e.response.status_code}")
104 logger.error(f"Response body: {error_body}")
105 logger.error(f"Request payload: {payload}")
106 raise Exception(f"Polar API error ({e.response.status_code}): {error_body}")
107 except Exception as e:
108 last_error = e
109 logger.error(f"Failed to create Polar checkout (attempt {attempt + 1}): {e}")
110 if attempt == max_retries - 1:
111 raise
112 await asyncio.sleep(1 * (attempt + 1)) # Exponential backoff
114 raise last_error or Exception("Failed to create checkout session after retries")
116 async def get_subscription(self, subscription_id: str) -> Dict[str, Any]:
117 """
118 Get subscription details from Polar.
120 Args:
121 subscription_id: Polar subscription ID
123 Returns:
124 Subscription data
125 """
126 try:
127 async with httpx.AsyncClient() as client:
128 response = await client.get(
129 f"{self.api_url}/subscriptions/{subscription_id}",
130 headers=self.headers
131 )
132 response.raise_for_status()
133 return response.json()
135 except Exception as e:
136 logger.error(f"Failed to get subscription: {e}")
137 raise
139 async def cancel_subscription(
140 self,
141 subscription_id: str,
142 at_period_end: bool = True
143 ) -> Dict[str, Any]:
144 """
145 Cancel a subscription.
147 Args:
148 subscription_id: Polar subscription ID
149 at_period_end: If True, cancel at end of period
151 Returns:
152 Updated subscription data
153 """
154 try:
155 async with httpx.AsyncClient() as client:
156 response = await client.post(
157 f"{self.api_url}/subscriptions/{subscription_id}/cancel",
158 headers=self.headers,
159 json={"at_period_end": at_period_end}
160 )
161 response.raise_for_status()
162 return response.json()
164 except Exception as e:
165 logger.error(f"Failed to cancel subscription: {e}")
166 raise
168 async def list_products(self) -> Dict[str, Any]:
169 """
170 List all available products.
172 Returns:
173 List of products
174 """
175 try:
176 async with httpx.AsyncClient() as client:
177 response = await client.get(
178 f"{self.api_url}/products",
179 headers=self.headers
180 )
181 response.raise_for_status()
182 return response.json()
184 except Exception as e:
185 logger.error(f"Failed to list products: {e}")
186 raise
188 def verify_webhook_signature(
189 self,
190 payload: bytes,
191 signature: str,
192 webhook_secret: Optional[str] = None
193 ) -> bool:
194 """
195 Verify Polar webhook signature.
197 Args:
198 payload: Raw webhook payload
199 signature: Signature from Polar-Signature header
200 webhook_secret: Webhook secret (optional)
202 Returns:
203 True if signature is valid
204 """
205 if not webhook_secret:
206 webhook_secret = POLAR_WEBHOOK_SECRET
208 if not webhook_secret:
209 logger.warning("Polar webhook secret not configured - skipping signature verification")
210 return True # Allow webhooks if secret not configured (dev mode)
212 try:
213 expected_signature = hmac.new(
214 webhook_secret.encode(),
215 payload,
216 hashlib.sha256
217 ).hexdigest()
219 return hmac.compare_digest(signature, expected_signature)
221 except Exception as e:
222 logger.error(f"Failed to verify webhook signature: {e}")
223 return False
225 async def process_webhook_event(self, event_data: Dict[str, Any]) -> Dict[str, Any]:
226 """
227 Process a Polar webhook event.
229 Args:
230 event_data: Webhook event data
232 Returns:
233 Processing result
234 """
235 event_type = event_data.get("type")
236 logger.info(f"Processing Polar webhook event: {event_type}")
238 try:
239 if event_type == "subscription.created":
240 return await self._handle_subscription_created(event_data)
241 elif event_type == "subscription.updated":
242 return await self._handle_subscription_updated(event_data)
243 elif event_type == "subscription.cancelled":
244 return await self._handle_subscription_cancelled(event_data)
245 elif event_type == "payment.succeeded":
246 return await self._handle_payment_succeeded(event_data)
247 elif event_type == "payment.failed":
248 return await self._handle_payment_failed(event_data)
249 else:
250 logger.warning(f"Unhandled webhook event type: {event_type}")
251 return {"status": "ignored", "event_type": event_type}
253 except Exception as e:
254 logger.error(f"Failed to process webhook event: {e}")
255 raise
257 async def _handle_subscription_created(self, event_data: Dict[str, Any]) -> Dict[str, Any]:
258 """Handle subscription.created event."""
259 subscription = event_data.get("data", {})
261 return {
262 "status": "processed",
263 "action": "subscription_created",
264 "subscription_id": subscription.get("id"),
265 "customer_id": subscription.get("customer_id"),
266 "product_id": subscription.get("product_id")
267 }
269 async def _handle_subscription_updated(self, event_data: Dict[str, Any]) -> Dict[str, Any]:
270 """Handle subscription.updated event."""
271 subscription = event_data.get("data", {})
273 return {
274 "status": "processed",
275 "action": "subscription_updated",
276 "subscription_id": subscription.get("id")
277 }
279 async def _handle_subscription_cancelled(self, event_data: Dict[str, Any]) -> Dict[str, Any]:
280 """Handle subscription.cancelled event."""
281 subscription = event_data.get("data", {})
283 return {
284 "status": "processed",
285 "action": "subscription_cancelled",
286 "subscription_id": subscription.get("id")
287 }
289 async def _handle_payment_succeeded(self, event_data: Dict[str, Any]) -> Dict[str, Any]:
290 """Handle payment.succeeded event."""
291 payment = event_data.get("data", {})
293 return {
294 "status": "processed",
295 "action": "payment_succeeded",
296 "payment_id": payment.get("id")
297 }
299 async def _handle_payment_failed(self, event_data: Dict[str, Any]) -> Dict[str, Any]:
300 """Handle payment.failed event."""
301 payment = event_data.get("data", {})
303 return {
304 "status": "processed",
305 "action": "payment_failed",
306 "payment_id": payment.get("id")
307 }
309 def get_tier_from_product(self, product_name: str) -> str:
310 """
311 Map Polar product name to Alprina tier.
313 Args:
314 product_name: Product name from Polar
316 Returns:
317 Tier name (developer, pro, enterprise)
318 """
319 product_lower = product_name.lower()
321 if "developer" in product_lower:
322 return "developer"
323 elif "pro" in product_lower:
324 return "pro"
325 elif "enterprise" in product_lower or "team" in product_lower:
326 return "team"
327 else:
328 return "none"
330 def get_tier_from_product_id(self, product_id: str) -> str:
331 """
332 Map Polar product ID to Alprina tier.
334 Args:
335 product_id: Product ID from Polar
337 Returns:
338 Tier name (developer, pro, team)
339 """
340 # Map of product IDs to tiers
341 PRODUCT_ID_MAP = {
342 # Monthly Plans (with metering)
343 "68443920-6061-434f-880d-83d4efd50fde": "developer", # Developer Monthly
344 "fa25e85e-5295-4dd5-bdd9-5cb5cac15a0b": "pro", # Pro Monthly
345 "41768ba5-f37d-417d-a10e-fb240b702cb6": "team", # Team Monthly
347 # Annual Plans (fixed price, no metering)
348 "e59df0ee-7287-4132-8edd-3b5fdf4a30f3": "developer", # Developer Annual
349 "eb0d9d5a-fceb-485d-aaae-36b50d8731f4": "pro", # Pro Annual
350 "2da941e8-450a-4498-a4a4-b3539456219e": "team", # Team Annual
351 }
353 return PRODUCT_ID_MAP.get(product_id, "none")
355 def get_tier_limits(self, tier: str) -> Dict[str, Any]:
356 """
357 Get usage limits for a tier.
359 Args:
360 tier: Tier name
362 Returns:
363 Dictionary with limits
364 """
365 limits = {
366 "free": {
367 "scans_per_month": 5,
368 "files_per_scan": 100,
369 "api_requests_per_hour": 10,
370 "parallel_scans": False,
371 "sequential_scans": False,
372 "coordinated_chains": False,
373 "advanced_reports": False
374 },
375 "developer": {
376 "scans_per_month": 100,
377 "files_per_scan": 500,
378 "api_requests_per_hour": 60,
379 "parallel_scans": False,
380 "sequential_scans": False,
381 "coordinated_chains": False,
382 "advanced_reports": False
383 },
384 "pro": {
385 "scans_per_month": None, # Unlimited
386 "files_per_scan": 5000,
387 "api_requests_per_hour": 300,
388 "parallel_scans": True,
389 "sequential_scans": True,
390 "coordinated_chains": True,
391 "advanced_reports": True
392 },
393 "enterprise": {
394 "scans_per_month": None, # Custom
395 "files_per_scan": None, # Unlimited
396 "api_requests_per_hour": None, # Unlimited
397 "parallel_scans": True,
398 "sequential_scans": True,
399 "coordinated_chains": True,
400 "advanced_reports": True
401 }
402 }
404 return limits.get(tier, limits["free"])
406 async def ingest_usage_event(
407 self,
408 customer_id: str,
409 event_name: str,
410 metadata: Dict[str, Any]
411 ) -> Dict[str, Any]:
412 """
413 Send usage event to Polar for billing.
415 Args:
416 customer_id: Polar customer ID or external user ID
417 event_name: Event type (security_scan, ai_analysis, etc.)
418 metadata: Additional event data
420 Returns:
421 API response
422 """
423 try:
424 async with httpx.AsyncClient() as client:
425 response = await client.post(
426 f"{self.api_url}/events/ingest",
427 headers=self.headers,
428 json={
429 "events": [{
430 "name": event_name,
431 "external_customer_id": customer_id,
432 "metadata": metadata
433 }]
434 },
435 timeout=10.0 # Don't block scan if Polar is slow
436 )
437 response.raise_for_status()
438 logger.info(f"Successfully ingested {event_name} event for customer {customer_id}")
439 return response.json()
441 except Exception as e:
442 logger.error(f"Failed to ingest usage event to Polar: {e}")
443 # Don't fail the scan if billing API fails
444 return {"status": "failed", "error": str(e)}
446 async def ingest_scan_usage(
447 self,
448 user_id: str,
449 scan_type: str,
450 workflow_mode: str,
451 files_scanned: int,
452 findings_count: int,
453 duration: float,
454 agent: str
455 ) -> None:
456 """
457 Track security scan usage.
459 Args:
460 user_id: User ID
461 scan_type: Type of scan
462 workflow_mode: Workflow mode used
463 files_scanned: Number of files
464 findings_count: Vulnerabilities found
465 duration: Scan duration in seconds
466 agent: Agent used
467 """
468 await self.ingest_usage_event(
469 customer_id=user_id,
470 event_name="security_scan",
471 metadata={
472 "scan_type": scan_type,
473 "workflow_mode": workflow_mode,
474 "files_scanned": files_scanned,
475 "findings_count": findings_count,
476 "duration_seconds": round(duration, 2),
477 "agent": agent,
478 "timestamp": datetime.utcnow().isoformat()
479 }
480 )
482 logger.info(
483 f"Tracked scan usage for user {user_id}: "
484 f"{workflow_mode} {scan_type} scan with {agent}"
485 )
487 async def ingest_ai_usage(
488 self,
489 user_id: str,
490 model: str,
491 input_tokens: int,
492 output_tokens: int,
493 agent: str,
494 analysis_type: str = "vulnerability_assessment"
495 ) -> None:
496 """
497 Track AI token usage.
499 Args:
500 user_id: User ID
501 model: LLM model used
502 input_tokens: Input tokens
503 output_tokens: Output tokens
504 agent: Agent that used AI
505 analysis_type: Type of analysis performed
506 """
507 total_tokens = input_tokens + output_tokens
509 await self.ingest_usage_event(
510 customer_id=user_id,
511 event_name="ai_analysis",
512 metadata={
513 "model": model,
514 "input_tokens": input_tokens,
515 "output_tokens": output_tokens,
516 "total_tokens": total_tokens,
517 "agent": agent,
518 "analysis_type": analysis_type,
519 "timestamp": datetime.utcnow().isoformat()
520 }
521 )
523 logger.info(
524 f"Tracked AI usage for user {user_id}: "
525 f"{total_tokens} tokens on {model} via {agent}"
526 )
529# Create singleton instance
530polar_service = PolarService()