Coverage for polar/integrations/stripe/service.py: 21%

354 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-12-05 16:17 +0000

1import uuid 1ab

2from collections.abc import AsyncGenerator, AsyncIterator, Sequence 1ab

3from typing import TYPE_CHECKING, Literal, Unpack, cast, overload 1ab

4 

5import stripe as stripe_lib 1ab

6import structlog 1ab

7 

8from polar.config import settings 1ab

9from polar.enums import SubscriptionRecurringInterval 1ab

10from polar.exceptions import PolarError 1ab

11from polar.integrations.stripe.utils import get_expandable_id 1ab

12from polar.kit.utils import utc_now 1ab

13from polar.logfire import instrument_httpx 1ab

14from polar.logging import Logger 1ab

15 

16if TYPE_CHECKING: 16 ↛ 17line 16 didn't jump to line 17 because the condition on line 16 was never true1ab

17 from polar.account.schemas import AccountCreateForOrganization 

18 from polar.models import Product, ProductPrice, User 

19 

20stripe_lib.api_key = settings.STRIPE_SECRET_KEY 1ab

21 

22stripe_http_client = stripe_lib.HTTPXClient(allow_sync_methods=True) 1ab

23instrument_httpx(stripe_http_client._client_async) 1ab

24stripe_lib.default_http_client = stripe_http_client 1ab

25 

26log: Logger = structlog.get_logger() 1ab

27 

28 

29StripeCancellationReasons = Literal[ 1ab

30 "customer_service", 

31 "low_quality", 

32 "missing_features", 

33 "other", 

34 "switched_service", 

35 "too_complex", 

36 "too_expensive", 

37 "unused", 

38] 

39 

40 

41class StripeError(PolarError): ... 1ab

42 

43 

44class MissingOrganizationBillingEmail(StripeError): 1ab

45 def __init__(self, organization_id: uuid.UUID) -> None: 1ab

46 self.organization_id = organization_id 

47 message = f"The organization {organization_id} billing email is not set." 

48 super().__init__(message) 

49 

50 

51class MissingLatestInvoiceForOutofBandSubscription(StripeError): 1ab

52 def __init__(self, subscription_id: str) -> None: 1ab

53 self.subscription_id = subscription_id 

54 message = f"The subscription {subscription_id} does not have a latest invoice." 

55 super().__init__(message) 

56 

57 

58class MissingPaymentMethod(StripeError): 1ab

59 def __init__(self, subscription_id: str) -> None: 1ab

60 self.subscription_id = subscription_id 

61 message = ( 

62 f"Tried to upgrade subscription {subscription_id} " 

63 "but the customer has no attached payment method." 

64 ) 

65 super().__init__(message, 400) 

66 

67 

68class StripeService: 1ab

69 async def retrieve_intent(self, id: str) -> stripe_lib.PaymentIntent: 1ab

70 return await stripe_lib.PaymentIntent.retrieve_async(id) 

71 

72 async def create_account( 1ab

73 self, account: "AccountCreateForOrganization", name: str | None 

74 ) -> stripe_lib.Account: 

75 log.info( 

76 "stripe.account.create", 

77 country=account.country, 

78 name=name, 

79 ) 

80 create_params: stripe_lib.Account.CreateParams = { 

81 "country": account.country, 

82 "type": "express", 

83 "capabilities": {"transfers": {"requested": True}}, 

84 "settings": { 

85 "payouts": {"schedule": {"interval": "manual"}}, 

86 }, 

87 } 

88 

89 if name: 

90 create_params["business_profile"] = {"name": name} 

91 

92 if account.country != "US": 

93 create_params["tos_acceptance"] = {"service_agreement": "recipient"} 

94 

95 return await stripe_lib.Account.create_async(**create_params) 

96 

97 async def update_account(self, id: str, name: str | None) -> None: 1ab

98 log.info( 

99 "stripe.account.update", 

100 account_id=id, 

101 name=name, 

102 ) 

103 obj = {} 

104 if name: 

105 obj["business_profile"] = {"name": name} 

106 await stripe_lib.Account.modify_async(id, **obj) 

107 

108 async def account_exists(self, id: str) -> bool: 1ab

109 try: 

110 account = await stripe_lib.Account.retrieve_async(id) 

111 return bool(account) 

112 except stripe_lib.PermissionError: 

113 return False 

114 

115 async def delete_account(self, id: str) -> stripe_lib.Account: 1ab

116 # TODO: Check if this fails when account balance is non-zero 

117 log.info( 

118 "stripe.account.delete", 

119 account_id=id, 

120 ) 

121 return await stripe_lib.Account.delete_async(id) 

122 

123 async def retrieve_balance(self, id: str) -> tuple[str, int]: 1ab

124 # Return available balance in the account's default currency (we assume that 

125 # there is no balance in other currencies for now) 

126 account = await stripe_lib.Account.retrieve_async(id) 

127 balance = await stripe_lib.Balance.retrieve_async(stripe_account=id) 

