Coverage for src/ai_lls_lib/payment/stripe_manager.py: 58%

212 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-02 17:06 +0000

1"""Stripe API management with metadata conventions.""" 

2 

3import logging 

4import os 

5from typing import Any, cast 

6 

7try: 

8 import stripe 

9 

10 HAS_STRIPE = True 

11except ImportError: 

12 stripe = None # type: ignore[assignment] 

13 HAS_STRIPE = False 

14 

15from .models import Plan 

16 

17logger = logging.getLogger(__name__) 

18 

19 

20class StripeManager: 

21 """ 

22 Manages Stripe resources with metadata conventions. 

23 Uses metadata to discover and filter products/prices. 

24 """ 

25 

26 METADATA_SCHEMA = { 

27 "product_type": "landline_scrubber", 

28 "environment": None, # Set at runtime 

29 "tier": None, 

30 "credits": None, 

31 "active": "true", 

32 } 

33 

34 def __init__(self, api_key: str | None = None, environment: str | None = None): 

35 """Initialize with Stripe API key and environment.""" 

36 if not HAS_STRIPE or not stripe: 

37 raise ImportError("stripe package not installed. Run: pip install stripe") 

38 

39 self.api_key = api_key or os.environ.get("STRIPE_SECRET_KEY") 

40 if not self.api_key: 

41 raise ValueError("Stripe API key not provided and STRIPE_SECRET_KEY not set") 

42 

43 stripe.api_key = self.api_key 

44 self.environment = environment or os.environ.get("ENVIRONMENT", "staging") 

45 

46 def list_plans(self) -> list[Plan]: 

47 """ 

48 Fetch active plans from Stripe using metadata. 

49 Returns list of Plan objects sorted by price. 

50 """ 

51 try: 

52 # Fetch all active prices with expanded product data 

53 prices = stripe.Price.list(active=True, expand=["data.product"], limit=100) 

54 

55 plans = [] 

56 for price in prices.data: 

57 metadata = cast(dict[str, str], price.metadata or {}) 

58 

59 # Filter by our metadata conventions 

60 if ( 

61 metadata["product_type"] == "landline_scrubber" 

62 if "product_type" in metadata 

63 else False 

64 ) and (metadata["active"] == "true" if "active" in metadata else False): 

65 # Convert to Plan object 

66 # price.product is expanded to Product object due to expand param 

67 product = price.product 

68 if isinstance(product, str): 

69 # Shouldn't happen with expand, but handle gracefully 

70 continue 

71 plan = Plan.from_stripe_price(price, product) # type: ignore[arg-type] 

72 plans.append(plan) 

73 

74 # Sort by price amount 

75 plans.sort(key=lambda p: p.plan_amount) 

76 

77 logger.info(f"Found {len(plans)} active plans for environment {self.environment}") 

78 return plans 

79 

80 except stripe.error.StripeError as e: 

81 logger.error(f"Stripe error listing plans: {e}") 

82 # Let error propagate - no fallback to mock data 

83 raise 

84 

85 def create_setup_intent(self, user_id: str) -> dict[str, str]: 

86 """ 

87 Create a SetupIntent for secure payment method collection. 

88 Frontend will confirm this with Stripe Elements. 

89 """ 

90 try: 

91 # Get or create customer 

92 customer = self._get_or_create_customer(user_id) 

93 

94 # Create SetupIntent 

95 setup_intent = stripe.SetupIntent.create( 

96 customer=customer.id, metadata={"user_id": user_id, "environment": self.environment} 

97 ) 

98 

99 return { 

100 "client_secret": setup_intent.client_secret, 

101 "setup_intent_id": setup_intent.id, 

102 "customer_id": customer.id, 

103 } 

104 

105 except stripe.error.StripeError as e: 

106 logger.error(f"Stripe error creating setup intent: {e}") 

107 raise 

108 

109 def attach_payment_method( 

110 self, user_id: str, payment_method_id: str, billing_details: dict[str, Any] 

111 ) -> dict[str, Any]: 

