Coverage for src/alprina_cli/api/webhooks.py: 0%

117 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-14 11:27 +0100

1""" 

2Polar Webhook Handler 

3Processes subscription lifecycle events from Polar 

4""" 

5import os 

6import hmac 

7import hashlib 

8from typing import Dict, Any, Optional 

9from datetime import datetime, timedelta 

10from fastapi import Request, HTTPException, status 

11import logging 

12from alprina_cli.api.services.neon_service import NeonService 

13 

14logger = logging.getLogger(__name__) 

15 

16class PolarWebhookHandler: 

17 """Handle Polar webhook events for subscription management""" 

18 

19 WEBHOOK_SECRET = os.getenv("POLAR_WEBHOOK_SECRET") 

20 

21 # Polar Product IDs (from your configuration) 

22 # NOTE: No free plan - all plans include 7-day trial 

23 PRODUCT_IDS = { 

24 "developer": "68443920-6061-434f-880d-83d4efd50fde", 

25 "pro": "fa25e85e-5295-4dd5-bdd9-5cb5cac15a0b", 

26 "team": "41768ba5-f37d-417d-a10e-fb240b702cb6" 

27 } 

28 

29 # Tier configuration (all with 7-day trial) 

30 TIER_CONFIG = { 

31 "developer": {"scan_limit": 100, "seats": 1, "price": 29}, 

32 "pro": {"scan_limit": 500, "seats": 1, "price": 49}, 

33 "team": {"scan_limit": 2000, "seats": 5, "price": 99} 

34 } 

35 

36 def __init__(self): 

37 self.neon = NeonService() 

38 logger.info("Webhook handler initialized with Neon database") 

39 

40 def verify_signature(self, payload: bytes, signature: str) -> bool: 

41 """ 

42 Verify webhook signature from Polar. 

43 

44 Args: 

45 payload: Raw request body 

46 signature: Signature from X-Polar-Signature header 

47 

48 Returns: 

49 True if signature is valid 

50 """ 

51 if not self.WEBHOOK_SECRET: 

52 logger.warning("POLAR_WEBHOOK_SECRET not set - skipping verification") 

53 return True 

54 

55 expected_signature = hmac.new( 

56 self.WEBHOOK_SECRET.encode(), 

57 payload, 

58 hashlib.sha256 

59 ).hexdigest() 

60 

61 return hmac.compare_digest(signature, expected_signature) 

62 

63 def get_tier_from_product_id(self, product_id: str) -> Optional[str]: 

64 """Get tier name from Polar product ID""" 

65 for tier, pid in self.PRODUCT_IDS.items(): 

66 if pid == product_id: 

67 return tier 

68 return None 

69 

70 async def log_webhook_event(self, event_type: str, payload: Dict[str, Any], error: Optional[str] = None) -> None: 

71 """Log webhook event to database""" 

72 try: 

73 pool = await self.neon.get_pool() 

74 event_id = payload.get('id', 'unknown') 

75 

76 await pool.execute( 

77 """ 

78 INSERT INTO webhook_events (event_id, event_type, payload, processed, error, created_at) 

79 VALUES ($1, $2, $3, $4, $5, $6) 

80 """, 

81 event_id, 

82 event_type, 

83 payload, 

84 error is None, 

85 error, 

86 datetime.utcnow() 

87 ) 

88 logger.info(f"✅ Logged webhook event: {event_type}") 

89 except Exception as e: 

90 logger.error(f"❌ Failed to log webhook event: {str(e)}") 

91 

92 async def handle_subscription_created(self, data: Dict[str, Any]) -> None: 

93 """ 

94 Handle subscription.created event. 

95 Create new subscription record in database. 

96 """ 

97 pool = await self.neon.get_pool() 

98 

99 subscription = data.get('data', {}) 

100 product_id = subscription.get('product_id') 

101 user_email = subscription.get('customer_email') or subscription.get('customer', {}).get('email') 

102 polar_subscription_id = subscription.get('id') 

103 

104 if not user_email: 

105 raise Exception("No customer email in subscription data") 

106 

107 tier = self.get_tier_from_product_id(product_id) 

108 if not tier: 

109 raise Exception(f"Unknown product ID: {product_id}") 

110 

111 config = self.TIER_CONFIG[tier] 

112 

