ai-lls-lib 1.4.0rc2__py3-none-any.whl → 1.4.0rc4__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.
Files changed (34) hide show
  1. ai_lls_lib/__init__.py +1 -1
  2. ai_lls_lib/auth/__init__.py +4 -4
  3. ai_lls_lib/auth/context_parser.py +68 -68
  4. ai_lls_lib/cli/__init__.py +3 -3
  5. ai_lls_lib/cli/__main__.py +30 -30
  6. ai_lls_lib/cli/aws_client.py +115 -115
  7. ai_lls_lib/cli/commands/__init__.py +3 -3
  8. ai_lls_lib/cli/commands/admin.py +174 -174
  9. ai_lls_lib/cli/commands/cache.py +142 -142
  10. ai_lls_lib/cli/commands/stripe.py +377 -377
  11. ai_lls_lib/cli/commands/test_stack.py +216 -216
  12. ai_lls_lib/cli/commands/verify.py +111 -111
  13. ai_lls_lib/cli/env_loader.py +122 -122
  14. ai_lls_lib/core/__init__.py +3 -3
  15. ai_lls_lib/core/cache.py +106 -106
  16. ai_lls_lib/core/models.py +77 -77
  17. ai_lls_lib/core/processor.py +295 -295
  18. ai_lls_lib/core/verifier.py +84 -84
  19. ai_lls_lib/payment/__init__.py +13 -13
  20. ai_lls_lib/payment/credit_manager.py +186 -193
  21. ai_lls_lib/payment/models.py +102 -102
  22. ai_lls_lib/payment/stripe_manager.py +487 -487
  23. ai_lls_lib/payment/webhook_processor.py +215 -215
  24. ai_lls_lib/providers/__init__.py +7 -7
  25. ai_lls_lib/providers/base.py +28 -28
  26. ai_lls_lib/providers/external.py +87 -87
  27. ai_lls_lib/providers/stub.py +48 -48
  28. ai_lls_lib/testing/__init__.py +3 -3
  29. ai_lls_lib/testing/fixtures.py +104 -104
  30. {ai_lls_lib-1.4.0rc2.dist-info → ai_lls_lib-1.4.0rc4.dist-info}/METADATA +1 -1
  31. ai_lls_lib-1.4.0rc4.dist-info/RECORD +33 -0
  32. ai_lls_lib-1.4.0rc2.dist-info/RECORD +0 -33
  33. {ai_lls_lib-1.4.0rc2.dist-info → ai_lls_lib-1.4.0rc4.dist-info}/WHEEL +0 -0
  34. {ai_lls_lib-1.4.0rc2.dist-info → ai_lls_lib-1.4.0rc4.dist-info}/entry_points.txt +0 -0