112 """ 

113 Attach a payment method to customer (legacy path). 

114 Returns whether this is the first card. 

115 """ 

116 try: 

117 # Get or create customer 

118 customer = self._get_or_create_customer(user_id) 

119 

120 # Attach payment method to customer 

121 stripe.PaymentMethod.attach(payment_method_id, customer=customer.id) 

122 

123 # Update billing details if provided 

124 if billing_details: 

125 stripe.PaymentMethod.modify( 

126 payment_method_id, 

127 billing_details=billing_details, # type: ignore[arg-type] 

128 ) 

129 

130 # Check if this is the first payment method 

131 payment_methods = stripe.PaymentMethod.list(customer=customer.id, type="card") 

132 

133 first_card = len(payment_methods.data) == 1 

134 

135 # Set as default if first card 

136 if first_card: 

137 stripe.Customer.modify( 

138 customer.id, invoice_settings={"default_payment_method": payment_method_id} 

139 ) 

140 

141 return { 

142 "payment_method_id": payment_method_id, 

143 "first_card": first_card, 

144 "customer_id": customer.id, 

145 } 

146 

147 except stripe.error.StripeError as e: 

148 logger.error(f"Stripe error attaching payment method: {e}") 

149 raise 

150 

151 def verify_payment_method(self, user_id: str, payment_method_id: str) -> dict[str, Any]: 

152 """ 

153 Perform $1 verification charge on new payment method. 

154 """ 

155 try: 

156 customer = self._get_or_create_customer(user_id) 

157 

158 # Create $1 verification charge 

159 payment_intent = stripe.PaymentIntent.create( 

160 amount=100, # $1.00 in cents 

161 currency="usd", 

162 customer=customer.id, 

163 payment_method=payment_method_id, 

164 off_session=True, 

165 confirm=True, 

166 description="Card verification - $1 charge", 

167 metadata={ 

168 "user_id": user_id, 

169 "type": "verification", 

170 "environment": self.environment, 

171 }, 

172 ) 

173 

174 return {"status": payment_intent.status, "payment_intent_id": payment_intent.id} 

175 

176 except stripe.error.StripeError as e: 

177 logger.error(f"Stripe error verifying payment method: {e}") 

178 raise 

179 

180 def create_subscription_checkout( 

181 self, 

182 price_id: str, 

183 user_id: str, 

184 success_url: str, 

185 cancel_url: str, 

186 customer_id: str | None = None, 

187 ) -> Any: 

188 """Create a Stripe Checkout Session in subscription mode. 

189 

190 The mirror image of charge_prepaid's guard: only recurring prices are accepted 

191 here. user_id is set in subscription metadata so the subscription webhooks can 

192 write subscription_status for the right account -- without it a paid 

193 subscription is never fulfilled. 

194 

195 Returns the Checkout Session (id and url are what callers redirect with). 

196 """ 

197 price = stripe.Price.retrieve(price_id) 

198 if not getattr(price, "recurring", None): 

199 raise ValueError( 

200 f"Price {price_id} is not a subscription price; use the one-time purchase path" 

201 ) 

202 

203 if customer_id is None: 

204 customer_id = self._get_or_create_customer(user_id).id 

205 

206 session = stripe.checkout.Session.create( 

207 mode="subscription", 

208 customer=customer_id, 

209 line_items=[{"price": price_id, "quantity": 1}], 

210 success_url=success_url, 

211 cancel_url=cancel_url, 

212 subscription_data={"metadata": {"user_id": user_id, "environment": self.environment}}, 

213 metadata={"user_id": user_id, "environment": self.environment}, 

214 ) 

215 logger.info(f"Created subscription checkout session {session.id} for user {user_id}") 

216 return session 

217 

218 def charge_prepaid( 

219 self, 

220 user_id: str, 

221 reference_code: str, 

222 amount: float | None = None, 

223 stripe_customer_id: str | None = None, 

224 ) -> dict[str, Any]: 