113 # Check if subscription already exists 

114 existing = await pool.fetchrow( 

115 "SELECT id FROM user_subscriptions WHERE polar_subscription_id = $1", 

116 polar_subscription_id 

117 ) 

118 

119 if existing: 

120 logger.info(f"Subscription {polar_subscription_id} already exists") 

121 return 

122 

123 # Find or create user by email 

124 user = await pool.fetchrow( 

125 "SELECT id FROM users WHERE email = $1", 

126 user_email 

127 ) 

128 

129 if not user: 

130 # Create user if doesn't exist (from Polar checkout) 

131 user_id = await pool.fetchval( 

132 """ 

133 INSERT INTO users (email, tier, scans_per_month, requests_per_hour, created_at, updated_at) 

134 VALUES ($1, $2, $3, $4, $5, $6) 

135 RETURNING id 

136 """, 

137 user_email, 

138 tier, 

139 config['scan_limit'], 

140 100 if tier == 'developer' else 200, 

141 datetime.utcnow(), 

142 datetime.utcnow() 

143 ) 

144 logger.info(f"✅ Created new user {user_email}") 

145 else: 

146 user_id = user['id'] 

147 

148 # Update existing user's tier and limits 

149 await pool.execute( 

150 """ 

151 UPDATE users 

152 SET tier = $1, scans_per_month = $2, requests_per_hour = $3, updated_at = $4 

153 WHERE id = $5 

154 """, 

155 tier, 

156 config['scan_limit'], 

157 100 if tier == 'developer' else 200, 

158 datetime.utcnow(), 

159 user_id 

160 ) 

161 logger.info(f"✅ Updated user {user_email} to {tier} tier") 

162 

163 # Create new subscription 

164 await pool.execute( 

165 """ 

166 INSERT INTO user_subscriptions ( 

167 user_id, email, polar_subscription_id, polar_product_id, 

168 tier, status, scan_limit, scans_used, seats_limit, seats_used, 

169 price_amount, price_currency, current_period_start, current_period_end, 

170 created_at, updated_at 

171 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) 

172 """, 

173 user_id, 

174 user_email, 

175 polar_subscription_id, 

176 product_id, 

177 tier, 

178 'active', 

179 config['scan_limit'], 

180 0, # scans_used 

181 config['seats'], 

182 1, # seats_used 

183 config['price'], 

184 'EUR', 

185 datetime.utcnow(), 

186 datetime.utcnow() + timedelta(days=30), 

187 datetime.utcnow(), 

188 datetime.utcnow() 

189 ) 

190 

191 logger.info(f"✅ Created subscription for {user_email} - {tier} tier") 

192 

193 async def handle_subscription_updated(self, data: Dict[str, Any]) -> None: 

194 """ 

195 Handle subscription.updated event. 

196 Update subscription details (plan changes, etc). 

197 """ 

198 pool = await self.neon.get_pool() 

199 

200 subscription = data.get('data', {}) 

201 polar_subscription_id = subscription.get('id') 

202 product_id = subscription.get('product_id') 

203 status = subscription.get('status', 'active') 

204 

205 tier = self.get_tier_from_product_id(product_id) 

206 if not tier: 

207 raise Exception(f"Unknown product ID: {product_id}") 

208 

209 config = self.TIER_CONFIG[tier] 

210 

211 # Update subscription 

212 await pool.execute( 

213 """ 

214 UPDATE user_subscriptions 

215 SET polar_product_id = $1, tier = $2, status = $3, 

216 scan_limit = $4, seats_limit = $5, price_amount = $6, updated_at = $7 

217 WHERE polar_subscription_id = $8 

218 """, 

219 product_id, 

220 tier, 

221 status, 

222 config['scan_limit'], 

223 config['seats'], 

224 config['price'], 

225 datetime.utcnow(), 

226 polar_subscription_id 

227 ) 

228 

229 logger.info(f"✅ Updated subscription {polar_subscription_id} to {tier} tier") 

230 

231 async def handle_subscription_canceled(self, data: Dict[str, Any]) -> None: 

232 """ 

233 Handle subscription.canceled event. 

234 Mark subscription as canceled. 

235 """ 

236 pool = await self.neon.get_pool() 

237 

238 subscription = data.get('data', {}) 