128 for b in balance.available: 

129 if b.currency == account.default_currency: 

130 return (b.currency, b.amount) 

131 return (cast(str, account.default_currency), 0) 

132 

133 async def create_account_link( 1ab

134 self, stripe_id: str, return_path: str 

135 ) -> stripe_lib.AccountLink: 

136 refresh_url = settings.generate_external_url( 

137 f"/v1/integrations/stripe/refresh?return_path={return_path}" 

138 ) 

139 return_url = settings.generate_frontend_url(return_path) 

140 return await stripe_lib.AccountLink.create_async( 

141 account=stripe_id, 

142 refresh_url=refresh_url, 

143 return_url=return_url, 

144 type="account_onboarding", 

145 ) 

146 

147 async def create_login_link(self, stripe_id: str) -> stripe_lib.LoginLink: 1ab

148 return await stripe_lib.Account.create_login_link_async(stripe_id) 

149 

150 async def transfer( 1ab

151 self, 

152 destination_stripe_id: str, 

153 amount: int, 

154 *, 

155 source_transaction: str | None = None, 

156 transfer_group: str | None = None, 

157 metadata: dict[str, str] | None = None, 

158 idempotency_key: str | None = None, 

159 ) -> stripe_lib.Transfer: 

160 log.info( 

161 "stripe.transfer.create", 

162 destination_account=destination_stripe_id, 

163 amount=amount, 

164 currency="usd", 

165 source_transaction=source_transaction, 

166 transfer_group=transfer_group, 

167 idempotency_key=idempotency_key, 

168 ) 

169 create_params: stripe_lib.Transfer.CreateParams = { 

170 "amount": amount, 

171 "currency": "usd", 

172 "destination": destination_stripe_id, 

173 "metadata": metadata or {}, 

174 "idempotency_key": idempotency_key, 

175 } 

176 if source_transaction is not None: 

177 create_params["source_transaction"] = source_transaction 

178 if transfer_group is not None: 

179 create_params["transfer_group"] = transfer_group 

180 

181 return await stripe_lib.Transfer.create_async(**create_params) 

182 

183 async def get_transfer(self, id: str) -> stripe_lib.Transfer: 1ab

184 return await stripe_lib.Transfer.retrieve_async(id) 

185 

186 async def update_transfer( 1ab

187 self, id: str, metadata: dict[str, str] 

188 ) -> stripe_lib.Transfer: 

189 update_params: stripe_lib.Transfer.ModifyParams = { 

190 "metadata": metadata, 

191 } 

192 return await stripe_lib.Transfer.modify_async(id, **update_params) 

193 

194 async def get_customer(self, customer_id: str) -> stripe_lib.Customer: 1ab

195 return await stripe_lib.Customer.retrieve_async(customer_id) 

196 

197 async def create_product( 1ab

198 self, 

199 name: str, 

200 *, 

201 description: str | None = None, 

202 metadata: dict[str, str] | None = None, 

203 ) -> stripe_lib.Product: 

204 log.info( 

205 "stripe.product.create", 

206 name=name, 

207 description=description, 

208 ) 

209 create_params: stripe_lib.Product.CreateParams = { 

210 "name": name, 

211 "metadata": metadata or {}, 

212 } 

213 if description is not None: 

214 create_params["description"] = description 

215 

216 return await stripe_lib.Product.create_async(**create_params) 

217 

218 async def create_price_for_product( 1ab

219 self, 

220 product: str, 

221 params: stripe_lib.Price.CreateParams, 

222 *, 

223 idempotency_key: str | None = None, 

224 ) -> stripe_lib.Price: 

225 log.info( 

226 "stripe.price.create", 

227 product_id=product, 

228 amount=params.get("unit_amount"), 

229 currency=params.get("currency"), 

230 idempotency_key=idempotency_key, 

231 ) 

232 params = {**params, "product": product} 

233 if idempotency_key is not None: 

234 params["idempotency_key"] = idempotency_key 

235 

236 return await stripe_lib.Price.create_async(**params) 

237 

238 async def update_product( 1ab

239 self, product: str, **kwargs: Unpack[stripe_lib.Product.ModifyParams] 

240 ) -> stripe_lib.Product: 

241 return await stripe_lib.Product.modify_async(product, **kwargs) 

242 

243 async def archive_product(self, id: str) -> stripe_lib.Product: 1ab

244 log.info( 

245 "stripe.product.archive", 

246 product_id=id, 

247 ) 

248 return await stripe_lib.Product.modify_async(id, active=False) 

249 

250 async def unarchive_product(self, id: str) -> stripe_lib.Product: 1ab

251 log.info( 

252 "stripe.product.unarchive", 

253 product_id=id, 

254 ) 

255 return await stripe_lib.Product.modify_async(id, active=True) 

256 

257 async def archive_price(self, id: str) -> stripe_lib.Price: 1ab

258 return await stripe_lib.Price.modify_async(id, active=False) 

