Coverage for src/alprina_cli/api/routes/polar_webhooks.py: 13%

197 statements  

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

1""" 

2Polar Webhooks - /v1/webhooks/polar 

3 

4Handles Polar.sh webhook events for subscription management. 

5""" 

6 

7from fastapi import APIRouter, HTTPException, Request, Header, BackgroundTasks 

8from pydantic import BaseModel 

9from typing import Dict, Any, Optional 

10from datetime import datetime 

11from loguru import logger 

12 

13from ..services.polar_service import polar_service 

14from ..services.neon_service import neon_service 

15 

16router = APIRouter() 

17 

18 

19class WebhookResponse(BaseModel): 

20 """Webhook response model.""" 

21 status: str 

22 event_type: str 

23 event_id: str 

24 processed: bool 

25 message: str 

26 

27 

28@router.post("/webhooks/polar", response_model=WebhookResponse) 

29async def handle_polar_webhook( 

30 request: Request, 

31 background_tasks: BackgroundTasks, 

32 polar_signature: Optional[str] = Header(None, alias="Polar-Signature") 

33): 

34 """ 

35 Handle Polar.sh webhook events. 

36 

37 Processes subscription creation, updates, cancellations, and payments. 

38 

39 **Webhook Events:** 

40 - `subscription.created` - New subscription created 

41 - `subscription.updated` - Subscription modified 

42 - `subscription.cancelled` - Subscription cancelled 

43 - `payment.succeeded` - Payment successful 

44 - `payment.failed` - Payment failed 

45 

46 **Example webhook payload:** 

47 ```json 

48 { 

49 "type": "subscription.created", 

50 "id": "evt_123...", 

51 "data": { 

52 "id": "sub_123...", 

53 "customer_id": "cus_123...", 

54 "product_id": "prod_123...", 

55 "status": "active" 

56 } 

57 } 

58 ``` 

59 """ 

60 # Get raw body for signature verification 

61 body = await request.body() 

62 

63 # Verify webhook signature 

64 if polar_signature: 

65 is_valid = polar_service.verify_webhook_signature( 

66 body, 

67 polar_signature 

68 ) 

69 

70 if not is_valid: 

71 logger.error("Invalid Polar webhook signature") 

72 raise HTTPException( 

73 status_code=401, 

74 detail="Invalid webhook signature" 

75 ) 

76 

77 # Parse JSON payload 

78 try: 

79 payload = await request.json() 

80 except Exception as e: 

81 logger.error(f"Failed to parse webhook payload: {e}") 

82 raise HTTPException( 

83 status_code=400, 

84 detail="Invalid JSON payload" 

85 ) 

86 

87 event_type = payload.get("type") 

88 # Polar sends subscription ID in data.id, not at root level 

89 event_id = payload.get("id") or payload.get("data", {}).get("id") 

90 

91 if not event_type: 

92 raise HTTPException( 

93 status_code=400, 

94 detail="Missing event type" 

95 ) 

96 

97 if not event_id: 

98 # Use timestamp as fallback ID if no ID provided 

99 event_id = f"{event_type}_{payload.get('timestamp', datetime.utcnow().isoformat())}" 

100 

101 logger.info(f"Received Polar webhook: {event_type} ({event_id})") 

102 

103 # Log webhook event 

104 if neon_service.is_enabled(): 

105 await neon_service.log_webhook_event( 

106 event_type=event_type, 

107 event_id=event_id, 

108 payload=payload 

109 ) 

110 

111 # Process webhook in background 

112 background_tasks.add_task( 

113 process_webhook_background, 

114 event_type, 

115 event_id, 

116 payload 

117 ) 

118 

119 return WebhookResponse( 

120 status="received", 

121 event_type=event_type, 

122 event_id=event_id, 

123 processed=False, 

124 message="Webhook received and queued for processing" 

125 ) 

126 

127 

128async def process_webhook_background( 

129 event_type: str, 

130 event_id: str, 

131 payload: Dict[str, Any] 

132): 