225 """ 

226 Charge saved payment method for credit purchase. 

227 Supports both fixed-price and metadata-based variable-amount plans. 

228 

229 Pass stripe_customer_id when the caller has it stored. Without it the customer 

230 is found with Customer.search, which is eventually consistent: a purchase made 

231 seconds after the first card was added can miss the customer, create a second 

232 one with no card, and fail with "No payment method on file". 

233 """ 

234 try: 

235 customer = self._get_or_create_customer(user_id, stripe_customer_id=stripe_customer_id) 

236 

237 # Look up price from Stripe 

238 prices = stripe.Price.list(active=True, limit=100, expand=["data.product"]) 

239 price = None 

240 

241 for p in prices.data: 

242 metadata = cast(dict[str, str], p.metadata or {}) 

243 # Match by price ID or plan_reference in metadata 

244 plan_reference = ( 

245 metadata["plan_reference"] if "plan_reference" in metadata else None 

246 ) 

247 tier = metadata["tier"] if "tier" in metadata else None 

248 env = metadata["environment"] if "environment" in metadata else None 

249 if ( 

250 p.id == reference_code 

251 or plan_reference == reference_code 

252 or (tier == reference_code and env == self.environment) 

253 ): 

254 price = p 

255 break 

256 

257 if not price: 

258 raise ValueError(f"Invalid plan reference: {reference_code}") 

259 

260 # Fail closed on subscription prices. This path creates a one-time PaymentIntent; 

261 # charging a recurring price here takes the money and creates no subscription. 

262 # The check runs before the variable-amount branch so no price shape can bypass it. 

263 if getattr(price, "recurring", None): 

264 raise ValueError( 

265 f"Plan {reference_code} is a subscription and cannot be charged as a " 

266 "one-time purchase; use the subscription checkout" 

267 ) 

268 

269 price_metadata = cast(dict[str, str], price.metadata or {}) 

270 

271 # Check if this is a variable amount plan 

272 variable_amount = ( 

273 price_metadata["variable_amount"] if "variable_amount" in price_metadata else None 

274 ) 

275 if variable_amount == "true": 

276 # Variable amount plan - validate amount 

277 if not amount: 

278 raise ValueError("Amount required for variable-amount plan") 

279 

280 # Get validation rules from metadata 

281 min_amount = float( 

282 price_metadata["min_amount"] if "min_amount" in price_metadata else "5" 

283 ) 

284 if amount < min_amount: 

285 raise ValueError(f"Amount ${amount} is below minimum ${min_amount}") 

286 

287 # Check against default amounts if specified 

288 default_amounts_str = ( 

289 price_metadata["default_amounts"] if "default_amounts" in price_metadata else "" 

290 ) 

291 if default_amounts_str: 

292 allowed_amounts = [float(x.strip()) for x in default_amounts_str.split(",")] 

293 # Allow default amounts OR any amount >= minimum 

294 if amount not in allowed_amounts and amount < max(allowed_amounts): 

295 logger.info( 

296 f"Amount ${amount} not in defaults {allowed_amounts}, but allowed as >= ${min_amount}" 

297 ) 

298 

299 # Calculate credits based on credits_per_dollar 

300 credits_per_dollar = float( 

301 price_metadata["credits_per_dollar"] 

302 if "credits_per_dollar" in price_metadata 

303 else "285" 

304 ) 

305 credits_to_add = int(amount * credits_per_dollar) 

306 charge_amount = int(amount * 100) # Convert to cents 

307 

308 else: 

309 # Fixed price plan 

310 charge_amount = price.unit_amount or 0 

311 credits_str = price_metadata["credits"] if "credits" in price_metadata else "0" 

312 if credits_str.lower() == "unlimited": 

313 credits_to_add = 0 # Subscription handles this differently 

314 else: 

315 credits_to_add = int(credits_str) 

316 

317 # Get default payment method 

318 invoice_settings = customer.invoice_settings 

319 default_pm = ( 

320 invoice_settings["default_payment_method"] 

321 if invoice_settings and "default_payment_method" in invoice_settings 

322 else None 

323 ) 