259 

260 async def create_ad_hoc_custom_price( 1ab

261 self, 

262 product: "Product", 

263 product_price: "ProductPrice", 

264 amount: int, 

265 currency: str, 

266 *, 

267 idempotency_key: str | None = None, 

268 ) -> stripe_lib.Price: 

269 assert product.stripe_product_id is not None 

270 price_params: stripe_lib.Price.CreateParams = { 

271 "unit_amount": amount, 

272 "currency": currency, 

273 "metadata": { 

274 "product_price_id": str(product_price.id), 

275 }, 

276 } 

277 

278 if product.is_recurring: 

279 recurring_interval: SubscriptionRecurringInterval 

280 if product_price.legacy_recurring_interval: 

281 recurring_interval = product_price.legacy_recurring_interval 

282 else: 

283 assert product.recurring_interval is not None 

284 recurring_interval = product.recurring_interval 

285 price_params["recurring"] = { 

286 "interval": recurring_interval.as_literal(), 

287 } 

288 

289 return await self.create_price_for_product( 

290 product.stripe_product_id, 

291 price_params, 

292 idempotency_key=idempotency_key, 

293 ) 

294 

295 async def create_placeholder_price( 1ab

296 self, 

297 product: "Product", 

298 currency: str, 

299 *, 

300 idempotency_key: str | None = None, 

301 ) -> stripe_lib.Price: 

302 assert product.stripe_product_id is not None 

303 price_params: stripe_lib.Price.CreateParams = { 

304 "unit_amount": 0, 

305 "currency": currency, 

306 "metadata": { 

307 "product_id": str(product.id), 

308 }, 

309 } 

310 

311 if product.is_recurring: 

312 assert product.recurring_interval is not None 

313 recurring_interval = product.recurring_interval 

314 price_params["recurring"] = { 

315 "interval": recurring_interval.as_literal(), 

316 } 

317 

318 return await self.create_price_for_product( 

319 product.stripe_product_id, 

320 price_params, 

321 idempotency_key=idempotency_key, 

322 ) 

323 

324 async def update_subscription_price( 1ab

325 self, 

326 id: str, 

327 *, 

328 new_prices: list[str], 

329 proration_behavior: Literal["always_invoice", "create_prorations", "none"], 

330 metadata: dict[str, str], 

331 ) -> stripe_lib.Subscription: 

332 log.info( 

333 "stripe.subscription.update_price", 

334 subscription_id=id, 

335 new_prices=new_prices, 

336 proration_behavior=proration_behavior, 

337 ) 

338 subscription = await stripe_lib.Subscription.retrieve_async(id) 

339 

340 old_items = subscription["items"] 

341 new_items: list[stripe_lib.Subscription.ModifyParamsItem] = [] 

342 found_new_prices: set[str] = set() 

343 for item in old_items: 

344 if item.price.id not in new_prices: 

345 new_items.append({"id": item.id, "deleted": True}) 

346 else: 

347 new_items.append({"id": item.id, "deleted": False}) 

348 found_new_prices.add(item.price.id) 

349 for new_price in new_prices: 

350 if new_price not in found_new_prices: 

351 new_items.append({"price": new_price, "quantity": 1}) 

352 

353 try: 

354 return await stripe_lib.Subscription.modify_async( 

355 id, 

356 items=new_items, 

357 proration_behavior=proration_behavior, 

358 metadata=metadata, 

359 ) 

360 except stripe_lib.InvalidRequestError as e: 

361 error = e.error 

362 if ( 

363 error is not None 

364 and error.code == "resource_missing" 

365 and error.message is not None 

366 and "payment method" in error.message.lower() 

367 ): 

368 raise MissingPaymentMethod(id) 

369 raise 

370 

371 async def update_subscription_discount( 1ab

372 self, id: str, old_coupon: str | None, new_coupon: str | None 

373 ) -> stripe_lib.Subscription: 

374 log.info( 

375 "stripe.subscription.update_discount", 

376 subscription_id=id, 

377 old_coupon=old_coupon, 

378 new_coupon=new_coupon, 

379 ) 

380 if old_coupon is not None: 

381 await stripe_lib.Subscription.delete_discount_async(id) 

382 

383 modify_discount_params: list[stripe_lib.Subscription.ModifyParamsDiscount] = [] 

384 if new_coupon is not None: 

385 modify_discount_params.append({"coupon": new_coupon}) 

386 

387 return await stripe_lib.Subscription.modify_async( 

388 id, discounts=modify_discount_params 

389 ) 

390 

391 async def uncancel_subscription(self, id: str) -> stripe_lib.Subscription: 1ab

392 log.info( 

393 "stripe.subscription.uncancel", 

394 subscription_id=id, 

395 ) 

396 return await stripe_lib.Subscription.modify_async( 

397 id, 

398 cancel_at_period_end=False, 

399 ) 

400 