133 """ 

134 Process webhook event in background. 

135 

136 Args: 

137 event_type: Type of webhook event 

138 event_id: Unique event ID 

139 payload: Full webhook payload 

140 """ 

141 try: 

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

143 

144 if event_type == "checkout.completed": 

145 await handle_checkout_completed(payload) 

146 elif event_type == "checkout.updated": 

147 await handle_checkout_updated(payload) 

148 elif event_type == "subscription.created": 

149 await handle_subscription_created(payload) 

150 elif event_type == "subscription.updated": 

151 await handle_subscription_updated(payload) 

152 elif event_type in ["subscription.cancelled", "subscription.canceled"]: 

153 await handle_subscription_cancelled(payload) 

154 elif event_type == "benefit_grant.revoked": 

155 await handle_benefit_grant_revoked(payload) 

156 elif event_type == "benefit_grant.granted": 

157 await handle_benefit_grant_granted(payload) 

158 elif event_type == "payment.succeeded": 

159 await handle_payment_succeeded(payload) 

160 elif event_type == "payment.failed": 

161 await handle_payment_failed(payload) 

162 else: 

163 logger.warning(f"Unhandled webhook event: {event_type}") 

164 

165 # Mark as processed 

166 if neon_service.is_enabled(): 

167 await neon_service.mark_webhook_processed(event_id) 

168 

169 logger.info(f"Successfully processed webhook: {event_type}") 

170 

171 except Exception as e: 

172 logger.error(f"Failed to process webhook {event_type}: {e}") 

173 

174 # Log error 

175 if neon_service.is_enabled(): 

176 await neon_service.mark_webhook_error(event_id, str(e)) 

177 

178 

179async def handle_checkout_completed(payload: Dict[str, Any]): 

180 """ 

181 Handle checkout.completed event. 

182 

183 This is fired when a checkout is completed, before subscription.created. 

184 We can log it but the main work happens in subscription.created. 

185 """ 

186 data = payload.get("data", {}) 

187 

188 customer_email = data.get("customer_email") 

189 product_name = data.get("product", {}).get("name", "Unknown") 

190 

191 logger.info( 

192 f"Checkout completed for {customer_email} - {product_name}" 

193 ) 

194 

195 # Just log for now - the subscription.created event will do the actual work 

196 # This prevents 500 errors when checkout.completed is received 

197 

198 

199async def handle_subscription_created(payload: Dict[str, Any]): 

200 """ 

201 Handle subscription.created event. 

202 

203 Creates or updates user with subscription info. 

204 """ 

205 data = payload.get("data", {}) 

206 

207 subscription_id = data.get("id") 

208 customer_id = data.get("customer_id") 

209 product_id = data.get("product_id") 

210 status = data.get("status") 

211 

212 # Extract customer email from nested customer object 

213 customer = data.get("customer", {}) 

214 customer_email = customer.get("email") 

215 

216 if not customer_email: 

217 logger.error("No customer email in webhook payload!") 

218 return 

219 

220 logger.info( 

221 f"New subscription created: {subscription_id} " 

222 f"for customer {customer_id} ({customer_email}) " 

223 f"product: {product_id}" 

224 ) 

225 

226 # Get tier from product_id (fast, reliable) 

227 tier = polar_service.get_tier_from_product_id(product_id) 

228 

229 # Determine billing period (monthly or annual) 

230 annual_products = [ 

231 "e59df0ee-7287-4132-8edd-3b5fdf4a30f3", # Developer Annual 

232 "eb0d9d5a-fceb-485d-aaae-36b50d8731f4", # Pro Annual 

233 "2da941e8-450a-4498-a4a4-b3539456219e", # Team Annual 

234 ] 

235 billing_period = "annual" if product_id in annual_products else "monthly" 

236 

237 # Set scan limits based on tier and billing period 

