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.
- ai_lls_lib/__init__.py +32 -0
- ai_lls_lib/auth/__init__.py +4 -0
- ai_lls_lib/auth/context_parser.py +68 -0
- ai_lls_lib/cli/__init__.py +3 -0
- ai_lls_lib/cli/__main__.py +63 -0
- ai_lls_lib/cli/aws_client.py +123 -0
- ai_lls_lib/cli/commands/__init__.py +3 -0
- ai_lls_lib/cli/commands/admin.py +174 -0
- ai_lls_lib/cli/commands/cache.py +142 -0
- ai_lls_lib/cli/commands/monitor.py +297 -0
- ai_lls_lib/cli/commands/stripe.py +355 -0
- ai_lls_lib/cli/commands/test_stack.py +216 -0
- ai_lls_lib/cli/commands/verify.py +204 -0
- ai_lls_lib/cli/env_loader.py +129 -0
- ai_lls_lib/core/__init__.py +3 -0
- ai_lls_lib/core/cache.py +106 -0
- ai_lls_lib/core/models.py +77 -0
- ai_lls_lib/core/processor.py +295 -0
- ai_lls_lib/core/verifier.py +90 -0
- ai_lls_lib/payment/__init__.py +13 -0
- ai_lls_lib/payment/credit_manager.py +186 -0
- ai_lls_lib/payment/models.py +102 -0
- ai_lls_lib/payment/stripe_manager.py +486 -0
- ai_lls_lib/payment/webhook_processor.py +215 -0
- ai_lls_lib/providers/__init__.py +8 -0
- ai_lls_lib/providers/base.py +28 -0
- ai_lls_lib/providers/external.py +151 -0
- ai_lls_lib/providers/stub.py +48 -0
- ai_lls_lib/testing/__init__.py +3 -0
- ai_lls_lib/testing/fixtures.py +104 -0
- ai_lls_lib-2.0.0rc2.dist-info/METADATA +301 -0
- ai_lls_lib-2.0.0rc2.dist-info/RECORD +34 -0
- ai_lls_lib-2.0.0rc2.dist-info/WHEEL +4 -0
- ai_lls_lib-2.0.0rc2.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Real-time CloudWatch log monitoring for Lambda functions
|
|
3
|
+
"""
|
|
4
|
+
import click
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from typing import Optional, List, Dict
|
|
9
|
+
from botocore.exceptions import ClientError
|
|
10
|
+
from ai_lls_lib.cli.aws_client import AWSClient
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.syntax import Syntax
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.live import Live
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
from rich import box
|
|
20
|
+
HAS_RICH = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
HAS_RICH = False
|
|
23
|
+
Console = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_log_groups(aws_client: AWSClient, environment: str) -> List[str]:
|
|
27
|
+
"""Get relevant CloudWatch log groups for the environment"""
|
|
28
|
+
# Map environment to stack name pattern
|
|
29
|
+
stack_prefix = "landline-api-staging" if environment == "staging" else "landline-api"
|
|
30
|
+
|
|
31
|
+
# Key Lambda functions to monitor
|
|
32
|
+
lambda_functions = [
|
|
33
|
+
"PhoneVerifyHandler",
|
|
34
|
+
"BulkUploadHandler",
|
|
35
|
+
"BulkProcessHandler",
|
|
36
|
+
"PlansHandler",
|
|
37
|
+
"PaymentHandler",
|
|
38
|
+
"UserProfileHandler"
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
log_groups = []
|
|
42
|
+
for func in lambda_functions:
|
|
43
|
+
log_group = f"/aws/lambda/{stack_prefix}-{func}"
|
|
44
|
+
log_groups.append(log_group)
|
|
45
|
+
|
|
46
|
+
return log_groups
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def format_log_event(event: Dict, use_rich: bool = True) -> str:
|
|
50
|
+
"""Format a CloudWatch log event for display"""
|
|
51
|
+
timestamp = datetime.fromtimestamp(event.get('timestamp', 0) / 1000)
|
|
52
|
+
message = event.get('message', '')
|
|
53
|
+
|
|
54
|
+
# Try to parse JSON message
|
|
55
|
+
try:
|
|
56
|
+
if message.strip().startswith('{'):
|
|
57
|
+
parsed = json.loads(message)
|
|
58
|
+
|
|
59
|
+
# Check for external API calls
|
|
60
|
+
if 'external_api_call' in message.lower() or 'landlineremover.com' in message:
|
|
61
|
+
if use_rich and HAS_RICH:
|
|
62
|
+
return f"[bold green]{timestamp.strftime('%H:%M:%S')}[/bold green] [cyan]EXTERNAL API:[/cyan] {message}"
|
|
63
|
+
else:
|
|
64
|
+
return f"{timestamp.strftime('%H:%M:%S')} EXTERNAL API: {message}"
|
|
65
|
+
|
|
66
|
+
# Check for cache events
|
|
67
|
+
if 'cache' in message.lower():
|
|
68
|
+
if use_rich and HAS_RICH:
|
|
69
|
+
return f"[bold blue]{timestamp.strftime('%H:%M:%S')}[/bold blue] [yellow]CACHE:[/yellow] {message}"
|
|
70
|
+
else:
|
|
71
|
+
return f"{timestamp.strftime('%H:%M:%S')} CACHE: {message}"
|
|
72
|
+
|
|
73
|
+
# Check for errors
|
|
74
|
+
if 'error' in message.lower() or 'exception' in message.lower():
|
|
75
|
+
if use_rich and HAS_RICH:
|
|
76
|
+
return f"[bold red]{timestamp.strftime('%H:%M:%S')}[/bold red] [red]ERROR:[/red] {message}"
|
|
77
|
+
else:
|
|
78
|
+
return f"{timestamp.strftime('%H:%M:%S')} ERROR: {message}"
|
|
79
|
+
|
|
80
|
+
# Format structured logs
|
|
81
|
+
if use_rich and HAS_RICH:
|
|
82
|
+
message = json.dumps(parsed, indent=2)
|
|
83
|
+
except (json.JSONDecodeError, ValueError):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
# Default formatting
|
|
87
|
+
if use_rich and HAS_RICH:
|
|
88
|
+
return f"[dim]{timestamp.strftime('%H:%M:%S')}[/dim] {message}"
|
|
89
|
+
else:
|
|
90
|
+
return f"{timestamp.strftime('%H:%M:%S')} {message}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def tail_logs(aws_client: AWSClient, log_groups: List[str], duration: int = 300, follow: bool = True):
|
|
94
|
+
"""Tail CloudWatch logs in real-time"""
|
|
95
|
+
console = Console() if HAS_RICH else None
|
|
96
|
+
|
|
97
|
+
# Calculate start time
|
|
98
|
+
start_time = int((datetime.utcnow() - timedelta(seconds=duration)).timestamp() * 1000)
|
|
99
|
+
|
|
100
|
+
# Track last event time for each log group
|
|
101
|
+
last_event_times = {group: start_time for group in log_groups}
|
|
102
|
+
|
|
103
|
+
if console and HAS_RICH:
|
|
104
|
+
console.print(Panel.fit(
|
|
105
|
+
f"[bold cyan]Monitoring {len(log_groups)} Lambda functions[/bold cyan]\n"
|
|
106
|
+
f"[dim]Press Ctrl+C to stop[/dim]",
|
|
107
|
+
title="CloudWatch Log Monitor",
|
|
108
|
+
border_style="cyan"
|
|
109
|
+
))
|
|
110
|
+
console.print()
|
|
111
|
+
else:
|
|
112
|
+
click.echo(f"Monitoring {len(log_groups)} Lambda functions")
|
|
113
|
+
click.echo("Press Ctrl+C to stop\n")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
while True:
|
|
117
|
+
events_found = False
|
|
118
|
+
|
|
119
|
+
for log_group in log_groups:
|
|
120
|
+
try:
|
|
121
|
+
# Use filter_log_events for real-time tailing
|
|
122
|
+
response = aws_client.logs.filter_log_events(
|
|
123
|
+
logGroupName=log_group,
|
|
124
|
+
startTime=last_event_times[log_group],
|
|
125
|
+
limit=100
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
events = response.get('events', [])
|
|
129
|
+
|
|
130
|
+
if events:
|
|
131
|
+
events_found = True
|
|
132
|
+
# Update last event time
|
|
133
|
+
last_event_times[log_group] = events[-1]['timestamp'] + 1
|
|
134
|
+
|
|
135
|
+
# Display log group header
|
|
136
|
+
if console and HAS_RICH:
|
|
137
|
+
console.print(f"\n[bold magenta]═══ {log_group} ═══[/bold magenta]")
|
|
138
|
+
else:
|
|
139
|
+
click.echo(f"\n=== {log_group} ===")
|
|
140
|
+
|
|
141
|
+
# Display events
|
|
142
|
+
for event in events:
|
|
143
|
+
formatted = format_log_event(event, use_rich=(console is not None))
|
|
144
|
+
if console and HAS_RICH:
|
|
145
|
+
console.print(formatted)
|
|
146
|
+
else:
|
|
147
|
+
click.echo(formatted)
|
|
148
|
+
|
|
149
|
+
except ClientError as e:
|
|
150
|
+
if e.response['Error']['Code'] != 'ResourceNotFoundException':
|
|
151
|
+
if console and HAS_RICH:
|
|
152
|
+
console.print(f"[yellow]Warning: {e}[/yellow]")
|
|
153
|
+
else:
|
|
154
|
+
click.echo(f"Warning: {e}", err=True)
|
|
155
|
+
|
|
156
|
+
if not follow:
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
# Sleep briefly before next poll
|
|
160
|
+
time.sleep(2 if events_found else 5)
|
|
161
|
+
|
|
162
|
+
except KeyboardInterrupt:
|
|
163
|
+
if console and HAS_RICH:
|
|
164
|
+
console.print("\n[yellow]Monitoring stopped[/yellow]")
|
|
165
|
+
else:
|
|
166
|
+
click.echo("\nMonitoring stopped")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def start_live_tail(aws_client: AWSClient, log_groups: List[str]):
|
|
170
|
+
"""Start a CloudWatch Logs Live Tail session (requires AWS SDK v2)"""
|
|
171
|
+
console = Console() if HAS_RICH else None
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
# Start live tail session
|
|
175
|
+
response = aws_client.logs.start_live_tail(
|
|
176
|
+
logGroupIdentifiers=log_groups,
|
|
177
|
+
logStreamNamePrefixes=[],
|
|
178
|
+
logEventFilterPattern=""
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
session_id = response.get('sessionId')
|
|
182
|
+
session_url = response.get('sessionUrl')
|
|
183
|
+
|
|
184
|
+
if console and HAS_RICH:
|
|
185
|
+
console.print(Panel.fit(
|
|
186
|
+
f"[bold green]Live Tail session started[/bold green]\n"
|
|
187
|
+
f"Session ID: {session_id}\n"
|
|
188
|
+
f"[dim]Note: Live Tail API requires WebSocket support[/dim]",
|
|
189
|
+
title="CloudWatch Live Tail",
|
|
190
|
+
border_style="green"
|
|
191
|
+
))
|
|
192
|
+
else:
|
|
193
|
+
click.echo(f"Live Tail session started")
|
|
194
|
+
click.echo(f"Session ID: {session_id}")
|
|
195
|
+
click.echo(f"Note: Live Tail API requires WebSocket support")
|
|
196
|
+
|
|
197
|
+
# Note: Full WebSocket implementation would be needed here
|
|
198
|
+
# For now, fall back to filter_log_events polling
|
|
199
|
+
click.echo("\nFalling back to standard log polling...")
|
|
200
|
+
tail_logs(aws_client, log_groups, duration=300, follow=True)
|
|
201
|
+
|
|
202
|
+
except ClientError as e:
|
|
203
|
+
if 'start_live_tail' in str(e):
|
|
204
|
+
# Fall back to regular tailing if Live Tail not available
|
|
205
|
+
if console and HAS_RICH:
|
|
206
|
+
console.print("[yellow]Live Tail API not available, using standard polling[/yellow]")
|
|
207
|
+
else:
|
|
208
|
+
click.echo("Live Tail API not available, using standard polling")
|
|
209
|
+
tail_logs(aws_client, log_groups, duration=300, follow=True)
|
|
210
|
+
else:
|
|
211
|
+
raise
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@click.group(name="monitor")
|
|
215
|
+
def monitor_group():
|
|
216
|
+
"""Monitor CloudWatch logs in real-time"""
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@monitor_group.command()
|
|
221
|
+
@click.option('--staging', is_flag=True, help='Monitor staging environment (default: production)')
|
|
222
|
+
@click.option('--production', is_flag=True, help='Monitor production environment')
|
|
223
|
+
@click.option('--duration', default=300, help='How far back to start (seconds, default: 300)')
|
|
224
|
+
@click.option('--follow', is_flag=True, default=True, help='Follow logs in real-time (default: true)')
|
|
225
|
+
@click.option('--filter', 'filter_pattern', help='CloudWatch filter pattern to apply')
|
|
226
|
+
@click.option('--profile', help='AWS profile to use')
|
|
227
|
+
@click.option('--region', default='us-east-1', help='AWS region')
|
|
228
|
+
def logs(staging, production, duration, follow, filter_pattern, profile, region):
|
|
229
|
+
"""Tail CloudWatch logs for Lambda functions
|
|
230
|
+
|
|
231
|
+
Examples:
|
|
232
|
+
ai-lls monitor logs --staging
|
|
233
|
+
ai-lls monitor logs --production
|
|
234
|
+
ai-lls monitor logs --staging --filter "ERROR"
|
|
235
|
+
ai-lls monitor logs --duration 600 --follow
|
|
236
|
+
"""
|
|
237
|
+
# Determine environment
|
|
238
|
+
if staging and production:
|
|
239
|
+
raise click.ClickException("Cannot specify both --staging and --production")
|
|
240
|
+
|
|
241
|
+
environment = "staging" if staging else "production"
|
|
242
|
+
|
|
243
|
+
# Show warning if Rich not installed
|
|
244
|
+
if not HAS_RICH:
|
|
245
|
+
click.echo("Warning: Install 'rich' library for better output formatting", err=True)
|
|
246
|
+
click.echo("Run: pip install rich", err=True)
|
|
247
|
+
click.echo()
|
|
248
|
+
|
|
249
|
+
# Initialize AWS client
|
|
250
|
+
aws_client = AWSClient(region=region, profile=profile)
|
|
251
|
+
|
|
252
|
+
# Get log groups
|
|
253
|
+
log_groups = get_log_groups(aws_client, environment)
|
|
254
|
+
|
|
255
|
+
click.echo(f"Monitoring {environment} environment...")
|
|
256
|
+
click.echo(f"Log groups: {', '.join([g.split('/')[-1] for g in log_groups])}")
|
|
257
|
+
click.echo()
|
|
258
|
+
|
|
259
|
+
# Start tailing logs
|
|
260
|
+
tail_logs(aws_client, log_groups, duration=duration, follow=follow)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@monitor_group.command()
|
|
264
|
+
@click.option('--staging', is_flag=True, help='Monitor staging environment')
|
|
265
|
+
@click.option('--production', is_flag=True, help='Monitor production environment')
|
|
266
|
+
@click.option('--profile', help='AWS profile to use')
|
|
267
|
+
@click.option('--region', default='us-east-1', help='AWS region')
|
|
268
|
+
def live(staging, production, profile, region):
|
|
269
|
+
"""Start CloudWatch Logs Live Tail session (experimental)
|
|
270
|
+
|
|
271
|
+
Uses the CloudWatch Logs Live Tail API for real-time streaming.
|
|
272
|
+
Falls back to standard polling if Live Tail is not available.
|
|
273
|
+
|
|
274
|
+
Examples:
|
|
275
|
+
ai-lls monitor live --staging
|
|
276
|
+
ai-lls monitor live --production
|
|
277
|
+
"""
|
|
278
|
+
# Determine environment
|
|
279
|
+
if staging and production:
|
|
280
|
+
raise click.ClickException("Cannot specify both --staging and --production")
|
|
281
|
+
|
|
282
|
+
environment = "staging" if staging else "production"
|
|
283
|
+
|
|
284
|
+
# Initialize AWS client
|
|
285
|
+
aws_client = AWSClient(region=region, profile=profile)
|
|
286
|
+
|
|
287
|
+
# Get log groups
|
|
288
|
+
log_groups = get_log_groups(aws_client, environment)
|
|
289
|
+
|
|
290
|
+
click.echo(f"Starting Live Tail for {environment} environment...")
|
|
291
|
+
|
|
292
|
+
# Start live tail
|
|
293
|
+
start_live_tail(aws_client, log_groups)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# Export the group for registration
|
|
297
|
+
__all__ = ['monitor_group']
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Stripe management CLI commands."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import os
|
|
5
|
+
import json
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from ..env_loader import load_environment_config, get_stripe_key
|
|
8
|
+
|
|
9
|
+
@click.group(name="stripe")
|
|
10
|
+
def stripe_group():
|
|
11
|
+
"""Manage Stripe products and prices."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@stripe_group.command("seed")
|
|
16
|
+
@click.option("--environment", type=click.Choice(["staging", "production"]), required=True)
|
|
17
|
+
@click.option("--api-key", help="Stripe API key (overrides environment)")
|
|
18
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be created without making changes")
|
|
19
|
+
def seed_products(environment: str, api_key: Optional[str], dry_run: bool):
|
|
20
|
+
"""Create or update Stripe products and prices with metadata."""
|
|
21
|
+
try:
|
|
22
|
+
import stripe
|
|
23
|
+
except ImportError:
|
|
24
|
+
click.echo("Error: stripe package not installed. Run: pip install stripe", err=True)
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
# Load API key from environment if not provided
|
|
28
|
+
if not api_key:
|
|
29
|
+
api_key = get_stripe_key(environment)
|
|
30
|
+
if not api_key:
|
|
31
|
+
env_prefix = 'STAGING' if environment == 'staging' else 'PROD'
|
|
32
|
+
click.echo(f"Error: No Stripe API key found for {environment} environment", err=True)
|
|
33
|
+
click.echo(f"Set {env_prefix}_STRIPE_SECRET_KEY or STRIPE_SECRET_KEY", err=True)
|
|
34
|
+
return
|
|
35
|
+
click.echo(f"Using Stripe key for {environment} environment", err=True)
|
|
36
|
+
|
|
37
|
+
stripe.api_key = api_key
|
|
38
|
+
|
|
39
|
+
# Define the products and prices to create
|
|
40
|
+
products_config = [
|
|
41
|
+
{
|
|
42
|
+
"name": "Landline Scrubber - STANDARD",
|
|
43
|
+
"description": "One-time purchase",
|
|
44
|
+
"metadata": {
|
|
45
|
+
"product_type": "landline_scrubber",
|
|
46
|
+
"environment": environment,
|
|
47
|
+
"tier": "STANDARD"
|
|
48
|
+
},
|
|
49
|
+
"price": {
|
|
50
|
+
"unit_amount": 1000, # $10.00
|
|
51
|
+
"currency": "usd",
|
|
52
|
+
"metadata": {
|
|
53
|
+
"product_type": "landline_scrubber",
|
|
54
|
+
"environment": environment,
|
|
55
|
+
"plan_type": "prepaid",
|
|
56
|
+
"tier": "STANDARD",
|
|
57
|
+
"credits": "50000",
|
|
58
|
+
"plan_credits_text": "50,000 credits",
|
|
59
|
+
"percent_off": "",
|
|
60
|
+
"active": "true"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "Landline Scrubber - POWER",
|
|
66
|
+
"description": "Best value",
|
|
67
|
+
"metadata": {
|
|
68
|
+
"product_type": "landline_scrubber",
|
|
69
|
+
"environment": environment,
|
|
70
|
+
"tier": "POWER"
|
|
71
|
+
},
|
|
72
|
+
"price": {
|
|
73
|
+
"unit_amount": 5000, # $50.00
|
|
74
|
+
"currency": "usd",
|
|
75
|
+
"metadata": {
|
|
76
|
+
"product_type": "landline_scrubber",
|
|
77
|
+
"environment": environment,
|
|
78
|
+
"plan_type": "prepaid",
|
|
79
|
+
"tier": "POWER",
|
|
80
|
+
"credits": "285000",
|
|
81
|
+
"plan_credits_text": "285,000 credits",
|
|
82
|
+
"percent_off": "12.5% OFF",
|
|
83
|
+
"active": "true"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"name": "Landline Scrubber - ELITE",
|
|
89
|
+
"description": "Maximum savings",
|
|
90
|
+
"metadata": {
|
|
91
|
+
"product_type": "landline_scrubber",
|
|
92
|
+
"environment": environment,
|
|
93
|
+
"tier": "ELITE"
|
|
94
|
+
},
|
|
95
|
+
"price": {
|
|
96
|
+
"unit_amount": 10000, # $100.00
|
|
97
|
+
"currency": "usd",
|
|
98
|
+
"metadata": {
|
|
99
|
+
"product_type": "landline_scrubber",
|
|
100
|
+
"environment": environment,
|
|
101
|
+
"plan_type": "prepaid",
|
|
102
|
+
"tier": "ELITE",
|
|
103
|
+
"credits": "666660",
|
|
104
|
+
"plan_credits_text": "666,660 credits",
|
|
105
|
+
"percent_off": "25% OFF",
|
|
106
|
+
"active": "true"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "Landline Scrubber - UNLIMITED",
|
|
112
|
+
"description": "Monthly subscription",
|
|
113
|
+
"metadata": {
|
|
114
|
+
"product_type": "landline_scrubber",
|
|
115
|
+
"environment": environment,
|
|
116
|
+
"tier": "UNLIMITED"
|
|
117
|
+
},
|
|
118
|
+
"price": {
|
|
119
|
+
"unit_amount": 300000, # $3000.00
|
|
120
|
+
"currency": "usd",
|
|
121
|
+
"recurring": {"interval": "month"},
|
|
122
|
+
"metadata": {
|
|
123
|
+
"product_type": "landline_scrubber",
|
|
124
|
+
"environment": environment,
|
|
125
|
+
"plan_type": "postpaid",
|
|
126
|
+
"tier": "UNLIMITED",
|
|
127
|
+
"credits": "unlimited",
|
|
128
|
+
"plan_credits_text": "Unlimited",
|
|
129
|
+
"percent_off": "",
|
|
130
|
+
"active": "true"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
if dry_run:
|
|
137
|
+
click.echo("DRY RUN - Would create the following:")
|
|
138
|
+
for config in products_config:
|
|
139
|
+
click.echo(f"\nProduct: {config['name']}")
|
|
140
|
+
click.echo(f" Description: {config['description']}")
|
|
141
|
+
click.echo(f" Price: ${config['price']['unit_amount'] / 100:.2f}")
|
|
142
|
+
if "recurring" in config["price"]:
|
|
143
|
+
click.echo(f" Billing: Monthly subscription")
|
|
144
|
+
else:
|
|
145
|
+
click.echo(f" Billing: One-time payment")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
created_prices = []
|
|
149
|
+
|
|
150
|
+
for config in products_config:
|
|
151
|
+
try:
|
|
152
|
+
# Check if product already exists
|
|
153
|
+
existing_products = stripe.Product.list(limit=100)
|
|
154
|
+
product = None
|
|
155
|
+
for p in existing_products.data:
|
|
156
|
+
if (p.metadata.get("product_type") == "landline_scrubber" and
|
|
157
|
+
p.metadata.get("environment") == environment and
|
|
158
|
+
p.metadata.get("tier") == config["metadata"]["tier"] and
|
|
159
|
+
p.active): # Only use active products
|
|
160
|
+
product = p
|
|
161
|
+
click.echo(f"Found existing product: {product.name}")
|
|
162
|
+
break
|
|
163
|
+
|
|
164
|
+
if not product:
|
|
165
|
+
# Create new product
|
|
166
|
+
product = stripe.Product.create(
|
|
167
|
+
name=config["name"],
|
|
168
|
+
description=config["description"],
|
|
169
|
+
metadata=config["metadata"]
|
|
170
|
+
)
|
|
171
|
+
click.echo(f"Created product: {product.name}")
|
|
172
|
+
|
|
173
|
+
# Create price (always create new prices, don't modify existing)
|
|
174
|
+
price_data = {
|
|
175
|
+
"product": product.id,
|
|
176
|
+
"unit_amount": config["price"]["unit_amount"],
|
|
177
|
+
"currency": config["price"]["currency"],
|
|
178
|
+
"metadata": config["price"]["metadata"]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if "recurring" in config["price"]:
|
|
182
|
+
price_data["recurring"] = config["price"]["recurring"]
|
|
183
|
+
|
|
184
|
+
price = stripe.Price.create(**price_data)
|
|
185
|
+
created_prices.append(price.id)
|
|
186
|
+
click.echo(f" Created price: {price.id} (${price.unit_amount / 100:.2f})")
|
|
187
|
+
|
|
188
|
+
except stripe.error.StripeError as e:
|
|
189
|
+
click.echo(f"Error creating {config['name']}: {e}", err=True)
|
|
190
|
+
|
|
191
|
+
if created_prices:
|
|
192
|
+
click.echo(f"\nCreated {len(created_prices)} prices for {environment} environment")
|
|
193
|
+
click.echo("\nPrice IDs:")
|
|
194
|
+
for price_id in created_prices:
|
|
195
|
+
click.echo(f" {price_id}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@stripe_group.command("clean")
|
|
199
|
+
@click.option("--environment", type=click.Choice(["staging", "production"]), default="staging")
|
|
200
|
+
@click.option("--api-key", help="Stripe API key (overrides environment)")
|
|
201
|
+
@click.option("--force", is_flag=True, help="Skip confirmation")
|
|
202
|
+
def clean_products(environment: str, api_key: Optional[str], force: bool):
|
|
203
|
+
"""Remove all Landline Scrubber products and prices."""
|
|
204
|
+
import stripe
|
|
205
|
+
|
|
206
|
+
# Load API key from environment if not provided
|
|
207
|
+
if not api_key:
|
|
208
|
+
api_key = get_stripe_key(environment)
|
|
209
|
+
if not api_key:
|
|
210
|
+
click.echo(f"Error: No Stripe API key found for {environment} environment", err=True)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
stripe.api_key = api_key
|
|
214
|
+
|
|
215
|
+
if not force:
|
|
216
|
+
if not click.confirm(f"This will DELETE all Landline Scrubber products in {environment}. Continue?"):
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
# List all products
|
|
221
|
+
products = stripe.Product.list(limit=100)
|
|
222
|
+
deleted_count = 0
|
|
223
|
+
|
|
224
|
+
for product in products.data:
|
|
225
|
+
if (product.metadata.get("product_type") == "landline_scrubber" and
|
|
226
|
+
product.metadata.get("environment") == environment):
|
|
227
|
+
# Archive all prices first
|
|
228
|
+
prices = stripe.Price.list(product=product.id, limit=100)
|
|
229
|
+
for price in prices.data:
|
|
230
|
+
if price.active:
|
|
231
|
+
stripe.Price.modify(price.id, active=False)
|
|
232
|
+
click.echo(f" Archived price: {price.id}")
|
|
233
|
+
|
|
234
|
+
# Archive the product
|
|
235
|
+
stripe.Product.modify(product.id, active=False)
|
|
236
|
+
click.echo(f"Archived product: {product.name}")
|
|
237
|
+
deleted_count += 1
|
|
238
|
+
|
|
239
|
+
click.echo(f"\nArchived {deleted_count} products in {environment} environment")
|
|
240
|
+
|
|
241
|
+
except stripe.error.StripeError as e:
|
|
242
|
+
click.echo(f"Error: {e}", err=True)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@stripe_group.command("list")
|
|
246
|
+
@click.option("--environment", type=click.Choice(["staging", "production"]), default="staging")
|
|
247
|
+
@click.option("--api-key", help="Stripe API key (overrides environment)")
|
|
248
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
249
|
+
def list_products(environment: str, api_key: Optional[str], output_json: bool):
|
|
250
|
+
"""List all products and prices with metadata."""
|
|
251
|
+
try:
|
|
252
|
+
from ai_lls_lib.payment import StripeManager
|
|
253
|
+
except ImportError:
|
|
254
|
+
click.echo("Error: Payment module not found", err=True)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# Load API key from environment if not provided
|
|
258
|
+
if not api_key:
|
|
259
|
+
api_key = get_stripe_key(environment)
|
|
260
|
+
if not api_key:
|
|
261
|
+
env_prefix = 'STAGING' if environment == 'staging' else 'PROD'
|
|
262
|
+
click.echo(f"Error: No Stripe API key found for {environment} environment", err=True)
|
|
263
|
+
click.echo(f"Set {env_prefix}_STRIPE_SECRET_KEY or STRIPE_SECRET_KEY", err=True)
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
manager = StripeManager(api_key=api_key, environment=environment)
|
|
268
|
+
plans = manager.list_plans()
|
|
269
|
+
|
|
270
|
+
if output_json:
|
|
271
|
+
output = [plan.to_dict() for plan in plans]
|
|
272
|
+
click.echo(json.dumps(output, indent=2))
|
|
273
|
+
else:
|
|
274
|
+
click.echo(f"Active plans for {environment} environment:\n")
|
|
275
|
+
for plan in plans:
|
|
276
|
+
click.echo(f"{plan.plan_name}:")
|
|
277
|
+
click.echo(f" Price: ${plan.plan_amount:.2f}")
|
|
278
|
+
click.echo(f" Credits: {plan.plan_credits_text}")
|
|
279
|
+
click.echo(f" Type: {plan.plan_type}")
|
|
280
|
+
click.echo(f" Reference: {plan.plan_reference}")
|
|
281
|
+
if plan.percent_off:
|
|
282
|
+
click.echo(f" Discount: {plan.percent_off}")
|
|
283
|
+
click.echo()
|
|
284
|
+
|
|
285
|
+
except Exception as e:
|
|
286
|
+
click.echo(f"Error: {e}", err=True)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@stripe_group.command("webhook")
|
|
290
|
+
@click.option("--endpoint-url", help="Webhook endpoint URL")
|
|
291
|
+
@click.option("--environment", type=click.Choice(["staging", "production"]), default="staging")
|
|
292
|
+
@click.option("--api-key", help="Stripe API key (overrides environment)")
|
|
293
|
+
@click.option("--print-secret", is_flag=True, help="Print the webhook signing secret")
|
|
294
|
+
def setup_webhook(endpoint_url: Optional[str], environment: str, api_key: Optional[str], print_secret: bool):
|
|
295
|
+
"""Configure or display webhook endpoint."""
|
|
296
|
+
try:
|
|
297
|
+
import stripe
|
|
298
|
+
except ImportError:
|
|
299
|
+
click.echo("Error: stripe package not installed. Run: pip install stripe", err=True)
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
# Load API key from environment if not provided
|
|
303
|
+
if not api_key:
|
|
304
|
+
api_key = get_stripe_key(environment)
|
|
305
|
+
if not api_key:
|
|
306
|
+
env_prefix = 'STAGING' if environment == 'staging' else 'PROD'
|
|
307
|
+
click.echo(f"Error: No Stripe API key found for {environment} environment", err=True)
|
|
308
|
+
click.echo(f"Set {env_prefix}_STRIPE_SECRET_KEY or STRIPE_SECRET_KEY", err=True)
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
stripe.api_key = api_key
|
|
312
|
+
env_prefix = 'STAGING' if environment == 'staging' else 'PROD'
|
|
313
|
+
|
|
314
|
+
if print_secret:
|
|
315
|
+
# List existing webhooks
|
|
316
|
+
webhooks = stripe.WebhookEndpoint.list(limit=10)
|
|
317
|
+
if webhooks.data:
|
|
318
|
+
click.echo("Existing webhook endpoints:\n")
|
|
319
|
+
for webhook in webhooks.data:
|
|
320
|
+
click.echo(f"URL: {webhook.url}")
|
|
321
|
+
click.echo(f"ID: {webhook.id}")
|
|
322
|
+
click.echo(f"Secret: {webhook.secret}")
|
|
323
|
+
click.echo(f"Status: {webhook.status}")
|
|
324
|
+
click.echo()
|
|
325
|
+
else:
|
|
326
|
+
click.echo("No webhook endpoints configured")
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
if not endpoint_url:
|
|
330
|
+
click.echo("Error: --endpoint-url required to create webhook", err=True)
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
# Create webhook endpoint
|
|
335
|
+
webhook = stripe.WebhookEndpoint.create(
|
|
336
|
+
url=endpoint_url,
|
|
337
|
+
enabled_events=[
|
|
338
|
+
"checkout.session.completed",
|
|
339
|
+
"customer.subscription.created",
|
|
340
|
+
"customer.subscription.updated",
|
|
341
|
+
"customer.subscription.deleted",
|
|
342
|
+
"invoice.payment_succeeded",
|
|
343
|
+
"invoice.payment_failed"
|
|
344
|
+
]
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
click.echo(f"Webhook endpoint created:")
|
|
348
|
+
click.echo(f" URL: {webhook.url}")
|
|
349
|
+
click.echo(f" ID: {webhook.id}")
|
|
350
|
+
click.echo(f" Secret: {webhook.secret}")
|
|
351
|
+
click.echo(f"\nAdd this to your environment:")
|
|
352
|
+
click.echo(f" {env_prefix}_STRIPE_WEBHOOK_SECRET={webhook.secret}")
|
|
353
|
+
|
|
354
|
+
except stripe.error.StripeError as e:
|
|
355
|
+
click.echo(f"Error creating webhook: {e}", err=True)
|