401 async def cancel_subscription( 1ab

402 self, 

403 id: str, 

404 customer_reason: StripeCancellationReasons | None = None, 

405 customer_comment: str | None = None, 

406 ) -> stripe_lib.Subscription: 

407 log.info( 

408 "stripe.subscription.cancel", 

409 subscription_id=id, 

410 customer_reason=customer_reason, 

411 ) 

412 return await stripe_lib.Subscription.modify_async( 

413 id, 

414 cancel_at_period_end=True, 

415 cancellation_details=self._generate_subscription_cancellation_details( 

416 customer_reason=customer_reason, 

417 customer_comment=customer_comment, 

418 ), 

419 ) 

420 

421 async def revoke_subscription( 1ab

422 self, 

423 id: str, 

424 customer_reason: StripeCancellationReasons | None = None, 

425 customer_comment: str | None = None, 

426 ) -> stripe_lib.Subscription: 

427 log.info( 

428 "stripe.subscription.revoke", 

429 subscription_id=id, 

430 customer_reason=customer_reason, 

431 ) 

432 return await stripe_lib.Subscription.cancel_async( 

433 id, 

434 cancellation_details=self._generate_subscription_cancellation_details( 

435 customer_reason=customer_reason, 

436 customer_comment=customer_comment, 

437 ), 

438 ) 

439 

440 def _generate_subscription_cancellation_details( 1ab

441 self, 

442 customer_reason: StripeCancellationReasons | None = None, 

443 customer_comment: str | None = None, 

444 ) -> stripe_lib.Subscription.ModifyParamsCancellationDetails: 

445 details: stripe_lib.Subscription.ModifyParamsCancellationDetails = {} 

446 if customer_reason: 

447 details["feedback"] = customer_reason 

448 

449 if customer_comment: 

450 details["comment"] = customer_comment 

451 

452 return details 

453 

454 async def update_invoice( 1ab

455 self, id: str, **params: Unpack[stripe_lib.Invoice.ModifyParams] 

456 ) -> stripe_lib.Invoice: 

457 return await stripe_lib.Invoice.modify_async(id, **params) 

458 

459 async def get_balance_transaction(self, id: str) -> stripe_lib.BalanceTransaction: 1ab

460 return await stripe_lib.BalanceTransaction.retrieve_async(id) 

461 

462 async def get_invoice(self, id: str) -> stripe_lib.Invoice: 1ab

463 return await stripe_lib.Invoice.retrieve_async( 

464 id, expand=["total_tax_amounts.tax_rate"] 

465 ) 

466 

467 async def list_balance_transactions( 1ab

468 self, 

469 *, 

470 account_id: str | None = None, 

471 payout: str | None = None, 

472 type: str | None = None, 

473 expand: list[str] | None = None, 

474 ) -> AsyncIterator[stripe_lib.BalanceTransaction]: 

475 params: stripe_lib.BalanceTransaction.ListParams = { 

476 "limit": 100, 

477 "stripe_account": account_id, 

478 } 

479 if payout is not None: 

480 params["payout"] = payout 

481 if type is not None: 

482 params["type"] = type 

483 if expand is not None: 

484 params["expand"] = expand 

485 

486 result = await stripe_lib.BalanceTransaction.list_async(**params) 

487 return result.auto_paging_iter() 

488 

489 async def create_refund( 1ab

490 self, 

491 *, 

492 charge_id: str, 

493 amount: int, 

494 reason: Literal["duplicate", "requested_by_customer"], 

495 metadata: dict[str, str] | None = None, 

496 ) -> stripe_lib.Refund: 

497 log.info( 

498 "stripe.refund.create", 

499 charge_id=charge_id, 

500 amount=amount, 

501 reason=reason, 

502 ) 

503 stripe_metadata: Literal[""] | dict[str, str] = "" 

504 if metadata is not None: 

505 stripe_metadata = metadata 

506 

507 return await stripe_lib.Refund.create_async( 

508 charge=charge_id, 

509 amount=amount, 

510 reason=reason, 

511 metadata=stripe_metadata, 

512 ) 

513 

514 async def get_charge( 1ab

515 self, 

516 id: str, 

517 *, 

518 stripe_account: str | None = None, 

519 expand: list[str] | None = None, 

520 ) -> stripe_lib.Charge: 

521 return await stripe_lib.Charge.retrieve_async( 

522 id, stripe_account=stripe_account, expand=expand or [] 

523 ) 

524 

525 async def get_refund( 1ab

526 self, 

527 id: str, 

528 *, 

529 stripe_account: str | None = None, 

530 expand: list[str] | None = None, 

531 ) -> stripe_lib.Refund: 

532 return await stripe_lib.Refund.retrieve_async( 

533 id, stripe_account=stripe_account, expand=expand or [] 

534 ) 

535 

536 async def get_dispute( 1ab

537 self, 

538 id: str, 

539 *, 

540 stripe_account: str | None = None, 

541 expand: list[str] | None = None, 

542 ) -> stripe_lib.Dispute: 