238 scan_limits = { 

239 "developer": {"monthly": 100, "annual": 1200}, 

240 "pro": {"monthly": 500, "annual": 6000}, 

241 "team": {"monthly": 2000, "annual": 24000}, 

242 } 

243 scans_included = scan_limits.get(tier, {}).get(billing_period, 0) 

244 

245 # Set seat limits (Team plan only) 

246 seats_included = 5 if tier == "team" else 1 

247 

248 if tier == "none": 

249 # Fallback: Use product name from webhook payload (NOT API call) 

250 product = data.get("product", {}) 

251 product_name = product.get("name", "") 

252 

253 if product_name: 

254 tier = polar_service.get_tier_from_product(product_name) 

255 logger.info(f"Determined tier from product name: {tier} (from '{product_name}')") 

256 else: 

257 logger.error(f"Could not determine tier for product_id: {product_id}") 

258 tier = "developer" # Safe default 

259 

260 # Log warning if product_id not in map 

261 logger.warning( 

262 f"⚠️ Product ID {product_id} not in PRODUCT_ID_MAP! " 

263 f"Please add it to polar_service.py. Using tier: {tier}" 

264 ) 

265 else: 

266 logger.info(f"Determined tier={tier}, billing_period={billing_period}, scans_included={scans_included}") 

267 

268 # Get or create user 

269 if neon_service.is_enabled(): 

270 user = await neon_service.get_user_by_email(customer_email) 

271 

272 if user: 

273 # Update existing user (already signed up with Stack Auth) 

274 logger.info(f"Found existing user {user['id']} for {customer_email}") 

275 

276 # Check if this is a Stack Auth user 

277 has_stack_id = user.get('stack_user_id') is not None 

278 if has_stack_id: 

279 logger.info(f"✅ Linking Polar subscription to Stack Auth user: {user['stack_user_id']}") 

280 else: 

281 logger.info(f"⚠️ User {user['id']} has no stack_user_id - may be legacy user") 

282 

283 logger.info(f"Updating user with tier={tier}, status={status}") 

284 

285 # Calculate period dates 

286 from datetime import timedelta 

287 period_start = datetime.utcnow() 

288 period_days = 365 if billing_period == "annual" else 30 

289 period_end = period_start + timedelta(days=period_days) 

290 

291 await neon_service.update_user( 

292 user["id"], 

293 { 

294 "tier": tier, 

295 "billing_period": billing_period, 

296 "has_metering": billing_period == "monthly", 

297 "scans_included": scans_included, 

298 "scans_used_this_period": 0, 

299 "period_start": period_start, 

300 "period_end": period_end, 

301 "seats_included": seats_included, 

302 "seats_used": 1, 

303 "extra_seats": 0, 

304 "polar_customer_id": customer_id, 

305 "polar_subscription_id": subscription_id, 

306 "subscription_status": status, 

307 "subscription_started_at": datetime.utcnow().isoformat() 

308 } 

309 ) 

310 logger.info(f"✅ Successfully updated user {user['id']} with tier={tier}, billing={billing_period}") 

311 

312 else: 

313 # Create new user (purchased without signing up first) 

314 # This shouldn't normally happen, but handle gracefully 

315 logger.warning(f"⚠️ No user found for {customer_email} - creating new user from Polar subscription") 

316 # Calculate period dates 

317 from datetime import timedelta 

318 period_start = datetime.utcnow() 

319 period_days = 365 if billing_period == "annual" else 30 

320 period_end = period_start + timedelta(days=period_days) 

321 

322 user = await neon_service.create_user_from_subscription( 

323 email=customer_email, 

324 polar_customer_id=customer_id, 

325 polar_subscription_id=subscription_id, 

326 tier=tier, 

327 billing_period=billing_period, 

328 has_metering=billing_period == "monthly", 

329 scans_included=scans_included, 

330 period_start=period_start, 

331 period_end=period_end, 

332 seats_included=seats_included 

333 ) 

334 logger.info(f"Created new user {user['id']} from Polar subscription (billing={billing_period})") 

