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.
- ai_lls_lib/__init__.py +1 -1
- ai_lls_lib/auth/__init__.py +4 -4
- ai_lls_lib/auth/context_parser.py +68 -68
- ai_lls_lib/cli/__init__.py +3 -3
- ai_lls_lib/cli/__main__.py +30 -30
- ai_lls_lib/cli/aws_client.py +115 -115
- ai_lls_lib/cli/commands/__init__.py +3 -3
- ai_lls_lib/cli/commands/admin.py +174 -174
- ai_lls_lib/cli/commands/cache.py +142 -142
- ai_lls_lib/cli/commands/stripe.py +377 -377
- ai_lls_lib/cli/commands/test_stack.py +216 -216
- ai_lls_lib/cli/commands/verify.py +111 -111
- ai_lls_lib/cli/env_loader.py +122 -122
- ai_lls_lib/core/__init__.py +3 -3
- ai_lls_lib/core/cache.py +106 -106
- ai_lls_lib/core/models.py +77 -77
- ai_lls_lib/core/processor.py +295 -295
- ai_lls_lib/core/verifier.py +84 -84
- ai_lls_lib/payment/__init__.py +13 -13
- ai_lls_lib/payment/credit_manager.py +186 -193
- ai_lls_lib/payment/models.py +102 -102
- ai_lls_lib/payment/stripe_manager.py +487 -487
- ai_lls_lib/payment/webhook_processor.py +215 -215
- ai_lls_lib/providers/__init__.py +7 -7
- ai_lls_lib/providers/base.py +28 -28
- ai_lls_lib/providers/external.py +87 -87
- ai_lls_lib/providers/stub.py +48 -48
- ai_lls_lib/testing/__init__.py +3 -3
- ai_lls_lib/testing/fixtures.py +104 -104
- {ai_lls_lib-1.4.0rc2.dist-info → ai_lls_lib-1.4.0rc4.dist-info}/METADATA +1 -1
- ai_lls_lib-1.4.0rc4.dist-info/RECORD +33 -0
- ai_lls_lib-1.4.0rc2.dist-info/RECORD +0 -33
- {ai_lls_lib-1.4.0rc2.dist-info → ai_lls_lib-1.4.0rc4.dist-info}/WHEEL +0 -0
- {ai_lls_lib-1.4.0rc2.dist-info → ai_lls_lib-1.4.0rc4.dist-info}/entry_points.txt +0 -0
@@ -1,487 +1,487 @@
|
|
1
|
-
"""Stripe API management with metadata conventions."""
|
2
|
-
|
3
|
-
import os
|
4
|
-
from typing import List, Optional, Dict, Any
|
5
|
-
import logging
|
6
|
-
|
7
|
-
try:
|
8
|
-
import stripe
|
9
|
-
except ImportError:
|
10
|
-
stripe = None # Handle gracefully for testing
|
11
|
-
|
12
|
-
from .models import Plan
|
13
|
-
|
14
|
-
logger = logging.getLogger(__name__)
|
15
|
-
|
16
|
-
|
17
|
-
class StripeManager:
|
18
|
-
"""
|
19
|
-
Manages Stripe resources with metadata conventions.
|
20
|
-
Uses metadata to discover and filter products/prices.
|
21
|
-
"""
|
22
|
-
|
23
|
-
METADATA_SCHEMA = {
|
24
|
-
"product_type": "landline_scrubber",
|
25
|
-
"environment": None, # Set at runtime
|
26
|
-
"tier": None,
|
27
|
-
"credits": None,
|
28
|
-
"active": "true"
|
29
|
-
}
|
30
|
-
|
31
|
-
def __init__(self, api_key: Optional[str] = None, environment: Optional[str] = None):
|
32
|
-
"""Initialize with Stripe API key and environment."""
|
33
|
-
if not stripe:
|
34
|
-
raise ImportError("stripe package not installed. Run: pip install stripe")
|
35
|
-
|
36
|
-
self.api_key = api_key or os.environ.get("STRIPE_SECRET_KEY")
|
37
|
-
if not self.api_key:
|
38
|
-
raise ValueError("Stripe API key not provided and STRIPE_SECRET_KEY not set")
|
39
|
-
|
40
|
-
stripe.api_key = self.api_key
|
41
|
-
self.environment = environment or os.environ.get("ENVIRONMENT", "staging")
|
42
|
-
|
43
|
-
def list_plans(self) -> List[Plan]:
|
44
|
-
"""
|
45
|
-
Fetch active plans from Stripe using metadata.
|
46
|
-
Returns list of Plan objects sorted by price.
|
47
|
-
"""
|
48
|
-
try:
|
49
|
-
# Fetch all active prices with expanded product data
|
50
|
-
prices = stripe.Price.list(
|
51
|
-
active=True,
|
52
|
-
expand=["data.product"],
|
53
|
-
limit=100
|
54
|
-
)
|
55
|
-
|
56
|
-
plans = []
|
57
|
-
for price in prices.data:
|
58
|
-
metadata = price.metadata or {}
|
59
|
-
|
60
|
-
# Filter by our metadata conventions
|
61
|
-
if (metadata.get("product_type") == "landline_scrubber" and
|
62
|
-
metadata.get("environment") == self.environment and
|
63
|
-
metadata.get("active") == "true"):
|
64
|
-
|
65
|
-
# Convert to Plan object
|
66
|
-
plan = Plan.from_stripe_price(price, price.product)
|
67
|
-
plans.append(plan)
|
68
|
-
|
69
|
-
# Sort by price amount
|
70
|
-
plans.sort(key=lambda p: p.plan_amount)
|
71
|
-
|
72
|
-
logger.info(f"Found {len(plans)} active plans for environment {self.environment}")
|
73
|
-
return plans
|
74
|
-
|
75
|
-
except stripe.error.StripeError as e:
|
76
|
-
logger.error(f"Stripe error listing plans: {e}")
|
77
|
-
# Return mock data for development/testing
|
78
|
-
return self._get_mock_plans()
|
79
|
-
|
80
|
-
def create_setup_intent(self, user_id: str) -> Dict[str, str]:
|
81
|
-
"""
|
82
|
-
Create a SetupIntent for secure payment method collection.
|
83
|
-
Frontend will confirm this with Stripe Elements.
|
84
|
-
"""
|
85
|
-
try:
|
86
|
-
# Get or create customer
|
87
|
-
customer = self._get_or_create_customer(user_id)
|
88
|
-
|
89
|
-
# Create SetupIntent
|
90
|
-
setup_intent = stripe.SetupIntent.create(
|
91
|
-
customer=customer.id,
|
92
|
-
metadata={
|
93
|
-
"user_id": user_id,
|
94
|
-
"environment": self.environment
|
95
|
-
}
|
96
|
-
)
|
97
|
-
|
98
|
-
return {
|
99
|
-
"client_secret": setup_intent.client_secret,
|
100
|
-
"setup_intent_id": setup_intent.id,
|
101
|
-
"customer_id": customer.id
|
102
|
-
}
|
103
|
-
|
104
|
-
except stripe.error.StripeError as e:
|
105
|
-
logger.error(f"Stripe error creating setup intent: {e}")
|
106
|
-
raise
|
107
|
-
|
108
|
-
def attach_payment_method(self, user_id: str, payment_method_id: str, billing_details: Dict[str, Any]) -> Dict[str, Any]:
|
109
|
-
"""
|
110
|
-
Attach a payment method to customer (legacy path).
|
111
|
-
Returns whether this is the first card.
|
112
|
-
"""
|
113
|
-
try:
|
114
|
-
# Get or create customer
|
115
|
-
customer = self._get_or_create_customer(user_id)
|
116
|
-
|
117
|
-
# Attach payment method to customer
|
118
|
-
payment_method = stripe.PaymentMethod.attach(
|
119
|
-
payment_method_id,
|
120
|
-
customer=customer.id
|
121
|
-
)
|
122
|
-
|
123
|
-
# Update billing details if provided
|
124
|
-
if billing_details:
|
125
|
-
stripe.PaymentMethod.modify(
|
126
|
-
payment_method_id,
|
127
|
-
billing_details=billing_details
|
128
|
-
)
|
129
|
-
|
130
|
-
# Check if this is the first payment method
|
131
|
-
payment_methods = stripe.PaymentMethod.list(
|
132
|
-
customer=customer.id,
|
133
|
-
type="card"
|
134
|
-
)
|
135
|
-
|
136
|
-
first_card = len(payment_methods.data) == 1
|
137
|
-
|
138
|
-
# Set as default if first card
|
139
|
-
if first_card:
|
140
|
-
stripe.Customer.modify(
|
141
|
-
customer.id,
|
142
|
-
invoice_settings={"default_payment_method": payment_method_id}
|
143
|
-
)
|
144
|
-
|
145
|
-
return {
|
146
|
-
"payment_method_id": payment_method_id,
|
147
|
-
"first_card": first_card,
|
148
|
-
"customer_id": customer.id
|
149
|
-
}
|
150
|
-
|
151
|
-
except stripe.error.StripeError as e:
|
152
|
-
logger.error(f"Stripe error attaching payment method: {e}")
|
153
|
-
raise
|
154
|
-
|
155
|
-
def verify_payment_method(self, user_id: str, payment_method_id: str) -> Dict[str, Any]:
|
156
|
-
"""
|
157
|
-
Perform $1 verification charge on new payment method.
|
158
|
-
"""
|
159
|
-
try:
|
160
|
-
customer = self._get_or_create_customer(user_id)
|
161
|
-
|
162
|
-
# Create $1 verification charge
|
163
|
-
payment_intent = stripe.PaymentIntent.create(
|
164
|
-
amount=100, # $1.00 in cents
|
165
|
-
currency="usd",
|
166
|
-
customer=customer.id,
|
167
|
-
payment_method=payment_method_id,
|
168
|
-
off_session=True,
|
169
|
-
confirm=True,
|
170
|
-
description="Card verification - $1 charge",
|
171
|
-
metadata={
|
172
|
-
"user_id": user_id,
|
173
|
-
"type": "verification",
|
174
|
-
"environment": self.environment
|
175
|
-
}
|
176
|
-
)
|
177
|
-
|
178
|
-
return {
|
179
|
-
"status": payment_intent.status,
|
180
|
-
"payment_intent_id": payment_intent.id
|
181
|
-
}
|
182
|
-
|
183
|
-
except stripe.error.StripeError as e:
|
184
|
-
logger.error(f"Stripe error verifying payment method: {e}")
|
185
|
-
raise
|
186
|
-
|
187
|
-
def charge_prepaid(self, user_id: str, reference_code: str, amount: Optional[float] = None) -> Dict[str, Any]:
|
188
|
-
"""
|
189
|
-
Charge saved payment method for credit purchase.
|
190
|
-
Supports both fixed-price and metadata-based variable-amount plans.
|
191
|
-
"""
|
192
|
-
try:
|
193
|
-
customer = self._get_or_create_customer(user_id)
|
194
|
-
|
195
|
-
# Look up price from Stripe
|
196
|
-
prices = stripe.Price.list(active=True, limit=100, expand=["data.product"])
|
197
|
-
price = None
|
198
|
-
|
199
|
-
for p in prices.data:
|
200
|
-
metadata = p.metadata or {}
|
201
|
-
# Match by price ID or plan_reference in metadata
|
202
|
-
if (p.id == reference_code or
|
203
|
-
metadata.get("plan_reference") == reference_code or
|
204
|
-
(metadata.get("tier") == reference_code and
|
205
|
-
metadata.get("environment") == self.environment)):
|
206
|
-
price = p
|
207
|
-
break
|
208
|
-
|
209
|
-
if not price:
|
210
|
-
raise ValueError(f"Invalid plan reference: {reference_code}")
|
211
|
-
|
212
|
-
price_metadata = price.metadata or {}
|
213
|
-
|
214
|
-
# Check if this is a variable amount plan
|
215
|
-
if price_metadata.get("variable_amount") == "true":
|
216
|
-
# Variable amount plan - validate amount
|
217
|
-
if not amount:
|
218
|
-
raise ValueError("Amount required for variable-amount plan")
|
219
|
-
|
220
|
-
# Get validation rules from metadata
|
221
|
-
min_amount = float(price_metadata.get("min_amount", "5"))
|
222
|
-
if amount < min_amount:
|
223
|
-
raise ValueError(f"Amount ${amount} is below minimum ${min_amount}")
|
224
|
-
|
225
|
-
# Check against default amounts if specified
|
226
|
-
default_amounts_str = price_metadata.get("default_amounts", "")
|
227
|
-
if default_amounts_str:
|
228
|
-
allowed_amounts = [float(x.strip()) for x in default_amounts_str.split(",")]
|
229
|
-
# Allow default amounts OR any amount >= minimum
|
230
|
-
if amount not in allowed_amounts and amount < max(allowed_amounts):
|
231
|
-
logger.info(f"Amount ${amount} not in defaults {allowed_amounts}, but allowed as >= ${min_amount}")
|
232
|
-
|
233
|
-
# Calculate credits based on credits_per_dollar
|
234
|
-
credits_per_dollar = float(price_metadata.get("credits_per_dollar", "285"))
|
235
|
-
credits_to_add = int(amount * credits_per_dollar)
|
236
|
-
charge_amount = int(amount * 100) # Convert to cents
|
237
|
-
|
238
|
-
else:
|
239
|
-
# Fixed price plan
|
240
|
-
charge_amount = price.unit_amount
|
241
|
-
credits_str = price_metadata.get("credits", "0")
|
242
|
-
if credits_str.lower() == "unlimited":
|
243
|
-
credits_to_add = 0 # Subscription handles this differently
|
244
|
-
else:
|
245
|
-
credits_to_add = int(credits_str)
|
246
|
-
|
247
|
-
# Get default payment method
|
248
|
-
default_pm = customer.invoice_settings.get("default_payment_method")
|
249
|
-
if not default_pm:
|
250
|
-
# Try to get first payment method
|
251
|
-
payment_methods = stripe.PaymentMethod.list(
|
252
|
-
customer=customer.id,
|
253
|
-
type="card",
|
254
|
-
limit=1
|
255
|
-
)
|
256
|
-
if not payment_methods.data:
|
257
|
-
raise ValueError("No payment method on file")
|
258
|
-
default_pm = payment_methods.data[0].id
|
259
|
-
|
260
|
-
# Create payment intent
|
261
|
-
payment_intent = stripe.PaymentIntent.create(
|
262
|
-
amount=charge_amount,
|
263
|
-
currency="usd",
|
264
|
-
customer=customer.id,
|
265
|
-
payment_method=default_pm,
|
266
|
-
off_session=True,
|
267
|
-
confirm=True,
|
268
|
-
description=f"Credit purchase - {credits_to_add} credits",
|
269
|
-
metadata={
|
270
|
-
"user_id": user_id,
|
271
|
-
"credits": credits_to_add,
|
272
|
-
"reference_code": reference_code,
|
273
|
-
"environment": self.environment
|
274
|
-
}
|
275
|
-
)
|
276
|
-
|
277
|
-
return {
|
278
|
-
"id": payment_intent.id,
|
279
|
-
"status": payment_intent.status,
|
280
|
-
"credits_added": credits_to_add,
|
281
|
-
"amount_charged": charge_amount / 100 # Convert back to dollars
|
282
|
-
}
|
283
|
-
|
284
|
-
except stripe.error.StripeError as e:
|
285
|
-
logger.error(f"Stripe error processing payment: {e}")
|
286
|
-
raise
|
287
|
-
|
288
|
-
def customer_has_payment_method(self, stripe_customer_id: str) -> bool:
|
289
|
-
"""
|
290
|
-
Check if customer has any saved payment methods.
|
291
|
-
"""
|
292
|
-
try:
|
293
|
-
payment_methods = stripe.PaymentMethod.list(
|
294
|
-
customer=stripe_customer_id,
|
295
|
-
type="card",
|
296
|
-
limit=1
|
297
|
-
)
|
298
|
-
return len(payment_methods.data) > 0
|
299
|
-
except stripe.error.StripeError as e:
|
300
|
-
logger.error(f"Stripe error checking payment methods: {e}")
|
301
|
-
return False
|
302
|
-
|
303
|
-
def list_payment_methods(self, stripe_customer_id: str) -> Dict[str, Any]:
|
304
|
-
"""
|
305
|
-
List all payment methods for a customer.
|
306
|
-
"""
|
307
|
-
try:
|
308
|
-
# Get customer to find default payment method
|
309
|
-
customer = stripe.Customer.retrieve(stripe_customer_id)
|
310
|
-
default_pm_id = customer.invoice_settings.get("default_payment_method")
|
311
|
-
|
312
|
-
# List all payment methods
|
313
|
-
payment_methods = stripe.PaymentMethod.list(
|
314
|
-
customer=stripe_customer_id,
|
315
|
-
type="card"
|
316
|
-
)
|
317
|
-
|
318
|
-
items = []
|
319
|
-
for pm in payment_methods.data:
|
320
|
-
items.append({
|
321
|
-
"id": pm.id,
|
322
|
-
"brand": pm.card.brand,
|
323
|
-
"last4": pm.card.last4,
|
324
|
-
"exp_month": pm.card.exp_month,
|
325
|
-
"exp_year": pm.card.exp_year,
|
326
|
-
"is_default": pm.id == default_pm_id
|
327
|
-
})
|
328
|
-
|
329
|
-
return {
|
330
|
-
"items": items,
|
331
|
-
"default_payment_method_id": default_pm_id
|
332
|
-
}
|
333
|
-
|
334
|
-
except stripe.error.StripeError as e:
|
335
|
-
logger.error(f"Stripe error listing payment methods: {e}")
|
336
|
-
return {"items": [], "default_payment_method_id": None}
|
337
|
-
|
338
|
-
def _get_or_create_customer(self, user_id: str, email: Optional[str] = None) -> Any:
|
339
|
-
"""
|
340
|
-
Get existing Stripe customer or create new one.
|
341
|
-
First checks by user_id in metadata, then by email if provided.
|
342
|
-
"""
|
343
|
-
try:
|
344
|
-
# First try to find by user_id in metadata
|
345
|
-
customers = stripe.Customer.search(
|
346
|
-
query=f'metadata["user_id"]:"{user_id}"',
|
347
|
-
limit=1
|
348
|
-
)
|
349
|
-
|
350
|
-
if customers.data:
|
351
|
-
return customers.data[0]
|
352
|
-
|
353
|
-
# If email provided, try to find by email
|
354
|
-
if email:
|
355
|
-
customers = stripe.Customer.list(email=email, limit=1)
|
356
|
-
if customers.data:
|
357
|
-
# Update metadata with user_id
|
358
|
-
customer = customers.data[0]
|
359
|
-
stripe.Customer.modify(
|
360
|
-
customer.id,
|
361
|
-
metadata={"user_id": user_id}
|
362
|
-
)
|
363
|
-
return customer
|
364
|
-
|
365
|
-
# Create new customer
|
366
|
-
return stripe.Customer.create(
|
367
|
-
email=email,
|
368
|
-
metadata={
|
369
|
-
"user_id": user_id,
|
370
|
-
"environment": self.environment
|
371
|
-
}
|
372
|
-
)
|
373
|
-
|
374
|
-
except stripe.error.StripeError as e:
|
375
|
-
logger.error(f"Stripe error getting/creating customer: {e}")
|
376
|
-
raise
|
377
|
-
|
378
|
-
def create_subscription(self, user_id: str, email: str, price_id: str) -> Dict[str, Any]:
|
379
|
-
"""Create a subscription for unlimited access."""
|
380
|
-
try:
|
381
|
-
# Create or retrieve customer
|
382
|
-
customers = stripe.Customer.list(email=email, limit=1)
|
383
|
-
if customers.data:
|
384
|
-
customer = customers.data[0]
|
385
|
-
else:
|
386
|
-
customer = stripe.Customer.create(
|
387
|
-
email=email,
|
388
|
-
metadata={"user_id": user_id}
|
389
|
-
)
|
390
|
-
|
391
|
-
# Create subscription
|
392
|
-
subscription = stripe.Subscription.create(
|
393
|
-
customer=customer.id,
|
394
|
-
items=[{"price": price_id}],
|
395
|
-
metadata={
|
396
|
-
"user_id": user_id,
|
397
|
-
"environment": self.environment
|
398
|
-
}
|
399
|
-
)
|
400
|
-
|
401
|
-
return {
|
402
|
-
"subscription_id": subscription.id,
|
403
|
-
"status": subscription.status,
|
404
|
-
"customer_id": customer.id
|
405
|
-
}
|
406
|
-
|
407
|
-
except stripe.error.StripeError as e:
|
408
|
-
logger.error(f"Stripe error creating subscription: {e}")
|
409
|
-
raise
|
410
|
-
|
411
|
-
def pause_subscription(self, subscription_id: str) -> Dict[str, str]:
|
412
|
-
"""Pause a subscription."""
|
413
|
-
try:
|
414
|
-
subscription = stripe.Subscription.modify(
|
415
|
-
subscription_id,
|
416
|
-
pause_collection={"behavior": "mark_uncollectible"}
|
417
|
-
)
|
418
|
-
return {"message": "Subscription paused", "status": "paused"}
|
419
|
-
except stripe.error.StripeError as e:
|
420
|
-
logger.error(f"Stripe error pausing subscription: {e}")
|
421
|
-
raise
|
422
|
-
|
423
|
-
def resume_subscription(self, subscription_id: str) -> Dict[str, str]:
|
424
|
-
"""Resume a paused subscription."""
|
425
|
-
try:
|
426
|
-
subscription = stripe.Subscription.modify(
|
427
|
-
subscription_id,
|
428
|
-
pause_collection="" # Remove pause
|
429
|
-
)
|
430
|
-
return {"message": "Subscription resumed", "status": "active"}
|
431
|
-
except stripe.error.StripeError as e:
|
432
|
-
logger.error(f"Stripe error resuming subscription: {e}")
|
433
|
-
raise
|
434
|
-
|
435
|
-
def cancel_subscription(self, subscription_id: str) -> Dict[str, str]:
|
436
|
-
"""Cancel a subscription."""
|
437
|
-
try:
|
438
|
-
subscription = stripe.Subscription.delete(subscription_id)
|
439
|
-
return {"message": "Subscription cancelled", "status": "cancelled"}
|
440
|
-
except stripe.error.StripeError as e:
|
441
|
-
logger.error(f"Stripe error cancelling subscription: {e}")
|
442
|
-
raise
|
443
|
-
|
444
|
-
def _get_mock_plans(self) -> List[Plan]:
|
445
|
-
"""Return mock plans for development/testing."""
|
446
|
-
return [
|
447
|
-
Plan(
|
448
|
-
plan_reference="price_standard_mock",
|
449
|
-
plan_type="prepaid",
|
450
|
-
plan_name="STANDARD",
|
451
|
-
plan_subtitle="One-time purchase",
|
452
|
-
plan_amount=10.0,
|
453
|
-
plan_credits=5000,
|
454
|
-
plan_credits_text="5,000 credits",
|
455
|
-
percent_off=""
|
456
|
-
),
|
457
|
-
Plan(
|
458
|
-
plan_reference="price_power_mock",
|
459
|
-
plan_type="prepaid",
|
460
|
-
plan_name="POWER",
|
461
|
-
plan_subtitle="Best value",
|
462
|
-
plan_amount=50.0,
|
463
|
-
plan_credits=28500,
|
464
|
-
plan_credits_text="28,500 credits",
|
465
|
-
percent_off="12.5% OFF"
|
466
|
-
),
|
467
|
-
Plan(
|
468
|
-
plan_reference="price_elite_mock",
|
469
|
-
plan_type="prepaid",
|
470
|
-
plan_name="ELITE",
|
471
|
-
plan_subtitle="Maximum savings",
|
472
|
-
plan_amount=100.0,
|
473
|
-
plan_credits=66666,
|
474
|
-
plan_credits_text="66,666 credits",
|
475
|
-
percent_off="25% OFF"
|
476
|
-
),
|
477
|
-
Plan(
|
478
|
-
plan_reference="price_unlimited_mock",
|
479
|
-
plan_type="postpaid",
|
480
|
-
plan_name="UNLIMITED",
|
481
|
-
plan_subtitle="Monthly subscription",
|
482
|
-
plan_amount=299.0,
|
483
|
-
plan_credits=None,
|
484
|
-
plan_credits_text="Unlimited",
|
485
|
-
percent_off=""
|
486
|
-
)
|
487
|
-
]
|
1
|
+
"""Stripe API management with metadata conventions."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
from typing import List, Optional, Dict, Any
|
5
|
+
import logging
|
6
|
+
|
7
|
+
try:
|
8
|
+
import stripe
|
9
|
+
except ImportError:
|
10
|
+
stripe = None # Handle gracefully for testing
|
11
|
+
|
12
|
+
from .models import Plan
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
|
17
|
+
class StripeManager:
|
18
|
+
"""
|
19
|
+
Manages Stripe resources with metadata conventions.
|
20
|
+
Uses metadata to discover and filter products/prices.
|
21
|
+
"""
|
22
|
+
|
23
|
+
METADATA_SCHEMA = {
|
24
|
+
"product_type": "landline_scrubber",
|
25
|
+
"environment": None, # Set at runtime
|
26
|
+
"tier": None,
|
27
|
+
"credits": None,
|
28
|
+
"active": "true"
|
29
|
+
}
|
30
|
+
|
31
|
+
def __init__(self, api_key: Optional[str] = None, environment: Optional[str] = None):
|
32
|
+
"""Initialize with Stripe API key and environment."""
|
33
|
+
if not stripe:
|
34
|
+
raise ImportError("stripe package not installed. Run: pip install stripe")
|
35
|
+
|
36
|
+
self.api_key = api_key or os.environ.get("STRIPE_SECRET_KEY")
|
37
|
+
if not self.api_key:
|
38
|
+
raise ValueError("Stripe API key not provided and STRIPE_SECRET_KEY not set")
|
39
|
+
|
40
|
+
stripe.api_key = self.api_key
|
41
|
+
self.environment = environment or os.environ.get("ENVIRONMENT", "staging")
|
42
|
+
|
43
|
+
def list_plans(self) -> List[Plan]:
|
44
|
+
"""
|
45
|
+
Fetch active plans from Stripe using metadata.
|
46
|
+
Returns list of Plan objects sorted by price.
|
47
|
+
"""
|
48
|
+
try:
|
49
|
+
# Fetch all active prices with expanded product data
|
50
|
+
prices = stripe.Price.list(
|
51
|
+
active=True,
|
52
|
+
expand=["data.product"],
|
53
|
+
limit=100
|
54
|
+
)
|
55
|
+
|
56
|
+
plans = []
|
57
|
+
for price in prices.data:
|
58
|
+
metadata = price.metadata or {}
|
59
|
+
|
60
|
+
# Filter by our metadata conventions
|
61
|
+
if (metadata.get("product_type") == "landline_scrubber" and
|
62
|
+
metadata.get("environment") == self.environment and
|
63
|
+
metadata.get("active") == "true"):
|
64
|
+
|
65
|
+
# Convert to Plan object
|
66
|
+
plan = Plan.from_stripe_price(price, price.product)
|
67
|
+
plans.append(plan)
|
68
|
+
|
69
|
+
# Sort by price amount
|
70
|
+
plans.sort(key=lambda p: p.plan_amount)
|
71
|
+
|
72
|
+
logger.info(f"Found {len(plans)} active plans for environment {self.environment}")
|
73
|
+
return plans
|
74
|
+
|
75
|
+
except stripe.error.StripeError as e:
|
76
|
+
logger.error(f"Stripe error listing plans: {e}")
|
77
|
+
# Return mock data for development/testing
|
78
|
+
return self._get_mock_plans()
|
79
|
+
|
80
|
+
def create_setup_intent(self, user_id: str) -> Dict[str, str]:
|
81
|
+
"""
|
82
|
+
Create a SetupIntent for secure payment method collection.
|
83
|
+
Frontend will confirm this with Stripe Elements.
|
84
|
+
"""
|
85
|
+
try:
|
86
|
+
# Get or create customer
|
87
|
+
customer = self._get_or_create_customer(user_id)
|
88
|
+
|
89
|
+
# Create SetupIntent
|
90
|
+
setup_intent = stripe.SetupIntent.create(
|
91
|
+
customer=customer.id,
|
92
|
+
metadata={
|
93
|
+
"user_id": user_id,
|
94
|
+
"environment": self.environment
|
95
|
+
}
|
96
|
+
)
|
97
|
+
|
98
|
+
return {
|
99
|
+
"client_secret": setup_intent.client_secret,
|
100
|
+
"setup_intent_id": setup_intent.id,
|
101
|
+
"customer_id": customer.id
|
102
|
+
}
|
103
|
+
|
104
|
+
except stripe.error.StripeError as e:
|
105
|
+
logger.error(f"Stripe error creating setup intent: {e}")
|
106
|
+
raise
|
107
|
+
|
108
|
+
def attach_payment_method(self, user_id: str, payment_method_id: str, billing_details: Dict[str, Any]) -> Dict[str, Any]:
|
109
|
+
"""
|
110
|
+
Attach a payment method to customer (legacy path).
|
111
|
+
Returns whether this is the first card.
|
112
|
+
"""
|
113
|
+
try:
|
114
|
+
# Get or create customer
|
115
|
+
customer = self._get_or_create_customer(user_id)
|
116
|
+
|
117
|
+
# Attach payment method to customer
|
118
|
+
payment_method = stripe.PaymentMethod.attach(
|
119
|
+
payment_method_id,
|
120
|
+
customer=customer.id
|
121
|
+
)
|
122
|
+
|
123
|
+
# Update billing details if provided
|
124
|
+
if billing_details:
|
125
|
+
stripe.PaymentMethod.modify(
|
126
|
+
payment_method_id,
|
127
|
+
billing_details=billing_details
|
128
|
+
)
|
129
|
+
|
130
|
+
# Check if this is the first payment method
|
131
|
+
payment_methods = stripe.PaymentMethod.list(
|
132
|
+
customer=customer.id,
|
133
|
+
type="card"
|
134
|
+
)
|
135
|
+
|
136
|
+
first_card = len(payment_methods.data) == 1
|
137
|
+
|
138
|
+
# Set as default if first card
|
139
|
+
if first_card:
|
140
|
+
stripe.Customer.modify(
|
141
|
+
customer.id,
|
142
|
+
invoice_settings={"default_payment_method": payment_method_id}
|
143
|
+
)
|
144
|
+
|
145
|
+
return {
|
146
|
+
"payment_method_id": payment_method_id,
|
147
|
+
"first_card": first_card,
|
148
|
+
"customer_id": customer.id
|
149
|
+
}
|
150
|
+
|
151
|
+
except stripe.error.StripeError as e:
|
152
|
+
logger.error(f"Stripe error attaching payment method: {e}")
|
153
|
+
raise
|
154
|
+
|
155
|
+
def verify_payment_method(self, user_id: str, payment_method_id: str) -> Dict[str, Any]:
|
156
|
+
"""
|
157
|
+
Perform $1 verification charge on new payment method.
|
158
|
+
"""
|
159
|
+
try:
|
160
|
+
customer = self._get_or_create_customer(user_id)
|
161
|
+
|
162
|
+
# Create $1 verification charge
|
163
|
+
payment_intent = stripe.PaymentIntent.create(
|
164
|
+
amount=100, # $1.00 in cents
|
165
|
+
currency="usd",
|
166
|
+
customer=customer.id,
|
167
|
+
payment_method=payment_method_id,
|
168
|
+
off_session=True,
|
169
|
+
confirm=True,
|
170
|
+
description="Card verification - $1 charge",
|
171
|
+
metadata={
|
172
|
+
"user_id": user_id,
|
173
|
+
"type": "verification",
|
174
|
+
"environment": self.environment
|
175
|
+
}
|
176
|
+
)
|
177
|
+
|
178
|
+
return {
|
179
|
+
"status": payment_intent.status,
|
180
|
+
"payment_intent_id": payment_intent.id
|
181
|
+
}
|
182
|
+
|
183
|
+
except stripe.error.StripeError as e:
|
184
|
+
logger.error(f"Stripe error verifying payment method: {e}")
|
185
|
+
raise
|
186
|
+
|
187
|
+
def charge_prepaid(self, user_id: str, reference_code: str, amount: Optional[float] = None) -> Dict[str, Any]:
|
188
|
+
"""
|
189
|
+
Charge saved payment method for credit purchase.
|
190
|
+
Supports both fixed-price and metadata-based variable-amount plans.
|
191
|
+
"""
|
192
|
+
try:
|
193
|
+
customer = self._get_or_create_customer(user_id)
|
194
|
+
|
195
|
+
# Look up price from Stripe
|
196
|
+
prices = stripe.Price.list(active=True, limit=100, expand=["data.product"])
|
197
|
+
price = None
|
198
|
+
|
199
|
+
for p in prices.data:
|
200
|
+
metadata = p.metadata or {}
|
201
|
+
# Match by price ID or plan_reference in metadata
|
202
|
+
if (p.id == reference_code or
|
203
|
+
metadata.get("plan_reference") == reference_code or
|
204
|
+
(metadata.get("tier") == reference_code and
|
205
|
+
metadata.get("environment") == self.environment)):
|
206
|
+
price = p
|
207
|
+
break
|
208
|
+
|
209
|
+
if not price:
|
210
|
+
raise ValueError(f"Invalid plan reference: {reference_code}")
|
211
|
+
|
212
|
+
price_metadata = price.metadata or {}
|
213
|
+
|
214
|
+
# Check if this is a variable amount plan
|
215
|
+
if price_metadata.get("variable_amount") == "true":
|
216
|
+
# Variable amount plan - validate amount
|
217
|
+
if not amount:
|
218
|
+
raise ValueError("Amount required for variable-amount plan")
|
219
|
+
|
220
|
+
# Get validation rules from metadata
|
221
|
+
min_amount = float(price_metadata.get("min_amount", "5"))
|
222
|
+
if amount < min_amount:
|
223
|
+
raise ValueError(f"Amount ${amount} is below minimum ${min_amount}")
|
224
|
+
|
225
|
+
# Check against default amounts if specified
|
226
|
+
default_amounts_str = price_metadata.get("default_amounts", "")
|
227
|
+
if default_amounts_str:
|
228
|
+
allowed_amounts = [float(x.strip()) for x in default_amounts_str.split(",")]
|
229
|
+
# Allow default amounts OR any amount >= minimum
|
230
|
+
if amount not in allowed_amounts and amount < max(allowed_amounts):
|
231
|
+
logger.info(f"Amount ${amount} not in defaults {allowed_amounts}, but allowed as >= ${min_amount}")
|
232
|
+
|
233
|
+
# Calculate credits based on credits_per_dollar
|
234
|
+
credits_per_dollar = float(price_metadata.get("credits_per_dollar", "285"))
|
235
|
+
credits_to_add = int(amount * credits_per_dollar)
|
236
|
+
charge_amount = int(amount * 100) # Convert to cents
|
237
|
+
|
238
|
+
else:
|
239
|
+
# Fixed price plan
|
240
|
+
charge_amount = price.unit_amount
|
241
|
+
credits_str = price_metadata.get("credits", "0")
|
242
|
+
if credits_str.lower() == "unlimited":
|
243
|
+
credits_to_add = 0 # Subscription handles this differently
|
244
|
+
else:
|
245
|
+
credits_to_add = int(credits_str)
|
246
|
+
|
247
|
+
# Get default payment method
|
248
|
+
default_pm = customer.invoice_settings.get("default_payment_method")
|
249
|
+
if not default_pm:
|
250
|
+
# Try to get first payment method
|
251
|
+
payment_methods = stripe.PaymentMethod.list(
|
252
|
+
customer=customer.id,
|
253
|
+
type="card",
|
254
|
+
limit=1
|
255
|
+
)
|
256
|
+
if not payment_methods.data:
|
257
|
+
raise ValueError("No payment method on file")
|
258
|
+
default_pm = payment_methods.data[0].id
|
259
|
+
|
260
|
+
# Create payment intent
|
261
|
+
payment_intent = stripe.PaymentIntent.create(
|
262
|
+
amount=charge_amount,
|
263
|
+
currency="usd",
|
264
|
+
customer=customer.id,
|
265
|
+
payment_method=default_pm,
|
266
|
+
off_session=True,
|
267
|
+
confirm=True,
|
268
|
+
description=f"Credit purchase - {credits_to_add} credits",
|
269
|
+
metadata={
|
270
|
+
"user_id": user_id,
|
271
|
+
"credits": credits_to_add,
|
272
|
+
"reference_code": reference_code,
|
273
|
+
"environment": self.environment
|
274
|
+
}
|
275
|
+
)
|
276
|
+
|
277
|
+
return {
|
278
|
+
"id": payment_intent.id,
|
279
|
+
"status": payment_intent.status,
|
280
|
+
"credits_added": credits_to_add,
|
281
|
+
"amount_charged": charge_amount / 100 # Convert back to dollars
|
282
|
+
}
|
283
|
+
|
284
|
+
except stripe.error.StripeError as e:
|
285
|
+
logger.error(f"Stripe error processing payment: {e}")
|
286
|
+
raise
|
287
|
+
|
288
|
+
def customer_has_payment_method(self, stripe_customer_id: str) -> bool:
|
289
|
+
"""
|
290
|
+
Check if customer has any saved payment methods.
|
291
|
+
"""
|
292
|
+
try:
|
293
|
+
payment_methods = stripe.PaymentMethod.list(
|
294
|
+
customer=stripe_customer_id,
|
295
|
+
type="card",
|
296
|
+
limit=1
|
297
|
+
)
|
298
|
+
return len(payment_methods.data) > 0
|
299
|
+
except stripe.error.StripeError as e:
|
300
|
+
logger.error(f"Stripe error checking payment methods: {e}")
|
301
|
+
return False
|
302
|
+
|
303
|
+
def list_payment_methods(self, stripe_customer_id: str) -> Dict[str, Any]:
|
304
|
+
"""
|
305
|
+
List all payment methods for a customer.
|
306
|
+
"""
|
307
|
+
try:
|
308
|
+
# Get customer to find default payment method
|
309
|
+
customer = stripe.Customer.retrieve(stripe_customer_id)
|
310
|
+
default_pm_id = customer.invoice_settings.get("default_payment_method")
|
311
|
+
|
312
|
+
# List all payment methods
|
313
|
+
payment_methods = stripe.PaymentMethod.list(
|
314
|
+
customer=stripe_customer_id,
|
315
|
+
type="card"
|
316
|
+
)
|
317
|
+
|
318
|
+
items = []
|
319
|
+
for pm in payment_methods.data:
|
320
|
+
items.append({
|
321
|
+
"id": pm.id,
|
322
|
+
"brand": pm.card.brand,
|
323
|
+
"last4": pm.card.last4,
|
324
|
+
"exp_month": pm.card.exp_month,
|
325
|
+
"exp_year": pm.card.exp_year,
|
326
|
+
"is_default": pm.id == default_pm_id
|
327
|
+
})
|
328
|
+
|
329
|
+
return {
|
330
|
+
"items": items,
|
331
|
+
"default_payment_method_id": default_pm_id
|
332
|
+
}
|
333
|
+
|
334
|
+
except stripe.error.StripeError as e:
|
335
|
+
logger.error(f"Stripe error listing payment methods: {e}")
|
336
|
+
return {"items": [], "default_payment_method_id": None}
|
337
|
+
|
338
|
+
def _get_or_create_customer(self, user_id: str, email: Optional[str] = None) -> Any:
|
339
|
+
"""
|
340
|
+
Get existing Stripe customer or create new one.
|
341
|
+
First checks by user_id in metadata, then by email if provided.
|
342
|
+
"""
|
343
|
+
try:
|
344
|
+
# First try to find by user_id in metadata
|
345
|
+
customers = stripe.Customer.search(
|
346
|
+
query=f'metadata["user_id"]:"{user_id}"',
|
347
|
+
limit=1
|
348
|
+
)
|
349
|
+
|
350
|
+
if customers.data:
|
351
|
+
return customers.data[0]
|
352
|
+
|
353
|
+
# If email provided, try to find by email
|
354
|
+
if email:
|
355
|
+
customers = stripe.Customer.list(email=email, limit=1)
|
356
|
+
if customers.data:
|
357
|
+
# Update metadata with user_id
|
358
|
+
customer = customers.data[0]
|
359
|
+
stripe.Customer.modify(
|
360
|
+
customer.id,
|
361
|
+
metadata={"user_id": user_id}
|
362
|
+
)
|
363
|
+
return customer
|
364
|
+
|
365
|
+
# Create new customer
|
366
|
+
return stripe.Customer.create(
|
367
|
+
email=email,
|
368
|
+
metadata={
|
369
|
+
"user_id": user_id,
|
370
|
+
"environment": self.environment
|
371
|
+
}
|
372
|
+
)
|
373
|
+
|
374
|
+
except stripe.error.StripeError as e:
|
375
|
+
logger.error(f"Stripe error getting/creating customer: {e}")
|
376
|
+
raise
|
377
|
+
|
378
|
+
def create_subscription(self, user_id: str, email: str, price_id: str) -> Dict[str, Any]:
|
379
|
+
"""Create a subscription for unlimited access."""
|
380
|
+
try:
|
381
|
+
# Create or retrieve customer
|
382
|
+
customers = stripe.Customer.list(email=email, limit=1)
|
383
|
+
if customers.data:
|
384
|
+
customer = customers.data[0]
|
385
|
+
else:
|
386
|
+
customer = stripe.Customer.create(
|
387
|
+
email=email,
|
388
|
+
metadata={"user_id": user_id}
|
389
|
+
)
|
390
|
+
|
391
|
+
# Create subscription
|
392
|
+
subscription = stripe.Subscription.create(
|
393
|
+
customer=customer.id,
|
394
|
+
items=[{"price": price_id}],
|
395
|
+
metadata={
|
396
|
+
"user_id": user_id,
|
397
|
+
"environment": self.environment
|
398
|
+
}
|
399
|
+
)
|
400
|
+
|
401
|
+
return {
|
402
|
+
"subscription_id": subscription.id,
|
403
|
+
"status": subscription.status,
|
404
|
+
"customer_id": customer.id
|
405
|
+
}
|
406
|
+
|
407
|
+
except stripe.error.StripeError as e:
|
408
|
+
logger.error(f"Stripe error creating subscription: {e}")
|
409
|
+
raise
|
410
|
+
|
411
|
+
def pause_subscription(self, subscription_id: str) -> Dict[str, str]:
|
412
|
+
"""Pause a subscription."""
|
413
|
+
try:
|
414
|
+
subscription = stripe.Subscription.modify(
|
415
|
+
subscription_id,
|
416
|
+
pause_collection={"behavior": "mark_uncollectible"}
|
417
|
+
)
|
418
|
+
return {"message": "Subscription paused", "status": "paused"}
|
419
|
+
except stripe.error.StripeError as e:
|
420
|
+
logger.error(f"Stripe error pausing subscription: {e}")
|
421
|
+
raise
|
422
|
+
|
423
|
+
def resume_subscription(self, subscription_id: str) -> Dict[str, str]:
|
424
|
+
"""Resume a paused subscription."""
|
425
|
+
try:
|
426
|
+
subscription = stripe.Subscription.modify(
|
427
|
+
subscription_id,
|
428
|
+
pause_collection="" # Remove pause
|
429
|
+
)
|
430
|
+
return {"message": "Subscription resumed", "status": "active"}
|
431
|
+
except stripe.error.StripeError as e:
|
432
|
+
logger.error(f"Stripe error resuming subscription: {e}")
|
433
|
+
raise
|
434
|
+
|
435
|
+
def cancel_subscription(self, subscription_id: str) -> Dict[str, str]:
|
436
|
+
"""Cancel a subscription."""
|
437
|
+
try:
|
438
|
+
subscription = stripe.Subscription.delete(subscription_id)
|
439
|
+
return {"message": "Subscription cancelled", "status": "cancelled"}
|
440
|
+
except stripe.error.StripeError as e:
|
441
|
+
logger.error(f"Stripe error cancelling subscription: {e}")
|
442
|
+
raise
|
443
|
+
|
444
|
+
def _get_mock_plans(self) -> List[Plan]:
|
445
|
+
"""Return mock plans for development/testing."""
|
446
|
+
return [
|
447
|
+
Plan(
|
448
|
+
plan_reference="price_standard_mock",
|
449
|
+
plan_type="prepaid",
|
450
|
+
plan_name="STANDARD",
|
451
|
+
plan_subtitle="One-time purchase",
|
452
|
+
plan_amount=10.0,
|
453
|
+
plan_credits=5000,
|
454
|
+
plan_credits_text="5,000 credits",
|
455
|
+
percent_off=""
|
456
|
+
),
|
457
|
+
Plan(
|
458
|
+
plan_reference="price_power_mock",
|
459
|
+
plan_type="prepaid",
|
460
|
+
plan_name="POWER",
|
461
|
+
plan_subtitle="Best value",
|
462
|
+
plan_amount=50.0,
|
463
|
+
plan_credits=28500,
|
464
|
+
plan_credits_text="28,500 credits",
|
465
|
+
percent_off="12.5% OFF"
|
466
|
+
),
|
467
|
+
Plan(
|
468
|
+
plan_reference="price_elite_mock",
|
469
|
+
plan_type="prepaid",
|
470
|
+
plan_name="ELITE",
|
471
|
+
plan_subtitle="Maximum savings",
|
472
|
+
plan_amount=100.0,
|
473
|
+
plan_credits=66666,
|
474
|
+
plan_credits_text="66,666 credits",
|
475
|
+
percent_off="25% OFF"
|
476
|
+
),
|
477
|
+
Plan(
|
478
|
+
plan_reference="price_unlimited_mock",
|
479
|
+
plan_type="postpaid",
|
480
|
+
plan_name="UNLIMITED",
|
481
|
+
plan_subtitle="Monthly subscription",
|
482
|
+
plan_amount=299.0,
|
483
|
+
plan_credits=None,
|
484
|
+
plan_credits_text="Unlimited",
|
485
|
+
percent_off=""
|
486
|
+
)
|
487
|
+
]
|