ai-lls-lib 2.0.0rc2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ """Stripe webhook event processing."""
2
+
3
+ import json
4
+ import logging
5
+ from typing import Dict, Any
6
+
7
+ try:
8
+ import stripe
9
+ except ImportError:
10
+ stripe = None
11
+
12
+ from .credit_manager import CreditManager
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class WebhookProcessor:
18
+ """Process Stripe webhook events."""
19
+
20
+ def __init__(self, webhook_secret: str, credit_manager: CreditManager):
21
+ """Initialize with webhook secret and credit manager."""
22
+ self.webhook_secret = webhook_secret
23
+ self.credit_manager = credit_manager
24
+
25
+ def verify_and_parse(self, payload: str, signature: str) -> Dict[str, Any]:
26
+ """Verify webhook signature and parse event."""
27
+ if not stripe:
28
+ raise ImportError("stripe package not installed")
29
+
30
+ try:
31
+ event = stripe.Webhook.construct_event(
32
+ payload, signature, self.webhook_secret
33
+ )
34
+ return event
35
+ except ValueError as e:
36
+ logger.error(f"Invalid webhook payload: {e}")
37
+ raise
38
+ except stripe.error.SignatureVerificationError as e:
39
+ logger.error(f"Invalid webhook signature: {e}")
40
+ raise
41
+
42
+ def process_event(self, event: Dict[str, Any]) -> Dict[str, Any]:
43
+ """
44
+ Process a verified webhook event.
45
+ Returns response data.
46
+ """
47
+ event_type = event.get("type")
48
+ event_data = event.get("data", {}).get("object", {})
49
+
50
+ logger.info(f"Processing webhook event: {event_type}")
51
+
52
+ if event_type == "payment_intent.succeeded":
53
+ return self._handle_payment_intent_succeeded(event_data)
54
+
55
+ elif event_type == "checkout.session.completed":
56
+ return self._handle_checkout_completed(event_data)
57
+
58
+ elif event_type == "customer.subscription.created":
59
+ return self._handle_subscription_created(event_data)
60
+
61
+ elif event_type == "customer.subscription.updated":
62
+ return self._handle_subscription_updated(event_data)
63
+
64
+ elif event_type == "customer.subscription.deleted":
65
+ return self._handle_subscription_deleted(event_data)
66
+
67
+ elif event_type == "invoice.payment_succeeded":
68
+ return self._handle_invoice_paid(event_data)
69
+
70
+ elif event_type == "invoice.payment_failed":
71
+ return self._handle_invoice_failed(event_data)
72
+
73
+ elif event_type == "charge.dispute.created":
74
+ return self._handle_dispute_created(event_data)
75
+
76
+ else:
77
+ logger.info(f"Unhandled event type: {event_type}")
78
+ return {"message": f"Event {event_type} received but not processed"}
79
+
80
+ def _handle_checkout_completed(self, session: Dict[str, Any]) -> Dict[str, Any]:
81
+ """Handle successful checkout session for credit purchase."""
82
+ metadata = session.get("metadata", {})
83
+ user_id = metadata.get("user_id")
84
+
85
+ if not user_id:
86
+ logger.error("No user_id in checkout session metadata")
87
+ return {"error": "Missing user_id"}
88
+
89
+ # Get line items to determine credits purchased
90
+ if session.get("mode") == "payment":
91
+ # One-time payment for credits
92
+ # In production, fetch line items from Stripe to get price metadata
93
+ # For now, extract from session metadata if available
94
+ credits = int(metadata.get("credits", 0))
95
+
96
+ if credits > 0:
97
+ new_balance = self.credit_manager.add_credits(user_id, credits)
98
+ logger.info(f"Added {credits} credits to user {user_id}, new balance: {new_balance}")
99
+ return {"credits_added": credits, "new_balance": new_balance}
100
+
101
+ return {"message": "Checkout processed"}
102
+
103
+ def _handle_subscription_created(self, subscription: Dict[str, Any]) -> Dict[str, Any]:
104
+ """Handle new subscription creation."""
105
+ metadata = subscription.get("metadata", {})
106
+ user_id = metadata.get("user_id")
107
+ customer_id = subscription.get("customer")
108
+ subscription_id = subscription.get("id")
109
+ status = subscription.get("status")
110
+
111
+ if user_id:
112
+ self.credit_manager.set_subscription_state(
113
+ user_id=user_id,
114
+ status=status,
115
+ stripe_customer_id=customer_id,
116
+ stripe_subscription_id=subscription_id
117
+ )
118
+ logger.info(f"Created subscription {subscription_id} for user {user_id}")
119
+
120
+ return {"subscription_id": subscription_id, "status": status}
121
+
122
+ def _handle_subscription_updated(self, subscription: Dict[str, Any]) -> Dict[str, Any]:
123
+ """Handle subscription updates (pause/resume/etc)."""
124
+ metadata = subscription.get("metadata", {})
125
+ user_id = metadata.get("user_id")
126
+ subscription_id = subscription.get("id")
127
+ status = subscription.get("status")
128
+
129
+ if user_id:
130
+ self.credit_manager.set_subscription_state(
131
+ user_id=user_id,
132
+ status=status,
133
+ stripe_subscription_id=subscription_id
134
+ )
135
+ logger.info(f"Updated subscription {subscription_id} status to {status}")
136
+
137
+ return {"subscription_id": subscription_id, "status": status}
138
+
139
+ def _handle_subscription_deleted(self, subscription: Dict[str, Any]) -> Dict[str, Any]:
140
+ """Handle subscription cancellation."""
141
+ metadata = subscription.get("metadata", {})
142
+ user_id = metadata.get("user_id")
143
+ subscription_id = subscription.get("id")
144
+
145
+ if user_id:
146
+ self.credit_manager.set_subscription_state(
147
+ user_id=user_id,
148
+ status="cancelled",
149
+ stripe_subscription_id=subscription_id
150
+ )
151
+ logger.info(f"Cancelled subscription {subscription_id} for user {user_id}")
152
+
153
+ return {"subscription_id": subscription_id, "status": "cancelled"}
154
+
155
+ def _handle_invoice_paid(self, invoice: Dict[str, Any]) -> Dict[str, Any]:
156
+ """Handle successful subscription payment."""
157
+ # For monthly subscriptions, could grant monthly credit allotment here
158
+ # For now, just log the payment
159
+ customer_id = invoice.get("customer")
160
+ amount = invoice.get("amount_paid", 0) / 100.0
161
+ logger.info(f"Invoice paid: ${amount} from customer {customer_id}")
162
+ return {"amount_paid": amount}
163
+
164
+ def _handle_invoice_failed(self, invoice: Dict[str, Any]) -> Dict[str, Any]:
165
+ """Handle failed subscription payment."""
166
+ customer_id = invoice.get("customer")
167
+ logger.warning(f"Invoice payment failed for customer {customer_id}")
168
+ # Could pause subscription or send notification here
169
+ return {"status": "payment_failed"}
170
+
171
+ def _handle_payment_intent_succeeded(self, payment_intent: Dict[str, Any]) -> Dict[str, Any]:
172
+ """Handle successful payment intent (credit purchase)."""
173
+ metadata = payment_intent.get("metadata", {})
174
+ user_id = metadata.get("user_id")
175
+
176
+ if not user_id:
177
+ logger.error("No user_id in payment_intent metadata")
178
+ return {"error": "Missing user_id"}
179
+
180
+ # Check if this is a verification charge ($1)
181
+ if metadata.get("type") == "verification":
182
+ # This was the $1 verification, credits already added in payment_setup handler
183
+ logger.info(f"Verification charge completed for user {user_id}")
184
+ return {"type": "verification", "status": "completed"}
185
+
186
+ # Get credits from metadata (set during payment creation)
187
+ credits = int(metadata.get("credits", 0))
188
+
189
+ if credits > 0:
190
+ new_balance = self.credit_manager.add_credits(user_id, credits)
191
+ logger.info(f"Added {credits} credits to user {user_id}, new balance: {new_balance}")
192
+ return {"credits_added": credits, "new_balance": new_balance}
193
+
194
+ return {"message": "Payment processed"}
195
+
196
+ def _handle_dispute_created(self, dispute: Dict[str, Any]) -> Dict[str, Any]:
197
+ """Handle charge dispute (mark account as disputed)."""
198
+ # Get the charge and its metadata
199
+ charge_id = dispute.get("charge")
200
+
201
+ if not charge_id:
202
+ logger.error("No charge_id in dispute")
203
+ return {"error": "Missing charge_id"}
204
+
205
+ # In production, would fetch the charge from Stripe to get metadata
206
+ # For now, log the dispute for manual handling
207
+ amount = dispute.get("amount", 0) / 100.0
208
+ reason = dispute.get("reason", "unknown")
209
+
210
+ logger.warning(f"Dispute created for charge {charge_id}: ${amount}, reason: {reason}")
211
+
212
+ # TODO: Mark user account as disputed in CreditsTable
213
+ # This would prevent new purchases until resolved
214
+
215
+ return {"dispute_id": dispute.get("id"), "status": "created", "amount": amount}
@@ -0,0 +1,8 @@
1
+ """
2
+ Verification providers for phone number checking
3
+ """
4
+ from .base import VerificationProvider
5
+ from .stub import StubProvider
6
+ from .external import ExternalAPIProvider
7
+
8
+ __all__ = ["VerificationProvider", "StubProvider", "ExternalAPIProvider"]
@@ -0,0 +1,28 @@
1
+ """
2
+ Base protocol for verification providers
3
+ """
4
+ from typing import Protocol, Tuple
5
+ from ..core.models import LineType
6
+
7
+
8
+ class VerificationProvider(Protocol):
9
+ """
10
+ Protocol for phone verification providers.
11
+ All providers must implement this interface.
12
+ """
13
+
14
+ def verify_phone(self, phone: str) -> Tuple[LineType, bool]:
15
+ """
16
+ Verify a phone number's line type and DNC status.
17
+
18
+ Args:
19
+ phone: E.164 formatted phone number
20
+
21
+ Returns:
22
+ Tuple of (line_type, is_on_dnc_list)
23
+
24
+ Raises:
25
+ ValueError: If phone format is invalid
26
+ Exception: For provider-specific errors
27
+ """
28
+ ...
@@ -0,0 +1,151 @@
1
+ """
2
+ External API provider for production phone verification
3
+ """
4
+ import os
5
+ from typing import Tuple, Optional
6
+ import httpx
7
+ from aws_lambda_powertools import Logger
8
+ from ..core.models import LineType
9
+
10
+ logger = Logger()
11
+
12
+
13
+ class ExternalAPIProvider:
14
+ """
15
+ Production provider that calls external verification APIs.
16
+ Uses landlineremover.com which returns both line type and DNC status in a single call.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ api_key: Optional[str] = None,
22
+ timeout: float = 10.0
23
+ ):
24
+ """
25
+ Initialize external API provider.
26
+
27
+ Args:
28
+ api_key: API key for landlineremover.com (if not provided, uses environment)
29
+ timeout: HTTP request timeout in seconds
30
+ """
31
+ # Get environment-specific API key
32
+ # Default to 'prod' if not specified
33
+ env = os.environ.get("ENVIRONMENT", "prod").upper()
34
+
35
+ # Try environment-specific key first, then fall back to provided key
36
+ self.api_key = (
37
+ api_key or
38
+ os.environ.get(f"{env}_LANDLINE_API_KEY") or
39
+ os.environ.get("PHONE_VERIFY_API_KEY", "")
40
+ )
41
+
42
+ if not self.api_key:
43
+ logger.warning(f"No API key found for environment {env}")
44
+
45
+ self.api_url = "https://app.landlineremover.com/api/check-number"
46
+ self.timeout = timeout
47
+ self.http_client = httpx.Client(timeout=timeout)
48
+
49
+ def verify_phone(self, phone: str) -> Tuple[LineType, bool]:
50
+ """
51
+ Verify phone using landlineremover.com API.
52
+
53
+ This API returns both line type and DNC status in a single call,
54
+ which is more efficient than making two separate API calls.
55
+
56
+ Args:
57
+ phone: E.164 formatted phone number
58
+
59
+ Returns:
60
+ Tuple of (line_type, is_on_dnc_list)
61
+
62
+ Raises:
63
+ httpx.HTTPError: For API communication errors
64
+ ValueError: For invalid responses
65
+ """
66
+ logger.debug(f"Verifying phone {phone[:6]}*** via external API")
67
+
68
+ if not self.api_key:
69
+ raise ValueError("API key not configured")
70
+
71
+ try:
72
+ # Make single API call that returns both line type and DNC status
73
+ # The API may redirect, so we need to follow redirects
74
+ response = self.http_client.get(
75
+ self.api_url,
76
+ params={
77
+ "apikey": self.api_key,
78
+ "number": phone
79
+ },
80
+ follow_redirects=True
81
+ )
82
+
83
+ # Raise for HTTP errors
84
+ response.raise_for_status()
85
+
86
+ # Parse response
87
+ json_response = response.json()
88
+
89
+ # Extract data from response wrapper
90
+ if "data" in json_response:
91
+ data = json_response["data"]
92
+ else:
93
+ data = json_response
94
+
95
+ # Map line type from API response
96
+ line_type = self._map_line_type(data)
97
+
98
+ # Map DNC status - API uses "DNCType" field
99
+ # Values can be "dnc", "clean", etc.
100
+ dnc_type = data.get("DNCType", data.get("dnc_type", "")).lower()
101
+ is_dnc = dnc_type != "clean" and dnc_type != ""
102
+
103
+ logger.debug(
104
+ f"Verification complete for {phone[:6]}***",
105
+ extra={
106
+ "line_type": line_type.value,
107
+ "is_dnc": is_dnc,
108
+ "dnc_type": dnc_type
109
+ }
110
+ )
111
+
112
+ return line_type, is_dnc
113
+
114
+ except httpx.HTTPStatusError as e:
115
+ logger.error(f"API error: {e.response.status_code} - {e.response.text}")
116
+ raise ValueError(f"API request failed with status {e.response.status_code}")
117
+ except httpx.RequestError as e:
118
+ logger.error(f"Network error: {str(e)}")
119
+ raise ValueError(f"Network error during API call: {str(e)}")
120
+ except Exception as e:
121
+ logger.error(f"Unexpected error during verification: {str(e)}")
122
+ raise
123
+
124
+ def _map_line_type(self, data: dict) -> LineType:
125
+ """
126
+ Map API response to LineType enum.
127
+
128
+ Args:
129
+ data: API response dictionary
130
+
131
+ Returns:
132
+ LineType enum value
133
+ """
134
+ # API uses "LineType" (capitalized) field
135
+ line_type_str = data.get("LineType", data.get("line_type", "")).lower()
136
+
137
+ # Map common line types
138
+ line_type_map = {
139
+ "mobile": LineType.MOBILE,
140
+ "landline": LineType.LANDLINE,
141
+ "voip": LineType.VOIP,
142
+ "wireless": LineType.MOBILE, # Some APIs return "wireless" for mobile
143
+ "fixed": LineType.LANDLINE, # Some APIs return "fixed" for landline
144
+ }
145
+
146
+ return line_type_map.get(line_type_str, LineType.UNKNOWN)
147
+
148
+ def __del__(self):
149
+ """Cleanup HTTP client"""
150
+ if hasattr(self, 'http_client'):
151
+ self.http_client.close()
@@ -0,0 +1,48 @@
1
+ """
2
+ Stub provider for development and testing
3
+ """
4
+ from typing import Tuple
5
+ from aws_lambda_powertools import Logger
6
+ from ..core.models import LineType
7
+
8
+ logger = Logger()
9
+
10
+
11
+ class StubProvider:
12
+ """
13
+ Stub implementation for development and testing.
14
+ Uses deterministic rules based on phone number digits.
15
+ """
16
+
17
+ def verify_phone(self, phone: str) -> Tuple[LineType, bool]:
18
+ """
19
+ Verify using stub logic based on last digit.
20
+
21
+ Line type:
22
+ - Ends in 2 or 0: LANDLINE
23
+ - Otherwise: MOBILE
24
+
25
+ DNC status:
26
+ - Ends in 1 or 0: on DNC list
27
+ - Otherwise: not on DNC
28
+
29
+ Args:
30
+ phone: E.164 formatted phone number
31
+
32
+ Returns:
33
+ Tuple of (line_type, is_on_dnc_list)
34
+ """
35
+ logger.debug(f"Stub verification for {phone[:6]}***")
36
+
37
+ last_digit = phone[-1] if phone else '5'
38
+
39
+ # Determine line type
40
+ if last_digit in ['2', '0']:
41
+ line_type = LineType.LANDLINE
42
+ else:
43
+ line_type = LineType.MOBILE
44
+
45
+ # Determine DNC status
46
+ is_dnc = last_digit in ['1', '0']
47
+
48
+ return line_type, is_dnc
@@ -0,0 +1,3 @@
1
+ """
2
+ Testing utilities and fixtures
3
+ """
@@ -0,0 +1,104 @@
1
+ """
2
+ Test fixtures and utilities for ai-lls-lib
3
+ """
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import List, Dict, Any
6
+ from ai_lls_lib.core.models import PhoneVerification, LineType, VerificationSource
7
+
8
+ # Sample phone numbers for testing
9
+ # Using 201-555-01XX format which is designated for testing
10
+ TEST_PHONES = {
11
+ "valid_mobile": "+12015550153", # Ends in 3 - mobile, not on DNC
12
+ "valid_landline": "+12015550152", # Ends in 2 - landline, not on DNC
13
+ "dnc_mobile": "+12015550151", # Ends in 1 - mobile, on DNC
14
+ "dnc_landline": "+12015550150", # Ends in 0 - landline, on DNC
15
+ "invalid": "not-a-phone",
16
+ "missing_country": "2015550123",
17
+ "international": "+442071234567",
18
+ }
19
+
20
+ def create_test_verification(
21
+ phone: str = TEST_PHONES["valid_mobile"],
22
+ line_type: LineType = LineType.MOBILE,
23
+ dnc: bool = False,
24
+ cached: bool = False,
25
+ source: VerificationSource = VerificationSource.API
26
+ ) -> PhoneVerification:
27
+ """Create a test PhoneVerification object"""
28
+ return PhoneVerification(
29
+ phone_number=phone,
30
+ line_type=line_type,
31
+ dnc=dnc,
32
+ cached=cached,
33
+ verified_at=datetime.now(timezone.utc),
34
+ source=source
35
+ )
36
+
37
+ def create_test_csv_content(phones: List[str] = None) -> str:
38
+ """Create CSV content for testing bulk processing"""
39
+ if phones is None:
40
+ phones = [
41
+ TEST_PHONES["valid_mobile"],
42
+ TEST_PHONES["valid_landline"],
43
+ TEST_PHONES["dnc_mobile"],
44
+ ]
45
+
46
+ lines = ["name,phone,email"]
47
+ for i, phone in enumerate(phones):
48
+ lines.append(f"Test User {i},{phone},test{i}@example.com")
49
+
50
+ return "\n".join(lines)
51
+
52
+ def create_dynamodb_item(phone: str, line_type: LineType = LineType.MOBILE, dnc: bool = False) -> Dict[str, Any]:
53
+ """Create a DynamoDB item for testing"""
54
+ ttl = int((datetime.now(timezone.utc) + timedelta(days=30)).timestamp())
55
+
56
+ return {
57
+ "phone_number": phone,
58
+ "line_type": line_type.value, # Store as string in DynamoDB
59
+ "dnc": dnc,
60
+ "cached": True,
61
+ "verified_at": datetime.now(timezone.utc).isoformat(),
62
+ "source": VerificationSource.CACHE.value, # Store as string in DynamoDB
63
+ "ttl": ttl
64
+ }
65
+
66
+ def create_sqs_message(file_id: str, bucket: str, key: str, user_id: str) -> Dict[str, Any]:
67
+ """Create an SQS message for bulk processing"""
68
+ return {
69
+ "file_id": file_id,
70
+ "bucket": bucket,
71
+ "key": key,
72
+ "user_id": user_id,
73
+ "created_at": datetime.now(timezone.utc).isoformat()
74
+ }
75
+
76
+ def create_api_gateway_event(
77
+ phone: str = None,
78
+ user_id: str = "test-user",
79
+ method: str = "GET",
80
+ path: str = "/verify"
81
+ ) -> Dict[str, Any]:
82
+ """Create an API Gateway event for Lambda testing"""
83
+ event = {
84
+ "httpMethod": method,
85
+ "path": path,
86
+ "headers": {
87
+ "Authorization": "Bearer test-token"
88
+ },
89
+ "requestContext": {
90
+ "authorizer": {
91
+ "lambda": {
92
+ "principal_id": user_id,
93
+ "claims": {
94
+ "email": f"{user_id}@example.com"
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ if phone:
102
+ event["queryStringParameters"] = {"p": phone}
103
+
104
+ return event