324 if not default_pm: 

325 # Try to get first payment method 

326 payment_methods = stripe.PaymentMethod.list( 

327 customer=customer.id, type="card", limit=1 

328 ) 

329 if not payment_methods.data: 

330 raise ValueError("No payment method on file") 

331 default_pm = payment_methods.data[0].id 

332 

333 # Create payment intent 

334 payment_intent = stripe.PaymentIntent.create( 

335 amount=charge_amount, 

336 currency="usd", 

337 customer=customer.id, 

338 payment_method=default_pm, 

339 off_session=True, 

340 confirm=True, 

341 description=f"Credit purchase - {credits_to_add} credits", 

342 metadata={ 

343 "user_id": user_id, 

344 "credits": str(credits_to_add), 

345 "reference_code": reference_code, 

346 "environment": self.environment, 

347 }, 

348 ) 

349 

350 return { 

351 "id": payment_intent.id, 

352 "status": payment_intent.status, 

353 "credits_added": credits_to_add, 

354 "amount_charged": charge_amount / 100, # Convert back to dollars 

355 } 

356 

357 except stripe.error.StripeError as e: 

358 logger.error(f"Stripe error processing payment: {e}") 

359 raise 

360 

361 def customer_has_payment_method(self, stripe_customer_id: str) -> bool: 

362 """ 

363 Check if customer has any saved payment methods. 

364 """ 

365 try: 

366 payment_methods = stripe.PaymentMethod.list( 

367 customer=stripe_customer_id, type="card", limit=1 

368 ) 

369 return len(payment_methods.data) > 0 

370 except stripe.error.StripeError as e: 

371 logger.error(f"Stripe error checking payment methods: {e}") 

372 return False 

373 

374 def list_payment_methods(self, stripe_customer_id: str) -> dict[str, Any]: 

375 """ 

376 List all payment methods for a customer. 

377 """ 

378 try: 

379 # Get customer to find default payment method 

380 customer = stripe.Customer.retrieve(stripe_customer_id) 

381 invoice_settings = customer.invoice_settings 

382 default_pm_id = invoice_settings.default_payment_method if invoice_settings else None 

383 

384 # List all payment methods 

385 payment_methods = stripe.PaymentMethod.list(customer=stripe_customer_id, type="card") 

386 

387 items = [] 

388 for pm in payment_methods.data: 

389 card = pm.card 

390 if card: 

391 items.append( 

392 { 

393 "id": pm.id, 

394 "brand": card.brand, 

395 "last4": card.last4, 

396 "exp_month": card.exp_month, 

397 "exp_year": card.exp_year, 

398 "is_default": pm.id == default_pm_id, 

399 } 

400 ) 

401 

402 return {"items": items, "default_payment_method_id": default_pm_id} 

403 

404 except stripe.error.StripeError as e: 

405 logger.error(f"Stripe error listing payment methods: {e}") 

406 return {"items": [], "default_payment_method_id": None} 

407 

408 def _get_or_create_customer( 

409 self, user_id: str, email: str | None = None, stripe_customer_id: str | None = None 

410 ) -> Any: 

411 """ 

412 Get existing Stripe customer or create new one. 

413 A known customer id is retrieved directly (strongly consistent). Otherwise 

414 checks by user_id in metadata, then by email if provided. 

415 """ 

416 try: 

417 if stripe_customer_id: 

418 known = stripe.Customer.retrieve(stripe_customer_id) 

419 if not getattr(known, "deleted", False): 

420 return known 

421 

422 # First try to find by user_id in metadata 

423 search_results = stripe.Customer.search( 

424 query=f'metadata["user_id"]:"{user_id}"', limit=1 

425 ) 

426 

427 if search_results.data: 

428 return search_results.data[0] 

429 

430 # If email provided, try to find by email 

431 if email: 

432 email_results = stripe.Customer.list(email=email, limit=1) 

433 if email_results.data: 

434 # Update metadata with user_id 

435 customer = email_results.data[0] 

