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

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# 

12 

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 

24 

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) 

42 

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 

59 

60 

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 

67 

68 if stripe_config is None: 

69 webhook_key = get_app_config().stripe_webhook_key 

70 stripe_config = AppStripeConfig.default(webhook_key=webhook_key) 

71 

72 secret_key = stripe_config.secret_key 

73 

74 if stripe_client is None: 

75 stripe.api_key = secret_key 

76 stripe.api_version = stripe_config.stripe_version 

77 

78 stripe_client = StripeClient(secret_key) 

79 

80 return AppStripeProxy.instance(stripe_client=stripe_client, stripe_config=stripe_config) 

81 

82 

83class AppStripeProxy(Singleton): 

84 

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 

96 

97 if IS_DEBUG: 

98 msg = f"Initializing AppStripeProxy with config: {self._stripe_config}" 

99 LOG().debug(msg) 

100 

101 @property 

102 def client(self) -> StripeClient: 

103 return self._client 

104 

105 @property 

106 def risk_service(self) -> RiskService: 

107 from flipdare.services import get_risk_service 

108 

109 if self._risk_service is None: 

110 self._risk_service = get_risk_service() 

111 

112 return self._risk_service 

113 

114 @property 

115 def stripe_config(self) -> AppStripeConfig: 

116 return self._stripe_config 

117 

118 @property 

119 def platform_account_id(self) -> str: 

120 return self.stripe_config.platform_account_id 

121 

122 @property 

123 def log_creator(self) -> AppLogger: 

124 from flipdare.services import get_app_logger 

125 

126 if self._log_creator is None: 

127 self._log_creator = get_app_logger() 

128 

129 return self._log_creator 

130 

131 def request_options(self, account_id: str | None = None) -> RequestOptions: 

132 return self.stripe_config.request_options(account_id=account_id) 

133 

134 # ======================================================================== 

135 # ACCOUNT 

136 # ======================================================================== 

137 

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 ) 

175 

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 ) 

188 

189 if IS_DEBUG: 

190 msg = f"Retrieve account type {type(account)}, content:\n{stringify_debug(account)}\n" 

191 LOG().debug(msg) 

192 

193 except Exception as e: 

194 msg = f"Stripe account retrieval failed for account_id {account_id}: {e}" 

195 LOG().error(msg) 

196 

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 

202 

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 

210 

211 try: 

212 dto = AccountDTOFactory(account).create() 

213 

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) 

221 

222 return dto 

223 

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 ) 

232 

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 

239 

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 ) 

248 

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") 

261 

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 ) 

272 

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 ) 

279 

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 ) 

286 

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) 

292 

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}") 

297 

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 ) 

307 

308 # ======================================================================== 

309 # CUSTOMER 

310 # ======================================================================== 

311 

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) 

326 

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 ) 

339 

340 customer_id = customer.id 

341 if IS_DEBUG: 

342 msg = f"Created Stripe customer {customer_id} with email {email}" 

343 LOG().debug(msg) 

344 

345 return CustomerDTO.from_stripe_customer(customer) 

346 

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 ) 

355 

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) 

370 

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 ) 

380 

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) 

405 

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() 

412 

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 ) 

421 

422 # ======================================================================== 

423 # PAYMENT INTENTS 

424 # ======================================================================== 

425 

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 

434 

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() 

447 

448 LOG().warning(f"Params={params}") 

449 

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}") 

456 

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 ) 

465 

466 debug_msg = f"id={intent.id}, {debug_msg}" 

467 

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 ) 

477 

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}") 

488 

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 ) 

497 

498 LOG().trace(f"Created ephemeral key with content:\n{ephemeral_key!s}") 

499 

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 ) 

508 

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 ) 

525 

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}") 

539 

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 ) 

549 

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 ) 

560 

561 if IS_TRACE: 

562 msg = f"Found {len(intents.data)} payment intents for customer_id {customer_id}" 

563 LOG().trace(msg) 

564 

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 ) 

574 

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 

598 

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 ) 

613 

614 options = self.request_options() 

615 

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 ) 

631 

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) 

639 

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 ) 

655 

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() 

665 

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}") 

672 

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 ) 

681 

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) 

692 

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 ) 

702 

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: 

711 

712 if IS_TRACE: 

713 msg = f"Capturing payment intent {payment_intent_id} to charge user" 

714 LOG().trace(msg) 

715 

716 intent = self.client.v1.payment_intents.capture( 

717 payment_intent_id, 

718 params={"expand": StripeExpandParams.PAYMENT_INTENT}, 

719 options=self.request_options(), 

720 ) 

721 

722 if IS_TRACE: 

723 msg = f"Payment intent {payment_intent_id} captured successfully:\n{intent!s}" 

724 LOG().trace(msg) 

725 

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 ) 

734 

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 ) 

746 

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) 

764 

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 ) 

775 

776 if IS_TRACE: 

777 msg = f"Transfer {transfer.id} created successfully:\n{transfer!s}" 

778 LOG().trace(msg) 

779 

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 ) 

789 

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 ) 

801 

802 # ======================================================================== 

803 # REFUNDS 

804 # ======================================================================== 

805 

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) 

826 

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 

835 

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 ) 

849 

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 

854 

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) 

870 

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) 

883 

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) 

894 

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) 

905 

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) 

920 

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) 

928 

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 ) 

940 

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) 

949 

950 return RefundResult( 

951 app_fee_amount=app_fee_amount, 

952 refunded_app_fee_amount=refund_amount, 

953 stripe_fee=actual_stripe_fee, 

954 ) 

955 

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 ) 

964 

965 # ======================================================================== 

966 # INTERNAL HELPERS 

967 # ======================================================================== 

968 

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})" 

977 

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 } 

984 

985 if IS_DEBUG: 

986 msg = f"Creating link {debug_label} with params:\n{stringify_debug(params)}" 

987 LOG().debug(msg) 

988 

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 ) 

995 

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) 

1002 

1003 return account_link 

1004 

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 ) 

1013 

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) 

1035 

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) 

1053 

1054 return AccountDTOFactory(stripe_acc).create() 

1055 

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 ) 

1063 

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}") 

1067 

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 

1078 

1079 balance_tx = self.client.v1.balance_transactions.retrieve(str(charge_balance)) 

1080 

1081 # Get actual Stripe fee 

1082 return balance_tx.fee 

1083 

1084 except Exception as e: 

1085 LOG().error(f"Error processing refund: {e!s}") 

1086 return None 

1087 

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) 

1095 

1096 if dto.status is not None: 

1097 if IS_TRACE: 

1098 LOG().trace(f"Successfully processed refund for payment id {payment_intent_id}") 

1099 

1100 return dto 

1101 

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 )