335 

336 # Initialize usage tracking 

337 await neon_service.initialize_usage_tracking( 

338 user["id"], 

339 tier 

340 ) 

341 

342 

343async def handle_subscription_updated(payload: Dict[str, Any]): 

344 """ 

345 Handle subscription.updated event. 

346 

347 Updates user tier or subscription status. 

348 """ 

349 data = payload.get("data", {}) 

350 

351 subscription_id = data.get("id") 

352 status = data.get("status") 

353 customer = data.get("customer", {}) 

354 customer_email = customer.get("email") 

355 product_id = data.get("product_id") 

356 

357 logger.info(f"Subscription updated: {subscription_id}, status: {status}, email: {customer_email}") 

358 

359 if neon_service.is_enabled() and customer_email: 

360 # Try to find user by email first (more reliable than subscription ID) 

361 user = await neon_service.get_user_by_email(customer_email) 

362 

363 if not user: 

364 # Fallback: try by subscription ID 

365 user = await neon_service.get_user_by_subscription(subscription_id) 

366 

367 if user: 

368 # Determine tier from product_id 

369 tier = polar_service.get_tier_from_product_id(product_id) 

370 

371 await neon_service.update_user( 

372 user["id"], 

373 { 

374 "tier": tier, 

375 "subscription_status": status, 

376 "polar_subscription_id": subscription_id, 

377 "polar_customer_id": data.get("customer_id") 

378 } 

379 ) 

380 logger.info(f"Updated user {user['id']} - tier: {tier}, status: {status}") 

381 else: 

382 logger.warning(f"User not found for email: {customer_email}") 

383 

384 

385async def handle_subscription_cancelled(payload: Dict[str, Any]): 

386 """ 

387 Handle subscription.cancelled/canceled event. 

388 

389 Downgrades user to none tier (no free tier). 

390 """ 

391 data = payload.get("data", {}) 

392 

393 subscription_id = data.get("id") 

394 cancelled_at = data.get("cancelled_at") or data.get("canceled_at") 

395 customer = data.get("customer", {}) 

396 customer_email = customer.get("email") 

397 

398 logger.info(f"Subscription cancelled: {subscription_id}, email: {customer_email}") 

399 

400 if neon_service.is_enabled() and customer_email: 

401 # Try to find user by email first 

402 user = await neon_service.get_user_by_email(customer_email) 

403 

404 if not user: 

405 # Fallback to subscription ID 

406 user = await neon_service.get_user_by_subscription(subscription_id) 

407 

408 if user: 

409 await neon_service.update_user( 

410 user["id"], 

411 { 

412 "tier": "none", # Changed from "free" to "none" 

413 "subscription_status": "canceled", # Use American spelling 

414 "subscription_ends_at": cancelled_at, 

415 "scans_per_month": 0, # Reset limits 

416 "requests_per_hour": 0 

417 } 

418 ) 

419 logger.info(f"Downgraded user {user['id']} to none tier (subscription canceled)") 

420 else: 

421 logger.warning(f"User not found for canceled subscription: {customer_email}") 

422 

423 

424async def handle_payment_succeeded(payload: Dict[str, Any]): 

425 """ 

426 Handle payment.succeeded event. 

427 

428 Ensures subscription is active. 

429 """ 

430 data = payload.get("data", {}) 

431 

432 subscription_id = data.get("subscription_id") 

433 amount = data.get("amount") 

434 

435 logger.info( 

436 f"Payment succeeded for subscription {subscription_id}: " 

437 f"${amount/100:.2f}" 

438 ) 

439 

440 if neon_service.is_enabled(): 

441 user = await neon_service.get_user_by_subscription(subscription_id) 

442 

443 if user: 

444 await neon_service.update_user( 

445 user["id"], 

446 { 

447 "subscription_status": "active" 

448 } 

449 ) 

450 

451 

452async def handle_payment_failed(payload: Dict[str, Any]): 

