Coverage for functions \ flipdare \ payments \ app_stripe_proxy.py: 55%
363 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
1#!/usr/bin/env python
2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved.
3#
4# This file is part of Flipdare's proprietary software and contains
5# confidential and copyrighted material. Unauthorised copying,
6# modification, distribution, or use of this file is strictly
7# prohibited without prior written permission from Flipdare Pty Ltd.
8#
9# This software includes third-party components licensed under MIT,
10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details.
11#
13from __future__ import annotations
14from typing import Literal
15from warnings import deprecated
16from stripe import AccountLink as StripeAccountLink
17from stripe import EphemeralKey, ListObject, LoginLink
18from stripe import PaymentIntent as StripePaymentIntent
19from stripe import Refund as StripeRefund
20from stripe import Transfer as StripeTransfer
21from stripe import StripeClient
22from stripe.params import AccountLinkCreateParams
23from stripe.v2.core import Account as StripeV2Account
25from flipdare.backend.app_logger import AppLogger
26from flipdare.app_globals import validate_test_clock
27from flipdare.app_log import LOG
28from stripe import RequestOptions
29from flipdare.constants import IS_DEBUG, IS_TRACE
30from flipdare.core.singleton import Singleton
31from flipdare.error import AppStripeError, StripeErrorContext
32from flipdare.generated import (
33 PaymentCreateResponseSchema,
34 AppPaymentErrorCode,
35 AppFeeType,
36 StripeAccountType,
37 StripeCountryCode,
38 StripeCurrencyCode,
39 StripeIntentStatus,
40 StripeOnboardResult,
41)
43from flipdare.generated.shared.stripe.stripe_refund_reason import StripeRefundReason
44from flipdare.payments.app_stripe_config import AppStripeConfig
45from flipdare.payments.app_stripe_proxy_error import ProxyErrorBuilder, ProxyErrorMessage
46from flipdare.payments.data.app_payment_context import AppPaymentContext
47from flipdare.payments.dto.account_create_dto import AccountCreateDTO
48from flipdare.payments.dto.account_dto import AccountDTO, AccountDTOFactory
49from flipdare.payments.dto.customer_create_dto import CustomerCreateDTO
50from flipdare.payments.dto.customer_dto import CustomerDTO
51from flipdare.payments.dto.payment_intent_create_dto import PaymentIntentCreateDTO
52from flipdare.payments.dto.payment_intent_dto import PaymentIntentDTO
53from flipdare.payments.dto.refund_dto import RefundDTO
54from flipdare.payments.dto.transfer_dto import TransferDTO
55from flipdare.payments.payment_types import RefundResult, StripeLinkInfo
56from flipdare.service.payments.risk_service import RiskService
57from flipdare.payments.data.stripe_expand_params import StripeExpandParams
58from flipdare.util.debug_util import stringify_debug
61def create_stripe_proxy(
62 stripe_config: AppStripeConfig | None = None,
63 stripe_client: StripeClient | None = None,
64) -> AppStripeProxy:
65 from flipdare.app_config import get_app_config
66 import stripe
68 if stripe_config is None:
69 webhook_key = get_app_config().stripe_webhook_key
70 stripe_config = AppStripeConfig.default(webhook_key=webhook_key)
72 secret_key = stripe_config.secret_key
74 if stripe_client is None:
75 stripe.api_key = secret_key
76 stripe.api_version = stripe_config.stripe_version
78 stripe_client = StripeClient(secret_key)
80 return AppStripeProxy.instance(stripe_client=stripe_client, stripe_config=stripe_config)
83class AppStripeProxy(Singleton):
85 def __init__(
86 self,
87 stripe_config: AppStripeConfig,
88 stripe_client: StripeClient,
89 risk_service: RiskService | None = None,
90 log_creator: AppLogger | None = None,
91 ) -> None:
92 self._client = stripe_client
93 self._stripe_config = stripe_config
94 self._risk_service = risk_service
95 self._log_creator = log_creator
97 if IS_DEBUG:
98 msg = f"Initializing AppStripeProxy with config: {self._stripe_config}"
99 LOG().debug(msg)
101 @property
102 def client(self) -> StripeClient:
103 return self._client
105 @property
106 def risk_service(self) -> RiskService:
107 from flipdare.services import get_risk_service
109 if self._risk_service is None:
110 self._risk_service = get_risk_service()
112 return self._risk_service
114 @property
115 def stripe_config(self) -> AppStripeConfig:
116 return self._stripe_config
118 @property
119 def platform_account_id(self) -> str:
120 return self.stripe_config.platform_account_id
122 @property
123 def log_creator(self) -> AppLogger:
124 from flipdare.services import get_app_logger
126 if self._log_creator is None:
127 self._log_creator = get_app_logger()
129 return self._log_creator
131 def request_options(self, account_id: str | None = None) -> RequestOptions:
132 return self.stripe_config.request_options(account_id=account_id)
134 # ========================================================================
135 # ACCOUNT
136 # ========================================================================
138 def create_account(
139 self,
140 endpoint: str,
141 uid: str,
142 email: str,
143 first_name: str,
144 last_name: str,
145 country_code: StripeCountryCode,
146 currency_code: StripeCurrencyCode,
147 account_type: StripeAccountType,
148 test_clock: str | None = None,
149 ) -> AccountDTO:
150 match account_type:
151 case StripeAccountType.EXPRESS:
152 return self._create_account(
153 endpoint=endpoint,
154 uid=uid,
155 is_express=True,
156 email=email,
157 first_name=first_name,
158 last_name=last_name,
159 country_code=country_code,
160 currency_code=currency_code,
161 test_clock=test_clock,
162 )
163 case StripeAccountType.STANDARD:
164 return self._create_account(
165 endpoint=endpoint,
166 uid=uid,
167 is_express=False,
168 email=email,
169 first_name=first_name,
170 last_name=last_name,
171 country_code=country_code,
172 currency_code=currency_code,
173 test_clock=test_clock,
174 )
176 def get_account(
177 self,
178 endpoint: str,
179 account_id: str,
180 ) -> AccountDTO | None:
181 account: StripeV2Account | None = None
182 try:
183 account = self.client.v2.core.accounts.retrieve(
184 account_id,
185 params=StripeExpandParams.ACCOUNT,
186 options=self.request_options(),
187 )
189 if IS_DEBUG:
190 msg = f"Retrieve account type {type(account)}, content:\n{stringify_debug(account)}\n"
191 LOG().debug(msg)
193 except Exception as e:
194 msg = f"Stripe account retrieval failed for account_id {account_id}: {e}"
195 LOG().error(msg)
197 err_str = str(e)
198 if "does not have access to account" in err_str or "account does not exist" in err_str:
199 # this is recoverable by creating a new account, so we want to handle it differently from other API errors
200 LOG().error(f"Stripe account {account_id} not found or inaccessible: {e}")
201 return None
203 error = StripeErrorContext.from_code(
204 endpoint=endpoint,
205 error_code=AppPaymentErrorCode.API_ERROR,
206 cause=msg,
207 error=e,
208 )
209 raise AppStripeError.from_context(error) from e
211 try:
212 dto = AccountDTOFactory(account).create()
214 if IS_DEBUG:
215 msg = (
216 f"Stripe account retrieved for account_id {account_id}\n"
217 f"Raw account data:\n{stringify_debug(account)}"
218 f"Parsed account DTO for account_id {account_id}:\n{dto.debug_str}"
219 )
220 LOG().debug(msg)
222 return dto
224 except Exception as e:
225 ProxyErrorBuilder.raise_error(
226 ProxyErrorMessage.ACCOUNT_PARSE_FAILED,
227 endpoint=endpoint,
228 error=e,
229 ACCOUNT_ID=account_id,
230 STRIPE_ERROR=f"{e}\n\n{stringify_debug(account)}",
231 )
233 def check_account_accepts_payments(self, account_id: str) -> StripeOnboardResult | None:
234 try:
235 # Retrieve the account using V1 API
236 account = self.client.v1.accounts.retrieve(account_id)
237 account_dto = AccountDTOFactory(account).create()
238 return account_dto.onboard_result
240 except Exception as err:
241 ProxyErrorBuilder.raise_error(
242 ProxyErrorMessage.ACCOUNT_PAYMENTS_CHECK_FAILED,
243 endpoint="check_account_accepts_payments",
244 error=err,
245 ACCOUNT_ID=account_id,
246 STRIPE_ERROR=err,
247 )
249 def update_mcc(self, endpoint: str, account_id: str) -> StripeV2Account | None:
250 try:
251 account = self.client.v2.core.accounts.update(
252 account_id,
253 params={
254 "configuration": {"merchant": {"mcc": "5734"}},
255 "include": ["configuration.merchant"],
256 },
257 options=self.request_options(),
258 )
259 if IS_DEBUG:
260 LOG().debug(f"Updated MCC for account {account_id} to 5734")
262 return account
263 except Exception as e:
264 # not critical, the user can update the mcc ..
265 ProxyErrorBuilder.raise_error(
266 ProxyErrorMessage.ACCOUNT_MCC_UPDATE_FAILED,
267 endpoint=endpoint,
268 error=e,
269 ACCOUNT_ID=account_id,
270 STRIPE_ERROR=e,
271 )
273 def create_onboard_link(self, endpoint: str, info: StripeLinkInfo) -> StripeAccountLink:
274 return self._create_operation_link(
275 endpoint=endpoint,
276 link_type="account_onboarding",
277 info=info,
278 )
280 def create_update_link(self, endpoint: str, info: StripeLinkInfo) -> StripeAccountLink:
281 return self._create_operation_link(
282 endpoint=endpoint,
283 link_type="account_update",
284 info=info,
285 )
287 def create_login_link(self, endpoint: str, account_id: str) -> str:
288 # These are single-use URLs that expire after 24 hours
289 # therefore you should not store them ...
290 try:
291 login_link: LoginLink = self.client.v1.accounts.login_links.create(account_id)
293 # Access the URL from the response
294 url = login_link.url
295 if IS_DEBUG:
296 LOG().debug(f"Created login link for {account_id}, url: {url}")
298 return url
299 except Exception as e:
300 ProxyErrorBuilder.raise_error(
301 ProxyErrorMessage.LOGIN_LINK_CREATE_FAILED,
302 endpoint=endpoint,
303 error=e,
304 ACCOUNT_ID=account_id,
305 STRIPE_ERROR=e,
306 )
308 # ========================================================================
309 # CUSTOMER
310 # ========================================================================
312 def create_customer(
313 self,
314 endpoint: str,
315 uid: str,
316 email: str,
317 name: str,
318 country_code: StripeCountryCode,
319 tokens: tuple[str, str] | None = None,
320 test_clock: str | None = None,
321 ) -> CustomerDTO:
322 test_clock = validate_test_clock(test_clock)
323 if IS_DEBUG:
324 msg = f"Creating Stripe customer for {email}, name={name} tokens={tokens}"
325 LOG().debug(msg)
327 try:
328 customer = self.client.v2.core.accounts.create(
329 params=CustomerCreateDTO(
330 uid=uid,
331 email=email,
332 name=name,
333 country=country_code,
334 tokens=tokens,
335 test_clock=test_clock,
336 ).to_params(),
337 options=self.request_options(),
338 )
340 customer_id = customer.id
341 if IS_DEBUG:
342 msg = f"Created Stripe customer {customer_id} with email {email}"
343 LOG().debug(msg)
345 return CustomerDTO.from_stripe_customer(customer)
347 except Exception as e:
348 ProxyErrorBuilder.raise_error(
349 ProxyErrorMessage.CUSTOMER_CREATE_FAILED,
350 endpoint=endpoint,
351 error=e,
352 EMAIL=email,
353 STRIPE_ERROR=e,
354 )
356 def get_customer(
357 self,
358 endpoint: str,
359 customer_acct: str,
360 ) -> CustomerDTO:
361 # NOTE: does not check if customer is delinquent
362 try:
363 customer = self.client.v2.core.accounts.retrieve(
364 customer_acct,
365 options=self.request_options(),
366 )
367 if IS_DEBUG:
368 msg = f"Customer info for {customer_acct}:\n{customer!s}"
369 LOG().debug(msg)
371 return CustomerDTO.from_stripe_customer(customer)
372 except Exception as e:
373 ProxyErrorBuilder.raise_error(
374 ProxyErrorMessage.CUSTOMER_RETRIEVE_FAILED,
375 endpoint=endpoint,
376 error=e,
377 CUSTOMER_ID=customer_acct,
378 STRIPE_ERROR=e,
379 )
381 def upgrade_customer(
382 self,
383 endpoint: str,
384 uid: str,
385 customer_acct: str,
386 account_type: StripeAccountType,
387 email: str,
388 tokens: tuple[str, str],
389 country_code: StripeCountryCode,
390 currency_code: StripeCurrencyCode,
391 ) -> AccountDTO:
392 try:
393 is_express = account_type == StripeAccountType.EXPRESS
394 account_dto = AccountCreateDTO(
395 uid=uid,
396 is_express=is_express,
397 email=email,
398 tokens=tokens,
399 country_code=country_code,
400 currency_code=currency_code,
401 )
402 if IS_DEBUG:
403 msg = f"Account info for updating customer {customer_acct}:\n{account_dto!s}"
404 LOG().debug(msg)
406 account = self.client.v2.core.accounts.update(
407 customer_acct,
408 params=account_dto.upgrade_params(),
409 options=self.request_options(),
410 )
411 return AccountDTOFactory(account).create()
413 except Exception as e:
414 ProxyErrorBuilder.raise_error(
415 ProxyErrorMessage.CUSTOMER_RETRIEVE_FAILED,
416 endpoint=endpoint,
417 error=e,
418 CUSTOMER_ID=customer_acct,
419 STRIPE_ERROR=e,
420 )
422 # ========================================================================
423 # PAYMENT INTENTS
424 # ========================================================================
426 def create_payment_intent(
427 self,
428 endpoint: str,
429 ctx: AppPaymentContext,
430 ) -> PaymentCreateResponseSchema:
431 amount = ctx.amount
432 rcpt_currency_code = ctx.account.currency_code
433 rcpt_currency = rcpt_currency_code.stripe_code
435 account_id = ctx.account.account_id
436 customer_id = ctx.customer.customer_id
437 debug_msg = f":{customer_id} -> {account_id}, amount={amount} ({rcpt_currency}) "
438 intent: StripePaymentIntent | None = None
439 try:
440 params = PaymentIntentCreateDTO(
441 customer_id=customer_id,
442 account_id=account_id,
443 amount=amount,
444 currency_code=rcpt_currency_code,
445 fee_type=ctx.account.fee_type,
446 ).to_params()
448 LOG().warning(f"Params={params}")
450 intent = self.client.v1.payment_intents.create(
451 params=params,
452 options=self.request_options(),
453 )
454 if IS_TRACE:
455 LOG().trace(f"Payment intent created with id {intent.id} for {debug_msg}")
457 except Exception as e:
458 ProxyErrorBuilder.raise_error(
459 ProxyErrorMessage.INTENT_CREATE_FAILED,
460 endpoint=endpoint,
461 error=e,
462 CONTEXT=debug_msg,
463 STRIPE_ERROR=e,
464 )
466 debug_msg = f"id={intent.id}, {debug_msg}"
468 intent_id = intent.id
469 client_secret = intent.client_secret
470 if client_secret is None:
471 ProxyErrorBuilder.raise_error(
472 ProxyErrorMessage.INTENT_SECRET_MISSING,
473 endpoint=endpoint,
474 INTENT_ID=intent_id,
475 CONTEXT=debug_msg,
476 )
478 ephemeral_key: EphemeralKey | None = None
479 if IS_TRACE:
480 LOG().trace(f"Creating ephemeral key for customer_id {customer_id}")
481 try:
482 ephemeral_key = self.client.v1.ephemeral_keys.create(
483 options=self.request_options(),
484 params={"customer": customer_id},
485 )
486 if IS_TRACE:
487 LOG().trace(f"Created ephemeral key with id {ephemeral_key.id} for {debug_msg}")
489 except Exception as e:
490 ProxyErrorBuilder.raise_error(
491 ProxyErrorMessage.EPHEMERAL_KEY_CREATE_FAILED,
492 endpoint=endpoint,
493 error=e,
494 CONTEXT=debug_msg,
495 STRIPE_ERROR=e,
496 )
498 LOG().trace(f"Created ephemeral key with content:\n{ephemeral_key!s}")
500 ephemeral_key_secret = ephemeral_key.secret
501 if ephemeral_key_secret is None:
502 ProxyErrorBuilder.raise_error(
503 ProxyErrorMessage.EPHEMERAL_KEY_SECRET_MISSING,
504 endpoint=endpoint,
505 INTENT_ID=intent_id,
506 CONTEXT=debug_msg,
507 )
509 try:
510 return PaymentCreateResponseSchema(
511 uid=ctx.customer.uid,
512 pledge_amount=amount,
513 payment_intent_id=intent_id,
514 client_secret=client_secret,
515 ephemeral_key=ephemeral_key_secret,
516 )
517 except Exception as e:
518 ProxyErrorBuilder.raise_error(
519 ProxyErrorMessage.CHARGE_RESPONSE_CREATE_FAILED,
520 endpoint=endpoint,
521 error=e,
522 CONTEXT=debug_msg,
523 STRIPE_ERROR=e,
524 )
526 def get_payment_intent(
527 self,
528 endpoint: str,
529 payment_intent_id: str,
530 ) -> PaymentIntentDTO:
531 try:
532 intent = self.client.v1.payment_intents.retrieve(
533 payment_intent_id,
534 params={"expand": StripeExpandParams.PAYMENT_INTENT},
535 options=self.request_options(),
536 )
537 if IS_TRACE:
538 LOG().trace(f"Payment intent info for {payment_intent_id}:\n{intent!s}")
540 return PaymentIntentDTO.from_intent(intent)
541 except Exception as e:
542 ProxyErrorBuilder.raise_error(
543 ProxyErrorMessage.INTENT_RETRIEVE_FAILED,
544 endpoint=endpoint,
545 error=e,
546 CONTEXT=payment_intent_id,
547 STRIPE_ERROR=e,
548 )
550 def get_all_payment_intents(
551 self,
552 endpoint: str,
553 customer_id: str,
554 ) -> ListObject[StripePaymentIntent]:
555 try:
556 intents = self.client.v1.payment_intents.list(
557 params={"customer": customer_id, "expand": StripeExpandParams.PAYMENT_INTENT},
558 options=self.request_options(),
559 )
561 if IS_TRACE:
562 msg = f"Found {len(intents.data)} payment intents for customer_id {customer_id}"
563 LOG().trace(msg)
565 return intents
566 except Exception as e:
567 ProxyErrorBuilder.raise_error(
568 ProxyErrorMessage.INTENT_LIST_FAILED,
569 endpoint=endpoint,
570 error=e,
571 CUSTOMER_ID=customer_id,
572 STRIPE_ERROR=e,
573 )
575 def cancel_payment_intent(
576 self,
577 endpoint: str,
578 payment_intent_id: str,
579 ) -> bool:
580 try:
581 self.client.v1.payment_intents.cancel(
582 intent=payment_intent_id,
583 options=self.request_options(),
584 )
585 if IS_DEBUG:
586 LOG().debug(f"Payment intent {payment_intent_id} canceled successfully")
587 return True
588 except Exception as e:
589 LOG().error(f"Error canceling payment intent {payment_intent_id}: {e!s}")
590 error = StripeErrorContext.from_code(
591 endpoint=endpoint,
592 error_code=AppPaymentErrorCode.CANCEL_INTENT_FAILED,
593 cause=f"Failed to cancel payment intent {payment_intent_id}: {e!s}",
594 error=e,
595 )
596 self.log_creator.from_context(doc_id=payment_intent_id, ctx=error)
597 raise AppStripeError.from_context(error) from e
599 def reauthorize_payment_intent(
600 self,
601 endpoint: str,
602 customer_id: str,
603 account_id: str,
604 amount: int,
605 rcpt_currency_code: StripeCurrencyCode,
606 fee_type: AppFeeType,
607 payment_method_id: str,
608 ) -> PaymentIntentDTO:
609 debug_msg = (
610 f": reauthorize :{customer_id} -> {account_id}, "
611 f"amount={amount}[{fee_type.value}] {rcpt_currency_code} "
612 )
614 options = self.request_options()
616 old_intent: PaymentIntentDTO | None = None
617 try:
618 old_intent = self.get_payment_intent(
619 endpoint=endpoint,
620 payment_intent_id=payment_method_id,
621 )
622 except Exception as err:
623 # we cant really proceed, because we cant determine the status.
624 ProxyErrorBuilder.raise_error(
625 ProxyErrorMessage.INTENT_RETRIEVE_FAILED,
626 endpoint=endpoint,
627 error=err,
628 CONTEXT=f"reauth for {debug_msg}",
629 STRIPE_ERROR=err,
630 )
632 if old_intent.intent_status != StripeIntentStatus.CANCELED:
633 if IS_DEBUG:
634 msg = (
635 f"Reauthorizing old payment intent {payment_method_id}/{old_intent.intent_status}, "
636 f"for {debug_msg}"
637 )
638 LOG().debug(msg)
640 try:
641 self.cancel_payment_intent(
642 endpoint=endpoint,
643 payment_intent_id=payment_method_id,
644 )
645 except Exception as err:
646 # this is definitely a problem, we cant create a new intent without
647 # cancelling the old one...
648 ProxyErrorBuilder.raise_error(
649 ProxyErrorMessage.INTENT_CANCEL_FAILED,
650 endpoint=endpoint,
651 error=err,
652 CONTEXT=f"reauth for {debug_msg}",
653 STRIPE_ERROR=err,
654 )
656 new_intent: StripePaymentIntent | None = None
657 try:
658 params = PaymentIntentCreateDTO(
659 customer_id=customer_id,
660 account_id=account_id,
661 amount=amount,
662 currency_code=rcpt_currency_code,
663 fee_type=fee_type,
664 ).to_params()
666 new_intent = self.client.v1.payment_intents.create(
667 options=options,
668 params=params,
669 )
670 if IS_TRACE:
671 LOG().trace(f"Payment intent created with id {new_intent.id} for {debug_msg}")
673 except Exception as e:
674 ProxyErrorBuilder.raise_error(
675 ProxyErrorMessage.INTENT_CREATE_FAILED,
676 endpoint=endpoint,
677 error=e,
678 CONTEXT=debug_msg,
679 STRIPE_ERROR=e,
680 )
682 try:
683 # confirm with existing payment_method_id
684 confirmed_intent = self.client.v1.payment_intents.confirm(
685 new_intent.id,
686 params={"payment_method": payment_method_id},
687 options=options,
688 )
689 if IS_TRACE:
690 msg = f"Payment intent {new_intent.id} recreated for {debug_msg}"
691 LOG().trace(msg)
693 return PaymentIntentDTO.from_intent(confirmed_intent)
694 except Exception as e:
695 ProxyErrorBuilder.raise_error(
696 ProxyErrorMessage.INTENT_REAUTH_FAILED,
697 endpoint=endpoint,
698 error=e,
699 CONTEXT=debug_msg,
700 STRIPE_ERROR=e,
701 )
703 def CAPTURE_PAYMENT(
704 self,
705 endpoint: str,
706 payment_intent_id: str,
707 ) -> PaymentIntentDTO:
708 # !! IMPORTANT !! This actually charges the user, so should only be called once
709 intent: StripePaymentIntent | None = None
710 try:
712 if IS_TRACE:
713 msg = f"Capturing payment intent {payment_intent_id} to charge user"
714 LOG().trace(msg)
716 intent = self.client.v1.payment_intents.capture(
717 payment_intent_id,
718 params={"expand": StripeExpandParams.PAYMENT_INTENT},
719 options=self.request_options(),
720 )
722 if IS_TRACE:
723 msg = f"Payment intent {payment_intent_id} captured successfully:\n{intent!s}"
724 LOG().trace(msg)
726 except Exception as e:
727 ProxyErrorBuilder.raise_error(
728 ProxyErrorMessage.INTENT_CAPTURE_FAILED,
729 endpoint=endpoint,
730 error=e,
731 INTENT_ID=payment_intent_id,
732 STRIPE_ERROR=e,
733 )
735 try:
736 return PaymentIntentDTO.from_intent(intent)
737 except Exception as e:
738 # a code path error since we failed to decode the intent properly..
739 ProxyErrorBuilder.raise_error(
740 ProxyErrorMessage.INTENT_PARSE_FAILED,
741 endpoint=endpoint,
742 error=e,
743 INTENT_ID=payment_intent_id,
744 STRIPE_ERROR=f"{e}\n{stringify_debug(intent)}",
745 )
747 def TRANSFER_PAYMENT(
748 self,
749 endpoint: str,
750 customer_id: str,
751 obj_id: str,
752 account_id: str,
753 charge_id: str,
754 transfer_amount: int,
755 transfer_currency: StripeCurrencyCode,
756 ) -> TransferDTO:
757 # !! IMPORTANT !! This transfers from the platform account to the recipient,
758 # !! IMPORTANT !! so should only be called once and only after capture.
759 transfer: StripeTransfer | None = None
760 try:
761 if IS_TRACE:
762 msg = f"Transferring {transfer_amount} {transfer_currency.stripe_code} to account {account_id} for charge {charge_id}"
763 LOG().trace(msg)
765 transfer = self.client.v1.transfers.create(
766 params={
767 "amount": transfer_amount,
768 "currency": transfer_currency.stripe_code,
769 "destination": account_id,
770 "description": f"from {customer_id} for {obj_id}",
771 "source_transaction": charge_id,
772 },
773 options=self.request_options(),
774 )
776 if IS_TRACE:
777 msg = f"Transfer {transfer.id} created successfully:\n{transfer!s}"
778 LOG().trace(msg)
780 except Exception as e:
781 ProxyErrorBuilder.raise_error(
782 ProxyErrorMessage.TRANSFER_FAILED,
783 endpoint=endpoint,
784 error=e,
785 CHARGE_ID=charge_id,
786 ACCOUNT_ID=account_id,
787 STRIPE_ERROR=e,
788 )
790 try:
791 return TransferDTO.from_stripe_transfer(transfer)
792 except Exception as e:
793 # a code path error since we failed to decode the transfer properly..
794 ProxyErrorBuilder.raise_error(
795 ProxyErrorMessage.TRANSFER_PARSE_FAILED,
796 endpoint=endpoint,
797 error=e,
798 TRANSFER_ID=transfer.id if transfer else None,
799 STRIPE_ERROR=f"{e}\n{stringify_debug(transfer)}",
800 )
802 # ========================================================================
803 # REFUNDS
804 # ========================================================================
806 def REFUND_PAYMENT(
807 self,
808 endpoint: str,
809 customer_id: str,
810 obj_id: str,
811 payment_intent_id: str,
812 refund_amount: int,
813 reason: StripeRefundReason = StripeRefundReason.REQUESTED_BY_CUSTOMER,
814 ) -> RefundDTO:
815 """
816 Refund a payment made to a connected account
817 Args:
818 stripe_client: Initialized Stripe client
819 payment_intent_id: The ID of the payment intent to refund
820 refund_amount: Optional specific amount to refund (in cents)
821 reason: Optional reason for the refund
822 """
823 if IS_DEBUG:
824 msg = f"Refunding user {customer_id} for {refund_amount} on payment intent {payment_intent_id} for reason {reason}"
825 LOG().debug(msg)
827 try:
828 # Get the payment intent to find the charge
829 payment_intent = self.client.v1.payment_intents.retrieve(
830 payment_intent_id,
831 params={"expand": StripeExpandParams.PAYMENT_INTENT},
832 options=self.request_options(),
833 )
834 charge_id = payment_intent.latest_charge
836 refund = self.client.v1.refunds.create(
837 params={
838 "charge": str(charge_id),
839 "reason": reason.literal_value,
840 "amount": refund_amount, # Optional - omit for full refund
841 "reverse_transfer": True, # Recovers funds from connected account
842 "refund_application_fee": False, # Dont return app fee.
843 "metadata": {
844 "customer_id": customer_id,
845 "obj_id": obj_id,
846 },
847 },
848 )
850 return self._is_refund_confirmed(endpoint, payment_intent_id, refund)
851 except Exception as e:
852 LOG().error(f"Error processing refund: {e!s}")
853 raise
855 @deprecated("Not required, moved to using the Platform Pricing Tool.")
856 def refund_excessive_fee(
857 self,
858 pledge_id: str,
859 endpoint: str,
860 app_fee_amount: int, # expose this from intent so we ensure it exists..
861 intent_dto: PaymentIntentDTO,
862 ) -> RefundResult:
863 # because we cant get stripe fees until after the charge is made,
864 # and we use a conservative fee estimate, we need to refund any excess fee after the fact.
865 intent_id = intent_dto.intent_id
866 status = intent_dto.intent_status
867 if IS_TRACE:
868 msg = f"Refunding excessive fee for charge {intent_id}/{status} with amount {intent_dto.amount}"
869 LOG().trace(msg)
871 if intent_dto.validate_capture() is not None or status != StripeIntentStatus.SUCCEEDED:
872 msg = (
873 f"Cannot refund excessive fee for unconfirmed intent {intent_id} "
874 f"with status {status}"
875 )
876 LOG().warning(msg)
877 error = StripeErrorContext.from_code(
878 endpoint=endpoint,
879 error_code=AppPaymentErrorCode.INTENT_NOT_CONFIRMED,
880 cause=msg,
881 )
882 raise AppStripeError.from_context(error)
884 latest_charge_id = intent_dto.latest_charge_id
885 if latest_charge_id is None:
886 msg = f"Cannot refund excessive fee for intent {intent_id}: latest_charge_id is None"
887 LOG().warning(msg)
888 error = StripeErrorContext.from_code(
889 endpoint=endpoint,
890 error_code=AppPaymentErrorCode.INTENT_NOT_CONFIRMED,
891 cause=msg,
892 )
893 raise AppStripeError.from_context(error)
895 actual_stripe_fee = self._get_stripe_fee_from_charge(latest_charge_id)
896 if actual_stripe_fee is None:
897 msg = f"Cannot refund excessive fee for intent {intent_id} because actual stripe fee is None"
898 LOG().warning(msg)
899 error = StripeErrorContext.from_code(
900 endpoint=endpoint,
901 error_code=AppPaymentErrorCode.FEE_REFUND_FAILED,
902 cause=msg,
903 )
904 raise AppStripeError.from_context(error)
906 if app_fee_amount <= actual_stripe_fee:
907 # !! IMPORTANT !! THIS IS A PROBLEM.
908 # !! IMPORTANT !! WE ARE LOOSING MONEY..
909 # NOTE: we log so the admin team can investigate and manually refund the user,
910 # NOTE: but we have no way to automatically recover from this since we
911 # NOTE: dont want to refund the user more than we charged them.
912 msg = f"App fee ({app_fee_amount}) <= actual fee ({actual_stripe_fee}) for intent {intent_id}"
913 error = StripeErrorContext.from_code(
914 endpoint=endpoint,
915 error_code=AppPaymentErrorCode.LOOSING_MONEY,
916 cause=msg,
917 )
918 self.log_creator.from_context(doc_id=pledge_id, ctx=error)
919 raise AppStripeError.from_context(error)
921 refund_amount = app_fee_amount - actual_stripe_fee
922 if IS_DEBUG:
923 msg = (
924 f"Refunding excessive fee of {refund_amount} for intent {intent_id} "
925 f"(app fee: {app_fee_amount}, actual fee: {actual_stripe_fee})"
926 )
927 LOG().info(msg)
929 try:
930 self.client.v1.transfers.create(
931 params={
932 "amount": refund_amount,
933 "currency": intent_dto.currency,
934 "destination": self.platform_account_id,
935 "description": "App fee reimbursement",
936 "source_transaction": latest_charge_id,
937 },
938 options=self.request_options(),
939 )
941 actual_app_fee = app_fee_amount - refund_amount
942 if IS_DEBUG:
943 msg = (
944 f"Refunded excessive fee of {refund_amount} for intent {intent_id}, "
945 f"actual app fee is {actual_app_fee} (app fee: {app_fee_amount}, "
946 f"actual fee: {actual_stripe_fee})"
947 )
948 LOG().debug(msg)
950 return RefundResult(
951 app_fee_amount=app_fee_amount,
952 refunded_app_fee_amount=refund_amount,
953 stripe_fee=actual_stripe_fee,
954 )
956 except Exception as e:
957 ProxyErrorBuilder.raise_error(
958 ProxyErrorMessage.REFUND_TRANSFER_FAILED,
959 endpoint=endpoint,
960 error=e,
961 INTENT_ID=intent_id,
962 STRIPE_ERROR=e,
963 )
965 # ========================================================================
966 # INTERNAL HELPERS
967 # ========================================================================
969 def _create_operation_link(
970 self,
971 endpoint: str,
972 link_type: Literal["account_onboarding", "account_update"],
973 info: StripeLinkInfo,
974 ) -> StripeAccountLink:
975 account_id = info.stripe_account_id
976 debug_label = f"(account_id={account_id}, type={link_type})"
978 params: AccountLinkCreateParams = {
979 "account": account_id,
980 "refresh_url": self.stripe_config.webhook_config.refresh_url(info),
981 "return_url": self.stripe_config.webhook_config.return_url(info),
982 "type": link_type,
983 }
985 if IS_DEBUG:
986 msg = f"Creating link {debug_label} with params:\n{stringify_debug(params)}"
987 LOG().debug(msg)
989 try:
990 # accounts are always associated with platform.
991 account_link = self.client.v1.account_links.create(
992 params,
993 options=self.request_options(),
994 )
996 if IS_DEBUG:
997 msg = (
998 f"Created link {debug_label} with: {account_link.url}\n"
999 f"{stringify_debug(account_link)}"
1000 )
1001 LOG().debug(msg)
1003 return account_link
1005 except Exception as e:
1006 ProxyErrorBuilder.raise_error(
1007 ProxyErrorMessage.ACCOUNT_LINK_CREATE_FAILED,
1008 endpoint=endpoint,
1009 error=e,
1010 ACCOUNT_ID=account_id,
1011 STRIPE_ERROR=e,
1012 )
1014 def _create_account(
1015 self,
1016 endpoint: str,
1017 uid: str,
1018 is_express: bool,
1019 email: str,
1020 first_name: str,
1021 last_name: str,
1022 country_code: StripeCountryCode,
1023 currency_code: StripeCurrencyCode,
1024 test_clock: str | None = None,
1025 ) -> AccountDTO:
1026 acc_type = "express" if is_express else "standard"
1027 try:
1028 if IS_TRACE:
1029 msg = (
1030 f"Creating Stripe {acc_type} with PARAMS: uid={uid}, email={email}, "
1031 f"first_name={first_name}, last_name={last_name}, "
1032 f"country={country_code}, currency={currency_code}"
1033 )
1034 LOG().trace(msg)
1036 test_clock = validate_test_clock(test_clock)
1037 stripe_acc = self.client.v2.core.accounts.create(
1038 # accounts are always associated with platform.
1039 options=self.request_options(),
1040 params=AccountCreateDTO(
1041 uid=uid,
1042 is_express=is_express,
1043 email=email,
1044 tokens=(first_name, last_name),
1045 country_code=country_code,
1046 currency_code=currency_code,
1047 test_clock=test_clock,
1048 ).create_params(),
1049 )
1050 if IS_DEBUG:
1051 msg = f"Stripe {acc_type} account created with id {stripe_acc.id} for {email}"
1052 LOG().debug(msg)
1054 return AccountDTOFactory(stripe_acc).create()
1056 except Exception as e:
1057 ProxyErrorBuilder.account_error(
1058 endpoint=endpoint,
1059 account_type=acc_type,
1060 email=email,
1061 stripe_error=e,
1062 )
1064 def _get_stripe_fee_from_charge(self, charge_id: str) -> int | None:
1065 if IS_DEBUG:
1066 LOG().debug(f"Getting stripe fees for charge {charge_id}")
1068 try:
1069 charge = self.client.v1.charges.retrieve(
1070 charge_id,
1071 params={
1072 "expand": StripeExpandParams.CHARGE,
1073 },
1074 )
1075 charge_balance = charge.balance_transaction
1076 if charge_balance is None:
1077 return None
1079 balance_tx = self.client.v1.balance_transactions.retrieve(str(charge_balance))
1081 # Get actual Stripe fee
1082 return balance_tx.fee
1084 except Exception as e:
1085 LOG().error(f"Error processing refund: {e!s}")
1086 return None
1088 def _is_refund_confirmed( # noqa: RET503
1089 self,
1090 endpoint: str,
1091 payment_intent_id: str,
1092 refund: StripeRefund,
1093 ) -> RefundDTO:
1094 dto = RefundDTO(refund)
1096 if dto.status is not None:
1097 if IS_TRACE:
1098 LOG().trace(f"Successfully processed refund for payment id {payment_intent_id}")
1100 return dto
1102 LOG().error(f"Error processing refund: status is None (reason={dto.reason})")
1103 ProxyErrorBuilder.raise_error(
1104 ProxyErrorMessage.REFUND_NO_STATUS,
1105 endpoint=endpoint,
1106 REFUND_ID=dto.id,
1107 INTENT_ID=payment_intent_id,
1108 )