Coverage for polar/integrations/stripe/service.py: 21%
354 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 17:15 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 17:15 +0000
1import uuid 1ab
2from collections.abc import AsyncGenerator, AsyncIterator, Sequence 1ab
3from typing import TYPE_CHECKING, Literal, Unpack, cast, overload 1ab
5import stripe as stripe_lib 1ab
6import structlog 1ab
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
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
20stripe_lib.api_key = settings.STRIPE_SECRET_KEY 1ab
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
26log: Logger = structlog.get_logger() 1ab
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]
41class StripeError(PolarError): ... 1ab
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)
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)
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)
68class StripeService: 1ab
69 async def retrieve_intent(self, id: str) -> stripe_lib.PaymentIntent: 1ab
70 return await stripe_lib.PaymentIntent.retrieve_async(id)
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 }
89 if name:
90 create_params["business_profile"] = {"name": name}
92 if account.country != "US":
93 create_params["tos_acceptance"] = {"service_agreement": "recipient"}
95 return await stripe_lib.Account.create_async(**create_params)
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)
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
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)
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)
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 )
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)
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
181 return await stripe_lib.Transfer.create_async(**create_params)
183 async def get_transfer(self, id: str) -> stripe_lib.Transfer: 1ab
184 return await stripe_lib.Transfer.retrieve_async(id)
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)
194 async def get_customer(self, customer_id: str) -> stripe_lib.Customer: 1ab
195 return await stripe_lib.Customer.retrieve_async(customer_id)
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
216 return await stripe_lib.Product.create_async(**create_params)
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
236 return await stripe_lib.Price.create_async(**params)
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)
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)
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)
257 async def archive_price(self, id: str) -> stripe_lib.Price: 1ab
258 return await stripe_lib.Price.modify_async(id, active=False)
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 }
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 }
289 return await self.create_price_for_product(
290 product.stripe_product_id,
291 price_params,
292 idempotency_key=idempotency_key,
293 )
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 }
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 }
318 return await self.create_price_for_product(
319 product.stripe_product_id,
320 price_params,
321 idempotency_key=idempotency_key,
322 )
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)
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})
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
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)
383 modify_discount_params: list[stripe_lib.Subscription.ModifyParamsDiscount] = []
384 if new_coupon is not None:
385 modify_discount_params.append({"coupon": new_coupon})
387 return await stripe_lib.Subscription.modify_async(
388 id, discounts=modify_discount_params
389 )
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 )
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 )
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 )
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
449 if customer_comment:
450 details["comment"] = customer_comment
452 return details
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)
459 async def get_balance_transaction(self, id: str) -> stripe_lib.BalanceTransaction: 1ab
460 return await stripe_lib.BalanceTransaction.retrieve_async(id)
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 )
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
486 result = await stripe_lib.BalanceTransaction.list_async(**params)
487 return result.auto_paging_iter()
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
507 return await stripe_lib.Refund.create_async(
508 charge=charge_id,
509 amount=amount,
510 reason=reason,
511 metadata=stripe_metadata,
512 )
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 )
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 )
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 )
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 )
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)
579 async def get_payment_intent(self, id: str) -> stripe_lib.PaymentIntent: 1ab
580 return await stripe_lib.PaymentIntent.retrieve_async(id)
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)
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)
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
611 return await stripe_lib.Customer.create_async(**params)
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
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
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
647 return customer
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 )
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}]
704 subscription = await stripe_lib.Subscription.create_async(**params)
706 if subscription.latest_invoice is None:
707 raise MissingLatestInvoiceForOutofBandSubscription(subscription.id)
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
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)
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
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
755 subscription = await stripe_lib.Subscription.modify_async(
756 subscription_id, **modify_params
757 )
759 if subscription.latest_invoice is None:
760 raise MissingLatestInvoiceForOutofBandSubscription(subscription.id)
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 )
768 return subscription, invoice
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)
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 )
820 return invoice
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}]
848 invoice = await stripe_lib.Invoice.create_async(**params)
849 invoice_id = cast(str, invoice.id)
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)
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 )
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 )
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 )
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
898 return invoice, price_line_item_map
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 )
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)
927 async def get_tax_calculation(self, id: str) -> stripe_lib.tax.Calculation: 1ab
928 return await stripe_lib.tax.Calculation.retrieve_async(id)
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 )
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: ...
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: ...
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)
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)
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)
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)
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
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)
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)
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)
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 )
1044 async def get_tax_rate(self, id: str) -> stripe_lib.TaxRate: 1ab
1045 return await stripe_lib.TaxRate.retrieve_async(id)
1048stripe = StripeService() 1ab