453 """ 

454 Handle payment.failed event. 

455 

456 Marks subscription as past_due. 

457 """ 

458 data = payload.get("data", {}) 

459 

460 subscription_id = data.get("subscription_id") 

461 error_message = data.get("error", {}).get("message", "Unknown error") 

462 

463 logger.warning( 

464 f"Payment failed for subscription {subscription_id}: " 

465 f"{error_message}" 

466 ) 

467 

468 if neon_service.is_enabled(): 

469 user = await neon_service.get_user_by_subscription(subscription_id) 

470 

471 if user: 

472 await neon_service.update_user( 

473 user["id"], 

474 { 

475 "subscription_status": "past_due" 

476 } 

477 ) 

478 logger.info(f"Marked user {user['id']} subscription as past_due") 

479 

480 

481async def handle_checkout_updated(payload: Dict[str, Any]): 

482 """ 

483 Handle checkout.updated event. 

484 

485 Fired when checkout session is updated (e.g., payment method added). 

486 We just log it - no action needed. 

487 """ 

488 data = payload.get("data", {}) 

489 checkout_id = data.get("id") 

490 status = data.get("status") 

491 

492 logger.info(f"Checkout updated: {checkout_id} - status: {status}") 

493 

494 

495async def handle_benefit_grant_granted(payload: Dict[str, Any]): 

496 """ 

497 Handle benefit_grant.granted event. 

498 

499 Fired when a benefit (like credits) is granted to a customer. 

500 We can log it or track credits if needed. 

501 """ 

502 data = payload.get("data", {}) 

503 customer_email = data.get("customer", {}).get("email") 

504 benefit_desc = data.get("benefit", {}).get("description") 

505 

506 logger.info(f"Benefit granted to {customer_email}: {benefit_desc}") 

507 

508 

509async def handle_benefit_grant_revoked(payload: Dict[str, Any]): 

510 """ 

511 Handle benefit_grant.revoked event. 

512 

513 Fired when a benefit is revoked (e.g., subscription cancelled). 

514 We just log it - the subscription.cancelled event handles the main work. 

515 """ 

516 data = payload.get("data", {}) 

517 customer_email = data.get("customer", {}).get("email") 

518 benefit_desc = data.get("benefit", {}).get("description") 

519 

520 logger.info(f"Benefit revoked from {customer_email}: {benefit_desc}") 

521 

522 

523@router.post("/webhooks/polar/fix-user-tier") 

524async def fix_user_tier(email: str, tier: str): 

525 """ 

526 Manual endpoint to fix user tier in database. 

527 

528 USE THIS if webhook didn't update tier correctly. 

529 

530 Args: 

531 email: User email 

532 tier: Tier to set (developer, pro, team) 

533 

534 Example: 

535 POST /v1/webhooks/polar/fix-user-tier?email=malte@joshwagenbach.com&tier=developer 

536 """ 

537 if neon_service.is_enabled(): 

538 user = await neon_service.get_user_by_email(email) 

539 

540 if not user: 

541 raise HTTPException(status_code=404, detail=f"User not found: {email}") 

542 

543 await neon_service.update_user( 

544 user["id"], 

545 {"tier": tier} 

546 ) 

547 

548 logger.info(f"✅ Manually fixed tier for {email}: {tier}") 

549 

550 return { 

551 "status": "success", 

552 "message": f"Updated {email} to tier={tier}", 

553 "user_id": user["id"], 

554 "tier": tier 

555 } 

556 else: 

557 raise HTTPException(status_code=503, detail="Database not configured") 

558 

559 

560@router.get("/webhooks/polar/test") 

561async def test_webhook(): 

562 """ 

563 Test endpoint to verify webhook configuration. 

564 

565 Returns: 

566 Simple success message 

567 """ 

568 return { 

569 "status": "ok", 

570 "message": "Polar webhook endpoint is configured correctly", 

571 "endpoint": "/v1/webhooks/polar" 

572 }