543 return await stripe_lib.Dispute.retrieve_async( 

544 id, stripe_account=stripe_account, expand=expand or [] 

545 ) 

546 

547 async def create_payout( 1ab

548 self, 

549 *, 

550 stripe_account: str, 

551 amount: int, 

552 currency: str, 

553 metadata: dict[str, str] | None = None, 

554 ) -> stripe_lib.Payout: 

555 log.info( 

556 "stripe.payout.create", 

557 account_id=stripe_account, 

558 amount=amount, 

559 currency=currency, 

560 ) 

561 return await stripe_lib.Payout.create_async( 

562 stripe_account=stripe_account, 

563 amount=amount, 

564 currency=currency, 

565 metadata=metadata or {}, 

566 ) 

567 

568 async def create_payment_intent( 1ab

569 self, **params: Unpack[stripe_lib.PaymentIntent.CreateParams] 

570 ) -> stripe_lib.PaymentIntent: 

571 log.info( 

572 "stripe.payment_intent.create", 

573 amount=params.get("amount"), 

574 currency=params.get("currency"), 

575 customer=params.get("customer"), 

576 ) 

577 return await stripe_lib.PaymentIntent.create_async(**params) 

578 

579 async def get_payment_intent(self, id: str) -> stripe_lib.PaymentIntent: 1ab

580 return await stripe_lib.PaymentIntent.retrieve_async(id) 

581 

582 async def create_setup_intent( 1ab

583 self, **params: Unpack[stripe_lib.SetupIntent.CreateParams] 

584 ) -> stripe_lib.SetupIntent: 

585 log.info( 

586 "stripe.setup_intent.create", 

587 customer=params.get("customer"), 

588 usage=params.get("usage"), 

589 ) 

590 return await stripe_lib.SetupIntent.create_async(**params) 

591 

592 async def get_setup_intent( 1ab

593 self, id: str, **params: Unpack[stripe_lib.SetupIntent.RetrieveParams] 

594 ) -> stripe_lib.SetupIntent: 

595 return await stripe_lib.SetupIntent.retrieve_async(id, **params) 

596 

597 async def create_customer( 1ab

598 self, **params: Unpack[stripe_lib.Customer.CreateParams] 

599 ) -> stripe_lib.Customer: 

600 log.info( 

601 "stripe.customer.create", 

602 email=params.get("email"), 

603 name=params.get("name"), 

604 ) 

605 if settings.USE_TEST_CLOCK: 

606 test_clock = await stripe_lib.test_helpers.TestClock.create_async( 

607 frozen_time=int(utc_now().timestamp()) 

608 ) 

609 params["test_clock"] = test_clock.id 

610 

611 return await stripe_lib.Customer.create_async(**params) 

612 

613 async def update_customer( 1ab

614 self, 

615 id: str, 

616 tax_id: stripe_lib.Customer.CreateParamsTaxIdDatum | None = None, 

617 **params: Unpack[stripe_lib.Customer.ModifyParams], 

618 ) -> stripe_lib.Customer: 

619 log.info( 

620 "stripe.customer.update", 

621 customer_id=id, 

622 email=params.get("email"), 

623 name=params.get("name"), 

624 tax_id_type=tax_id.get("type") if tax_id else None, 

625 ) 

626 params = {**params, "expand": ["tax_ids"]} 

627 customer = await stripe_lib.Customer.modify_async(id, **params) 

628 if tax_id is None: 

629 return customer 

630 

631 if any( 

632 existing_tax_id.value == tax_id["value"] 

633 and existing_tax_id.type == tax_id["type"] 

634 for existing_tax_id in customer.tax_ids or [] 

635 ): 

636 return customer 

637 

638 try: 

639 await stripe_lib.Customer.create_tax_id_async(id, **tax_id) 

640 except stripe_lib.InvalidRequestError as e: 

641 # Potential race condition with Stripe not returning the new Tax ID 

642 # during our customer modification, but exists upon attempted 

643 # creation. Since the matching resource exists we can return vs. raise. 

644 if e.code != "resource_already_exists": 

645 raise e 

646 

647 return customer 

648 

649 async def create_customer_session( 1ab

650 self, customer_id: str 

651 ) -> stripe_lib.CustomerSession: 

652 return await stripe_lib.CustomerSession.create_async( 

653 components={ 

654 "payment_element": { 

655 "enabled": True, 

656 "features": { 

657 "payment_method_allow_redisplay_filters": [ 

658 "always", 

659 "limited", 

660 "unspecified", 

661 ], 

662 "payment_method_redisplay": "enabled", 

663 }, 

664 } 

665 }, 

666 customer=customer_id, 

667 ) 

668 

669 async def create_out_of_band_subscription( 1ab

670 self, 

671 *, 

672 customer: str, 

673 currency: str, 

674 prices: Sequence[str], 

675 coupon: str | None = None, 

676 automatic_tax: bool = True, 

677 metadata: dict[str, str] | None = None, 

678 invoice_metadata: dict[str, str] | None = None, 

679 idempotency_key: str | None = None, 

680 ) -> tuple[stripe_lib.Subscription, stripe_lib.Invoice]: 

