ai-lls-lib 1.4.0rc3__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 -186
  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.0rc3.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.0rc3.dist-info/RECORD +0 -33
  33. {ai_lls_lib-1.4.0rc3.dist-info → ai_lls_lib-1.4.0rc4.dist-info}/WHEEL +0 -0
  34. {ai_lls_lib-1.4.0rc3.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
+ ]