Coverage for src/ai_lls_lib/payment/webhook_processor.py: 89%
198 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 17:06 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 17:06 +0000
1"""Stripe webhook event processing."""
3import base64
4import json
5import logging
6from datetime import UTC, datetime
7from decimal import Decimal
8from typing import Any, cast
10try:
11 import stripe
13 HAS_STRIPE = True
14except ImportError:
15 stripe = None # type: ignore[assignment]
16 HAS_STRIPE = False
18try:
19 from botocore.exceptions import ClientError
21 HAS_BOTOCORE = True
22except ImportError:
23 HAS_BOTOCORE = False
25from .credit_manager import CreditManager
27logger = logging.getLogger(__name__)
29# Subscriptions grant no credits. An active subscription_status makes check_and_deduct
30# return unlimited=True and skip deduction entirely, so any balance granted here could
31# never be spent while the subscription is active (issue #134).
34class WebhookProcessor:
35 """Process Stripe webhook events."""
37 def __init__(self, webhook_secret: str, credit_manager: CreditManager):
38 """Initialize with webhook secret and credit manager."""
39 self.webhook_secret = webhook_secret
40 self.credit_manager = credit_manager
42 def verify_and_parse(self, payload: str, signature: str) -> dict[str, Any]:
43 """Verify webhook signature and parse event."""
44 if not HAS_STRIPE or not stripe:
45 raise ImportError("stripe package not installed")
47 try:
48 event = stripe.Webhook.construct_event(payload, signature, self.webhook_secret)
49 # stripe 15 objects are not mappings: dict(event) raises TypeError.
50 return cast(dict[str, Any], event.to_dict())
51 except ValueError as e:
52 logger.error(f"Invalid webhook payload: {e}")
53 raise
54 except stripe.error.SignatureVerificationError as e:
55 logger.error(f"Invalid webhook signature: {e}")
56 raise
58 def process_event(self, event: dict[str, Any]) -> dict[str, Any]:
59 """
60 Process a verified webhook event.
61 Returns response data.
62 """
63 event_type = event.get("type")
64 event_data = event.get("data", {}).get("object", {})
66 logger.info(f"Processing webhook event: {event_type}")
68 if event_type == "payment_intent.succeeded":
69 return self._handle_payment_intent_succeeded(event_data)
71 elif event_type == "checkout.session.completed":
72 return self._handle_checkout_completed(event_data)
74 elif event_type == "customer.subscription.created":
75 return self._handle_subscription_created(event_data)
77 elif event_type == "customer.subscription.updated":
78 return self._handle_subscription_updated(event_data)
80 elif event_type == "customer.subscription.deleted":
81 return self._handle_subscription_deleted(event_data)
83 elif event_type == "invoice.payment_succeeded":
84 return self._handle_invoice_paid(event_data)
86 elif event_type == "invoice.payment_failed":
87 return self._handle_invoice_failed(event_data)
89 elif event_type == "charge.dispute.created":
90 return self._handle_dispute_created(event_data)
92 else:
93 logger.info(f"Unhandled event type: {event_type}")
94 return {"message": f"Event {event_type} received but not processed"}
96 def process_eventbridge_event(self, event: dict[str, Any]) -> dict[str, Any]:
97 """Process a Stripe event delivered via EventBridge.
99 EventBridge events are pre-verified by AWS, so no signature
100 verification is needed. The Stripe event is in event["detail"].
101 Falls back to parsing event["body"] for direct webhook calls.
102 """
103 detail = event.get("detail", {})
105 if detail and "type" in detail:
106 return self.process_event(detail)
108 # Fallback: direct webhook call with body
109 body = event.get("body", "")
110 if event.get("isBase64Encoded") and body:
111 body = base64.b64decode(body).decode("utf-8")
113 if body:
114 stripe_event = json.loads(body)
115 return self.process_event(stripe_event)
117 logger.warning("EventBridge event has no detail or body")
118 return {"message": "No event data found"}
120 def _handle_checkout_completed(self, session: dict[str, Any]) -> dict[str, Any]:
121 """Handle successful checkout session for credit purchase."""
122 metadata = session.get("metadata", {})
123 user_id = metadata.get("user_id")
125 if not user_id:
126 logger.error("No user_id in checkout session metadata")
127 return {"error": "Missing user_id"}
129 if session.get("mode") == "payment":
130 credits = int(metadata.get("credits", 0))
131 # Stripe delivers events at least once. Key the grant on the payment intent so a
132 # redelivered session, or the matching payment_intent.succeeded event, cannot grant
133 # the same purchase twice. Fall back to the session id if no intent is attached.
134 idempotency_key = session.get("payment_intent") or session.get("id", "")
136 if credits > 0 and idempotency_key:
137 granted = self.credit_manager.idempotent_credit_grant(
138 user_id, credits, idempotency_key
139 )
140 if not granted:
141 logger.info(f"Checkout {idempotency_key} already processed for {user_id}")
142 return {"message": "Checkout already processed, credits not added"}
143 new_balance = self.credit_manager.get_balance(user_id)
144 logger.info(
145 f"Added {credits} credits to user {user_id}, new balance: {new_balance}"
146 )
147 return {"credits_added": credits, "new_balance": new_balance}
149 return {"message": "Checkout processed"}
151 def _handle_subscription_created(self, subscription: dict[str, Any]) -> dict[str, Any]:
152 """Handle new subscription creation: record state and the first billing period."""
153 metadata = subscription.get("metadata", {})
154 user_id = metadata.get("user_id")
155 customer_id = subscription.get("customer")
156 subscription_id = subscription.get("id")
157 status = str(subscription.get("status", "unknown"))
159 if not user_id:
160 logger.warning("No user_id in subscription metadata")
161 return {"subscription_id": subscription_id, "status": status}
163 self.credit_manager.set_subscription_state(
164 user_id=user_id,
165 status=status,
166 stripe_customer_id=customer_id,
167 stripe_subscription_id=subscription_id,
168 )
170 logger.info(f"Created subscription {subscription_id} for user {user_id} ({status})")
172 # Record the initial billing period so renewals can be detected
173 current_period_start = subscription.get("current_period_start")
174 if current_period_start and self.credit_manager.table:
175 try:
176 self.credit_manager.table.update_item(
177 Key={"user_id": user_id},
178 UpdateExpression="SET last_credited_period = :period",
179 ExpressionAttributeValues={
180 ":period": Decimal(str(current_period_start)),
181 },
182 )
183 except Exception as e:
184 logger.error(f"Error setting last_credited_period for {user_id}: {e}")
186 return {
187 "subscription_id": subscription_id,
188 "status": status,
189 }
191 def _handle_subscription_updated(self, subscription: dict[str, Any]) -> dict[str, Any]:
192 """Handle subscription updates and record billing-period renewals.
194 When a billing period changes (detected via current_period_start) the new
195 period is recorded with a conditional update so duplicate deliveries are inert.
196 """
197 metadata = subscription.get("metadata", {})
198 user_id = metadata.get("user_id")
199 subscription_id = subscription.get("id")
200 status = str(subscription.get("status", "unknown"))
202 if not user_id:
203 return {"subscription_id": subscription_id, "status": status}
205 # Always update subscription status
206 self.credit_manager.set_subscription_state(
207 user_id=user_id, status=status, stripe_subscription_id=subscription_id
208 )
210 renewed = False
211 current_period_start = subscription.get("current_period_start")
213 if status == "active" and current_period_start and self.credit_manager.table:
214 renewed = self._record_billing_period(user_id, current_period_start)
216 logger.info(
217 f"Updated subscription {subscription_id} status to {status}"
218 + (f", new billing period {current_period_start}" if renewed else "")
219 )
221 return {
222 "subscription_id": subscription_id,
223 "status": status,
224 }
226 def _record_billing_period(self, user_id: str, current_period_start: int) -> bool:
227 """Record a new billing period on the credits item.
229 Uses a conditional update on last_credited_period so duplicate webhook
230 deliveries and concurrent handlers cannot advance it twice.
232 Returns True if a new period was recorded, False if already recorded.
233 """
234 if not self.credit_manager.table:
235 return False
237 try:
238 # Read current last_credited_period
239 response = self.credit_manager.table.get_item(Key={"user_id": user_id})
240 item = response.get("Item", {})
241 last_period = item.get("last_credited_period")
243 period_decimal = Decimal(str(current_period_start))
245 # Check if this is a new period
246 if last_period is not None and period_decimal <= Decimal(str(last_period)):
247 logger.info(f"Period {current_period_start} already recorded for {user_id}")
248 return False
250 # Conditional update to prevent race conditions
251 if last_period is None:
252 condition = "attribute_not_exists(last_credited_period)"
253 expr_values = {":new_period": period_decimal}
254 else:
255 condition = "last_credited_period = :prev_period"
256 expr_values = {
257 ":new_period": period_decimal,
258 ":prev_period": Decimal(str(last_period)),
259 }
261 self.credit_manager.table.update_item(
262 Key={"user_id": user_id},
263 UpdateExpression="SET last_credited_period = :new_period",
264 ConditionExpression=condition,
265 ExpressionAttributeValues=expr_values,
266 )
268 logger.info(f"Recorded billing period {current_period_start} for {user_id}")
269 return True
271 except ClientError as e:
272 if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
273 logger.info(
274 f"Billing period {current_period_start} already recorded for {user_id} "
275 "(concurrent delivery)"
276 )
277 return False
278 logger.error(f"Error recording billing period for {user_id}: {e}")
279 raise
280 except Exception as e:
281 logger.error(f"Error recording billing period for {user_id}: {e}")
282 return False
284 def _handle_subscription_deleted(self, subscription: dict[str, Any]) -> dict[str, Any]:
285 """Handle subscription cancellation with timestamp."""
286 metadata = subscription.get("metadata", {})
287 user_id = metadata.get("user_id")
288 subscription_id = subscription.get("id")
290 if user_id:
291 self.credit_manager.set_subscription_state(
292 user_id=user_id, status="cancelled", stripe_subscription_id=subscription_id
293 )
295 # Set cancellation timestamp
296 if self.credit_manager.table:
297 try:
298 self.credit_manager.table.update_item(
299 Key={"user_id": user_id},
300 UpdateExpression="SET subscription_cancelled_at = :now",
301 ExpressionAttributeValues={
302 ":now": datetime.now(UTC).isoformat(),
303 },
304 )
305 except Exception as e:
306 logger.error(f"Error setting cancelled_at for {user_id}: {e}")
308 logger.info(f"Cancelled subscription {subscription_id} for user {user_id}")
310 return {"subscription_id": subscription_id, "status": "cancelled"}
312 def _handle_invoice_paid(self, invoice: dict[str, Any]) -> dict[str, Any]:
313 """Handle successful subscription payment."""
314 customer_id = invoice.get("customer")
315 amount = invoice.get("amount_paid", 0) / 100.0
316 logger.info(f"Invoice paid: ${amount} from customer {customer_id}")
317 return {"amount_paid": amount}
319 def _handle_invoice_failed(self, invoice: dict[str, Any]) -> dict[str, Any]:
320 """Handle failed subscription payment."""
321 customer_id = invoice.get("customer")
322 logger.warning(f"Invoice payment failed for customer {customer_id}")
323 return {"status": "payment_failed"}
325 def _handle_payment_intent_succeeded(self, payment_intent: dict[str, Any]) -> dict[str, Any]:
326 """Handle successful payment intent with idempotent credit grant."""
327 metadata = payment_intent.get("metadata", {})
328 user_id = metadata.get("user_id")
330 if not user_id:
331 logger.error("No user_id in payment_intent metadata")
332 return {"error": "Missing user_id"}
334 # Check if this is a verification charge ($1)
335 if metadata.get("type") == "verification":
336 logger.info(f"Verification charge completed for user {user_id}")
337 return {"type": "verification", "status": "completed"}
339 # Get credits from metadata (set during payment creation)
340 credits = int(metadata.get("credits", 0))
341 payment_intent_id = payment_intent.get("id", "")
343 if credits > 0 and payment_intent_id:
344 granted = self.credit_manager.idempotent_credit_grant(
345 user_id, credits, payment_intent_id
346 )
347 if granted:
348 new_balance = self.credit_manager.get_balance(user_id)
349 logger.info(
350 f"Added {credits} credits to user {user_id}, new balance: {new_balance}"
351 )
352 return {"credits_added": credits, "new_balance": new_balance}
353 else:
354 logger.info(f"Payment {payment_intent_id} already processed for {user_id}")
355 return {"message": "Payment already processed, credits not added"}
357 return {"message": "Payment processed"}
359 def _handle_dispute_created(self, dispute: dict[str, Any]) -> dict[str, Any]:
360 """Handle charge dispute (mark account as disputed)."""
361 charge_id = dispute.get("charge")
363 if not charge_id:
364 logger.error("No charge_id in dispute")
365 return {"error": "Missing charge_id"}
367 amount = dispute.get("amount", 0) / 100.0
368 reason = dispute.get("reason", "unknown")
370 logger.warning(f"Dispute created for charge {charge_id}: ${amount}, reason: {reason}")
372 return {"dispute_id": dispute.get("id"), "status": "created", "amount": amount}