681 log.info( 

682 "stripe.subscription.create_out_of_band", 

683 customer=customer, 

684 currency=currency, 

685 prices=list(prices), 

686 coupon=coupon, 

687 automatic_tax=automatic_tax, 

688 idempotency_key=idempotency_key, 

689 ) 

690 params: stripe_lib.Subscription.CreateParams = { 

691 "customer": customer, 

692 "currency": currency, 

693 "collection_method": "send_invoice", 

694 "days_until_due": 0, 

695 "items": [{"price": price, "quantity": 1} for price in prices], 

696 "metadata": metadata or {}, 

697 "automatic_tax": {"enabled": automatic_tax}, 

698 "expand": ["latest_invoice"], 

699 "idempotency_key": idempotency_key, 

700 } 

701 if coupon is not None: 

702 params["discounts"] = [{"coupon": coupon}] 

703 

704 subscription = await stripe_lib.Subscription.create_async(**params) 

705 

706 if subscription.latest_invoice is None: 

707 raise MissingLatestInvoiceForOutofBandSubscription(subscription.id) 

708 

709 invoice = cast(stripe_lib.Invoice, subscription.latest_invoice) 

710 invoice_id = get_expandable_id(invoice) 

711 invoice = await self._pay_out_of_band_subscription_invoice( 

712 invoice_id, invoice_metadata, idempotency_key 

713 ) 

714 return subscription, invoice 

715 

716 async def update_out_of_band_subscription( 1ab

717 self, 

718 *, 

719 subscription_id: str, 

720 new_prices: list[str], 

721 coupon: str | None = None, 

722 automatic_tax: bool = True, 

723 metadata: dict[str, str] | None = None, 

724 invoice_metadata: dict[str, str] | None = None, 

725 idempotency_key: str | None = None, 

726 ) -> tuple[stripe_lib.Subscription, stripe_lib.Invoice]: 

727 subscription = await stripe_lib.Subscription.retrieve_async(subscription_id) 

728 

729 modify_params: stripe_lib.Subscription.ModifyParams = { 

730 "collection_method": "send_invoice", 

731 "days_until_due": 0, 

732 "automatic_tax": {"enabled": automatic_tax}, 

733 } 

734 if coupon is not None: 

735 modify_params["discounts"] = [{"coupon": coupon}] 

736 if metadata is not None: 

737 modify_params["metadata"] = metadata 

738 if idempotency_key is not None: 

739 modify_params["idempotency_key"] = idempotency_key 

740 

741 old_items = subscription["items"] 

742 new_items: list[stripe_lib.Subscription.ModifyParamsItem] = [] 

743 found_new_prices: set[str] = set() 

744 for item in old_items: 

745 if item.price.id not in new_prices: 

746 new_items.append({"id": item.id, "deleted": True}) 

747 else: 

748 new_items.append({"id": item.id, "deleted": False}) 

749 found_new_prices.add(item.price.id) 

750 for new_price in new_prices: 

751 if new_price not in found_new_prices: 

752 new_items.append({"price": new_price, "quantity": 1}) 

753 modify_params["items"] = new_items 

754 

755 subscription = await stripe_lib.Subscription.modify_async( 

756 subscription_id, **modify_params 

757 ) 

758 

759 if subscription.latest_invoice is None: 

760 raise MissingLatestInvoiceForOutofBandSubscription(subscription.id) 

761 

762 invoice = cast(stripe_lib.Invoice, subscription.latest_invoice) 

763 invoice_id = get_expandable_id(invoice) 

764 invoice = await self._pay_out_of_band_subscription_invoice( 

765 invoice_id, invoice_metadata, idempotency_key 

766 ) 

767 

768 return subscription, invoice 

769 

770 async def set_automatically_charged_subscription( 1ab

771 self, 

772 subscription_id: str, 

773 payment_method: str | None, 

774 *, 

775 idempotency_key: str | None = None, 

776 ) -> stripe_lib.Subscription: 

777 params: stripe_lib.Subscription.ModifyParams = { 

778 "collection_method": "charge_automatically", 

779 "idempotency_key": idempotency_key, 

780 } 

781 if payment_method is not None: 

782 params["default_payment_method"] = payment_method 

783 return await stripe_lib.Subscription.modify_async(subscription_id, **params) 

784 

785 async def _pay_out_of_band_subscription_invoice( 1ab

786 self, 

787 invoice_id: str, 

788 metadata: dict[str, str] | None = None, 

789 idempotency_key: str | None = None, 

790 ) -> stripe_lib.Invoice: 

791 invoice = await stripe_lib.Invoice.modify_async( 

792 invoice_id, 

793 metadata=metadata or {}, 

794 idempotency_key=( 

795 f"{idempotency_key}_update_invoice" 

796 if idempotency_key is not None 

797 else None 

798 ), 

799 ) 