436 stripe.Customer.modify(customer.id, metadata={"user_id": user_id}) 

437 return customer 

438 

439 # Create new customer (email is optional) 

440 create_params: dict[str, Any] = { 

441 "metadata": {"user_id": user_id, "environment": self.environment} 

442 } 

443 if email: 

444 create_params["email"] = email 

445 return stripe.Customer.create(**create_params) 

446 

447 except stripe.error.StripeError as e: 

448 logger.error(f"Stripe error getting/creating customer: {e}") 

449 raise 

450 

451 def create_subscription(self, user_id: str, email: str, price_id: str) -> dict[str, Any]: 

452 """Create a subscription for unlimited access.""" 

453 try: 

454 # Create or retrieve customer 

455 customers = stripe.Customer.list(email=email, limit=1) 

456 if customers.data: 

457 customer = customers.data[0] 

458 else: 

459 customer = stripe.Customer.create(email=email, metadata={"user_id": user_id}) 

460 

461 # Create subscription 

462 subscription = stripe.Subscription.create( 

463 customer=customer.id, 

464 items=[{"price": price_id}], 

465 metadata={"user_id": user_id, "environment": self.environment}, 

466 ) 

467 

468 return { 

469 "subscription_id": subscription.id, 

470 "status": subscription.status, 

471 "customer_id": customer.id, 

472 } 

473 

474 except stripe.error.StripeError as e: 

475 logger.error(f"Stripe error creating subscription: {e}") 

476 raise 

477 

478 def pause_subscription(self, subscription_id: str) -> dict[str, str]: 

479 """Pause a subscription.""" 

480 try: 

481 stripe.Subscription.modify( 

482 subscription_id, pause_collection={"behavior": "mark_uncollectible"} 

483 ) 

484 return {"message": "Subscription paused", "status": "paused"} 

485 except stripe.error.StripeError as e: 

486 logger.error(f"Stripe error pausing subscription: {e}") 

487 raise 

488 

489 def resume_subscription(self, subscription_id: str) -> dict[str, str]: 

490 """Resume a paused subscription.""" 

491 try: 

492 stripe.Subscription.modify( 

493 subscription_id, 

494 pause_collection="", # Remove pause 

495 ) 

496 return {"message": "Subscription resumed", "status": "active"} 

497 except stripe.error.StripeError as e: 

498 logger.error(f"Stripe error resuming subscription: {e}") 

499 raise 

500 

501 def cancel_subscription(self, subscription_id: str) -> dict[str, Any]: 

502 """Cancel a subscription at the end of the period the customer has paid for. 

503 

504 The published refund policy gives no refunds or prorations for a cancelled 

505 month, so the customer keeps access until the period ends. Stripe then ends the 

506 subscription and sends customer.subscription.deleted, which records the 

507 cancellation. An immediate cancellation with a refund is a staff action in the 

508 Stripe dashboard and ends in the same event. 

509 """ 

510 try: 

511 subscription = stripe.Subscription.modify(subscription_id, cancel_at_period_end=True) 

512 # Since Stripe API 2025-03-31 the billing period is on the subscription item. 

513 item = subscription["items"]["data"][0] 

514 return { 

515 "message": "Subscription cancels at the end of the current period", 

516 "status": subscription["status"], 

517 "cancel_at_period_end": subscription["cancel_at_period_end"], 

518 "current_period_end": item["current_period_end"], 

519 } 

520 except stripe.error.StripeError as e: 

521 logger.error(f"Stripe error cancelling subscription: {e}") 

522 raise 

523 

524 def create_billing_portal_session(self, customer_id: str, return_url: str) -> str: 

525 """Create a Stripe Billing Portal session for the customer. 

526 

527 Returns the portal session URL. 

528 """ 

529 try: 

530 session = stripe.billing_portal.Session.create( 

531 customer=customer_id, 

532 return_url=return_url, 

533 ) 

534 return session.url 

535 except stripe.error.StripeError as e: 

536 logger.error(f"Stripe error creating billing portal session: {e}") 

537 raise