ai-lls-lib 1.0.0__py3-none-any.whl → 1.2.0__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.
- ai_lls_lib/__init__.py +1 -1
- ai_lls_lib/auth/__init__.py +4 -0
- ai_lls_lib/auth/context_parser.py +68 -0
- ai_lls_lib/cli/__main__.py +2 -1
- ai_lls_lib/cli/commands/stripe.py +307 -0
- ai_lls_lib/cli/commands/verify.py +3 -3
- ai_lls_lib/cli/env_loader.py +122 -0
- ai_lls_lib/core/processor.py +219 -59
- ai_lls_lib/core/verifier.py +29 -40
- ai_lls_lib/payment/__init__.py +13 -0
- ai_lls_lib/payment/credit_manager.py +174 -0
- ai_lls_lib/payment/models.py +96 -0
- ai_lls_lib/payment/stripe_manager.py +473 -0
- ai_lls_lib/payment/webhook_processor.py +163 -0
- ai_lls_lib/providers/__init__.py +7 -0
- ai_lls_lib/providers/base.py +28 -0
- ai_lls_lib/providers/external.py +87 -0
- ai_lls_lib/providers/stub.py +48 -0
- {ai_lls_lib-1.0.0.dist-info → ai_lls_lib-1.2.0.dist-info}/METADATA +61 -8
- ai_lls_lib-1.2.0.dist-info/RECORD +33 -0
- ai_lls_lib-1.0.0.dist-info/RECORD +0 -20
- {ai_lls_lib-1.0.0.dist-info → ai_lls_lib-1.2.0.dist-info}/WHEEL +0 -0
- {ai_lls_lib-1.0.0.dist-info → ai_lls_lib-1.2.0.dist-info}/entry_points.txt +0 -0
ai_lls_lib/core/processor.py
CHANGED
@@ -2,7 +2,8 @@
|
|
2
2
|
Bulk CSV processing for phone verification
|
3
3
|
"""
|
4
4
|
import csv
|
5
|
-
from
|
5
|
+
from io import StringIO
|
6
|
+
from typing import List, Optional, Iterator, Iterable
|
6
7
|
from aws_lambda_powertools import Logger
|
7
8
|
from .models import PhoneVerification
|
8
9
|
from .verifier import PhoneVerifier
|
@@ -16,47 +17,48 @@ class BulkProcessor:
|
|
16
17
|
def __init__(self, verifier: PhoneVerifier):
|
17
18
|
self.verifier = verifier
|
18
19
|
|
19
|
-
def
|
20
|
+
def process_csv(self, csv_text: str, phone_column: str = "phone") -> List[PhoneVerification]:
|
20
21
|
"""
|
21
|
-
Process CSV
|
22
|
+
Process CSV text content.
|
22
23
|
Returns list of verification results.
|
23
24
|
"""
|
24
25
|
results = []
|
25
26
|
|
26
27
|
try:
|
27
|
-
|
28
|
-
|
28
|
+
# Use StringIO to parse CSV text
|
29
|
+
csv_file = StringIO(csv_text)
|
30
|
+
reader = csv.DictReader(csv_file)
|
29
31
|
|
30
|
-
|
31
|
-
|
32
|
-
|
32
|
+
# Find phone column (case-insensitive)
|
33
|
+
headers = reader.fieldnames or []
|
34
|
+
phone_col = self._find_phone_column(headers, phone_column)
|
33
35
|
|
34
|
-
|
35
|
-
|
36
|
+
if not phone_col:
|
37
|
+
raise ValueError(f"Phone column '{phone_column}' not found in CSV")
|
36
38
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
39
|
+
for row_num, row in enumerate(reader, start=2): # Start at 2 (header is 1)
|
40
|
+
try:
|
41
|
+
phone = row.get(phone_col, "").strip()
|
42
|
+
if not phone:
|
43
|
+
logger.warning(f"Empty phone at row {row_num}")
|
44
|
+
continue
|
43
45
|
|
44
|
-
|
45
|
-
|
46
|
-
|
46
|
+
# Verify phone
|
47
|
+
result = self.verifier.verify(phone)
|
48
|
+
results.append(result)
|
47
49
|
|
48
|
-
|
49
|
-
|
50
|
-
|
50
|
+
# Log progress every 100 rows
|
51
|
+
if len(results) % 100 == 0:
|
52
|
+
logger.info(f"Processed {len(results)} phones")
|
51
53
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
54
|
+
except ValueError as e:
|
55
|
+
logger.warning(f"Invalid phone at row {row_num}: {str(e)}")
|
56
|
+
continue
|
57
|
+
except Exception as e:
|
58
|
+
logger.error(f"Error processing row {row_num}: {str(e)}")
|
59
|
+
continue
|
58
60
|
|
59
|
-
|
61
|
+
logger.info(f"Completed processing {len(results)} valid phones")
|
60
62
|
|
61
63
|
except Exception as e:
|
62
64
|
logger.error(f"CSV processing failed: {str(e)}")
|
@@ -88,48 +90,206 @@ class BulkProcessor:
|
|
88
90
|
|
89
91
|
def generate_results_csv(
|
90
92
|
self,
|
91
|
-
|
92
|
-
results: List[PhoneVerification]
|
93
|
-
|
94
|
-
) -> None:
|
93
|
+
original_csv_text: str,
|
94
|
+
results: List[PhoneVerification]
|
95
|
+
) -> str:
|
95
96
|
"""
|
96
97
|
Generate CSV with original data plus verification results.
|
97
98
|
Adds columns: line_type, dnc, cached
|
99
|
+
Returns CSV text string.
|
98
100
|
"""
|
99
101
|
# Create lookup dict
|
100
102
|
results_map = {r.phone_number: r for r in results}
|
101
103
|
|
102
|
-
|
103
|
-
|
104
|
+
# Parse original CSV
|
105
|
+
input_file = StringIO(original_csv_text)
|
106
|
+
reader = csv.DictReader(input_file)
|
107
|
+
headers = reader.fieldnames or []
|
108
|
+
|
109
|
+
# Add new columns
|
110
|
+
output_headers = headers + ["line_type", "dnc", "cached"]
|
111
|
+
|
112
|
+
# Create output CSV in memory
|
113
|
+
output = StringIO()
|
114
|
+
writer = csv.DictWriter(output, fieldnames=output_headers)
|
115
|
+
writer.writeheader()
|
116
|
+
|
117
|
+
phone_col = self._find_phone_column(headers, "phone")
|
118
|
+
|
119
|
+
for row in reader:
|
120
|
+
phone = row.get(phone_col, "").strip()
|
121
|
+
|
122
|
+
# Try to normalize for lookup
|
123
|
+
try:
|
124
|
+
normalized = self.verifier.normalize_phone(phone)
|
125
|
+
if normalized in results_map:
|
126
|
+
result = results_map[normalized]
|
127
|
+
row["line_type"] = result.line_type.value
|
128
|
+
row["dnc"] = "true" if result.dnc else "false"
|
129
|
+
row["cached"] = "true" if result.cached else "false"
|
130
|
+
else:
|
131
|
+
row["line_type"] = "unknown"
|
132
|
+
row["dnc"] = ""
|
133
|
+
row["cached"] = ""
|
134
|
+
except:
|
135
|
+
row["line_type"] = "invalid"
|
136
|
+
row["dnc"] = ""
|
137
|
+
row["cached"] = ""
|
138
|
+
|
139
|
+
writer.writerow(row)
|
140
|
+
|
141
|
+
# Return CSV text
|
142
|
+
return output.getvalue()
|
143
|
+
|
144
|
+
def process_csv_stream(
|
145
|
+
self,
|
146
|
+
lines: Iterable[str],
|
147
|
+
phone_column: str = "phone",
|
148
|
+
batch_size: int = 100
|
149
|
+
) -> Iterator[List[PhoneVerification]]:
|
150
|
+
"""
|
151
|
+
Process CSV lines as a stream, yielding batches of results.
|
152
|
+
Memory-efficient for large files.
|
153
|
+
|
154
|
+
Args:
|
155
|
+
lines: Iterator of CSV lines (including header)
|
156
|
+
phone_column: Column name containing phone numbers
|
157
|
+
batch_size: Number of results to accumulate before yielding
|
158
|
+
|
159
|
+
Yields:
|
160
|
+
Batches of PhoneVerification results
|
161
|
+
"""
|
162
|
+
lines_list = list(lines) # Need to iterate twice - once for headers, once for data
|
163
|
+
|
164
|
+
if not lines_list:
|
165
|
+
logger.error("Empty CSV stream")
|
166
|
+
return
|
167
|
+
|
168
|
+
# Parse header
|
169
|
+
header_line = lines_list[0]
|
170
|
+
reader = csv.DictReader(StringIO(header_line))
|
171
|
+
headers = reader.fieldnames or []
|
172
|
+
phone_col = self._find_phone_column(headers, phone_column)
|
173
|
+
|
174
|
+
if not phone_col:
|
175
|
+
raise ValueError(f"Phone column '{phone_column}' not found in CSV")
|
176
|
+
|
177
|
+
batch = []
|
178
|
+
row_num = 2 # Start at 2 (header is 1)
|
179
|
+
total_processed = 0
|
180
|
+
|
181
|
+
# Process data lines
|
182
|
+
for line in lines_list[1:]:
|
183
|
+
if not line.strip():
|
184
|
+
continue
|
185
|
+
|
186
|
+
try:
|
187
|
+
# Parse single line
|
188
|
+
row = next(csv.DictReader(StringIO(line), fieldnames=headers))
|
189
|
+
phone = row.get(phone_col, "").strip()
|
190
|
+
|
191
|
+
if not phone:
|
192
|
+
logger.warning(f"Empty phone at row {row_num}")
|
193
|
+
row_num += 1
|
194
|
+
continue
|
195
|
+
|
196
|
+
# Verify phone
|
197
|
+
result = self.verifier.verify(phone)
|
198
|
+
batch.append(result)
|
199
|
+
total_processed += 1
|
200
|
+
|
201
|
+
# Yield batch when full
|
202
|
+
if len(batch) >= batch_size:
|
203
|
+
logger.info(f"Processed batch of {len(batch)} phones (total: {total_processed})")
|
204
|
+
yield batch
|
205
|
+
batch = []
|
206
|
+
|
207
|
+
except ValueError as e:
|
208
|
+
logger.warning(f"Invalid phone at row {row_num}: {str(e)}")
|
209
|
+
except Exception as e:
|
210
|
+
logger.error(f"Error processing row {row_num}: {str(e)}")
|
211
|
+
finally:
|
212
|
+
row_num += 1
|
213
|
+
|
214
|
+
# Yield remaining results
|
215
|
+
if batch:
|
216
|
+
logger.info(f"Processed final batch of {len(batch)} phones (total: {total_processed})")
|
217
|
+
yield batch
|
218
|
+
|
219
|
+
logger.info(f"Stream processing completed. Total processed: {total_processed}")
|
220
|
+
|
221
|
+
def generate_results_csv_stream(
|
222
|
+
self,
|
223
|
+
original_lines: Iterable[str],
|
224
|
+
results_stream: Iterator[List[PhoneVerification]],
|
225
|
+
phone_column: str = "phone"
|
226
|
+
) -> Iterator[str]:
|
227
|
+
"""
|
228
|
+
Generate CSV results as a stream, line by line.
|
229
|
+
Memory-efficient for large files.
|
230
|
+
|
231
|
+
Args:
|
232
|
+
original_lines: Iterator of original CSV lines
|
233
|
+
results_stream: Iterator of batched PhoneVerification results
|
234
|
+
phone_column: Column name containing phone numbers
|
235
|
+
|
236
|
+
Yields:
|
237
|
+
CSV lines with verification results added
|
238
|
+
"""
|
239
|
+
lines_iter = iter(original_lines)
|
240
|
+
|
241
|
+
# Read and yield modified header
|
242
|
+
try:
|
243
|
+
header_line = next(lines_iter)
|
244
|
+
reader = csv.DictReader(StringIO(header_line))
|
104
245
|
headers = reader.fieldnames or []
|
105
246
|
|
106
247
|
# Add new columns
|
107
248
|
output_headers = headers + ["line_type", "dnc", "cached"]
|
249
|
+
yield ','.join(output_headers) + '\n'
|
108
250
|
|
109
|
-
|
110
|
-
writer = csv.DictWriter(outfile, fieldnames=output_headers)
|
111
|
-
writer.writeheader()
|
251
|
+
phone_col = self._find_phone_column(headers, phone_column)
|
112
252
|
|
113
|
-
|
253
|
+
except StopIteration:
|
254
|
+
return
|
114
255
|
|
115
|
-
|
116
|
-
|
256
|
+
# Build results lookup from stream
|
257
|
+
results_map = {}
|
258
|
+
for batch in results_stream:
|
259
|
+
for result in batch:
|
260
|
+
results_map[result.phone_number] = result
|
261
|
+
|
262
|
+
# Reset lines iterator
|
263
|
+
lines_iter = iter(original_lines)
|
264
|
+
next(lines_iter) # Skip header
|
265
|
+
|
266
|
+
# Process and yield data lines
|
267
|
+
for line in lines_iter:
|
268
|
+
if not line.strip():
|
269
|
+
continue
|
270
|
+
|
271
|
+
row = next(csv.DictReader(StringIO(line), fieldnames=headers))
|
272
|
+
phone = row.get(phone_col, "").strip()
|
273
|
+
|
274
|
+
# Add verification results
|
275
|
+
try:
|
276
|
+
normalized = self.verifier.normalize_phone(phone)
|
277
|
+
if normalized in results_map:
|
278
|
+
result = results_map[normalized]
|
279
|
+
row["line_type"] = result.line_type.value
|
280
|
+
row["dnc"] = "true" if result.dnc else "false"
|
281
|
+
row["cached"] = "true" if result.cached else "false"
|
282
|
+
else:
|
283
|
+
row["line_type"] = "unknown"
|
284
|
+
row["dnc"] = ""
|
285
|
+
row["cached"] = ""
|
286
|
+
except:
|
287
|
+
row["line_type"] = "invalid"
|
288
|
+
row["dnc"] = ""
|
289
|
+
row["cached"] = ""
|
117
290
|
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
row["line_type"] = result.line_type
|
124
|
-
row["dnc"] = "true" if result.dnc else "false"
|
125
|
-
row["cached"] = "true" if result.cached else "false"
|
126
|
-
else:
|
127
|
-
row["line_type"] = "unknown"
|
128
|
-
row["dnc"] = ""
|
129
|
-
row["cached"] = ""
|
130
|
-
except:
|
131
|
-
row["line_type"] = "invalid"
|
132
|
-
row["dnc"] = ""
|
133
|
-
row["cached"] = ""
|
134
|
-
|
135
|
-
writer.writerow(row)
|
291
|
+
# Write row
|
292
|
+
output = StringIO()
|
293
|
+
writer = csv.DictWriter(output, fieldnames=output_headers)
|
294
|
+
writer.writerow(row)
|
295
|
+
yield output.getvalue()
|
ai_lls_lib/core/verifier.py
CHANGED
@@ -2,14 +2,13 @@
|
|
2
2
|
Phone verification logic - checks line type and DNC status
|
3
3
|
"""
|
4
4
|
import os
|
5
|
-
import re
|
6
5
|
from datetime import datetime, timezone
|
7
6
|
from typing import Optional
|
8
|
-
import httpx
|
9
7
|
import phonenumbers
|
10
8
|
from aws_lambda_powertools import Logger
|
11
9
|
from .models import PhoneVerification, LineType, VerificationSource
|
12
10
|
from .cache import DynamoDBCache
|
11
|
+
from ..providers import VerificationProvider, StubProvider
|
13
12
|
|
14
13
|
logger = Logger()
|
15
14
|
|
@@ -17,11 +16,16 @@ logger = Logger()
|
|
17
16
|
class PhoneVerifier:
|
18
17
|
"""Verifies phone numbers for line type and DNC status"""
|
19
18
|
|
20
|
-
def __init__(self, cache: DynamoDBCache):
|
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
|
+
"""
|
21
27
|
self.cache = cache
|
22
|
-
self.
|
23
|
-
self.phone_api_key = os.environ.get("PHONE_VERIFY_API_KEY", "")
|
24
|
-
self.http_client = httpx.Client(timeout=10.0)
|
28
|
+
self.provider = provider or StubProvider()
|
25
29
|
|
26
30
|
def normalize_phone(self, phone: str) -> str:
|
27
31
|
"""Normalize phone to E.164 format"""
|
@@ -37,8 +41,8 @@ class PhoneVerifier:
|
|
37
41
|
logger.error(f"Phone normalization failed: {str(e)}")
|
38
42
|
raise ValueError(f"Invalid phone format: {phone}")
|
39
43
|
|
40
|
-
def
|
41
|
-
"""
|
44
|
+
def verify(self, phone: str) -> PhoneVerification:
|
45
|
+
"""Verify phone number for line type and DNC status"""
|
42
46
|
normalized = self.normalize_phone(phone)
|
43
47
|
|
44
48
|
# Check cache first
|
@@ -46,9 +50,8 @@ class PhoneVerifier:
|
|
46
50
|
if cached:
|
47
51
|
return cached
|
48
52
|
|
49
|
-
#
|
50
|
-
line_type = self.
|
51
|
-
dnc_status = self._check_dnc_sync(normalized)
|
53
|
+
# Use provider to verify
|
54
|
+
line_type, dnc_status = self.provider.verify_phone(normalized)
|
52
55
|
|
53
56
|
result = PhoneVerification(
|
54
57
|
phone_number=normalized,
|
@@ -64,32 +67,18 @@ class PhoneVerifier:
|
|
64
67
|
|
65
68
|
return result
|
66
69
|
|
67
|
-
def
|
68
|
-
"""
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
# TODO: Implement actual DNC API call
|
83
|
-
# Would use self.dnc_api_key or os.environ.get("DNC_CHECK_API_KEY")
|
84
|
-
logger.info(f"Checking DNC status for {phone[:6]}***")
|
85
|
-
|
86
|
-
# Stub implementation based on last digit:
|
87
|
-
# - Ends in 1 or 0: on DNC list
|
88
|
-
# - Otherwise: not on DNC
|
89
|
-
last_digit = phone[-1] if phone else '5'
|
90
|
-
return last_digit in ['1', '0']
|
91
|
-
|
92
|
-
def __del__(self):
|
93
|
-
"""Cleanup HTTP client"""
|
94
|
-
if hasattr(self, 'http_client'):
|
95
|
-
self.http_client.close()
|
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
|
@@ -0,0 +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
|
+
]
|
@@ -0,0 +1,174 @@
|
|
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"
|