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

1""" 

2Polar.sh Integration Service 

3 

4Handles Polar API interactions and webhook processing. 

5""" 

6 

7import os 

8import httpx 

9import hmac 

10import hashlib 

11from typing import Dict, Any, Optional 

12from datetime import datetime 

13from loguru import logger 

14 

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") 

19 

20 

21class PolarService: 

22 """Service for Polar.sh payment platform integration.""" 

23 

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 } 

31 

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. 

42 

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 

49 

50 Returns: 

51 Checkout session data with URL 

52 """ 

53 import asyncio 

54 

55 # Build payload according to Polar API docs 

56 payload = { 

57 "product_price_id": product_price_id, 

58 "success_url": success_url, 

59 } 

60 

61 if customer_email: 

62 payload["customer_email"] = customer_email 

63 

64 if customer_metadata: 

65 payload["metadata"] = customer_metadata 

66 

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 ) 

77 

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.") 

88 

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}") 

93 

94 response.raise_for_status() 

95 result = response.json() 

96 logger.info(f"Checkout session created: {result.get('id')}") 

97 return result 

98 

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 

113 

114 raise last_error or Exception("Failed to create checkout session after retries") 

115 

116 async def get_subscription(self, subscription_id: str) -> Dict[str, Any]: 

117 """ 

118 Get subscription details from Polar. 

119 

120 Args: 

121 subscription_id: Polar subscription ID 

122 

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() 

134 

135 except Exception as e: 

136 logger.error(f"Failed to get subscription: {e}") 

137 raise 

138 

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. 

146 

147 Args: 

148 subscription_id: Polar subscription ID 

149 at_period_end: If True, cancel at end of period 

150 

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() 

163 

164 except Exception as e: 

165 logger.error(f"Failed to cancel subscription: {e}") 

166 raise 

167 

168 async def list_products(self) -> Dict[str, Any]: 

169 """ 

170 List all available products. 

171 

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() 

183 

184 except Exception as e: 

185 logger.error(f"Failed to list products: {e}") 

186 raise 

187 

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. 

196 

197 Args: 

198 payload: Raw webhook payload 

199 signature: Signature from Polar-Signature header 

200 webhook_secret: Webhook secret (optional) 

201 

202 Returns: 

203 True if signature is valid 

204 """ 

205 if not webhook_secret: 

206 webhook_secret = POLAR_WEBHOOK_SECRET 

207 

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) 

211 

212 try: 

213 expected_signature = hmac.new( 

214 webhook_secret.encode(), 

215 payload, 

216 hashlib.sha256 

217 ).hexdigest() 

218 

219 return hmac.compare_digest(signature, expected_signature) 

220 

221 except Exception as e: 

222 logger.error(f"Failed to verify webhook signature: {e}") 

223 return False 

224 

225 async def process_webhook_event(self, event_data: Dict[str, Any]) -> Dict[str, Any]: 

226 """ 

227 Process a Polar webhook event. 

228 

229 Args: 

230 event_data: Webhook event data 

231 

232 Returns: 

233 Processing result 

234 """ 

235 event_type = event_data.get("type") 

236 logger.info(f"Processing Polar webhook event: {event_type}") 

237 

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} 

252 

253 except Exception as e: 

254 logger.error(f"Failed to process webhook event: {e}") 

255 raise 

256 

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", {}) 

260 

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 } 

268 

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", {}) 

272 

273 return { 

274 "status": "processed", 

275 "action": "subscription_updated", 

276 "subscription_id": subscription.get("id") 

277 } 

278 

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", {}) 

282 

283 return { 

284 "status": "processed", 

285 "action": "subscription_cancelled", 

286 "subscription_id": subscription.get("id") 

287 } 

288 

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", {}) 

292 

293 return { 

294 "status": "processed", 

295 "action": "payment_succeeded", 

296 "payment_id": payment.get("id") 

297 } 

298 

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", {}) 

302 

303 return { 

304 "status": "processed", 

305 "action": "payment_failed", 

306 "payment_id": payment.get("id") 

307 } 

308 

309 def get_tier_from_product(self, product_name: str) -> str: 

310 """ 

311 Map Polar product name to Alprina tier. 

312 

313 Args: 

314 product_name: Product name from Polar 

315 

316 Returns: 

317 Tier name (developer, pro, enterprise) 

318 """ 

319 product_lower = product_name.lower() 

320 

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" 

329 

330 def get_tier_from_product_id(self, product_id: str) -> str: 

331 """ 

332 Map Polar product ID to Alprina tier. 

333 

334 Args: 

335 product_id: Product ID from Polar 

336 

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 

346 

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 } 

352 

353 return PRODUCT_ID_MAP.get(product_id, "none") 

354 

355 def get_tier_limits(self, tier: str) -> Dict[str, Any]: 

356 """ 

357 Get usage limits for a tier. 

358 

359 Args: 

360 tier: Tier name 

361 

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 } 

403 

404 return limits.get(tier, limits["free"]) 

405 

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. 

414 

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 

419 

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() 

440 

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)} 

445 

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. 

458 

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 ) 

481 

482 logger.info( 

483 f"Tracked scan usage for user {user_id}: " 

484 f"{workflow_mode} {scan_type} scan with {agent}" 

485 ) 

486 

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. 

498 

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 

508 

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 ) 

522 

523 logger.info( 

524 f"Tracked AI usage for user {user_id}: " 

525 f"{total_tokens} tokens on {model} via {agent}" 

526 ) 

527 

528 

529# Create singleton instance 

530polar_service = PolarService()