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
« 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
14logger = logging.getLogger(__name__)
16class PolarWebhookHandler:
17 """Handle Polar webhook events for subscription management"""
19 WEBHOOK_SECRET = os.getenv("POLAR_WEBHOOK_SECRET")
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 }
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 }
36 def __init__(self):
37 self.neon = NeonService()
38 logger.info("Webhook handler initialized with Neon database")
40 def verify_signature(self, payload: bytes, signature: str) -> bool:
41 """
42 Verify webhook signature from Polar.
44 Args:
45 payload: Raw request body
46 signature: Signature from X-Polar-Signature header
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
55 expected_signature = hmac.new(
56 self.WEBHOOK_SECRET.encode(),
57 payload,
58 hashlib.sha256
59 ).hexdigest()
61 return hmac.compare_digest(signature, expected_signature)
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
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')
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)}")
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()
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')
104 if not user_email:
105 raise Exception("No customer email in subscription data")
107 tier = self.get_tier_from_product_id(product_id)
108 if not tier:
109 raise Exception(f"Unknown product ID: {product_id}")
111 config = self.TIER_CONFIG[tier]
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 )
119 if existing:
120 logger.info(f"Subscription {polar_subscription_id} already exists")
121 return
123 # Find or create user by email
124 user = await pool.fetchrow(
125 "SELECT id FROM users WHERE email = $1",
126 user_email
127 )
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']
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")
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 )
191 logger.info(f"✅ Created subscription for {user_email} - {tier} tier")
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()
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')
205 tier = self.get_tier_from_product_id(product_id)
206 if not tier:
207 raise Exception(f"Unknown product ID: {product_id}")
209 config = self.TIER_CONFIG[tier]
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 )
229 logger.info(f"✅ Updated subscription {polar_subscription_id} to {tier} tier")
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()
238 subscription = data.get('data', {})
239 polar_subscription_id = subscription.get('id')
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 )
254 logger.info(f"✅ Canceled subscription {polar_subscription_id}")
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()
263 subscription = data.get('data', {})
264 polar_subscription_id = subscription.get('id')
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 )
279 logger.info(f"✅ Revoked subscription {polar_subscription_id}")
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()
288 subscription = data.get('data', {})
289 polar_subscription_id = subscription.get('id')
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 )
305 logger.info(f"✅ Renewed subscription {polar_subscription_id} - reset usage counters")
307 async def process_webhook(self, request: Request) -> Dict[str, Any]:
308 """
309 Main webhook processor.
311 Args:
312 request: FastAPI request object
314 Returns:
315 Response data
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', '')
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 )
331 # Parse JSON
332 import json
333 payload = json.loads(body)
335 event_type = payload.get('type')
336 logger.info(f"📥 Received webhook: {event_type}")
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}")
353 # Log successful processing
354 await self.log_webhook_event(event_type, payload)
356 return {"status": "success", "event": event_type}
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 )