800 if invoice.status == "draft": 

801 invoice = await stripe_lib.Invoice.finalize_invoice_async( 

802 invoice_id, 

803 idempotency_key=( 

804 f"{idempotency_key}_finalize_invoice" 

805 if idempotency_key is not None 

806 else None 

807 ), 

808 ) 

809 if invoice.status == "open": 

810 await stripe_lib.Invoice.pay_async( 

811 invoice_id, 

812 paid_out_of_band=True, 

813 idempotency_key=( 

814 f"{idempotency_key}_pay_invoice" 

815 if idempotency_key is not None 

816 else None 

817 ), 

818 ) 

819 

820 return invoice 

821 

822 async def create_out_of_band_invoice( 1ab

823 self, 

824 *, 

825 customer: str, 

826 currency: str, 

827 prices: Sequence[str], 

828 coupon: str | None = None, 

829 automatic_tax: bool = True, 

830 metadata: dict[str, str] | None = None, 

831 idempotency_key: str | None = None, 

832 ) -> tuple[stripe_lib.Invoice, dict[str, stripe_lib.InvoiceLineItem]]: 

833 params: stripe_lib.Invoice.CreateParams = { 

834 "auto_advance": True, 

835 "collection_method": "send_invoice", 

836 "days_until_due": 0, 

837 "customer": customer, 

838 "metadata": metadata or {}, 

839 "automatic_tax": {"enabled": automatic_tax}, 

840 "currency": currency, 

841 "idempotency_key": ( 

842 f"{idempotency_key}_invoice" if idempotency_key else None 

843 ), 

844 } 

845 if coupon is not None: 

846 params["discounts"] = [{"coupon": coupon}] 

847 

848 invoice = await stripe_lib.Invoice.create_async(**params) 

849 invoice_id = cast(str, invoice.id) 

850 

851 item_map: dict[str, tuple[str, stripe_lib.InvoiceItem]] = {} 

852 for price in prices: 

853 invoice_item = await stripe_lib.InvoiceItem.create_async( 

854 customer=customer, 

855 currency=currency, 

856 price=price, 

857 invoice=invoice_id, 

858 quantity=1, 

859 idempotency_key=( 

860 f"{idempotency_key}_{price}_invoice_item" 

861 if idempotency_key 

862 else None 

863 ), 

864 ) 

865 item_map[invoice_item.id] = (price, invoice_item) 

866 

867 # Manually retrieve the invoice, as we've seen cases where finalization below 

868 # failed because the idempotency request returned us a draft invoice, 

869 # but the invoice was actually already finalized. 

870 invoice = await stripe_lib.Invoice.retrieve_async( 

871 invoice_id, expand=["total_tax_amounts.tax_rate"] 

872 ) 

873 

874 if invoice.status == "draft": 

875 invoice = await stripe_lib.Invoice.finalize_invoice_async( 

876 invoice_id, 

877 idempotency_key=( 

878 f"{idempotency_key}_finalize_invoice" if idempotency_key else None 

879 ), 

880 expand=["total_tax_amounts.tax_rate"], 

881 ) 

882 

883 if invoice.status == "open": 

884 await stripe_lib.Invoice.pay_async( 

885 invoice_id, 

886 paid_out_of_band=True, 

887 idempotency_key=( 

888 f"{idempotency_key}_pay_invoice" if idempotency_key else None 

889 ), 

890 ) 

891 

892 price_line_item_map = {} 

893 for line_item in invoice.lines.data: 

894 assert line_item.invoice_item is not None 

895 price_id, invoice_item = item_map[get_expandable_id(line_item.invoice_item)] 

896 price_line_item_map[price_id] = line_item 

897 

898 return invoice, price_line_item_map 

899 

900 async def create_invoice_item( 1ab

901 self, 

902 *, 

903 customer: str, 

904 invoice: str, 

905 amount: int, 

906 currency: str, 

907 description: str, 

908 tax_behavior: Literal["exclusive", "inclusive"] = "exclusive", 

909 metadata: dict[str, str] | None = None, 

910 ) -> stripe_lib.InvoiceItem: 

911 return await stripe_lib.InvoiceItem.create_async( 

912 customer=customer, 

913 invoice=invoice, 

914 amount=amount, 

915 currency=currency, 

916 description=description, 

917 tax_behavior=tax_behavior, 

918 metadata=metadata or {}, 

919 ) 

920 

921 async def create_tax_calculation( 1ab

922 self, 

923 **params: Unpack[stripe_lib.tax.Calculation.CreateParams], 

924 ) -> stripe_lib.tax.Calculation: 

925 return await stripe_lib.tax.Calculation.create_async(**params) 

926 

927 async def get_tax_calculation(self, id: str) -> stripe_lib.tax.Calculation: 1ab

928 return await stripe_lib.tax.Calculation.retrieve_async(id) 

929 