239 polar_subscription_id = subscription.get('id') 

240 

241 # Update subscription status 

242 await pool.execute( 

243 """ 

244 UPDATE user_subscriptions 

245 SET status = $1, canceled_at = $2, updated_at = $3 

246 WHERE polar_subscription_id = $4 

247 """, 

248 'canceled', 

249 datetime.utcnow(), 

250 datetime.utcnow(), 

251 polar_subscription_id 

252 ) 

253 

254 logger.info(f"✅ Canceled subscription {polar_subscription_id}") 

255 

256 async def handle_subscription_revoked(self, data: Dict[str, Any]) -> None: 

257 """ 

258 Handle subscription.revoked event. 

259 Revoke access immediately (payment failed, etc). 

260 """ 

261 pool = await self.neon.get_pool() 

262 

263 subscription = data.get('data', {}) 

264 polar_subscription_id = subscription.get('id') 

265 

266 # Update subscription status 

267 await pool.execute( 

268 """ 

269 UPDATE user_subscriptions 

270 SET status = $1, revoked_at = $2, updated_at = $3 

271 WHERE polar_subscription_id = $4 

272 """, 

273 'revoked', 

274 datetime.utcnow(), 

275 datetime.utcnow(), 

276 polar_subscription_id 

277 ) 

278 

279 logger.info(f"✅ Revoked subscription {polar_subscription_id}") 

280 

281 async def handle_subscription_renewed(self, data: Dict[str, Any]) -> None: 

282 """ 

283 Handle subscription billing period renewal. 

284 Reset usage counters for new billing period. 

285 """ 

286 pool = await self.neon.get_pool() 

287 

288 subscription = data.get('data', {}) 

289 polar_subscription_id = subscription.get('id') 

290 

291 # Reset usage and update period 

292 await pool.execute( 

293 """ 

294 UPDATE user_subscriptions 

295 SET scans_used = $1, current_period_start = $2, current_period_end = $3, updated_at = $4 

296 WHERE polar_subscription_id = $5 

297 """, 

298 0, 

299 datetime.utcnow(), 

300 datetime.utcnow() + timedelta(days=30), 

301 datetime.utcnow(), 

302 polar_subscription_id 

303 ) 

304 

305 logger.info(f"✅ Renewed subscription {polar_subscription_id} - reset usage counters") 

306 

307 async def process_webhook(self, request: Request) -> Dict[str, Any]: 

308 """ 

309 Main webhook processor. 

310 

311 Args: 

312 request: FastAPI request object 

313 

314 Returns: 

315 Response data 

316 

317 Raises: 

318 HTTPException: If signature invalid or processing fails 

319 """ 

320 # Get raw body for signature verification 

321 body = await request.body() 

322 signature = request.headers.get('X-Polar-Signature', '') 

323 

324 # Verify signature 

325 if not self.verify_signature(body, signature): 

326 raise HTTPException( 

327 status_code=status.HTTP_401_UNAUTHORIZED, 

328 detail="Invalid webhook signature" 

329 ) 

330 

331 # Parse JSON 

332 import json 

333 payload = json.loads(body) 

334 

335 event_type = payload.get('type') 

336 logger.info(f"📥 Received webhook: {event_type}") 

337 

338 try: 

339 # Route to appropriate handler 

340 if event_type == 'subscription.created': 

341 await self.handle_subscription_created(payload) 

342 elif event_type == 'subscription.updated': 

343 await self.handle_subscription_updated(payload) 

344 elif event_type == 'subscription.canceled': 

345 await self.handle_subscription_canceled(payload) 

346 elif event_type == 'subscription.revoked': 

347 await self.handle_subscription_revoked(payload) 

348 elif event_type == 'subscription.renewed': 

349 await self.handle_subscription_renewed(payload) 

350 else: 

351 logger.warning(f"Unhandled event type: {event_type}") 

352 

353 # Log successful processing 

354 await self.log_webhook_event(event_type, payload) 

355 

356 return {"status": "success", "event": event_type} 

357 

358 except Exception as e: 

359 logger.error(f"Failed to process webhook: {str(e)}") 

360 await self.log_webhook_event(event_type, payload, error=str(e)) 

361 raise HTTPException( 

362 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

363 detail=f"Webhook processing failed: {str(e)}" 

364 )