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
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
1"""
2Polar Webhooks - /v1/webhooks/polar
4Handles Polar.sh webhook events for subscription management.
5"""
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
13from ..services.polar_service import polar_service
14from ..services.neon_service import neon_service
16router = APIRouter()
19class WebhookResponse(BaseModel):
20 """Webhook response model."""
21 status: str
22 event_type: str
23 event_id: str
24 processed: bool
25 message: str
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.
37 Processes subscription creation, updates, cancellations, and payments.
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
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()
63 # Verify webhook signature
64 if polar_signature:
65 is_valid = polar_service.verify_webhook_signature(
66 body,
67 polar_signature
68 )
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 )
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 )
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")
91 if not event_type:
92 raise HTTPException(
93 status_code=400,
94 detail="Missing event type"
95 )
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())}"
101 logger.info(f"Received Polar webhook: {event_type} ({event_id})")
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 )
111 # Process webhook in background
112 background_tasks.add_task(
113 process_webhook_background,
114 event_type,
115 event_id,
116 payload
117 )
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 )
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.
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}")
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}")
165 # Mark as processed
166 if neon_service.is_enabled():
167 await neon_service.mark_webhook_processed(event_id)
169 logger.info(f"Successfully processed webhook: {event_type}")
171 except Exception as e:
172 logger.error(f"Failed to process webhook {event_type}: {e}")
174 # Log error
175 if neon_service.is_enabled():
176 await neon_service.mark_webhook_error(event_id, str(e))
179async def handle_checkout_completed(payload: Dict[str, Any]):
180 """
181 Handle checkout.completed event.
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", {})
188 customer_email = data.get("customer_email")
189 product_name = data.get("product", {}).get("name", "Unknown")
191 logger.info(
192 f"Checkout completed for {customer_email} - {product_name}"
193 )
195 # Just log for now - the subscription.created event will do the actual work
196 # This prevents 500 errors when checkout.completed is received
199async def handle_subscription_created(payload: Dict[str, Any]):
200 """
201 Handle subscription.created event.
203 Creates or updates user with subscription info.
204 """
205 data = payload.get("data", {})
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")
212 # Extract customer email from nested customer object
213 customer = data.get("customer", {})
214 customer_email = customer.get("email")
216 if not customer_email:
217 logger.error("No customer email in webhook payload!")
218 return
220 logger.info(
221 f"New subscription created: {subscription_id} "
222 f"for customer {customer_id} ({customer_email}) "
223 f"product: {product_id}"
224 )
226 # Get tier from product_id (fast, reliable)
227 tier = polar_service.get_tier_from_product_id(product_id)
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"
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)
245 # Set seat limits (Team plan only)
246 seats_included = 5 if tier == "team" else 1
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", "")
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
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}")
268 # Get or create user
269 if neon_service.is_enabled():
270 user = await neon_service.get_user_by_email(customer_email)
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}")
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")
283 logger.info(f"Updating user with tier={tier}, status={status}")
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)
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}")
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)
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})")
336 # Initialize usage tracking
337 await neon_service.initialize_usage_tracking(
338 user["id"],
339 tier
340 )
343async def handle_subscription_updated(payload: Dict[str, Any]):
344 """
345 Handle subscription.updated event.
347 Updates user tier or subscription status.
348 """
349 data = payload.get("data", {})
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")
357 logger.info(f"Subscription updated: {subscription_id}, status: {status}, email: {customer_email}")
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)
363 if not user:
364 # Fallback: try by subscription ID
365 user = await neon_service.get_user_by_subscription(subscription_id)
367 if user:
368 # Determine tier from product_id
369 tier = polar_service.get_tier_from_product_id(product_id)
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}")
385async def handle_subscription_cancelled(payload: Dict[str, Any]):
386 """
387 Handle subscription.cancelled/canceled event.
389 Downgrades user to none tier (no free tier).
390 """
391 data = payload.get("data", {})
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")
398 logger.info(f"Subscription cancelled: {subscription_id}, email: {customer_email}")
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)
404 if not user:
405 # Fallback to subscription ID
406 user = await neon_service.get_user_by_subscription(subscription_id)
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}")
424async def handle_payment_succeeded(payload: Dict[str, Any]):
425 """
426 Handle payment.succeeded event.
428 Ensures subscription is active.
429 """
430 data = payload.get("data", {})
432 subscription_id = data.get("subscription_id")
433 amount = data.get("amount")
435 logger.info(
436 f"Payment succeeded for subscription {subscription_id}: "
437 f"${amount/100:.2f}"
438 )
440 if neon_service.is_enabled():
441 user = await neon_service.get_user_by_subscription(subscription_id)
443 if user:
444 await neon_service.update_user(
445 user["id"],
446 {
447 "subscription_status": "active"
448 }
449 )
452async def handle_payment_failed(payload: Dict[str, Any]):
453 """
454 Handle payment.failed event.
456 Marks subscription as past_due.
457 """
458 data = payload.get("data", {})
460 subscription_id = data.get("subscription_id")
461 error_message = data.get("error", {}).get("message", "Unknown error")
463 logger.warning(
464 f"Payment failed for subscription {subscription_id}: "
465 f"{error_message}"
466 )
468 if neon_service.is_enabled():
469 user = await neon_service.get_user_by_subscription(subscription_id)
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")
481async def handle_checkout_updated(payload: Dict[str, Any]):
482 """
483 Handle checkout.updated event.
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")
492 logger.info(f"Checkout updated: {checkout_id} - status: {status}")
495async def handle_benefit_grant_granted(payload: Dict[str, Any]):
496 """
497 Handle benefit_grant.granted event.
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")
506 logger.info(f"Benefit granted to {customer_email}: {benefit_desc}")
509async def handle_benefit_grant_revoked(payload: Dict[str, Any]):
510 """
511 Handle benefit_grant.revoked event.
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")
520 logger.info(f"Benefit revoked from {customer_email}: {benefit_desc}")
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.
528 USE THIS if webhook didn't update tier correctly.
530 Args:
531 email: User email
532 tier: Tier to set (developer, pro, team)
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)
540 if not user:
541 raise HTTPException(status_code=404, detail=f"User not found: {email}")
543 await neon_service.update_user(
544 user["id"],
545 {"tier": tier}
546 )
548 logger.info(f"✅ Manually fixed tier for {email}: {tier}")
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")
560@router.get("/webhooks/polar/test")
561async def test_webhook():
562 """
563 Test endpoint to verify webhook configuration.
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 }