930 async def create_tax_transaction( 1ab

931 self, calculation_id: str, reference: str 

932 ) -> stripe_lib.tax.Transaction: 

933 log.info( 

934 "stripe.tax.transaction.create", 

935 calculation_id=calculation_id, 

936 reference=reference, 

937 ) 

938 return await stripe_lib.tax.Transaction.create_from_calculation_async( 

939 calculation=calculation_id, 

940 reference=reference, 

941 idempotency_key=f"polar:tax_transaction:{reference}", 

942 ) 

943 

944 @overload 1ab

945 async def revert_tax_transaction( 945 ↛ exitline 945 didn't return from function 'revert_tax_transaction' because 1ab

946 self, original_transaction_id: str, mode: Literal["full"], reference: str 

947 ) -> stripe_lib.tax.Transaction: ... 

948 

949 @overload 1ab

950 async def revert_tax_transaction( 950 ↛ exitline 950 didn't return from function 'revert_tax_transaction' because 1ab

951 self, 

952 original_transaction_id: str, 

953 mode: Literal["partial"], 

954 reference: str, 

955 amount: int, 

956 ) -> stripe_lib.tax.Transaction: ... 

957 

958 async def revert_tax_transaction( 1ab

959 self, 

960 original_transaction_id: str, 

961 mode: Literal["full", "partial"], 

962 reference: str, 

963 amount: int | None = None, 

964 ) -> stripe_lib.tax.Transaction: 

965 params: stripe_lib.tax.Transaction.CreateReversalParams = { 

966 "mode": mode, 

967 "original_transaction": original_transaction_id, 

968 "reference": reference, 

969 "idempotency_key": f"polar:tax_transaction_revert:{reference}", 

970 } 

971 if mode == "partial" and amount is not None: 

972 params["flat_amount"] = amount 

973 return await stripe_lib.tax.Transaction.create_reversal_async(**params) 

974 

975 async def create_coupon( 1ab

976 self, **params: Unpack[stripe_lib.Coupon.CreateParams] 

977 ) -> stripe_lib.Coupon: 

978 log.info( 

979 "stripe.coupon.create", 

980 coupon_id=params.get("id"), 

981 percent_off=params.get("percent_off"), 

982 amount_off=params.get("amount_off"), 

983 currency=params.get("currency"), 

984 ) 

985 return await stripe_lib.Coupon.create_async(**params) 

986 

987 async def update_coupon( 1ab

988 self, id: str, **params: Unpack[stripe_lib.Coupon.ModifyParams] 

989 ) -> stripe_lib.Coupon: 

990 return await stripe_lib.Coupon.modify_async(id, **params) 

991 

992 async def delete_coupon(self, id: str) -> stripe_lib.Coupon: 1ab

993 log.info( 

994 "stripe.coupon.delete", 

995 coupon_id=id, 

996 ) 

997 return await stripe_lib.Coupon.delete_async(id) 

998 

999 async def list_payment_methods( 1ab

1000 self, customer: str 

1001 ) -> AsyncGenerator[stripe_lib.PaymentMethod]: 

1002 payment_methods = await stripe_lib.Customer.list_payment_methods_async(customer) 

1003 async for payment_method in payment_methods.auto_paging_iter(): 

1004 yield payment_method 

1005 

1006 async def get_payment_method( 1ab

1007 self, payment_method_id: str 

1008 ) -> stripe_lib.PaymentMethod: 

1009 return await stripe_lib.PaymentMethod.retrieve_async(payment_method_id) 

1010 

1011 async def delete_payment_method( 1ab

1012 self, payment_method_id: str 

1013 ) -> stripe_lib.PaymentMethod: 

1014 log.info( 

1015 "stripe.payment_method.delete", 

1016 payment_method_id=payment_method_id, 

1017 ) 

1018 return await stripe_lib.PaymentMethod.detach_async(payment_method_id) 

1019 

1020 async def get_verification_session( 1ab

1021 self, id: str 

1022 ) -> stripe_lib.identity.VerificationSession: 

1023 return await stripe_lib.identity.VerificationSession.retrieve_async(id) 

1024 

1025 async def create_verification_session( 1ab

1026 self, user: "User" 

1027 ) -> stripe_lib.identity.VerificationSession: 

1028 return await stripe_lib.identity.VerificationSession.create_async( 

1029 type="document", 

1030 options={ 

1031 "document": { 

1032 "allowed_types": ["driving_license", "id_card", "passport"], 

1033 "require_live_capture": True, 

1034 "require_matching_selfie": True, 

1035 } 

1036 }, 

1037 provided_details={ 

1038 "email": user.email, 

1039 }, 

1040 client_reference_id=str(user.id), 

1041 metadata={"user_id": str(user.id)}, 

1042 ) 

1043 

1044 async def get_tax_rate(self, id: str) -> stripe_lib.TaxRate: 1ab

1045 return await stripe_lib.TaxRate.retrieve_async(id) 

1046 

1047 

1048stripe = StripeService() 1ab