@@ -1,84 +1,84 @@
1
- """
2
- Phone verification logic - checks line type and DNC status
3
- """
4
- import os
5
- from datetime import datetime, timezone
6
- from typing import Optional
7
- import phonenumbers
8
- from aws_lambda_powertools import Logger
9
- from .models import PhoneVerification, LineType, VerificationSource
10
- from .cache import DynamoDBCache
11
- from ..providers import VerificationProvider, StubProvider
12
-
13
- logger = Logger()
14
-
15
-
16
- class PhoneVerifier:
17
- """Verifies phone numbers for line type and DNC status"""
18
-
19
- def __init__(self, cache: DynamoDBCache, provider: Optional[VerificationProvider] = None):
20
- """
21
- Initialize phone verifier.
22
-
23
- Args:
24
- cache: DynamoDB cache for storing results
25
- provider: Verification provider (defaults to StubProvider)
26
- """
27
- self.cache = cache
28
- self.provider = provider or StubProvider()
29
-
30
- def normalize_phone(self, phone: str) -> str:
31
- """Normalize phone to E.164 format"""
32
- try:
33
- # Parse with US as default country
34
- parsed = phonenumbers.parse(phone, "US")
35
- if not phonenumbers.is_valid_number(parsed):
36
- raise ValueError(f"Invalid phone number: {phone}")
37
-
38
- # Format as E.164
39
- return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
40
- except Exception as e:
41
- logger.error(f"Phone normalization failed: {str(e)}")
42
- raise ValueError(f"Invalid phone format: {phone}")
43
-
44
- def verify(self, phone: str) -> PhoneVerification:
45
- """Verify phone number for line type and DNC status"""
46
- normalized = self.normalize_phone(phone)
47
-
48
- # Check cache first
49
- cached = self.cache.get(normalized)
50
- if cached:
51
- return cached
52
-
53
- # Use provider to verify
54
- line_type, dnc_status = self.provider.verify_phone(normalized)
55
-
56
- result = PhoneVerification(
57
- phone_number=normalized,
58
- line_type=line_type,
59
- dnc=dnc_status,
60
- cached=False,
61
- verified_at=datetime.now(timezone.utc),
62
- source=VerificationSource.API
63
- )
64
-
65
- # Store in cache
66
- self.cache.set(normalized, result)
67
-
68
- return result
69
-
70
- def _check_line_type(self, phone: str) -> LineType:
71
- """
72
- Check line type (for backwards compatibility with CLI).
73
- Delegates to provider.
74
- """
75
- line_type, _ = self.provider.verify_phone(phone)
76
- return line_type
77
-
78
- def _check_dnc(self, phone: str) -> bool:
79
- """
80
- Check DNC status (for backwards compatibility with CLI).
81
- Delegates to provider.
82
- """
83
- _, dnc_status = self.provider.verify_phone(phone)
84
- return dnc_status
1
+ """
2
+ Phone verification logic - checks line type and DNC status
3
+ """
4
+ import os
5
+ from datetime import datetime, timezone
6
+ from typing import Optional
7
+ import phonenumbers
8
+ from aws_lambda_powertools import Logger
9
+ from .models import PhoneVerification, LineType, VerificationSource
10
+ from .cache import DynamoDBCache
11
+ from ..providers import VerificationProvider, StubProvider
12
+
13
+ logger = Logger()
14
+
15
+
16
+ class PhoneVerifier:
17
+ """Verifies phone numbers for line type and DNC status"""
18
+
19
+ def __init__(self, cache: DynamoDBCache, provider: Optional[VerificationProvider] = None):
20
+ """
21
+ Initialize phone verifier.
22
+
23
+ Args:
24
+ cache: DynamoDB cache for storing results
25
+ provider: Verification provider (defaults to StubProvider)
26
+ """
27
+ self.cache = cache
28
+ self.provider = provider or StubProvider()
29
+
30
+ def normalize_phone(self, phone: str) -> str:
31
+ """Normalize phone to E.164 format"""
32
+ try:
33
+ # Parse with US as default country
34
+ parsed = phonenumbers.parse(phone, "US")
35
+ if not phonenumbers.is_valid_number(parsed):
36
+ raise ValueError(f"Invalid phone number: {phone}")
37
+
38
+ # Format as E.164
39
+ return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
40
+ except Exception as e:
41
+ logger.error(f"Phone normalization failed: {str(e)}")
42
+ raise ValueError(f"Invalid phone format: {phone}")
43
+
44
+ def verify(self, phone: str) -> PhoneVerification:
45
+ """Verify phone number for line type and DNC status"""
46
+ normalized = self.normalize_phone(phone)
47
+
48
+ # Check cache first
49
+ cached = self.cache.get(normalized)
50
+ if cached:
51
+ return cached
52
+
53
+ # Use provider to verify
54
+ line_type, dnc_status = self.provider.verify_phone(normalized)
55
+
56
+ result = PhoneVerification(
57
+ phone_number=normalized,
58
+ line_type=line_type,
59
+ dnc=dnc_status,
60
+ cached=False,
61
+ verified_at=datetime.now(timezone.utc),
62
+ source=VerificationSource.API
63
+ )
64
+
65
+ # Store in cache
66
+ self.cache.set(normalized, result)
67
+
68
+ return result
69
+
70
+ def _check_line_type(self, phone: str) -> LineType:
71
+ """
72
+ Check line type (for backwards compatibility with CLI).
73
+ Delegates to provider.
74
+ """
75
+ line_type, _ = self.provider.verify_phone(phone)
76
+ return line_type
77
+
78
+ def _check_dnc(self, phone: str) -> bool:
79
+ """
80
+ Check DNC status (for backwards compatibility with CLI).
81
+ Delegates to provider.
82
+ """
83
+ _, dnc_status = self.provider.verify_phone(phone)
84
+ return dnc_status
@@ -1,13 +1,13 @@
1
- """Payment module for Landline Scrubber."""
2
-
3
- from .models import Plan, PlanType, SubscriptionStatus
4
- from .stripe_manager import StripeManager
5
- from .credit_manager import CreditManager
6
-
7
- __all__ = [
8
- "Plan",
9
- "PlanType",
10
- "SubscriptionStatus",
11
- "StripeManager",
12
- "CreditManager",
13
- ]
1
+ """Payment module for Landline Scrubber."""
2
+
3
+ from .models import Plan, PlanType, SubscriptionStatus
4
+ from .stripe_manager import StripeManager
5
+ from .credit_manager import CreditManager
6
+
7
+ __all__ = [
8
+ "Plan",
9
+ "PlanType",
10
+ "SubscriptionStatus",
11
+ "StripeManager",
12
+ "CreditManager",
13
+ ]
@@ -1,193 +1,186 @@
1
- """Credit balance management with DynamoDB."""
2
-
3
- import os
4
- from typing import Optional, Dict, Any
5
- from decimal import Decimal
6
- import logging
7
- from datetime import datetime
8
-
9
- try:
10
- import boto3
11
- from botocore.exceptions import ClientError
12
- except ImportError:
13
- boto3 = None # Handle gracefully for testing
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
-
18
- class CreditManager:
19
- """
20
- Manages user credit balances in DynamoDB CreditsTable.
21
- """
22
-
23
- def __init__(self, table_name: Optional[str] = None):
24
- """Initialize with DynamoDB table."""
25
- if not boto3:
26
- logger.warning("boto3 not installed, using mock credit manager")
27
- self.table = None
28
- return
29
-
30
- self.dynamodb = boto3.resource("dynamodb")
31
- self.table_name = table_name or os.environ.get("CREDITS_TABLE", "CreditsTable")
32
-
33
- try:
34
- self.table = self.dynamodb.Table(self.table_name)
35
- except Exception as e:
36
- logger.error(f"Failed to connect to DynamoDB table {self.table_name}: {e}")
37
- self.table = None
38
-
39
- def get_balance(self, user_id: str) -> int:
40
- """Get current credit balance for a user."""
41
- if not self.table:
42
- return 1000 # Mock balance for testing
43
-
44
- try:
45
- response = self.table.get_item(Key={"user_id": user_id})
46
- if "Item" in response:
47
- return int(response["Item"].get("credits", 0))
48
- return 0
49
- except ClientError as e:
50
- logger.error(f"Error getting balance for {user_id}: {e}")
51
- return 0
52
-
53
- def add_credits(self, user_id: str, amount: int) -> int:
54
- """Add credits to user balance and return new balance."""
55
- if not self.table:
56
- return 1000 + amount # Mock for testing
57
-
58
- try:
59
- response = self.table.update_item(
60
- Key={"user_id": user_id},
61
- UpdateExpression="ADD credits :amount SET updated_at = :now",
62
- ExpressionAttributeValues={
63
- ":amount": Decimal(amount),
64
- ":now": datetime.utcnow().isoformat()
65
- },
66
- ReturnValues="ALL_NEW"
67
- )
68
- return int(response["Attributes"]["credits"])
69
- except ClientError as e:
70
- logger.error(f"Error adding credits for {user_id}: {e}")
71
- raise
72
-
73
- def deduct_credits(self, user_id: str, amount: int) -> bool:
74
- """
75
- Deduct credits from user balance.
76
- Returns True if successful, False if insufficient balance.
77
- """
78
- if not self.table:
79
- return True # Mock for testing
80
-
81
- try:
82
- # Conditional update - only deduct if balance >= amount
83
- self.table.update_item(
84
- Key={"user_id": user_id},
85
- UpdateExpression="ADD credits :negative_amount SET updated_at = :now",
86
- ConditionExpression="credits >= :amount",
87
- ExpressionAttributeValues={
88
- ":negative_amount": Decimal(-amount),
89
- ":amount": Decimal(amount),
90
- ":now": datetime.utcnow().isoformat()
91
- }
92
- )
93
- return True
94
- except ClientError as e:
95
- if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
96
- logger.info(f"Insufficient credits for {user_id}")
97
- return False
98
- logger.error(f"Error deducting credits for {user_id}: {e}")
99
- raise
100
-
101
- def set_subscription_state(
102
- self,
103
- user_id: str,
104
- status: str,
105
- stripe_customer_id: Optional[str] = None,
106
- stripe_subscription_id: Optional[str] = None
107
- ) -> None:
108
- """Update subscription state in CreditsTable."""
109
- if not self.table:
110
- return # Mock for testing
111
-
112
- try:
113
- update_expr = "SET subscription_status = :status, updated_at = :now"
114
- expr_values = {
115
- ":status": status,
116
- ":now": datetime.utcnow().isoformat()
117
- }
118
-
119
- if stripe_customer_id:
120
- update_expr += ", stripe_customer_id = :customer_id"
121
- expr_values[":customer_id"] = stripe_customer_id
122
-
123
- if stripe_subscription_id:
124
- update_expr += ", stripe_subscription_id = :subscription_id"
125
- expr_values[":subscription_id"] = stripe_subscription_id
126
-
127
- self.table.update_item(
128
- Key={"user_id": user_id},
129
- UpdateExpression=update_expr,
130
- ExpressionAttributeValues=expr_values
131
- )
132
- except ClientError as e:
133
- logger.error(f"Error updating subscription state for {user_id}: {e}")
134
- raise
135
-
136
- def get_user_payment_info(self, user_id: str) -> Dict[str, Any]:
137
- """Get user's payment-related information."""
138
- if not self.table:
139
- return {
140
- "credits": 1000,
141
- "stripe_customer_id": None,
142
- "stripe_subscription_id": None,
143
- "subscription_status": None
144
- }
145
-
146
- try:
147
- response = self.table.get_item(Key={"user_id": user_id})
148
- if "Item" in response:
149
- item = response["Item"]
150
- return {
151
- "credits": int(item.get("credits", 0)),
152
- "stripe_customer_id": item.get("stripe_customer_id"),
153
- "stripe_subscription_id": item.get("stripe_subscription_id"),
154
- "subscription_status": item.get("subscription_status")
155
- }
156
- return {
157
- "credits": 0,
158
- "stripe_customer_id": None,
159
- "stripe_subscription_id": None,
160
- "subscription_status": None
161
- }
162
- except ClientError as e:
163
- logger.error(f"Error getting payment info for {user_id}: {e}")
164
- return {
165
- "credits": 0,
166
- "stripe_customer_id": None,
167
- "stripe_subscription_id": None,
168
- "subscription_status": None
169
- }
170
-
171
- def has_unlimited_access(self, user_id: str) -> bool:
172
- """Check if user has unlimited access via active subscription."""
173
- info = self.get_user_payment_info(user_id)
174
- return info.get("subscription_status") == "active"
175
-
176
- def set_stripe_customer_id(self, user_id: str, stripe_customer_id: str) -> None:
177
- """Store Stripe customer ID for a user."""
178
- if not self.table:
179
- return # Mock for testing
180
-
181
- try:
182
- self.table.update_item(
183
- Key={"user_id": user_id},
184
- UpdateExpression="SET stripe_customer_id = :customer_id, updated_at = :now",
185
- ExpressionAttributeValues={
186
- ":customer_id": stripe_customer_id,
187
- ":now": datetime.utcnow().isoformat()
188
- }
189
- )
190
- logger.info(f"Stored Stripe customer ID {stripe_customer_id} for user {user_id}")
191
- except ClientError as e:
192
- logger.error(f"Error storing Stripe customer ID for {user_id}: {e}")
193
- raise
1
+ """Credit balance management with DynamoDB."""
2
+
3
+ import os
4
+ from typing import Optional, Dict, Any
5
+ from decimal import Decimal
6
+ import logging
7
+ from datetime import datetime
8
+
9
+ try:
10
+ import boto3
11
+ from botocore.exceptions import ClientError
12
+ except ImportError:
13
+ boto3 = None # Handle gracefully for testing
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class CreditManager:
19
+ """
20
+ Manages user credit balances in DynamoDB CreditsTable.
21
+ """
22
+
23
+ def __init__(self, table_name: Optional[str] = None):
24
+ """Initialize with DynamoDB table."""
25
+ if not boto3:
26
+ raise RuntimeError("boto3 is required for CreditManager")
27
+
28
+ self.dynamodb = boto3.resource("dynamodb")
29
+ self.table_name = table_name if table_name else os.environ['CREDITS_TABLE']
30
+
31
+ try:
32
+ self.table = self.dynamodb.Table(self.table_name)
33
+ except Exception as e:
34
+ logger.error(f"Failed to connect to DynamoDB table {self.table_name}: {e}")
35
+ self.table = None
36
+
37
+ def get_balance(self, user_id: str) -> int:
38
+ """Get current credit balance for a user."""
39
+ if not self.table:
40
+ raise RuntimeError(f"DynamoDB table {self.table_name} not accessible")
41
+
42
+ try:
43
+ response = self.table.get_item(Key={"user_id": user_id})
44
+ if "Item" in response:
45
+ return int(response["Item"].get("credits", 0))
46
+ return 0
47
+ except ClientError as e:
48
+ logger.error(f"Error getting balance for {user_id}: {e}")
49
+ return 0
50
+
51
+ def add_credits(self, user_id: str, amount: int) -> int:
52
+ """Add credits to user balance and return new balance."""
53
+ if not self.table:
54
+ raise RuntimeError(f"DynamoDB table {self.table_name} not accessible")
55
+
56
+ try:
57
+ response = self.table.update_item(
58
+ Key={"user_id": user_id},
59
+ UpdateExpression="ADD credits :amount SET updated_at = :now",
60
+ ExpressionAttributeValues={
61
+ ":amount": Decimal(amount),
62
+ ":now": datetime.utcnow().isoformat()
63
+ },
64
+ ReturnValues="ALL_NEW"
65
+ )
66
+ return int(response["Attributes"]["credits"])
67
+ except ClientError as e:
68
+ logger.error(f"Error adding credits for {user_id}: {e}")
69
+ raise
70
+
71
+ def deduct_credits(self, user_id: str, amount: int) -> bool:
72
+ """
73
+ Deduct credits from user balance.
74
+ Returns True if successful, False if insufficient balance.
75
+ """
76
+ if not self.table:
77
+ raise RuntimeError(f"DynamoDB table {self.table_name} not accessible")
78
+
79
+ try:
80
+ # Conditional update - only deduct if balance >= amount
81
+ self.table.update_item(
82
+ Key={"user_id": user_id},
83
+ UpdateExpression="ADD credits :negative_amount SET updated_at = :now",
84
+ ConditionExpression="credits >= :amount",
85
+ ExpressionAttributeValues={
86
+ ":negative_amount": Decimal(-amount),
87
+ ":amount": Decimal(amount),
88
+ ":now": datetime.utcnow().isoformat()
89
+ }
90
+ )
91
+ return True
92
+ except ClientError as e:
93
+ if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
94
+ logger.info(f"Insufficient credits for {user_id}")
95
+ return False
96
+ logger.error(f"Error deducting credits for {user_id}: {e}")
97
+ raise
98
+
99
+ def set_subscription_state(
100
+ self,
101
+ user_id: str,
102
+ status: str,
103
+ stripe_customer_id: Optional[str] = None,
104
+ stripe_subscription_id: Optional[str] = None
105
+ ) -> None:
106
+ """Update subscription state in CreditsTable."""
107
+ if not self.table:
108
+ raise RuntimeError(f"DynamoDB table {self.table_name} not accessible")
109
+
110
+ try:
111
+ update_expr = "SET subscription_status = :status, updated_at = :now"
112
+ expr_values = {
113
+ ":status": status,
114
+ ":now": datetime.utcnow().isoformat()
115
+ }
116
+
117
+ if stripe_customer_id:
118
+ update_expr += ", stripe_customer_id = :customer_id"
119
+ expr_values[":customer_id"] = stripe_customer_id
120
+
121
+ if stripe_subscription_id:
122
+ update_expr += ", stripe_subscription_id = :subscription_id"
123
+ expr_values[":subscription_id"] = stripe_subscription_id
124
+
125
+ self.table.update_item(
126
+ Key={"user_id": user_id},
127
+ UpdateExpression=update_expr,
128
+ ExpressionAttributeValues=expr_values
129
+ )
130
+ except ClientError as e:
131
+ logger.error(f"Error updating subscription state for {user_id}: {e}")
132
+ raise
133
+
134
+ def get_user_payment_info(self, user_id: str) -> Dict[str, Any]:
135
+ """Get user's payment-related information."""
136
+ if not self.table:
137
+ raise RuntimeError(f"DynamoDB table {self.table_name} not accessible")
138
+
139
+ try:
140
+ response = self.table.get_item(Key={"user_id": user_id})
141
+ if "Item" in response:
142
+ item = response["Item"]
143
+ return {
144
+ "credits": int(item.get("credits", 0)),
145
+ "stripe_customer_id": item.get("stripe_customer_id"),
146
+ "stripe_subscription_id": item.get("stripe_subscription_id"),
147
+ "subscription_status": item.get("subscription_status")
148
+ }
149
+ return {
150
+ "credits": 0,
151
+ "stripe_customer_id": None,
152
+ "stripe_subscription_id": None,
153
+ "subscription_status": None
154
+ }
155
+ except ClientError as e:
156
+ logger.error(f"Error getting payment info for {user_id}: {e}")
157
+ return {
158
+ "credits": 0,
159
+ "stripe_customer_id": None,
160
+ "stripe_subscription_id": None,
161
+ "subscription_status": None
162
+ }
163
+
164
+ def has_unlimited_access(self, user_id: str) -> bool:
165
+ """Check if user has unlimited access via active subscription."""
166
+ info = self.get_user_payment_info(user_id)
167
+ return info.get("subscription_status") == "active"
168
+
169
+ def set_stripe_customer_id(self, user_id: str, stripe_customer_id: str) -> None:
170
+ """Store Stripe customer ID for a user."""
171
+ if not self.table:
172
+ raise RuntimeError(f"DynamoDB table {self.table_name} not accessible")
173
+
174
+ try:
175
+ self.table.update_item(
176
+ Key={"user_id": user_id},
177
+ UpdateExpression="SET stripe_customer_id = :customer_id, updated_at = :now",
178
+ ExpressionAttributeValues={
179
+ ":customer_id": stripe_customer_id,
180
+ ":now": datetime.utcnow().isoformat()
181
+ }
182
+ )
183
+ logger.info(f"Stored Stripe customer ID {stripe_customer_id} for user {user_id}")
184
+ except ClientError as e:
185
+ logger.error(f"Error storing Stripe customer ID for {user_id}: {e}")
186
+ raise